fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
270
resources/js/modules/livecomponent/ModalManager.js
Normal file
270
resources/js/modules/livecomponent/ModalManager.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user