@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
JavaScript
/*!
* 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