118 lines
3.3 KiB
TypeScript
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));
|
|
},
|
|
};
|
|
}
|
|
|