/** * @sc4rfurryx/proteusjs/popover * HTML Popover API wrapper with robust focus/inert handling * * @version 2.0.0 * @author sc4rfurry * @license MIT */ export interface PopoverOptions { type?: 'menu' | 'dialog' | 'tooltip'; trapFocus?: boolean; restoreFocus?: boolean; closeOnEscape?: boolean; onOpen?: () => void; onClose?: () => void; } export interface PopoverController { open(): void; close(): void; toggle(): void; destroy(): void; } /** * Unified API for menus, tooltips, and dialogs using the native Popover API * with robust focus/inert handling */ export function attach( trigger: Element | string, panel: Element | string, opts: PopoverOptions = {} ): PopoverController { const triggerEl = typeof trigger === 'string' ? document.querySelector(trigger) : trigger; const panelEl = typeof panel === 'string' ? document.querySelector(panel) : panel; if (!triggerEl || !panelEl) { throw new Error('Both trigger and panel elements must exist'); } const { type = 'menu', trapFocus = type === 'dialog', restoreFocus = true, closeOnEscape = true, onOpen, onClose } = opts; let isOpen = false; let previousFocus: Element | null = null; let focusTrap: FocusTrap | null = null; // Check for native Popover API support const hasPopoverAPI = 'popover' in HTMLElement.prototype; // Set up ARIA attributes const setupAria = () => { const panelId = panelEl.id || `popover-${Math.random().toString(36).substr(2, 9)}`; panelEl.id = panelId; triggerEl.setAttribute('aria-expanded', 'false'); triggerEl.setAttribute('aria-controls', panelId); if (type === 'menu') { triggerEl.setAttribute('aria-haspopup', 'menu'); panelEl.setAttribute('role', 'menu'); } else if (type === 'dialog') { triggerEl.setAttribute('aria-haspopup', 'dialog'); panelEl.setAttribute('role', 'dialog'); panelEl.setAttribute('aria-modal', 'true'); } else if (type === 'tooltip') { triggerEl.setAttribute('aria-describedby', panelId); panelEl.setAttribute('role', 'tooltip'); } }; // Set up native popover if supported const setupNativePopover = () => { if (hasPopoverAPI) { (panelEl as any).popover = type === 'dialog' ? 'manual' : 'auto'; triggerEl.setAttribute('popovertarget', panelEl.id); } }; // Focus trap implementation class FocusTrap { private focusableElements: Element[] = []; constructor(private container: Element) { this.updateFocusableElements(); } private updateFocusableElements() { const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; this.focusableElements = Array.from(this.container.querySelectorAll(selector)); } activate() { this.updateFocusableElements(); if (this.focusableElements.length > 0) { (this.focusableElements[0] as HTMLElement).focus(); } document.addEventListener('keydown', this.handleKeyDown); } deactivate() { document.removeEventListener('keydown', this.handleKeyDown); } private handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const firstElement = this.focusableElements[0] as HTMLElement; const lastElement = this.focusableElements[this.focusableElements.length - 1] as HTMLElement; if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; } const open = () => { if (isOpen) return; if (restoreFocus) { previousFocus = document.activeElement; } if (hasPopoverAPI) { (panelEl as any).showPopover(); } else { (panelEl as HTMLElement).style.display = 'block'; panelEl.setAttribute('data-popover-open', 'true'); } triggerEl.setAttribute('aria-expanded', 'true'); isOpen = true; if (trapFocus) { focusTrap = new FocusTrap(panelEl); focusTrap.activate(); } if (onOpen) { onOpen(); } }; const close = () => { if (!isOpen) return; if (hasPopoverAPI) { (panelEl as any).hidePopover(); } else { (panelEl as HTMLElement).style.display = 'none'; panelEl.removeAttribute('data-popover-open'); } triggerEl.setAttribute('aria-expanded', 'false'); isOpen = false; if (focusTrap) { focusTrap.deactivate(); focusTrap = null; } if (restoreFocus && previousFocus) { (previousFocus as HTMLElement).focus(); previousFocus = null; } if (onClose) { onClose(); } }; const toggle = () => { if (isOpen) { close(); } else { open(); } }; const handleKeyDown = (e: KeyboardEvent) => { if (closeOnEscape && e.key === 'Escape' && isOpen) { e.preventDefault(); close(); } }; const handleClick = (e: Event) => { e.preventDefault(); toggle(); }; const destroy = () => { triggerEl.removeEventListener('click', handleClick); document.removeEventListener('keydown', handleKeyDown); if (focusTrap) { focusTrap.deactivate(); } if (isOpen) { close(); } }; // Initialize setupAria(); setupNativePopover(); triggerEl.addEventListener('click', handleClick); document.addEventListener('keydown', handleKeyDown); return { open, close, toggle, destroy }; } // Export default object for convenience export default { attach };