import { FocusTrap, getFocusable } from '../shared/focus'; import { uid } from '../shared/dom'; import { MODAL_CLOSE_EVENT, MODAL_OPEN_EVENT, type ModalOptions } from './modal.types'; const SELECTORS = { content: '[data-c42-modal-content]', overlay: '[data-c42-modal-overlay]', close: '[data-c42-modal-close]', } as const; /** * Headless modal/dialog controller. Manages open state, focus trapping, scroll * locking, Escape/overlay dismissal, ARIA wiring and focus restoration. * * Markup: * ```html *
*
*
* * ... *
*
* ``` */ export class Modal { private readonly root: HTMLElement; private readonly content: HTMLElement; private readonly overlay: HTMLElement | null; private readonly closeOnOverlayClick: boolean; private readonly closeOnEscape: boolean; private readonly lockScroll: boolean; private readonly focusTrap: FocusTrap; private open = false; private previouslyFocused: HTMLElement | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: ModalOptions = {}) { const content = root.querySelector(SELECTORS.content); if (!content) { throw new Error('[42/modal] Needs a content element.'); } this.root = root; this.content = content; this.overlay = root.querySelector(SELECTORS.overlay); this.closeOnOverlayClick = options.closeOnOverlayClick ?? true; this.closeOnEscape = options.closeOnEscape ?? true; this.lockScroll = options.lockScroll ?? true; this.focusTrap = new FocusTrap(content); this.init(); if (options.defaultOpen) { this.openModal(); } } private init(): void { if (!this.content.hasAttribute('role')) { this.content.setAttribute('role', 'dialog'); } this.content.setAttribute('aria-modal', 'true'); if (!this.content.id) { this.content.id = uid('modal'); } this.root.setAttribute('hidden', ''); this.root.dataset.state = 'closed'; if (this.overlay && this.closeOnOverlayClick) { const onOverlayClick = (): void => this.close(); this.overlay.addEventListener('click', onOverlayClick); this.cleanups.push(() => this.overlay?.removeEventListener('click', onOverlayClick)); } const closeButtons = Array.from(this.root.querySelectorAll(SELECTORS.close)); closeButtons.forEach((button) => { const onClick = (): void => this.close(); button.addEventListener('click', onClick); this.cleanups.push(() => button.removeEventListener('click', onClick)); }); } private readonly onKeydown = (event: KeyboardEvent): void => { if (event.key === 'Escape' && this.closeOnEscape && this.open) { event.preventDefault(); this.close(); } }; openModal(): void { if (this.open) { return; } this.open = true; this.previouslyFocused = document.activeElement as HTMLElement | null; this.root.removeAttribute('hidden'); this.root.dataset.state = 'open'; if (this.lockScroll) { document.body.style.overflow = 'hidden'; } this.focusTrap.activate(); document.addEventListener('keydown', this.onKeydown, true); const [firstFocusable] = getFocusable(this.content); (firstFocusable ?? this.content).focus(); this.root.dispatchEvent(new CustomEvent(MODAL_OPEN_EVENT, { bubbles: true })); } close(): void { if (!this.open) { return; } this.open = false; this.root.setAttribute('hidden', ''); this.root.dataset.state = 'closed'; if (this.lockScroll) { document.body.style.overflow = ''; } this.focusTrap.deactivate(); document.removeEventListener('keydown', this.onKeydown, true); this.previouslyFocused?.focus?.(); this.previouslyFocused = null; this.root.dispatchEvent(new CustomEvent(MODAL_CLOSE_EVENT, { bubbles: true })); } toggle(): void { if (this.open) { this.close(); } else { this.openModal(); } } get isOpen(): boolean { return this.open; } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.close(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }