/** * Action Loading Manager for LiveComponents * * Provides skeleton loading states during component actions with: * - Automatic skeleton overlay during actions * - Configurable skeleton templates per component * - Smooth transitions * - Fragment-aware loading */ export class ActionLoadingManager { constructor() { this.loadingStates = new Map(); // componentId → loading state this.skeletonTemplates = new Map(); // componentId → template this.config = { showDelay: 150, // ms before showing skeleton transitionDuration: 200, // ms for transitions preserveContent: true, // Keep content visible under skeleton opacity: 0.6 // Skeleton overlay opacity }; } /** * Show loading state for component * * @param {string} componentId - Component ID * @param {HTMLElement} element - Component element * @param {Object} options - Loading options */ showLoading(componentId, element, options = {}) { // Check if already loading if (this.loadingStates.has(componentId)) { return; // Already showing loading state } const showDelay = options.showDelay ?? this.config.showDelay; const fragments = options.fragments || null; // Delay showing skeleton (for fast responses) const timeoutId = setTimeout(() => { this.createSkeletonOverlay(componentId, element, fragments, options); }, showDelay); // Store loading state this.loadingStates.set(componentId, { timeoutId, element, fragments, options }); } /** * Create skeleton overlay * * @param {string} componentId - Component ID * @param {HTMLElement} element - Component element * @param {Array|null} fragments - Fragment names to show skeleton for * @param {Object} options - Options */ createSkeletonOverlay(componentId, element, fragments, options) { // Get skeleton template const template = this.getSkeletonTemplate(componentId, element, fragments); // Create overlay container const overlay = document.createElement('div'); overlay.className = 'livecomponent-loading-overlay'; overlay.setAttribute('data-component-id', componentId); overlay.setAttribute('aria-busy', 'true'); overlay.setAttribute('aria-label', 'Loading...'); overlay.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, ${this.config.opacity}); z-index: 10; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity ${this.config.transitionDuration}ms ease; pointer-events: none; `; // Add skeleton content overlay.innerHTML = template; // Ensure element has relative positioning const originalPosition = element.style.position; if (getComputedStyle(element).position === 'static') { element.style.position = 'relative'; } // Append overlay element.appendChild(overlay); // Animate in requestAnimationFrame(() => { overlay.style.opacity = '1'; }); // Update loading state const state = this.loadingStates.get(componentId); if (state) { state.overlay = overlay; state.originalPosition = originalPosition; } } /** * Get skeleton template for component * * @param {string} componentId - Component ID * @param {HTMLElement} element - Component element * @param {Array|null} fragments - Fragment names * @returns {string} Skeleton HTML */ getSkeletonTemplate(componentId, element, fragments) { // Check for custom template const customTemplate = this.skeletonTemplates.get(componentId); if (customTemplate) { return typeof customTemplate === 'function' ? customTemplate(element, fragments) : customTemplate; } // If fragments specified, create fragment-specific skeletons if (fragments && fragments.length > 0) { return this.createFragmentSkeletons(fragments); } // Default skeleton template return this.createDefaultSkeleton(element); } /** * Create default skeleton template * * @param {HTMLElement} element - Component element * @returns {string} Skeleton HTML */ createDefaultSkeleton(element) { const height = element.offsetHeight || 200; const width = element.offsetWidth || '100%'; return `
`; } /** * Create fragment-specific skeletons * * @param {Array} fragments - Fragment names * @returns {string} Skeleton HTML */ createFragmentSkeletons(fragments) { return fragments.map(fragmentName => `
`).join(''); } /** * Hide loading state * * @param {string} componentId - Component ID */ hideLoading(componentId) { const state = this.loadingStates.get(componentId); if (!state) { return; } // Clear timeout if not yet shown if (state.timeoutId) { clearTimeout(state.timeoutId); } // Remove overlay if exists if (state.overlay) { // Animate out state.overlay.style.opacity = '0'; setTimeout(() => { if (state.overlay && state.overlay.parentNode) { state.overlay.parentNode.removeChild(state.overlay); } }, this.config.transitionDuration); } // Restore original position if (state.originalPosition !== undefined) { state.element.style.position = state.originalPosition; } // Remove from map this.loadingStates.delete(componentId); } /** * Register custom skeleton template for component * * @param {string} componentId - Component ID * @param {string|Function} template - Template HTML or function that returns HTML */ registerTemplate(componentId, template) { this.skeletonTemplates.set(componentId, template); } /** * Unregister skeleton template * * @param {string} componentId - Component ID */ unregisterTemplate(componentId) { this.skeletonTemplates.delete(componentId); } /** * Check if component is loading * * @param {string} componentId - Component ID * @returns {boolean} True if loading */ isLoading(componentId) { return this.loadingStates.has(componentId); } /** * Update configuration * * @param {Object} newConfig - New configuration */ updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; } } // Create singleton instance export const actionLoadingManager = new ActionLoadingManager();