mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
feat: add ModalManager class for unified modal system
Amit Haridas
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user