Manage nested dialogs through a central stack
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
|
||||
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
|
||||
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
|
||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
|
||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
|
||||
|
||||
22
webui/scripts/test-dialog-focus-structure.mjs
Normal file
22
webui/scripts/test-dialog-focus-structure.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = readFileSync("src/components/Dialog.tsx", "utf8");
|
||||
const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
|
||||
|
||||
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
|
||||
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
|
||||
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
|
||||
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
|
||||
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
|
||||
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");
|
||||
assert(stackSource.includes('panel.setAttribute("inert", "")'), "underlying dialog panels become inert");
|
||||
assert(stackSource.includes('panel.setAttribute("aria-hidden", "true")'), "underlying dialogs leave the accessibility tree");
|
||||
assert(source.includes('data-dialog-stack-state="topmost"'), "Dialog exposes stack state for custom keyboard interactions");
|
||||
assert(source.includes("dialogIsTopmost(stackIdRef.current)"), "underlying backdrops cannot close their dialog");
|
||||
assert(source.includes("shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)"), "Dialog preserves explicit backdrop-close conditions");
|
||||
|
||||
console.log("Dialog focus and stack lifecycle structure checks passed.");
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useId, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
|
||||
import {
|
||||
dialogIsTopmost,
|
||||
nextDialogActivationOrder,
|
||||
registerDialog,
|
||||
type DialogStackId
|
||||
} from "./dialogStack";
|
||||
|
||||
export type DialogProps = {
|
||||
open: boolean;
|
||||
@@ -46,6 +53,20 @@ export default function Dialog({
|
||||
}: DialogProps) {
|
||||
const titleId = useId();
|
||||
const canClose = Boolean(onClose) && !closeDisabled;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const stackIdRef = useRef<DialogStackId>(Symbol("govoplan-dialog"));
|
||||
const activationOrderRef = useRef<number | null>(null);
|
||||
const canCloseRef = useRef(canClose);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(
|
||||
typeof document !== "undefined" && typeof HTMLElement !== "undefined" && document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
);
|
||||
canCloseRef.current = canClose;
|
||||
onCloseRef.current = onClose;
|
||||
if (!open) activationOrderRef.current = null;
|
||||
else if (activationOrderRef.current === null) activationOrderRef.current = nextDialogActivationOrder();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedTitle = translateReactNode(title, translateText);
|
||||
const renderedChildren = translateReactNode(children, translateText);
|
||||
@@ -53,15 +74,36 @@ export default function Dialog({
|
||||
const translatedCloseLabel = translateText(closeLabel);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canClose) return undefined;
|
||||
if (open || typeof document === "undefined") return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose?.();
|
||||
const rememberFocusedElement = () => {
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) return;
|
||||
if (panelRef.current?.contains(activeElement)) return;
|
||||
restoreFocusRef.current = activeElement;
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [canClose, onClose, open]);
|
||||
rememberFocusedElement();
|
||||
document.addEventListener("focusin", rememberFocusedElement);
|
||||
return () => document.removeEventListener("focusin", rememberFocusedElement);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === "undefined") return undefined;
|
||||
|
||||
const panel = panelRef.current;
|
||||
const activationOrder = activationOrderRef.current;
|
||||
if (!panel || activationOrder === null) return undefined;
|
||||
|
||||
return registerDialog({
|
||||
id: stackIdRef.current,
|
||||
activationOrder,
|
||||
panel,
|
||||
restoreFocus: restoreFocusRef.current,
|
||||
canClose: () => canCloseRef.current,
|
||||
onClose: () => onCloseRef.current?.()
|
||||
}, document.activeElement);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -70,13 +112,19 @@ export default function Dialog({
|
||||
className={joinClasses("dialog-backdrop", backdropClassName)}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (closeOnBackdrop && canClose && event.target === event.currentTarget) onClose?.();
|
||||
if (
|
||||
dialogIsTopmost(stackIdRef.current)
|
||||
&& shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)
|
||||
) onClose?.();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={joinClasses("dialog-panel", className)}
|
||||
role={role}
|
||||
aria-modal="true"
|
||||
data-dialog-stack-state="topmost"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
|
||||
106
webui/src/components/dialogInteractions.ts
Normal file
106
webui/src/components/dialogInteractions.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"area[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled]):not([type=\"hidden\"])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"details > summary:first-of-type",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"[contenteditable]:not([contenteditable=\"false\"])",
|
||||
"[tabindex]:not([tabindex=\"-1\"])"
|
||||
].join(",");
|
||||
|
||||
export type DialogKeyboardEvent = Pick<KeyboardEvent, "key" | "shiftKey" | "preventDefault">;
|
||||
|
||||
function elementIsAvailable(element: HTMLElement): boolean {
|
||||
if (element.tabIndex < 0 || element.hasAttribute("disabled")) return false;
|
||||
if (element.closest('[hidden], [inert], [aria-hidden="true"]')) return false;
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== "none" && style.visibility !== "hidden";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function focusWithoutScrolling(element: HTMLElement): void {
|
||||
try {
|
||||
element.focus({ preventScroll: true });
|
||||
} catch {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function dialogFocusableElements(panel: HTMLElement): HTMLElement[] {
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(elementIsAvailable);
|
||||
}
|
||||
|
||||
export function focusDialogOnOpen(panel: HTMLElement, activeElement: Element | null): HTMLElement {
|
||||
if (activeElement && panel.contains(activeElement)) return activeElement as HTMLElement;
|
||||
const focusable = dialogFocusableElements(panel);
|
||||
const requested = panel.querySelector<HTMLElement>("[autofocus]");
|
||||
const target = requested && focusable.includes(requested) ? requested : (focusable[0] ?? panel);
|
||||
focusWithoutScrolling(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function trapDialogFocus(
|
||||
panel: HTMLElement,
|
||||
event: DialogKeyboardEvent,
|
||||
activeElement: Element | null
|
||||
): boolean {
|
||||
if (event.key !== "Tab") return false;
|
||||
|
||||
const focusable = dialogFocusableElements(panel);
|
||||
let target: HTMLElement | null = null;
|
||||
|
||||
if (focusable.length === 0) {
|
||||
target = panel;
|
||||
} else {
|
||||
const activeIndex = activeElement ? focusable.indexOf(activeElement as HTMLElement) : -1;
|
||||
if (activeIndex < 0) {
|
||||
target = event.shiftKey ? focusable[focusable.length - 1] : focusable[0];
|
||||
} else if (event.shiftKey && activeIndex === 0) {
|
||||
target = focusable[focusable.length - 1];
|
||||
} else if (!event.shiftKey && activeIndex === focusable.length - 1) {
|
||||
target = focusable[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) return false;
|
||||
event.preventDefault();
|
||||
focusWithoutScrolling(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleDialogKeyDown(
|
||||
panel: HTMLElement,
|
||||
event: DialogKeyboardEvent,
|
||||
activeElement: Element | null,
|
||||
canClose: boolean,
|
||||
onClose?: () => void
|
||||
): boolean {
|
||||
if (event.key === "Tab") return trapDialogFocus(panel, event, activeElement);
|
||||
if (event.key !== "Escape" || !canClose) return false;
|
||||
onClose?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function restoreDialogFocus(element: HTMLElement | null): boolean {
|
||||
if (!element || element.isConnected === false) return false;
|
||||
focusWithoutScrolling(element);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldCloseDialogOnBackdrop(
|
||||
target: EventTarget | null,
|
||||
currentTarget: EventTarget | null,
|
||||
closeOnBackdrop: boolean,
|
||||
canClose: boolean
|
||||
): boolean {
|
||||
return closeOnBackdrop && canClose && target === currentTarget;
|
||||
}
|
||||
158
webui/src/components/dialogStack.ts
Normal file
158
webui/src/components/dialogStack.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
focusDialogOnOpen,
|
||||
handleDialogKeyDown,
|
||||
restoreDialogFocus,
|
||||
type DialogKeyboardEvent
|
||||
} from "./dialogInteractions";
|
||||
|
||||
export type DialogStackId = symbol;
|
||||
|
||||
export type DialogStackRegistration = {
|
||||
id: DialogStackId;
|
||||
activationOrder: number;
|
||||
panel: HTMLElement;
|
||||
restoreFocus: HTMLElement | null;
|
||||
canClose: () => boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
type DialogStackEntry = DialogStackRegistration & {
|
||||
restoreTargets: HTMLElement[];
|
||||
};
|
||||
|
||||
const openDialogs: DialogStackEntry[] = [];
|
||||
let activationSequence = 0;
|
||||
let listeningForKeyDown = false;
|
||||
|
||||
function topDialog(): DialogStackEntry | null {
|
||||
return openDialogs[openDialogs.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function currentActiveElement(): Element | null {
|
||||
return typeof document === "undefined" ? null : document.activeElement;
|
||||
}
|
||||
|
||||
function appendUniqueTargets(targets: HTMLElement[], additions: Array<HTMLElement | null>): HTMLElement[] {
|
||||
const next = [...targets];
|
||||
additions.forEach((target) => {
|
||||
if (target && !next.includes(target)) next.push(target);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function exposeAsTopmost(panel: HTMLElement): void {
|
||||
panel.removeAttribute("inert");
|
||||
panel.removeAttribute("aria-hidden");
|
||||
panel.setAttribute("aria-modal", "true");
|
||||
panel.setAttribute("data-dialog-stack-state", "topmost");
|
||||
}
|
||||
|
||||
function hideAsUnderlying(panel: HTMLElement): void {
|
||||
panel.setAttribute("inert", "");
|
||||
panel.setAttribute("aria-hidden", "true");
|
||||
panel.removeAttribute("aria-modal");
|
||||
panel.setAttribute("data-dialog-stack-state", "underlying");
|
||||
}
|
||||
|
||||
function syncStackAccessibility(): void {
|
||||
const topIndex = openDialogs.length - 1;
|
||||
openDialogs.forEach((entry, index) => {
|
||||
if (index === topIndex) exposeAsTopmost(entry.panel);
|
||||
else hideAsUnderlying(entry.panel);
|
||||
});
|
||||
}
|
||||
|
||||
function handleWindowKeyDown(event: KeyboardEvent): void {
|
||||
handleTopDialogKeyDown(event, currentActiveElement());
|
||||
}
|
||||
|
||||
function syncWindowKeyDownListener(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (openDialogs.length > 0 && !listeningForKeyDown) {
|
||||
window.addEventListener("keydown", handleWindowKeyDown);
|
||||
listeningForKeyDown = true;
|
||||
} else if (openDialogs.length === 0 && listeningForKeyDown) {
|
||||
window.removeEventListener("keydown", handleWindowKeyDown);
|
||||
listeningForKeyDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function focusAfterTopmostCloses(entry: DialogStackEntry): void {
|
||||
const nextTop = topDialog();
|
||||
if (nextTop) {
|
||||
const targetInsideNextDialog = entry.restoreTargets.find(
|
||||
(target) => target.isConnected !== false && nextTop.panel.contains(target)
|
||||
);
|
||||
if (targetInsideNextDialog && restoreDialogFocus(targetInsideNextDialog)) return;
|
||||
focusDialogOnOpen(nextTop.panel, currentActiveElement());
|
||||
return;
|
||||
}
|
||||
|
||||
entry.restoreTargets.some((target) => restoreDialogFocus(target));
|
||||
}
|
||||
|
||||
function unregisterDialog(id: DialogStackId): void {
|
||||
const index = openDialogs.findIndex((entry) => entry.id === id);
|
||||
if (index < 0) return;
|
||||
|
||||
const wasTopmost = index === openDialogs.length - 1;
|
||||
const [entry] = openDialogs.splice(index, 1);
|
||||
exposeAsTopmost(entry.panel);
|
||||
syncStackAccessibility();
|
||||
syncWindowKeyDownListener();
|
||||
|
||||
if (wasTopmost) focusAfterTopmostCloses(entry);
|
||||
}
|
||||
|
||||
export function nextDialogActivationOrder(): number {
|
||||
activationSequence += 1;
|
||||
return activationSequence;
|
||||
}
|
||||
|
||||
export function registerDialog(
|
||||
registration: DialogStackRegistration,
|
||||
activeElement: Element | null
|
||||
): () => void {
|
||||
const existingIndex = openDialogs.findIndex((entry) => entry.id === registration.id);
|
||||
if (existingIndex >= 0) openDialogs.splice(existingIndex, 1);
|
||||
|
||||
const entry: DialogStackEntry = {
|
||||
...registration,
|
||||
restoreTargets: appendUniqueTargets([], [registration.restoreFocus])
|
||||
};
|
||||
openDialogs.push(entry);
|
||||
openDialogs.sort((left, right) => left.activationOrder - right.activationOrder);
|
||||
|
||||
const entryIndex = openDialogs.indexOf(entry);
|
||||
const entryBelow = openDialogs[entryIndex - 1];
|
||||
if (entryBelow) entry.restoreTargets = appendUniqueTargets(entry.restoreTargets, entryBelow.restoreTargets);
|
||||
for (let index = entryIndex + 1; index < openDialogs.length; index += 1) {
|
||||
openDialogs[index].restoreTargets = appendUniqueTargets(openDialogs[index].restoreTargets, entry.restoreTargets);
|
||||
}
|
||||
|
||||
if (topDialog() === entry) focusDialogOnOpen(entry.panel, activeElement);
|
||||
syncStackAccessibility();
|
||||
syncWindowKeyDownListener();
|
||||
|
||||
return () => unregisterDialog(registration.id);
|
||||
}
|
||||
|
||||
export function handleTopDialogKeyDown(
|
||||
event: DialogKeyboardEvent,
|
||||
activeElement: Element | null
|
||||
): boolean {
|
||||
const entry = topDialog();
|
||||
if (!entry) return false;
|
||||
return handleDialogKeyDown(entry.panel, event, activeElement, entry.canClose(), entry.onClose);
|
||||
}
|
||||
|
||||
export function dialogIsTopmost(id: DialogStackId): boolean {
|
||||
const entry = topDialog();
|
||||
return !entry || entry.id === id;
|
||||
}
|
||||
|
||||
export function resetDialogStackForTests(): void {
|
||||
openDialogs.splice(0).forEach((entry) => exposeAsTopmost(entry.panel));
|
||||
activationSequence = 0;
|
||||
syncWindowKeyDownListener();
|
||||
}
|
||||
306
webui/tests/dialog-focus.test.tsx
Normal file
306
webui/tests/dialog-focus.test.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import {
|
||||
focusDialogOnOpen,
|
||||
handleDialogKeyDown,
|
||||
restoreDialogFocus,
|
||||
shouldCloseDialogOnBackdrop,
|
||||
trapDialogFocus
|
||||
} from "../src/components/dialogInteractions";
|
||||
import {
|
||||
handleTopDialogKeyDown,
|
||||
nextDialogActivationOrder,
|
||||
registerDialog,
|
||||
resetDialogStackForTests
|
||||
} from "../src/components/dialogStack";
|
||||
|
||||
type FocusableFixture = HTMLElement & { focusCount: number; attributes: Map<string, string> };
|
||||
|
||||
function focusableFixture({ connected = true, hidden = false }: { connected?: boolean; hidden?: boolean } = {}): FocusableFixture {
|
||||
const attributes = new Map<string, string>();
|
||||
if (hidden) attributes.set("hidden", "");
|
||||
const fixture = {
|
||||
attributes,
|
||||
focusCount: 0,
|
||||
tabIndex: 0,
|
||||
isConnected: connected,
|
||||
getAttribute: (name: string) => attributes.get(name) ?? null,
|
||||
hasAttribute: (name: string) => attributes.has(name),
|
||||
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||
removeAttribute: (name: string) => attributes.delete(name),
|
||||
closest: () => attributes.has("hidden") || attributes.has("inert") || attributes.get("aria-hidden") === "true" ? fixture : null,
|
||||
focus() {
|
||||
fixture.focusCount += 1;
|
||||
}
|
||||
};
|
||||
return fixture as unknown as FocusableFixture;
|
||||
}
|
||||
|
||||
function panelFixture(elements: FocusableFixture[], autofocus?: FocusableFixture) {
|
||||
const panel = focusableFixture();
|
||||
panel.tabIndex = -1;
|
||||
Object.assign(panel, {
|
||||
contains: (element: Element | null) => element === panel || elements.includes(element as FocusableFixture),
|
||||
querySelectorAll: () => elements,
|
||||
querySelector: (selector: string) => selector === "[autofocus]" ? (autofocus ?? null) : null
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
function keyboardEvent(key: string, shiftKey = false) {
|
||||
const fixture = {
|
||||
key,
|
||||
shiftKey,
|
||||
prevented: 0,
|
||||
preventDefault() {
|
||||
fixture.prevented += 1;
|
||||
}
|
||||
};
|
||||
return fixture;
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<Dialog open title="Dialog title" onClose={() => undefined}>
|
||||
<button type="button">First action</button>
|
||||
</Dialog>
|
||||
);
|
||||
assert(markup.includes('role="dialog"'), "dialog semantics are preserved");
|
||||
assert(markup.includes('aria-modal="true"'), "modal semantics are preserved");
|
||||
assert(markup.includes('data-dialog-stack-state="topmost"'), "dialog exposes its initial stack state");
|
||||
assert(markup.includes('tabindex="-1"'), "dialog panel can receive fallback focus");
|
||||
|
||||
const first = focusableFixture();
|
||||
const requested = focusableFixture();
|
||||
const entryPanel = panelFixture([first, requested], requested);
|
||||
assertEqual(focusDialogOnOpen(entryPanel, null), requested, "autofocus target receives initial focus");
|
||||
assertEqual(requested.focusCount, 1, "autofocus target is focused once");
|
||||
|
||||
const defaultEntryPanel = panelFixture([first]);
|
||||
assertEqual(focusDialogOnOpen(defaultEntryPanel, null), first, "first available control receives initial focus");
|
||||
assertEqual(first.focusCount, 1, "first available control is focused once");
|
||||
|
||||
const retained = focusableFixture();
|
||||
const retainedPanel = panelFixture([retained]);
|
||||
assertEqual(focusDialogOnOpen(retainedPanel, retained), retained, "existing focus inside the dialog is retained");
|
||||
assertEqual(retained.focusCount, 0, "retained focus is not moved redundantly");
|
||||
|
||||
const emptyPanel = panelFixture([]);
|
||||
assertEqual(focusDialogOnOpen(emptyPanel, null), emptyPanel, "panel receives focus when no control is available");
|
||||
assertEqual(emptyPanel.focusCount, 1, "fallback panel is focused");
|
||||
|
||||
const boundaryFirst = focusableFixture();
|
||||
const boundaryMiddle = focusableFixture();
|
||||
const boundaryLast = focusableFixture();
|
||||
const boundaryPanel = panelFixture([boundaryFirst, boundaryMiddle, boundaryLast]);
|
||||
|
||||
const forwardWrap = keyboardEvent("Tab");
|
||||
assert(trapDialogFocus(boundaryPanel, forwardWrap, boundaryLast), "forward Tab wraps at the last control");
|
||||
assertEqual(forwardWrap.prevented, 1, "forward wrap prevents focus from leaving the dialog");
|
||||
assertEqual(boundaryFirst.focusCount, 1, "forward wrap focuses the first control");
|
||||
|
||||
const backwardWrap = keyboardEvent("Tab", true);
|
||||
assert(trapDialogFocus(boundaryPanel, backwardWrap, boundaryFirst), "Shift+Tab wraps at the first control");
|
||||
assertEqual(backwardWrap.prevented, 1, "backward wrap prevents focus from leaving the dialog");
|
||||
assertEqual(boundaryLast.focusCount, 1, "backward wrap focuses the last control");
|
||||
|
||||
const interiorTab = keyboardEvent("Tab");
|
||||
assert(!trapDialogFocus(boundaryPanel, interiorTab, boundaryMiddle), "interior Tab keeps native focus order");
|
||||
assertEqual(interiorTab.prevented, 0, "interior Tab is not prevented");
|
||||
|
||||
const outsideTab = keyboardEvent("Tab");
|
||||
assert(trapDialogFocus(boundaryPanel, outsideTab, focusableFixture()), "focus cannot enter the modal from outside its boundary");
|
||||
assertEqual(boundaryFirst.focusCount, 2, "outside focus is redirected to the first control");
|
||||
|
||||
let closeCount = 0;
|
||||
const escape = keyboardEvent("Escape");
|
||||
assert(handleDialogKeyDown(boundaryPanel, escape, boundaryFirst, true, () => { closeCount += 1; }), "Escape closes a closable dialog");
|
||||
assertEqual(closeCount, 1, "Escape invokes onClose once");
|
||||
assertEqual(escape.prevented, 0, "Escape preserves the existing default-event behavior");
|
||||
|
||||
const blockedEscape = keyboardEvent("Escape");
|
||||
assert(!handleDialogKeyDown(boundaryPanel, blockedEscape, boundaryFirst, false, () => { closeCount += 1; }), "Escape is ignored while closing is disabled");
|
||||
assertEqual(closeCount, 1, "disabled Escape does not invoke onClose");
|
||||
|
||||
const opener = focusableFixture();
|
||||
assert(restoreDialogFocus(opener), "connected opener focus is restored");
|
||||
assertEqual(opener.focusCount, 1, "opener receives restored focus once");
|
||||
assert(!restoreDialogFocus(focusableFixture({ connected: false })), "removed opener is not focused");
|
||||
|
||||
const backdrop = {} as EventTarget;
|
||||
const child = {} as EventTarget;
|
||||
assert(shouldCloseDialogOnBackdrop(backdrop, backdrop, true, true), "direct backdrop press closes a closable dialog");
|
||||
assert(!shouldCloseDialogOnBackdrop(child, backdrop, true, true), "presses inside the panel do not close the dialog");
|
||||
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, false, true), "backdrop closing can be disabled");
|
||||
assert(!shouldCloseDialogOnBackdrop(backdrop, backdrop, true, false), "backdrop press respects closeDisabled");
|
||||
|
||||
resetDialogStackForTests();
|
||||
const outerOpener = focusableFixture();
|
||||
const parentOpener = focusableFixture();
|
||||
const parentLast = focusableFixture();
|
||||
const parentPanel = panelFixture([parentOpener, parentLast]);
|
||||
const childFirst = focusableFixture();
|
||||
const childLast = focusableFixture();
|
||||
const childPanel = panelFixture([childFirst, childLast]);
|
||||
const parentId = Symbol("parent-dialog");
|
||||
const childId = Symbol("child-dialog");
|
||||
const parentOrder = nextDialogActivationOrder();
|
||||
const childOrder = nextDialogActivationOrder();
|
||||
let parentCloseCount = 0;
|
||||
let childCloseCount = 0;
|
||||
let childCanClose = false;
|
||||
|
||||
// React may commit a nested child's effect before its parent effect. Activation
|
||||
// order is allocated during render so the child must remain topmost either way.
|
||||
const unregisterChild = registerDialog({
|
||||
id: childId,
|
||||
activationOrder: childOrder,
|
||||
panel: childPanel,
|
||||
restoreFocus: parentOpener,
|
||||
canClose: () => childCanClose,
|
||||
onClose: () => { childCloseCount += 1; }
|
||||
}, parentOpener);
|
||||
const unregisterParent = registerDialog({
|
||||
id: parentId,
|
||||
activationOrder: parentOrder,
|
||||
panel: parentPanel,
|
||||
restoreFocus: outerOpener,
|
||||
canClose: () => true,
|
||||
onClose: () => { parentCloseCount += 1; }
|
||||
}, childFirst);
|
||||
|
||||
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "underlying", "parent dialog is marked as underlying");
|
||||
assert(parentPanel.hasAttribute("inert"), "parent dialog is inert while a child is open");
|
||||
assertEqual(parentPanel.getAttribute("aria-hidden"), "true", "parent dialog is hidden from assistive technology");
|
||||
assertEqual(parentPanel.getAttribute("aria-modal"), null, "underlying dialog no longer claims modal semantics");
|
||||
assertEqual(childPanel.getAttribute("data-dialog-stack-state"), "topmost", "child dialog is marked as topmost");
|
||||
assertEqual(childPanel.getAttribute("aria-modal"), "true", "child dialog owns modal semantics");
|
||||
|
||||
const stackedTab = keyboardEvent("Tab");
|
||||
const parentFocusBeforeTab = parentOpener.focusCount;
|
||||
assert(handleTopDialogKeyDown(stackedTab, childLast), "topmost dialog traps Tab");
|
||||
assertEqual(stackedTab.prevented, 1, "stacked Tab wrapping is prevented once");
|
||||
assertEqual(parentOpener.focusCount, parentFocusBeforeTab, "underlying dialog does not process Tab");
|
||||
|
||||
const disabledStackedEscape = keyboardEvent("Escape");
|
||||
assert(!handleTopDialogKeyDown(disabledStackedEscape, childFirst), "disabled child ignores Escape without falling through");
|
||||
assertEqual(childCloseCount, 0, "disabled child remains open");
|
||||
assertEqual(parentCloseCount, 0, "disabled child Escape does not close the parent");
|
||||
|
||||
childCanClose = true;
|
||||
const stackedEscape = keyboardEvent("Escape");
|
||||
assert(handleTopDialogKeyDown(stackedEscape, childFirst), "topmost dialog handles Escape");
|
||||
assertEqual(childCloseCount, 1, "one Escape closes the child once");
|
||||
assertEqual(parentCloseCount, 0, "one Escape does not close the parent");
|
||||
|
||||
const parentFocusBeforeChildClose = parentOpener.focusCount;
|
||||
unregisterChild();
|
||||
assertEqual(parentOpener.focusCount, parentFocusBeforeChildClose + 1, "closing the child restores focus inside the parent");
|
||||
assert(!parentPanel.hasAttribute("inert"), "parent dialog becomes interactive after the child closes");
|
||||
assertEqual(parentPanel.getAttribute("aria-hidden"), null, "parent dialog returns to the accessibility tree");
|
||||
assertEqual(parentPanel.getAttribute("aria-modal"), "true", "parent dialog regains modal semantics");
|
||||
assertEqual(parentPanel.getAttribute("data-dialog-stack-state"), "topmost", "parent dialog becomes topmost");
|
||||
|
||||
const parentEscape = keyboardEvent("Escape");
|
||||
assert(handleTopDialogKeyDown(parentEscape, parentOpener), "parent handles Escape after child cleanup");
|
||||
assertEqual(parentCloseCount, 1, "parent closes only on its own Escape handling");
|
||||
unregisterParent();
|
||||
assertEqual(outerOpener.focusCount, 1, "closing the final dialog restores the outer opener");
|
||||
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), outerOpener), "keyboard handling stops when the stack is empty");
|
||||
|
||||
resetDialogStackForTests();
|
||||
const forcedOuterOpener = focusableFixture();
|
||||
const removedParentOpener = focusableFixture();
|
||||
const removedParentPanel = panelFixture([removedParentOpener]);
|
||||
const forcedChildControl = focusableFixture();
|
||||
const forcedChildPanel = panelFixture([forcedChildControl]);
|
||||
const unregisterForcedParent = registerDialog({
|
||||
id: Symbol("forced-parent-dialog"),
|
||||
activationOrder: nextDialogActivationOrder(),
|
||||
panel: removedParentPanel,
|
||||
restoreFocus: forcedOuterOpener,
|
||||
canClose: () => true
|
||||
}, forcedOuterOpener);
|
||||
const unregisterForcedChild = registerDialog({
|
||||
id: Symbol("forced-child-dialog"),
|
||||
activationOrder: nextDialogActivationOrder(),
|
||||
panel: forcedChildPanel,
|
||||
restoreFocus: removedParentOpener,
|
||||
canClose: () => true
|
||||
}, removedParentOpener);
|
||||
unregisterForcedParent();
|
||||
Object.assign(removedParentOpener, { isConnected: false });
|
||||
unregisterForcedChild();
|
||||
assertEqual(forcedOuterOpener.focusCount, 1, "removing a dialog tree restores the nearest connected outer opener");
|
||||
|
||||
resetDialogStackForTests();
|
||||
const strictModeOpener = focusableFixture();
|
||||
const strictModeControl = focusableFixture();
|
||||
const strictModePanel = panelFixture([strictModeControl]);
|
||||
const strictModeRegistration = {
|
||||
id: Symbol("strict-mode-dialog"),
|
||||
activationOrder: nextDialogActivationOrder(),
|
||||
panel: strictModePanel,
|
||||
restoreFocus: strictModeOpener,
|
||||
canClose: () => true
|
||||
};
|
||||
const unregisterStrictModeFirstPass = registerDialog(strictModeRegistration, strictModeOpener);
|
||||
unregisterStrictModeFirstPass();
|
||||
const unregisterStrictModeReplay = registerDialog(strictModeRegistration, strictModeOpener);
|
||||
assertEqual(strictModeControl.focusCount, 2, "StrictMode effect replay returns focus to the dialog");
|
||||
assertEqual(strictModeOpener.focusCount, 1, "StrictMode cleanup restores the opener before replay");
|
||||
unregisterStrictModeReplay();
|
||||
assertEqual(strictModeOpener.focusCount, 2, "StrictMode replay cleanup restores focus without leaving a stack entry");
|
||||
assert(!handleTopDialogKeyDown(keyboardEvent("Escape"), strictModeOpener), "StrictMode replay leaves no stale topmost dialog");
|
||||
|
||||
resetDialogStackForTests();
|
||||
let keydownListenerAdds = 0;
|
||||
let keydownListenerRemovals = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener(type: string) {
|
||||
if (type === "keydown") keydownListenerAdds += 1;
|
||||
},
|
||||
removeEventListener(type: string) {
|
||||
if (type === "keydown") keydownListenerRemovals += 1;
|
||||
},
|
||||
getComputedStyle() {
|
||||
return { display: "block", visibility: "visible" };
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
const listenerOuter = focusableFixture();
|
||||
const listenerParentPanel = panelFixture([focusableFixture()]);
|
||||
const listenerChildPanel = panelFixture([focusableFixture()]);
|
||||
const unregisterListenerParent = registerDialog({
|
||||
id: Symbol("listener-parent"),
|
||||
activationOrder: nextDialogActivationOrder(),
|
||||
panel: listenerParentPanel,
|
||||
restoreFocus: listenerOuter,
|
||||
canClose: () => true
|
||||
}, listenerOuter);
|
||||
const unregisterListenerChild = registerDialog({
|
||||
id: Symbol("listener-child"),
|
||||
activationOrder: nextDialogActivationOrder(),
|
||||
panel: listenerChildPanel,
|
||||
restoreFocus: listenerParentPanel,
|
||||
canClose: () => true
|
||||
}, listenerParentPanel);
|
||||
assertEqual(keydownListenerAdds, 1, "one window keyboard listener serves the whole dialog stack");
|
||||
unregisterListenerChild();
|
||||
assertEqual(keydownListenerRemovals, 0, "keyboard listener remains while a parent dialog is open");
|
||||
unregisterListenerParent();
|
||||
assertEqual(keydownListenerRemovals, 1, "keyboard listener is removed after the final dialog closes");
|
||||
} finally {
|
||||
resetDialogStackForTests();
|
||||
Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"include": [
|
||||
"tests/data-grid-actions.test.tsx",
|
||||
"tests/dialog-focus.test.tsx",
|
||||
"tests/mail-components.test.tsx",
|
||||
"src/components/CredentialPanel.tsx",
|
||||
"src/components/email/EmailAddressInput.tsx",
|
||||
@@ -27,6 +28,9 @@
|
||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||
"src/components/Button.tsx",
|
||||
"src/components/DismissibleAlert.tsx",
|
||||
"src/components/Dialog.tsx",
|
||||
"src/components/dialogInteractions.ts",
|
||||
"src/components/dialogStack.ts",
|
||||
"src/components/FormField.tsx",
|
||||
"src/components/ToggleSwitch.tsx"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user