NODEDC_DESIGN_GUIDELINE/packages/ui-dom/src/modal.ts

118 lines
3.3 KiB
TypeScript

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