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