import { FocusTrap, getFocusable } from '../shared/focus';
import { uid } from '../shared/dom';
import {
DRAWER_CLOSE_EVENT,
DRAWER_OPEN_EVENT,
type DrawerOptions,
type DrawerSide,
} from './drawer.types';
const SELECTORS = {
content: '[data-c42-drawer-content]',
overlay: '[data-c42-drawer-overlay]',
close: '[data-c42-drawer-close]',
} as const;
/**
* Headless drawer controller. A slide-in panel anchored to an edge. Manages
* open state, focus trapping, scroll locking, Escape/overlay dismissal, ARIA
* wiring and focus restoration. Mirrors the modal behaviour but reflects its
* anchored edge on `data-side`.
*
* Markup:
* ```html
*
* ```
*/
export class Drawer {
private readonly root: HTMLElement;
private readonly content: HTMLElement;
private readonly overlay: HTMLElement | null;
private readonly side: DrawerSide;
private readonly closeOnOverlayClick: boolean;
private readonly closeOnEscape: boolean;
private readonly lockScroll: boolean;
private readonly focusTrap: FocusTrap;
private opened = false;
private previouslyFocused: HTMLElement | null = null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: DrawerOptions = {}) {
const content = root.querySelector(SELECTORS.content);
if (!content) {
throw new Error('[42/drawer] Needs a content element.');
}
this.root = root;
this.content = content;
this.overlay = root.querySelector(SELECTORS.overlay);
this.side = options.side ?? 'right';
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.open();
}
}
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('drawer');
}
this.root.setAttribute('hidden', '');
this.root.dataset.state = 'closed';
this.root.dataset.side = this.side;
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.opened) {
event.preventDefault();
this.close();
}
};
open(): void {
if (this.opened) {
return;
}
this.opened = 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(DRAWER_OPEN_EVENT, { bubbles: true }));
}
close(): void {
if (!this.opened) {
return;
}
this.opened = 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(DRAWER_CLOSE_EVENT, { bubbles: true }));
}
toggle(): void {
if (this.opened) {
this.close();
} else {
this.open();
}
}
get isOpen(): boolean {
return this.opened;
}
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 = [];
}
}