Some checks failed
Deploy Application / deploy (push) Has been cancelled
271 lines
8.6 KiB
JavaScript
271 lines
8.6 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
}
|