fix: Keep the notification popup open while its text is selected (#2706)

This commit is contained in:
Dermot Duffy
2026-08-22 21:29:04 -07:00
committed by GitHub
parent b2f3243f3e
commit 965f2b4c9d
10 changed files with 360 additions and 16 deletions
+19
View File
@@ -71,6 +71,25 @@ export const releaseKey = async (key: string): Promise<void> =>
export const pressTab = async (): Promise<void> => await userEvent.tab();
/**
* Press Tab until the page reaches the state the caller is waiting for,
* reporting whether it got there. `maximumPresses` is a runaway guard rather
* than a count: a page that never reaches that state fails the caller instead
* of tabbing forever.
*/
export const tabUntil = async (
isReached: () => boolean,
maximumPresses: number,
): Promise<boolean> => {
for (let press = 0; press < maximumPresses; ++press) {
await pressTab();
if (isReached()) {
return true;
}
}
return false;
};
/**
* Click an element with a real pointer, which is the only kind that carries the
* browser's own behaviour: the press moves focus, and an element that stops the
@@ -8,6 +8,7 @@ import {
getFocusedElement,
pressKey,
pressTab,
tabUntil,
} from '../browser/dom';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import {
@@ -52,17 +53,7 @@ const mountCard = async (): Promise<MountedCard> => {
const tabPastCard = async (card: MountedCard): Promise<void> => {
const bound = deepQueryAll(card.card, '*').length;
// Tabbing starts from the top of the page, so the first press is the one
// that reaches the card.
await pressTab();
for (
let press = 0;
press < bound && card.card.contains(document.activeElement);
press++
) {
await pressTab();
}
await tabUntil(() => !card.card.contains(document.activeElement), bound);
};
/**
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest';
import type { Notification } from '../../../src/config/schema/actions/types';
import { createLogAction } from '../../../src/utils/action';
import {
clickElement,
deepQuery,
getFocusedElement,
pressKey,
pressTab,
} from '../../browser/dom';
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
import {
CARD_INITIALIZED_MESSAGE,
createGenericCameraHASS,
createInitializedAutomation,
createStillImageCardConfig,
} from '../../browser/test-utils';
const TRIGGER_ENTITY = 'input_boolean.notify';
const BODY_TEXT = 'This camera does not support two-way audio.';
const CONTROL_TAPPED_MESSAGE = /control tapped/;
const NOTIFICATION: Notification = {
heading: { text: 'Two-way audio unavailable' },
body: { text: BODY_TEXT },
};
const mount = async (
notification: Notification = NOTIFICATION,
): Promise<MountedCard> => {
const hass = createGenericCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
return await MountedCardFactory.createFromSource(
createStillImageCardConfig({
automations: [
createInitializedAutomation(),
{
triggers: [{ trigger: 'state', entity: TRIGGER_ENTITY, to: 'on' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'notification',
notification,
},
],
},
],
}),
hass,
);
};
const showNotification = async (card: MountedCard): Promise<HTMLElement> => {
card.setEntityState(TRIGGER_ENTITY, 'on');
return await card.waitForSelector<HTMLElement>('.notification');
};
describe('NotificationPopupController', () => {
it('should keep the notification open when its own text is pressed', async () => {
const card = await mount();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
const elsewhere = document.createElement('button');
document.body.appendChild(elsewhere);
elsewhere.focus();
const notification = await showNotification(card);
const body = deepQuery<HTMLElement>(card.card, '.detail.body span');
expect(body?.textContent).toBe(BODY_TEXT);
if (!body) {
return;
}
await clickElement(body);
expect(notification.classList.contains('exiting')).toBe(false);
});
it('should dismiss the notification when the page outside it is pressed', async () => {
const card = await mount();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
const outside = document.createElement('button');
outside.textContent = 'outside';
document.body.appendChild(outside);
const notification = await showNotification(card);
expect(notification.classList.contains('exiting')).toBe(false);
await clickElement(outside);
expect(notification.classList.contains('exiting')).toBe(true);
});
it('should take focus when notification is opened', async () => {
const card = await mount();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
const elsewhere = document.createElement('button');
document.body.appendChild(elsewhere);
elsewhere.focus();
const notification = await showNotification(card);
expect(getFocusedElement()).toBe(notification);
});
it('should move focus to its close control on Tab', async () => {
const card = await mount();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
await showNotification(card);
const close = deepQuery<HTMLElement>(card.card, 'button.close');
expect(close).toBeTruthy();
await pressTab();
expect(getFocusedElement()).toBe(close);
});
it('should return focus to where it was once dismissed', async () => {
const card = await mount();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
const elsewhere = document.createElement('button');
document.body.appendChild(elsewhere);
elsewhere.focus();
await showNotification(card);
expect(getFocusedElement()).not.toBe(elsewhere);
await pressKey('Escape');
await card.waitForRender(
() => (deepQuery(card.card, '.notification') ? null : true),
'the notification to be removed',
);
expect(getFocusedElement()).toBe(elsewhere);
});
it('should activate a notification control from the keyboard', async () => {
const card = await mount({
...NOTIFICATION,
controls: [
{
icon: 'mdi:refresh',
tooltip: 'Retry',
dismiss: true,
actions: { tap_action: createLogAction(CONTROL_TAPPED_MESSAGE.source) },
},
],
});
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
await showNotification(card);
await pressTab();
expect(getFocusedElement()).toBe(deepQuery(card.card, '.notification .control'));
await pressKey('Enter');
await card.console.waitForMessage(CONTROL_TAPPED_MESSAGE);
});
});
@@ -1,4 +1,4 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { assert, describe, expect, it, onTestFinished, vi } from 'vitest';
import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller';
import { POP_OUT_ANIMATION_NAME } from '../../../src/utils/animation';
@@ -79,6 +79,96 @@ describe('NotificationPopupController', () => {
});
});
describe('focus', () => {
// The notification element must be in the document and focusable for the
// controller to be able to move focus to it.
const createFocusablePopup = (): HTMLElement => {
const popup = document.createElement('div');
popup.setAttribute('tabindex', '-1');
document.body.appendChild(popup);
return popup;
};
it('should take focus when the notification appears', () => {
const popup = createFocusablePopup();
const { controller } = create(() => popup);
controller.hostUpdated();
expect(document.activeElement).toBe(popup);
});
it('should leave focus alone on later updates', () => {
const popup = createFocusablePopup();
const { controller } = create(() => popup);
controller.hostUpdated();
const control = document.createElement('button');
document.body.appendChild(control);
control.focus();
controller.hostUpdated();
expect(document.activeElement).toBe(control);
});
it('should do nothing when there is no notification element', () => {
const { controller } = create(() => null);
expect(() => controller.hostUpdated()).not.toThrow();
});
it('should return focus to the element that had it', () => {
const before = document.createElement('button');
document.body.appendChild(before);
before.focus();
const popup = createFocusablePopup();
const { controller } = create(() => popup);
controller.hostUpdated();
popup.remove();
controller.hostDisconnected();
expect(document.activeElement).toBe(before);
});
it('should leave focus alone when something else has taken it', () => {
const before = document.createElement('button');
document.body.appendChild(before);
before.focus();
const { controller } = create();
const elsewhere = document.createElement('button');
document.body.appendChild(elsewhere);
elsewhere.focus();
controller.hostDisconnected();
expect(document.activeElement).toBe(elsewhere);
});
it('should do nothing when nothing had focus', () => {
const activeElement = Object.getOwnPropertyDescriptor(
Document.prototype,
'activeElement',
);
assert(activeElement);
Object.defineProperty(document, 'activeElement', {
configurable: true,
get: () => null,
});
onTestFinished(() => {
Object.defineProperty(document, 'activeElement', activeElement);
});
const { controller } = create();
expect(() => controller.hostDisconnected()).not.toThrow();
});
});
describe('keydown', () => {
it('should dismiss and consume the Escape key', () => {
const { popup } = create();
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import type { IssueTriggerEventData } from '../../../src/card-controller/issues/types';
import { fireAdvancedCameraCardEvent } from '../../../src/utils/fire-advanced-camera-card-event';
import { getShadowRootHost } from '../../../src/utils/shadow-root';
import { deepQueryAll, getFocusedElement, tabUntil } from '../../browser/dom';
import { MountedCardFactory } from '../../browser/mounted-card';
import {
createGenericCameraHASS,
createStillImageCardConfig,
} from '../../browser/test-utils';
const BLOCK_ELEMENT = 'advanced-camera-card-notification-block';
const MAXIMUM_TAB_PRESSES = 15;
describe('AdvancedCameraCardNotificationBlock', () => {
it('should let the keyboard reach the retry control on an issue', async () => {
const card = await MountedCardFactory.createFromSource(
createStillImageCardConfig(),
createGenericCameraHASS(),
);
const views = await card.waitForSelector('advanced-camera-card-views');
fireAdvancedCameraCardEvent<IssueTriggerEventData>(views, 'issue:trigger', {
key: 'initialization',
error: new Error('Initialization failed'),
});
const control = await card.waitForRender(
() =>
deepQueryAll(card.card, '.control').find(
(element) => getShadowRootHost(element)?.localName === BLOCK_ELEMENT,
) ?? null,
'a control on the issue block',
);
expect(
await tabUntil(() => getFocusedElement() === control, MAXIMUM_TAB_PRESSES),
).toBe(true);
});
});