/** * 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 = `