Files
advanced-camera-card/tests/components-lib/notification/notification-popup-controller.browser.test.ts
T
Matthijsanddermotduffy e6bb0eb0e0 fix: claim pointer focus without a visible focus ring (#2733)
Since f240646 (#2639, first released in v8.0.0) the card claims focus on
any `pointerdown` inside it, so that `key` triggers receive their
keyboard events (`keyboard-state-manager.ts`). The claim is a script
call, `element.focus({ preventScroll: true })`, and script-initiated
focus comes with the browser's focus indicator: after a pointer press
while focus was outside the card, the card matches `:focus-visible` and
Chromium draws its default ring around the entire card (measured:
`outline: auto 1px rgb(238, 238, 238)`, a bright line on a dark
dashboard). The ring then persists until focus leaves the card, which
users experience as a white border that appears intermittently when they
click or tap the card.

Ordinary dashboard cards are unaffected because they rely on the
browser's native pointer focus, which shows no indicator. Isolated in
the same browser, a plain `tabindex` element gains focus from a click
without matching `:focus-visible`, while `focus()` from script does
match it. v7 did not claim focus at all, so it never showed this.

**Change:** pass the intent along with the claim: `element.focus({
preventScroll: true, focusVisible: false })`. This code path only runs
for pointer interaction, where no indicator is wanted. Keyboard focus
does not pass through it: tabbing to the card keeps its ring, and the
`key` trigger support from #2639 is unchanged. Browsers without
`FocusOptions.focusVisible` ignore the option and simply keep today's
behaviour. (`focusVisible` is not yet in the bundled TypeScript DOM
types, hence the small global augmentation.)

**Verification:**

- Unit test asserts the focus claim carries `focusVisible: false`.
- Measured in Chromium 152 on a live dashboard: before, a pointer press
on the card leaves it `:focus-visible` with the UA default ring; after,
the same press focuses the card without one, and reaching the card with
Tab still shows the ring. In the same browser, `focus({ focusVisible:
false })` verifiably suppresses `:focus-visible` where a plain `focus()`
sets it.
- `yarn run test`, `yarn run test:browser` (chromium and firefox), `yarn
run lint` and `yarn run typecheck` pass. The webkit browser run fails
one focus test in this local environment, identically on unmodified
`main`, so it is unrelated to this change.

---------

Co-authored-by: dermotduffy <dermot.duffy@gmail.com>
2026-08-30 14:04:40 -07:00

190 lines
5.6 KiB
TypeScript

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');
};
const dismissNotification = async (card: MountedCard): Promise<void> => {
await pressKey('Escape');
await card.waitForRender(
() => (deepQuery(card.card, '.notification') ? null : true),
'the notification to be removed',
);
};
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 dismissNotification(card);
expect(getFocusedElement()).toBe(elsewhere);
});
it('should return focus without a visible focus ring', async () => {
const card = await mount();
await card.console.waitForMessage(CARD_INITIALIZED_MESSAGE);
const elsewhere = document.createElement('button');
document.body.appendChild(elsewhere);
// Focused as a pointer press leaves it: with no ring, which is the state
// the return of focus must not change.
elsewhere.focus({ focusVisible: false });
await showNotification(card);
await dismissNotification(card);
expect(getFocusedElement()).toBe(elsewhere);
expect(elsewhere.matches(':focus-visible')).toBe(false);
});
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);
});
});