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

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