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 = [];
}
}