/** * Loading State Manager for LiveComponents * * Manages different loading indicators (skeleton, spinner, progress) during actions. * Integrates with OptimisticStateManager and ActionLoadingManager. */ export class LoadingStateManager { constructor(actionLoadingManager, optimisticStateManager) { this.actionLoadingManager = actionLoadingManager; this.optimisticStateManager = optimisticStateManager; this.loadingConfigs = new Map(); // componentId → loading config this.config = { defaultType: 'skeleton', // skeleton, spinner, progress, none showDelay: 150, hideDelay: 0 }; } /** * Configure loading state for component * * @param {string} componentId - Component ID * @param {Object} config - Loading configuration */ configure(componentId, config) { this.loadingConfigs.set(componentId, { type: config.type || this.config.defaultType, showDelay: config.showDelay ?? this.config.showDelay, hideDelay: config.hideDelay ?? this.config.hideDelay, template: config.template || null, ...config }); } /** * Show loading state for action * * @param {string} componentId - Component ID * @param {HTMLElement} element - Component element * @param {Object} options - Loading options */ showLoading(componentId, element, options = {}) { const config = this.loadingConfigs.get(componentId) || { type: this.config.defaultType, showDelay: this.config.showDelay }; const loadingType = options.type || config.type; // Skip if type is 'none' or optimistic UI is enabled if (loadingType === 'none') { return; } // Check if optimistic UI is active (no loading needed) const pendingOps = this.optimisticStateManager.getPendingOperations(componentId); if (pendingOps.length > 0 && options.optimistic !== false) { // Optimistic UI is active - skip loading indicator return; } switch (loadingType) { case 'skeleton': this.actionLoadingManager.showLoading(componentId, element, { ...options, showDelay: config.showDelay }); break; case 'spinner': this.showSpinner(componentId, element, config); break; case 'progress': this.showProgress(componentId, element, config); break; default: // Fallback to skeleton this.actionLoadingManager.showLoading(componentId, element, options); } } /** * Hide loading state * * @param {string} componentId - Component ID */ hideLoading(componentId) { // Hide skeleton loading this.actionLoadingManager.hideLoading(componentId); // Hide spinner this.hideSpinner(componentId); // Hide progress this.hideProgress(componentId); } /** * Show spinner loading indicator * * @param {string} componentId - Component ID * @param {HTMLElement} element - Component element * @param {Object} config - Configuration */ showSpinner(componentId, element, config) { // Remove existing spinner this.hideSpinner(componentId); const spinner = document.createElement('div'); spinner.className = 'livecomponent-loading-spinner'; spinner.setAttribute('data-component-id', componentId); spinner.setAttribute('aria-busy', 'true'); spinner.setAttribute('aria-label', 'Loading...'); spinner.innerHTML = `
Loading... `; spinner.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.8); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; z-index: 10; opacity: 0; transition: opacity ${this.config.showDelay}ms ease; `; // Ensure element has relative positioning if (getComputedStyle(element).position === 'static') { element.style.position = 'relative'; } element.appendChild(spinner); // Animate in requestAnimationFrame(() => { spinner.style.opacity = '1'; }); // Store reference const loadingConfig = this.loadingConfigs.get(componentId) || {}; loadingConfig.spinner = spinner; this.loadingConfigs.set(componentId, loadingConfig); } /** * Hide spinner * * @param {string} componentId - Component ID */ hideSpinner(componentId) { const config = this.loadingConfigs.get(componentId); if (!config || !config.spinner) { return; } const spinner = config.spinner; spinner.style.opacity = '0'; setTimeout(() => { if (spinner.parentNode) { spinner.parentNode.removeChild(spinner); } delete config.spinner; }, 200); } /** * Show progress bar * * @param {string} componentId - Component ID * @param {HTMLElement} element - Component element * @param {Object} config - Configuration */ showProgress(componentId, element, config) { // Remove existing progress this.hideProgress(componentId); const progress = document.createElement('div'); progress.className = 'livecomponent-loading-progress'; progress.setAttribute('data-component-id', componentId); progress.setAttribute('aria-busy', 'true'); progress.innerHTML = `
`; progress.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; height: 3px; background: #e0e0e0; z-index: 10; opacity: 0; transition: opacity ${this.config.showDelay}ms ease; `; const progressBar = progress.querySelector('.progress-bar'); progressBar.style.cssText = ` height: 100%; background: #2196F3; transition: width 0.3s ease; `; // Ensure element has relative positioning if (getComputedStyle(element).position === 'static') { element.style.position = 'relative'; } element.appendChild(progress); // Animate in requestAnimationFrame(() => { progress.style.opacity = '1'; // Simulate progress (can be updated via updateProgress) this.updateProgress(componentId, 30); }); // Store reference const loadingConfig = this.loadingConfigs.get(componentId) || {}; loadingConfig.progress = progress; this.loadingConfigs.set(componentId, loadingConfig); } /** * Update progress bar * * @param {string} componentId - Component ID * @param {number} percent - Progress percentage (0-100) */ updateProgress(componentId, percent) { const config = this.loadingConfigs.get(componentId); if (!config || !config.progress) { return; } const progressBar = config.progress.querySelector('.progress-bar'); if (progressBar) { progressBar.style.width = `${Math.min(100, Math.max(0, percent))}%`; } } /** * Hide progress bar * * @param {string} componentId - Component ID */ hideProgress(componentId) { const config = this.loadingConfigs.get(componentId); if (!config || !config.progress) { return; } const progress = config.progress; // Complete progress bar this.updateProgress(componentId, 100); setTimeout(() => { progress.style.opacity = '0'; setTimeout(() => { if (progress.parentNode) { progress.parentNode.removeChild(progress); } delete config.progress; }, 200); }, 300); } /** * Get loading configuration for component * * @param {string} componentId - Component ID * @returns {Object} Loading configuration */ getConfig(componentId) { return this.loadingConfigs.get(componentId) || { type: this.config.defaultType, showDelay: this.config.showDelay }; } /** * Clear configuration for component * * @param {string} componentId - Component ID */ clearConfig(componentId) { this.hideLoading(componentId); this.loadingConfigs.delete(componentId); } }