feat: add ModalManager class for unified modal system

Amit Haridas
This commit is contained in:
2026-03-24 16:20:03 +05:30
parent 763bea2a87
commit 253608e17f
+218
View File
@@ -0,0 +1,218 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.0.0
*/
export class ModalManager {
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
static #openModals = [];
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Setup close triggers
this.#setupCloseTriggers();
}
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach(el => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
}
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector))
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
}
open() {
if (this.#isOpen) return;
// Store last focused element
this.#lastFocusedElement = document.activeElement;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
}
close() {
if (!this.#isOpen) return;
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
// Hide modal
this.#modal.classList.remove('open');
this.#isOpen = false;
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
l => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
}
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
}
isOpen() {
return this.#isOpen;
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
}
}
// Export for use in renderer
window.ModalManager = ModalManager;