Manage nested dialogs through a central stack

This commit is contained in:
2026-07-21 03:18:08 +02:00
parent 183bf7aef0
commit 1839693575
7 changed files with 653 additions and 8 deletions

View File

@@ -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"

View 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.");

View File

@@ -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}
>

View 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;
}

View 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();
}

View 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");
}

View File

@@ -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"
]