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

316 lines
9.7 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.
/**
* Drawer Manager for LiveComponents
*
* Manages drawer/sidebar components with:
* - Slide-in animation (CSS Transitions)
* - Overlay backdrop management
* - ESC key handling
* - Focus management (focus trapping)
* - Stack management for multiple drawers
*/
export class DrawerManager {
constructor() {
this.drawerStack = []; // Array of { componentId, drawerElement, overlayElement, zIndex, closeOnEscape, closeOnOverlay }
this.baseZIndex = 1040;
this.zIndexIncrement = 10;
this.escapeHandler = null;
}
/**
* Open drawer and add to stack
* @param {string} componentId - Component ID
* @param {Object} options - Drawer options
* @returns {Object} Drawer instance
*/
open(componentId, options = {}) {
const {
title = '',
content = '',
position = 'left',
width = '400px',
showOverlay = true,
closeOnOverlay = true,
closeOnEscape = true,
animation = 'slide'
} = options;
// Calculate z-index
const zIndex = this.baseZIndex + (this.drawerStack.length * this.zIndexIncrement);
// Create overlay if needed
let overlay = null;
if (showOverlay) {
overlay = this.createOverlay(zIndex, closeOnOverlay, componentId);
document.body.appendChild(overlay);
}
// Create drawer element
const drawer = this.createDrawerElement(componentId, title, content, position, width, animation, zIndex + 1);
document.body.appendChild(drawer);
// Track in stack
const stackItem = {
componentId,
drawerElement: drawer,
overlayElement: overlay,
zIndex: zIndex + 1,
closeOnEscape,
closeOnOverlay
};
this.drawerStack.push(stackItem);
// Setup event handlers
this.setupDrawerHandlers(drawer, overlay, componentId, closeOnOverlay, closeOnEscape);
// Update ESC handler
this.updateEscapeHandler();
// Animate in
requestAnimationFrame(() => {
if (overlay) {
overlay.classList.add('drawer-overlay--show');
}
drawer.classList.add('drawer--show');
});
// Focus management
this.focusDrawer(drawer);
return {
drawer: drawer,
overlay: overlay,
close: () => this.close(componentId),
isOpen: () => drawer.classList.contains('drawer--show')
};
}
/**
* Close drawer and remove from stack
* @param {string} componentId - Component ID
*/
close(componentId) {
const index = this.drawerStack.findIndex(item => item.componentId === componentId);
if (index === -1) {
return;
}
const { drawerElement, overlayElement } = this.drawerStack[index];
// Animate out
drawerElement.classList.remove('drawer--show');
if (overlayElement) {
overlayElement.classList.remove('drawer-overlay--show');
}
// Remove from DOM after animation
setTimeout(() => {
if (drawerElement.parentNode) {
drawerElement.parentNode.removeChild(drawerElement);
}
if (overlayElement && overlayElement.parentNode) {
overlayElement.parentNode.removeChild(overlayElement);
}
}, 300); // Match CSS transition duration
// Remove from stack
this.drawerStack.splice(index, 1);
// Update ESC handler
this.updateEscapeHandler();
// Focus previous drawer or return focus to body
if (this.drawerStack.length > 0) {
const previousDrawer = this.drawerStack[this.drawerStack.length - 1];
this.focusDrawer(previousDrawer.drawerElement);
} else {
// Return focus to previously focused element
const activeElement = document.activeElement;
if (activeElement && activeElement !== document.body) {
activeElement.blur();
}
document.body.focus();
}
}
/**
* Close all drawers
*/
closeAll() {
while (this.drawerStack.length > 0) {
const { componentId } = this.drawerStack[this.drawerStack.length - 1];
this.close(componentId);
}
}
/**
* Get topmost drawer
* @returns {Object|null}
*/
getTopDrawer() {
if (this.drawerStack.length === 0) {
return null;
}
return this.drawerStack[this.drawerStack.length - 1];
}
/**
* Check if drawer is open
* @param {string} componentId - Component ID
* @returns {boolean}
*/
isOpen(componentId) {
return this.drawerStack.some(item => item.componentId === componentId);
}
/**
* Create overlay element
* @param {number} zIndex - Z-index value
* @param {boolean} closeOnOverlay - Whether to close on overlay click
* @param {string} componentId - Component ID
* @returns {HTMLElement}
*/
createOverlay(zIndex, closeOnOverlay, componentId) {
const overlay = document.createElement('div');
overlay.className = 'drawer-overlay';
overlay.style.zIndex = zIndex.toString();
overlay.setAttribute('role', 'presentation');
overlay.setAttribute('aria-hidden', 'true');
if (closeOnOverlay) {
overlay.addEventListener('click', () => {
this.close(componentId);
});
}
return overlay;
}
/**
* Create drawer element
* @param {string} componentId - Component ID
* @param {string} title - Drawer title
* @param {string} content - Drawer content
* @param {string} position - Position (left/right)
* @param {string} width - Drawer width
* @param {string} animation - Animation type
* @param {number} zIndex - Z-index value
* @returns {HTMLElement}
*/
createDrawerElement(componentId, title, content, position, width, animation, zIndex) {
const drawer = document.createElement('div');
drawer.className = `drawer drawer--${position} drawer-animation-${animation}`;
drawer.id = `drawer-${componentId}`;
drawer.style.zIndex = zIndex.toString();
drawer.style.width = width;
drawer.setAttribute('role', 'dialog');
drawer.setAttribute('aria-modal', 'true');
drawer.setAttribute('aria-labelledby', title ? `drawer-title-${componentId}` : '');
drawer.innerHTML = `
<div class="drawer-content">
${title ? `
<div class="drawer-header">
<h3 class="drawer-title" id="drawer-title-${componentId}">${title}</h3>
<button class="drawer-close" aria-label="Close drawer">×</button>
</div>
` : ''}
<div class="drawer-body">${content}</div>
</div>
`;
return drawer;
}
/**
* Setup event handlers for drawer
* @param {HTMLElement} drawer - Drawer element
* @param {HTMLElement|null} overlay - Overlay element
* @param {string} componentId - Component ID
* @param {boolean} closeOnOverlay - Whether to close on overlay click
* @param {boolean} closeOnEscape - Whether to close on ESC key
*/
setupDrawerHandlers(drawer, overlay, componentId, closeOnOverlay, closeOnEscape) {
// Close button
const closeBtn = drawer.querySelector('.drawer-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
this.close(componentId);
});
}
// Overlay click (already handled in createOverlay, but ensure it's set)
if (overlay && closeOnOverlay) {
// Already set in createOverlay
}
}
/**
* Update ESC key handler
*/
updateEscapeHandler() {
// Remove existing handler
if (this.escapeHandler) {
document.removeEventListener('keydown', this.escapeHandler);
this.escapeHandler = null;
}
// Add handler if there are drawers
if (this.drawerStack.length > 0) {
const topDrawer = this.getTopDrawer();
if (topDrawer && topDrawer.closeOnEscape) {
this.escapeHandler = (e) => {
if (e.key === 'Escape' && !e.defaultPrevented) {
e.preventDefault();
this.close(topDrawer.componentId);
}
};
document.addEventListener('keydown', this.escapeHandler);
}
}
}
/**
* Focus drawer element
* @param {HTMLElement} drawer - Drawer element
*/
focusDrawer(drawer) {
if (!drawer) {
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 = drawer.querySelector(focusableSelectors);
if (focusableElement) {
requestAnimationFrame(() => {
focusableElement.focus();
});
} else {
requestAnimationFrame(() => {
drawer.focus();
});
}
}
/**
* Cleanup all drawers
*/
destroy() {
this.closeAll();
if (this.escapeHandler) {
document.removeEventListener('keydown', this.escapeHandler);
this.escapeHandler = null;
}
}
}