import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift } from '@floating-ui/dom';
import { uid } from '../shared/dom';
import { FocusTrap, getFocusable } from '../shared/focus';
import {
POPOVER_CLOSE_EVENT,
POPOVER_OPEN_EVENT,
type Placement,
type PopoverOptions,
} from './popover.types';
const SELECTORS = {
trigger: '[data-c42-popover-trigger]',
content: '[data-c42-popover-content]',
} as const;
/**
* Headless popover controller. Unlike a tooltip, the content is interactive
* (links, forms, buttons): it toggles on click, manages focus, closes on
* outside-click / Escape, and positions with floating-ui. Applies no visual
* styles — only ARIA wiring and `data-state`.
*
* Markup:
* ```html
*
* ```
*/
export class Popover {
private readonly root: HTMLElement;
private readonly trigger: HTMLElement;
private readonly content: HTMLElement;
private readonly placement: Placement;
private readonly offset: number;
private readonly autoFocus: boolean;
private readonly trapFocus: boolean;
private readonly closeOnOutsideClick: boolean;
private readonly trap: FocusTrap | null;
private open = false;
private stopAutoUpdate: (() => void) | null = null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: PopoverOptions = {}) {
const trigger = root.querySelector(SELECTORS.trigger);
const content = root.querySelector(SELECTORS.content);
if (!trigger || !content) {
throw new Error('[42/popover] Needs a trigger and a content element.');
}
this.root = root;
this.trigger = trigger;
this.content = content;
this.placement = options.placement ?? 'bottom';
this.offset = options.offset ?? 8;
this.autoFocus = options.autoFocus ?? true;
this.trapFocus = options.trapFocus ?? false;
this.closeOnOutsideClick = options.closeOnOutsideClick ?? true;
this.trap = this.trapFocus ? new FocusTrap(content) : null;
this.init();
}
private init(): void {
const contentId = this.content.id || uid('popover');
this.content.id = contentId;
this.content.setAttribute('role', 'dialog');
if (!this.content.hasAttribute('tabindex')) {
this.content.setAttribute('tabindex', '-1');
}
this.trigger.setAttribute('aria-haspopup', 'dialog');
this.trigger.setAttribute('aria-controls', contentId);
this.trigger.setAttribute('aria-expanded', 'false');
this.content.setAttribute('hidden', '');
this.content.dataset.state = 'closed';
this.trigger.dataset.state = 'closed';
const onTriggerClick = (): void => this.toggle();
const onKeydown = (event: Event): void => this.onKeydown(event as KeyboardEvent);
const onOutsidePointer = (event: Event): void => this.onOutsidePointer(event);
this.trigger.addEventListener('click', onTriggerClick);
this.root.addEventListener('keydown', onKeydown);
document.addEventListener('pointerdown', onOutsidePointer, true);
this.cleanups.push(
() => this.trigger.removeEventListener('click', onTriggerClick),
() => this.root.removeEventListener('keydown', onKeydown),
() => document.removeEventListener('pointerdown', onOutsidePointer, true),
);
}
private onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && this.open) {
event.preventDefault();
this.close();
this.trigger.focus();
}
}
private onOutsidePointer(event: Event): void {
if (!this.open || !this.closeOnOutsideClick) {
return;
}
if (!this.root.contains(event.target as Node)) {
this.close();
}
}
toggle(): void {
if (this.open) {
this.close();
} else {
this.openPopover();
}
}
openPopover(): void {
if (this.open) {
return;
}
this.open = true;
this.content.removeAttribute('hidden');
this.content.dataset.state = 'open';
this.trigger.dataset.state = 'open';
this.trigger.setAttribute('aria-expanded', 'true');
this.stopAutoUpdate = autoUpdate(this.trigger, this.content, () => void this.position());
this.trap?.activate();
if (this.autoFocus) {
const focusable = getFocusable(this.content);
(focusable[0] ?? this.content).focus();
}
this.root.dispatchEvent(new CustomEvent(POPOVER_OPEN_EVENT, { bubbles: true }));
}
close(): void {
if (!this.open) {
return;
}
this.open = false;
this.content.setAttribute('hidden', '');
this.content.dataset.state = 'closed';
this.trigger.dataset.state = 'closed';
this.trigger.setAttribute('aria-expanded', 'false');
this.trap?.deactivate();
this.stopAutoUpdate?.();
this.stopAutoUpdate = null;
this.root.dispatchEvent(new CustomEvent(POPOVER_CLOSE_EVENT, { bubbles: true }));
}
private async position(): Promise {
const { x, y } = await computePosition(this.trigger, this.content, {
placement: this.placement,
middleware: [offsetMiddleware(this.offset), flip(), shift({ padding: 5 })],
});
Object.assign(this.content.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' });
}
get isOpen(): boolean {
return this.open;
}
/** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */
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 = [];
}
}