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

337 lines
10 KiB
JavaScript

/**
* 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
* @param {HTMLElement} actionElement - Optional action element (for per-action indicators)
*/
showLoading(componentId, element, options = {}, actionElement = null) {
const config = this.loadingConfigs.get(componentId) || {
type: this.config.defaultType,
showDelay: this.config.showDelay
};
// Check for per-action indicator (data-lc-indicator)
if (actionElement?.dataset.lcIndicator) {
const indicatorSelector = actionElement.dataset.lcIndicator;
const indicators = document.querySelectorAll(indicatorSelector);
indicators.forEach(indicator => {
if (indicator instanceof HTMLElement) {
indicator.style.display = '';
indicator.setAttribute('aria-busy', 'true');
indicator.classList.add('lc-indicator-active');
}
});
// Don't show default loading if custom indicator is used
return;
}
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
* @param {HTMLElement} actionElement - Optional action element (for per-action indicators)
*/
hideLoading(componentId, actionElement = null) {
// Hide per-action indicators if specified
if (actionElement?.dataset.lcIndicator) {
const indicatorSelector = actionElement.dataset.lcIndicator;
const indicators = document.querySelectorAll(indicatorSelector);
indicators.forEach(indicator => {
if (indicator instanceof HTMLElement) {
indicator.removeAttribute('aria-busy');
indicator.classList.remove('lc-indicator-active');
}
});
}
// 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 = `
<div class="spinner"></div>
<span class="spinner-text">Loading...</span>
`;
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 = `
<div class="progress-bar" style="width: 0%;"></div>
`;
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);
}
}