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();
|
});
|