mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +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