fix: Keep the notification popup open while its text is selected (#2706)
This commit is contained in:
@@ -4,11 +4,13 @@ import { hasPopOutAnimationEnded } from '../../utils/animation.js';
|
|||||||
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
||||||
|
|
||||||
// Manages the popup notification's modal interaction: dismiss on outside
|
// Manages the popup notification's modal interaction: dismiss on outside
|
||||||
// interaction or Escape, and emit the dismiss event once the pop-out animation
|
// interaction or Escape, hold focus while it is shown, and emit the dismiss
|
||||||
// finishes.
|
// event once the pop-out animation finishes.
|
||||||
export class NotificationPopupController implements ReactiveController {
|
export class NotificationPopupController implements ReactiveController {
|
||||||
private _host: ReactiveControllerHost & HTMLElement;
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
private _getNotificationElement: () => HTMLElement | null;
|
private _getNotificationElement: () => HTMLElement | null;
|
||||||
|
private _elementFocusedBeforePopup: Element | null = null;
|
||||||
|
private _hasTakenFocus = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
host: ReactiveControllerHost & HTMLElement,
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
@@ -20,6 +22,8 @@ export class NotificationPopupController implements ReactiveController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public hostConnected(): void {
|
public hostConnected(): void {
|
||||||
|
this._elementFocusedBeforePopup = document.activeElement;
|
||||||
|
|
||||||
window.addEventListener('click', this._handleOutsideInteraction);
|
window.addEventListener('click', this._handleOutsideInteraction);
|
||||||
window.addEventListener('focusin', this._handleOutsideInteraction);
|
window.addEventListener('focusin', this._handleOutsideInteraction);
|
||||||
|
|
||||||
@@ -29,10 +33,31 @@ export class NotificationPopupController implements ReactiveController {
|
|||||||
window.addEventListener('keydown', this._handleKeyDown, { capture: true });
|
window.addEventListener('keydown', this._handleKeyDown, { capture: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hostUpdated(): void {
|
||||||
|
const notification = this._getNotificationElement();
|
||||||
|
|
||||||
|
if (notification && !this._hasTakenFocus) {
|
||||||
|
this._hasTakenFocus = true;
|
||||||
|
notification.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public hostDisconnected(): void {
|
public hostDisconnected(): void {
|
||||||
window.removeEventListener('click', this._handleOutsideInteraction);
|
window.removeEventListener('click', this._handleOutsideInteraction);
|
||||||
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
||||||
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
|
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
|
||||||
|
|
||||||
|
this._hasTakenFocus = false;
|
||||||
|
|
||||||
|
// Focus returns to whatever held it before the popup appeared, unless
|
||||||
|
// something else has taken focus since.
|
||||||
|
if (
|
||||||
|
this._elementFocusedBeforePopup instanceof HTMLElement &&
|
||||||
|
document.activeElement === document.body
|
||||||
|
) {
|
||||||
|
this._elementFocusedBeforePopup.focus();
|
||||||
|
}
|
||||||
|
this._elementFocusedBeforePopup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public dismiss = (): void => {
|
public dismiss = (): void => {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function renderControl(
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="${classMap(classes)}"
|
class="${classMap(classes)}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
title=${control.tooltip ?? ''}
|
title=${control.tooltip ?? ''}
|
||||||
.actionHandler=${actionHandler({
|
.actionHandler=${actionHandler({
|
||||||
hasHold: hasAction(control.actions?.hold_action),
|
hasHold: hasAction(control.actions?.hold_action),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class AdvancedCameraCardNotification extends LitElement {
|
|||||||
<div class="backdrop" @click=${this._popupController.dismiss}></div>
|
<div class="backdrop" @click=${this._popupController.dismiss}></div>
|
||||||
<div
|
<div
|
||||||
class="notification"
|
class="notification"
|
||||||
|
tabindex="-1"
|
||||||
${ref(this._refNotification)}
|
${ref(this._refNotification)}
|
||||||
@animationend=${this._popupController.handleAnimationEnd}
|
@animationend=${this._popupController.handleAnimationEnd}
|
||||||
>
|
>
|
||||||
@@ -70,11 +71,16 @@ export class AdvancedCameraCardNotification extends LitElement {
|
|||||||
)}
|
)}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
<div class="close" @click=${this._popupController.dismiss}>
|
<button
|
||||||
|
class="close"
|
||||||
|
title=${localize('common.close')}
|
||||||
|
aria-label=${localize('common.close')}
|
||||||
|
@click=${this._popupController.dismiss}
|
||||||
|
>
|
||||||
<advanced-camera-card-icon
|
<advanced-camera-card-icon
|
||||||
.icon=${{ icon: 'mdi:close' }}
|
.icon=${{ icon: 'mdi:close' }}
|
||||||
></advanced-camera-card-icon>
|
></advanced-camera-card-icon>
|
||||||
</div>
|
</button>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
${heading ? renderDetail(heading, 'heading') : ''}
|
${heading ? renderDetail(heading, 'heading') : ''}
|
||||||
${renderNotificationBody(this.notification, context)}
|
${renderNotificationBody(this.notification, context)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"advanced_camera_card": "Advanced Camera Card",
|
"advanced_camera_card": "Advanced Camera Card",
|
||||||
"advanced_camera_card_description": "An Advanced Camera Card",
|
"advanced_camera_card_description": "An Advanced Camera Card",
|
||||||
|
"close": "Close",
|
||||||
"event": "Event",
|
"event": "Event",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"in_progress": "In progress...",
|
"in_progress": "In progress...",
|
||||||
|
|||||||
@@ -113,6 +113,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
border-radius: var(--advanced-camera-card-button-border-radius);
|
border-radius: var(--advanced-camera-card-button-border-radius);
|
||||||
|
|
||||||
background: color-mix(
|
background: color-mix(
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ export const releaseKey = async (key: string): Promise<void> =>
|
|||||||
|
|
||||||
export const pressTab = async (): Promise<void> => await userEvent.tab();
|
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
|
* 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
|
* browser's own behaviour: the press moves focus, and an element that stops the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getFocusedElement,
|
getFocusedElement,
|
||||||
pressKey,
|
pressKey,
|
||||||
pressTab,
|
pressTab,
|
||||||
|
tabUntil,
|
||||||
} from '../browser/dom';
|
} from '../browser/dom';
|
||||||
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
@@ -52,17 +53,7 @@ const mountCard = async (): Promise<MountedCard> => {
|
|||||||
const tabPastCard = async (card: MountedCard): Promise<void> => {
|
const tabPastCard = async (card: MountedCard): Promise<void> => {
|
||||||
const bound = deepQueryAll(card.card, '*').length;
|
const bound = deepQueryAll(card.card, '*').length;
|
||||||
|
|
||||||
// Tabbing starts from the top of the page, so the first press is the one
|
await tabUntil(() => !card.card.contains(document.activeElement), bound);
|
||||||
// that reaches the card.
|
|
||||||
await pressTab();
|
|
||||||
|
|
||||||
for (
|
|
||||||
let press = 0;
|
|
||||||
press < bound && card.card.contains(document.activeElement);
|
|
||||||
press++
|
|
||||||
) {
|
|
||||||
await pressTab();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller';
|
||||||
import { POP_OUT_ANIMATION_NAME } from '../../../src/utils/animation';
|
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', () => {
|
describe('keydown', () => {
|
||||||
it('should dismiss and consume the Escape key', () => {
|
it('should dismiss and consume the Escape key', () => {
|
||||||
const { popup } = create();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user