class Popup { constructor() { if (Popup.instance) { return Popup.instance; } Popup.instance = this; this.a11y = window.jvbA11y; this.popups = new Map(); this.activePopup = null; this.clickHandler = this.handleClick.bind(this); this.keyHandler = this.handleEscape.bind(this); document.addEventListener('click', this.clickHandler); } /** * Register a new popup * @param {Object} config - { toggle, popup, name, onOpen, onClose, onEscape } * @returns {Object} API for this popup */ registerPopup(config = {}) { const popup = config.popup; const toggle = config.toggle; if (!popup || !toggle) { console.warn('Popup registration requires both popup and toggle elements'); return null; } const id = toggle.id || '.' + toggle.className.split(' ').join('.'); const entry = { id, toggle, popup, name: config.name || 'Popup', onOpen: config.onOpen || (() => {}), onClose: config.onClose || (() => {}), onEscape: config.onEscape || (() => {}), isOpen: false }; this.popups.set(id, entry); return this.getPopupAPI(id); } getPopupAPI(id) { return { openPopup: (message) => this.openPopup(id, message), closePopup: (message) => this.closePopup(id, message), togglePopup: () => this.togglePopup(id), isOpen: () => this.popups.get(id)?.isOpen || false, destroy: () => this.popups.delete(id) }; } /** * Single document-level click handler for all popups */ handleClick(e) { // Check if click is on any registered toggle for (const [id, entry] of this.popups) { if (e.target === entry.toggle || e.target.closest(id)) { this.togglePopup(id); return; } } // If a popup is active, close it on outside click if (this.activePopup) { const entry = this.popups.get(this.activePopup); if (entry && !entry.popup.contains(e.target)) { this.closePopup(this.activePopup); } else if (entry && window.targetCheck(e, '.close')) { this.closePopup(this.activePopup); } } } handleEscape(e) { if (e.key === 'Escape' && this.activePopup) { const entry = this.popups.get(this.activePopup); this.closePopup(this.activePopup, `Closed ${entry.name} with escape key`); entry.onEscape(); } } togglePopup(id) { const entry = this.popups.get(id); if (!entry) return; if (entry.isOpen) { this.closePopup(id); } else { this.openPopup(id); } } openPopup(id, message) { const entry = this.popups.get(id); if (!entry) return; // Close any currently active popup first if (this.activePopup && this.activePopup !== id) { this.closePopup(this.activePopup); } entry.isOpen = true; this.activePopup = id; entry.popup.classList.add('expanded'); entry.toggle.classList.add('expanded'); entry.toggle.title = `Hide ${entry.name}`; entry.toggle.ariaExpanded = true; entry.toggle.querySelector('span').textContent = `Close ${entry.name}`; this.a11y.announce(message || `Opened ${entry.name}`); entry.onOpen(); document.addEventListener('keydown', this.keyHandler); } closePopup(id, message) { const entry = this.popups.get(id); if (!entry) return; entry.isOpen = false; if (this.activePopup === id) { this.activePopup = null; } entry.popup.classList.remove('expanded'); entry.toggle.classList.remove('expanded'); entry.toggle.title = `Show ${entry.name}`; entry.toggle.ariaExpanded = false; entry.toggle.querySelector('span').textContent = ''; this.a11y.announce(message || `Closed ${entry.name}`); entry.onClose(); // Only remove keydown if no popups are active if (!this.activePopup) { document.removeEventListener('keydown', this.keyHandler); } } destroy() { document.removeEventListener('click', this.clickHandler); document.removeEventListener('keydown', this.keyHandler); this.popups.clear(); this.activePopup = null; } } document.addEventListener('DOMContentLoaded', function () { window.jvbPopup = new Popup(); });