159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
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();
|
|
}
|