From 18396935751b719030d0ef7955298daa66020eaf Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 03:18:08 +0200 Subject: [PATCH] Manage nested dialogs through a central stack --- webui/package.json | 1 + webui/scripts/test-dialog-focus-structure.mjs | 22 ++ webui/src/components/Dialog.tsx | 64 +++- webui/src/components/dialogInteractions.ts | 106 ++++++ webui/src/components/dialogStack.ts | 158 +++++++++ webui/tests/dialog-focus.test.tsx | 306 ++++++++++++++++++ webui/tsconfig.component-tests.json | 4 + 7 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 webui/scripts/test-dialog-focus-structure.mjs create mode 100644 webui/src/components/dialogInteractions.ts create mode 100644 webui/src/components/dialogStack.ts create mode 100644 webui/tests/dialog-focus.test.tsx diff --git a/webui/package.json b/webui/package.json index 131f2a2..90bb0aa 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" diff --git a/webui/scripts/test-dialog-focus-structure.mjs b/webui/scripts/test-dialog-focus-structure.mjs new file mode 100644 index 0000000..745f490 --- /dev/null +++ b/webui/scripts/test-dialog-focus-structure.mjs @@ -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."); diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx index a38e99f..a377e2d 100644 --- a/webui/src/components/Dialog.tsx +++ b/webui/src/components/Dialog.tsx @@ -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(null); + const stackIdRef = useRef(Symbol("govoplan-dialog")); + const activationOrderRef = useRef(null); + const canCloseRef = useRef(canClose); + const onCloseRef = useRef(onClose); + const restoreFocusRef = useRef( + 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?.(); }} >
diff --git a/webui/src/components/dialogInteractions.ts b/webui/src/components/dialogInteractions.ts new file mode 100644 index 0000000..4c3d596 --- /dev/null +++ b/webui/src/components/dialogInteractions.ts @@ -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; + +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(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("[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; +} diff --git a/webui/src/components/dialogStack.ts b/webui/src/components/dialogStack.ts new file mode 100644 index 0000000..6028bab --- /dev/null +++ b/webui/src/components/dialogStack.ts @@ -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[] { + 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(); +} diff --git a/webui/tests/dialog-focus.test.tsx b/webui/tests/dialog-focus.test.tsx new file mode 100644 index 0000000..169bf5a --- /dev/null +++ b/webui/tests/dialog-focus.test.tsx @@ -0,0 +1,306 @@ +function assert(condition: unknown, message = "assertion failed"): void { + if (!condition) throw new Error(message); +} + +function assertEqual(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 }; + +function focusableFixture({ connected = true, hidden = false }: { connected?: boolean; hidden?: boolean } = {}): FocusableFixture { + const attributes = new Map(); + 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( + undefined}> + + +); +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"); +} diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index ab29e7b..d2a9983 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -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" ]