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