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 *
* *
* A link * *
*
* ``` */ 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 = []; } }