UNPKG

@sc4rfurryx/proteusjs

Version:

The Modern Web Development Framework for Accessible, Responsive, and High-Performance Applications. Intelligent container queries, fluid typography, WCAG compliance, and performance optimization.

192 lines (190 loc) 6.09 kB
/*! * ProteusJS v2.0.0 * Shape-shifting responsive design that adapts like the sea god himself * (c) 2025 sc4rfurry * Released under the MIT License */ /** * @sc4rfurryx/proteusjs/popover * HTML Popover API wrapper with robust focus/inert handling * * @version 2.0.0 * @author sc4rfurry * @license MIT */ /** * Unified API for menus, tooltips, and dialogs using the native Popover API * with robust focus/inert handling */ function attach(trigger, panel, opts = {}) { 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 = null; let focusTrap = 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.popover = type === 'dialog' ? 'manual' : 'auto'; triggerEl.setAttribute('popovertarget', panelEl.id); } }; // Focus trap implementation class FocusTrap { constructor(container) { this.container = container; this.focusableElements = []; this.handleKeyDown = (e) => { if (e.key !== 'Tab') return; const firstElement = this.focusableElements[0]; const lastElement = this.focusableElements[this.focusableElements.length - 1]; if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; this.updateFocusableElements(); } 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].focus(); } document.addEventListener('keydown', this.handleKeyDown); } deactivate() { document.removeEventListener('keydown', this.handleKeyDown); } } const open = () => { if (isOpen) return; if (restoreFocus) { previousFocus = document.activeElement; } if (hasPopoverAPI) { panelEl.showPopover(); } else { panelEl.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.hidePopover(); } else { panelEl.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.focus(); previousFocus = null; } if (onClose) { onClose(); } }; const toggle = () => { if (isOpen) { close(); } else { open(); } }; const handleKeyDown = (e) => { if (closeOnEscape && e.key === 'Escape' && isOpen) { e.preventDefault(); close(); } }; const handleClick = (e) => { 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 var index = { attach }; export { attach, index as default }; //# sourceMappingURL=popover.esm.js.map