const focusableSelector = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", ].join(","); export interface ModalControllerOptions { overlay: HTMLElement; dialog: HTMLElement; closeControls?: Iterable; closeOnBackdrop?: boolean; closeOnEscape?: boolean; lockBodyScroll?: boolean; onOpenChange?: (open: boolean) => void; } export interface ModalController { open: () => void; close: () => void; isOpen: () => boolean; destroy: () => void; } export function createModalController({ overlay, dialog, closeControls = [], closeOnBackdrop = true, closeOnEscape = true, lockBodyScroll = true, onOpenChange, }: ModalControllerOptions): ModalController { let openState = false; let previousFocus: HTMLElement | null = null; let previousOverflow = ""; const controls = Array.from(closeControls); overlay.classList.add("nodedc-overlay", "nodedc-ui-root"); dialog.classList.add("nodedc-window"); dialog.setAttribute("role", "dialog"); dialog.setAttribute("aria-modal", "true"); dialog.tabIndex = -1; overlay.hidden = true; const focusFirst = () => { const first = dialog.querySelector(focusableSelector); (first ?? dialog).focus(); }; const open = () => { if (openState) return; openState = true; previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; previousOverflow = document.body.style.overflow; if (lockBodyScroll) document.body.style.overflow = "hidden"; overlay.hidden = false; requestAnimationFrame(focusFirst); onOpenChange?.(true); }; const close = () => { if (!openState) return; openState = false; overlay.hidden = true; if (lockBodyScroll) document.body.style.overflow = previousOverflow; previousFocus?.focus(); onOpenChange?.(false); }; const handleOverlayPointerDown = (event: PointerEvent) => { if (closeOnBackdrop && event.target === overlay) close(); }; const handleKeyDown = (event: KeyboardEvent) => { if (!openState) return; if (event.key === "Escape" && closeOnEscape) { event.preventDefault(); close(); return; } if (event.key !== "Tab") return; const focusable = Array.from(dialog.querySelectorAll(focusableSelector)); if (focusable.length === 0) { event.preventDefault(); dialog.focus(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; overlay.addEventListener("pointerdown", handleOverlayPointerDown); document.addEventListener("keydown", handleKeyDown); controls.forEach((control) => control.addEventListener("click", close)); return { open, close, isOpen: () => openState, destroy: () => { close(); overlay.removeEventListener("pointerdown", handleOverlayPointerDown); document.removeEventListener("keydown", handleKeyDown); controls.forEach((control) => control.removeEventListener("click", close)); }, }; }