| | |
| | | class Popup { |
| | | constructor(config = {}) { |
| | | this.config = { |
| | | toggle: document.querySelector('[data-toggles]'), |
| | | popup: document.querySelector('.jvb-pop'), |
| | | name: 'Popup', |
| | | onOpen: () =>{}, |
| | | onClose: () =>{}, |
| | | onEscape: () =>{}, |
| | | ... config |
| | | }; |
| | | constructor() { |
| | | if (Popup.instance) { |
| | | return Popup.instance; |
| | | } |
| | | Popup.instance = this; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.toggleID = this.config.toggle.id === '' ? '.'+this.config.toggle.className.split(' ').join('.') : this.config.toggle.id; |
| | | this.popups = new Map(); |
| | | this.activePopup = null; |
| | | |
| | | this.initListeners(); |
| | | } |
| | | |
| | | initListeners() |
| | | { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | this.keyHandler = this.handleEscape.bind(this); |
| | | |
| | | document.addEventListener('click', this.handleToggle.bind(this)); |
| | | } |
| | | |
| | | handleToggle(e) { |
| | | if (e.target === this.config.toggle || e.target.closest(this.toggleID)){ |
| | | this.togglePopup(); |
| | | } |
| | | } |
| | | handleClick(e) { |
| | | if (!this.config.popup.contains(e.target) |
| | | && e.target !== this.config.toggle) { |
| | | this.closePopup(); |
| | | } else if (window.targetCheck(e, '.close')) { |
| | | this.closePopup(); |
| | | } |
| | | } |
| | | |
| | | togglePopup() { |
| | | if (!this.config.popup.classList.contains('expanded')) { |
| | | this.openPopup(); |
| | | } else { |
| | | this.closePopup(); |
| | | } |
| | | } |
| | | openPopup(message = `Opened ${this.config.name}`) { |
| | | this.config.popup.classList.add('expanded'); |
| | | this.config.toggle.classList.add('expanded'); |
| | | this.config.toggle.title = `Hide ${this.config.name}`; |
| | | this.config.toggle.ariaExpanded = true; |
| | | this.config.toggle.querySelector('span').textContent = `Close ${this.config.name}`; |
| | | this.a11y.announce(message); |
| | | this.config.onOpen(); |
| | | document.addEventListener('keydown', this.keyHandler); |
| | | document.addEventListener('click', this.clickHandler); |
| | | } |
| | | closePopup(message = `Closed ${this.config.name}`) { |
| | | this.config.popup.classList.remove('expanded'); |
| | | this.config.toggle.classList.remove('expanded'); |
| | | this.config.toggle.title = `Show ${this.config.name}`; |
| | | this.config.toggle.ariaExpanded = false; |
| | | |
| | | this.config.toggle.querySelector('span').textContent = ''; |
| | | this.a11y.announce(message); |
| | | this.config.onClose(); |
| | | document.removeEventListener('keydown', this.keyHandler); |
| | | document.removeEventListener('click', this.clickHandler); |
| | | } |
| | | handleEscape(e) { |
| | | if (e.key === 'Escape') { |
| | | this.closePopup(`Closed ${this.config.name} with escape key`); |
| | | this.config.onEscape(); |
| | | /** |
| | | * 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; |
| | | } |
| | | } |
| | | |
| | | window.jvbPopup = Popup; |
| | | document.addEventListener('DOMContentLoaded', function () { |
| | | window.jvbPopup = new Popup(); |
| | | }); |