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>
246 lines
8.2 KiB
TypeScript
246 lines
8.2 KiB
TypeScript
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';
|
|
import { createLitElement } from '../../test-utils';
|
|
|
|
// @vitest-environment jsdom
|
|
describe('NotificationPopupController', () => {
|
|
const create = (getNotificationElement?: () => HTMLElement | null) => {
|
|
const host = createLitElement();
|
|
document.body.appendChild(host);
|
|
const popup = document.createElement('div');
|
|
|
|
const controller = new NotificationPopupController(
|
|
host,
|
|
getNotificationElement ?? (() => popup),
|
|
);
|
|
controller.hostConnected();
|
|
|
|
// Cleanup (disconnecting the window listeners and clearing the DOM) is
|
|
// registered per test, so leaked listeners cannot bleed into later tests.
|
|
onTestFinished(() => {
|
|
controller.hostDisconnected();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
return { host, popup, controller };
|
|
};
|
|
|
|
it('should add itself to the host', () => {
|
|
const { host, controller } = create();
|
|
expect(host.addController).toHaveBeenCalledWith(controller);
|
|
});
|
|
|
|
describe('dismiss', () => {
|
|
it('should mark the notification element as exiting', () => {
|
|
const { popup, controller } = create();
|
|
controller.dismiss();
|
|
expect(popup.classList.contains('exiting')).toBe(true);
|
|
});
|
|
|
|
it('should do nothing when there is no notification element', () => {
|
|
const controller = new NotificationPopupController(createLitElement(), () => null);
|
|
expect(() => controller.dismiss()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('outside interaction', () => {
|
|
it('should dismiss on a click outside the host', () => {
|
|
const { popup } = create();
|
|
const outside = document.createElement('div');
|
|
document.body.appendChild(outside);
|
|
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
|
expect(popup.classList.contains('exiting')).toBe(true);
|
|
});
|
|
|
|
it('should dismiss on a focus outside the host', () => {
|
|
const { popup } = create();
|
|
const outside = document.createElement('div');
|
|
document.body.appendChild(outside);
|
|
outside.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
|
|
expect(popup.classList.contains('exiting')).toBe(true);
|
|
});
|
|
|
|
it('should not dismiss on an interaction inside the host', () => {
|
|
const { host, popup } = create();
|
|
host.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
|
expect(popup.classList.contains('exiting')).toBe(false);
|
|
});
|
|
|
|
it('should stop listening once disconnected', () => {
|
|
const { popup, controller } = create();
|
|
controller.hostDisconnected();
|
|
|
|
const outside = document.createElement('div');
|
|
document.body.appendChild(outside);
|
|
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
|
expect(popup.classList.contains('exiting')).toBe(false);
|
|
});
|
|
});
|
|
|
|
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 return focus without a visible focus ring', () => {
|
|
const before = document.createElement('button');
|
|
document.body.appendChild(before);
|
|
before.focus();
|
|
const focus = vi.spyOn(before, 'focus');
|
|
|
|
const popup = createFocusablePopup();
|
|
const { controller } = create(() => popup);
|
|
controller.hostUpdated();
|
|
popup.remove();
|
|
|
|
controller.hostDisconnected();
|
|
|
|
expect(focus).toHaveBeenCalledWith({ focusVisible: false });
|
|
});
|
|
|
|
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();
|
|
const ev = new KeyboardEvent('keydown', {
|
|
key: 'Escape',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
document.body.dispatchEvent(ev);
|
|
expect(popup.classList.contains('exiting')).toBe(true);
|
|
expect(ev.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('should ignore other keys', () => {
|
|
const { popup } = create();
|
|
const ev = new KeyboardEvent('keydown', {
|
|
key: 'a',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
document.body.dispatchEvent(ev);
|
|
expect(popup.classList.contains('exiting')).toBe(false);
|
|
expect(ev.defaultPrevented).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('animation end', () => {
|
|
// Dispatch a real `animationend` event on the element the handler is bound
|
|
// to, so `target` and `currentTarget` are genuinely the same node.
|
|
const dispatchAnimationEnd = (
|
|
controller: NotificationPopupController,
|
|
animationName: string,
|
|
): void => {
|
|
const element = document.createElement('div');
|
|
element.addEventListener('animationend', controller.handleAnimationEnd);
|
|
|
|
const ev = new Event('animationend');
|
|
Object.defineProperty(ev, 'animationName', { value: animationName });
|
|
element.dispatchEvent(ev);
|
|
};
|
|
|
|
it('should dispatch the dismiss event when the pop-out animation ends', () => {
|
|
const { host, controller } = create();
|
|
const dismissed = vi.fn();
|
|
host.addEventListener('advanced-camera-card:notification:dismiss', dismissed);
|
|
dispatchAnimationEnd(controller, POP_OUT_ANIMATION_NAME);
|
|
expect(dismissed).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore other animations ending', () => {
|
|
const { host, controller } = create();
|
|
const dismissed = vi.fn();
|
|
host.addEventListener('advanced-camera-card:notification:dismiss', dismissed);
|
|
dispatchAnimationEnd(controller, 'pop-in');
|
|
expect(dismissed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|