Files
michaelschiemer/resources/js/modules/livecomponent/ModalManager.js
2025-11-24 21:28:25 +01:00

271 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Modal Manager for LiveComponents
*
* Manages modal stack using native <dialog> element with showModal().
*
* Native <dialog> Features:
* - Automatic backdrop/overlay (::backdrop pseudo-element)
* - Automatic focus management (focus trapping)
* - Automatic ESC handling (cancel event)
* - Native modal semantics (blocks background interaction)
* - Better accessibility (ARIA attributes)
*
* Browser Support:
* - Chrome 37+ (2014)
* - Firefox 98+ (2022)
* - Safari 15.4+ (2022)
*/
export class ModalManager {
constructor() {
this.modalStack = []; // Array of { componentId, dialogElement, zIndex, closeOnEscape, closeOnBackdrop }
this.baseZIndex = 1050; // Bootstrap modal default
this.zIndexIncrement = 10;
}
/**
* Open modal using native <dialog> element
* @param {string} componentId - Component ID
* @param {Object} options - Modal options
* @returns {Object} Modal instance with dialog element
*/
open(componentId, options = {}) {
const {
title = '',
content = '',
size = 'medium',
buttons = [],
closeOnBackdrop = true,
closeOnEscape = true
} = options;
// Calculate z-index for stacking
const zIndex = this.baseZIndex + (this.modalStack.length * this.zIndexIncrement);
// Create native <dialog> element
const dialog = document.createElement('dialog');
dialog.className = `livecomponent-modal livecomponent-modal--${size}`;
dialog.id = `modal-${componentId}`;
dialog.style.zIndex = zIndex.toString();
// Set content
dialog.innerHTML = this.createModalContent(title, content, buttons);
// Add to DOM
document.body.appendChild(dialog);
// Setup event handlers BEFORE showing modal
this.setupDialogHandlers(dialog, componentId, closeOnBackdrop, closeOnEscape, buttons);
// Show modal using native showModal() - this blocks background interaction
dialog.showModal();
// Track in stack
const stackItem = {
componentId,
dialogElement: dialog,
zIndex,
closeOnEscape,
closeOnBackdrop
};
this.modalStack.push(stackItem);
// Focus management (native <dialog> handles focus trapping, but we set initial focus)
this.focusModal(dialog);
// Return wrapper object for compatibility
return {
dialog: dialog,
close: () => this.close(componentId),
isOpen: () => dialog.open
};
}
/**
* Setup event handlers for dialog element
* @param {HTMLDialogElement} dialog - Dialog element
* @param {string} componentId - Component ID
* @param {boolean} closeOnBackdrop - Whether to close on backdrop click
* @param {boolean} closeOnEscape - Whether to close on ESC key
* @param {Array} buttons - Button configurations
*/
setupDialogHandlers(dialog, componentId, closeOnBackdrop, closeOnEscape, buttons) {
// Native cancel event (triggered by ESC key)
dialog.addEventListener('cancel', (e) => {
e.preventDefault();
if (closeOnEscape) {
this.close(componentId);
}
});
// Backdrop click handling
// Note: Native <dialog> doesn't have built-in backdrop click handling
// We need to check if click target is the dialog element itself (backdrop)
if (closeOnBackdrop) {
dialog.addEventListener('click', (e) => {
// If click target is the dialog element itself (not its children), it's a backdrop click
if (e.target === dialog) {
this.close(componentId);
}
});
}
// Close button
const closeBtn = dialog.querySelector('.modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this.close(componentId);
});
}
// Button actions
dialog.querySelectorAll('[data-modal-action]').forEach((btn, index) => {
btn.addEventListener('click', () => {
const buttonConfig = buttons[index];
if (buttonConfig && buttonConfig.action) {
// Trigger action if specified
if (buttonConfig.action === 'close') {
this.close(componentId);
}
}
});
});
}
/**
* Close modal and remove from stack
* @param {string} componentId - Component ID
*/
close(componentId) {
const index = this.modalStack.findIndex(item => item.componentId === componentId);
if (index === -1) {
return;
}
const { dialogElement } = this.modalStack[index];
// Close dialog using native close() method
if (dialogElement && dialogElement.open) {
dialogElement.close();
}
// Remove from DOM
if (dialogElement.parentNode) {
dialogElement.parentNode.removeChild(dialogElement);
}
// Remove from stack
this.modalStack.splice(index, 1);
// Focus previous modal or return focus to body
if (this.modalStack.length > 0) {
const previousModal = this.modalStack[this.modalStack.length - 1];
this.focusModal(previousModal.dialogElement);
} else {
// Return focus to previously focused element or body
// Native <dialog> handles this automatically, but we ensure it
const activeElement = document.activeElement;
if (activeElement && activeElement !== document.body) {
activeElement.blur();
}
document.body.focus();
}
}
/**
* Close all modals
*/
closeAll() {
while (this.modalStack.length > 0) {
const { componentId } = this.modalStack[this.modalStack.length - 1];
this.close(componentId);
}
}
/**
* Get topmost modal
* @returns {Object|null}
*/
getTopModal() {
if (this.modalStack.length === 0) {
return null;
}
return this.modalStack[this.modalStack.length - 1];
}
/**
* Check if modal is open
* @param {string} componentId - Component ID
* @returns {boolean}
*/
isOpen(componentId) {
return this.modalStack.some(item => item.componentId === componentId);
}
/**
* Focus modal element
* Native <dialog> handles focus trapping automatically, but we set initial focus
* @param {HTMLDialogElement} dialog - Dialog element
*/
focusModal(dialog) {
if (!dialog) {
return;
}
// Find first focusable element
const focusableSelectors = [
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
const focusableElement = dialog.querySelector(focusableSelectors);
if (focusableElement) {
// Small delay to ensure dialog is fully rendered
requestAnimationFrame(() => {
focusableElement.focus();
});
} else {
// Fallback: focus dialog itself
requestAnimationFrame(() => {
dialog.focus();
});
}
}
/**
* Create modal content HTML
* @param {string} title - Modal title
* @param {string} content - Modal content
* @param {Array} buttons - Button configurations
* @returns {string}
*/
createModalContent(title, content, buttons) {
const buttonsHtml = buttons.map((btn, index) => {
const btnClass = btn.class || btn.className || 'btn-secondary';
return `<button class="btn ${btnClass}" data-modal-action="${index}">${btn.text || 'Button'}</button>`;
}).join('');
return `
<div class="livecomponent-modal-content">
${title ? `<div class="modal-header">
<h3 class="modal-title">${title}</h3>
<button class="modal-close" aria-label="Close">×</button>
</div>` : ''}
<div class="modal-body">${content}</div>
${buttons.length > 0 ? `<div class="modal-footer">${buttonsHtml}</div>` : ''}
</div>
`;
}
/**
* Cleanup all modals
*/
destroy() {
this.closeAll();
}
}