Complete 4.3.2: Loading States - comprehensive loading system implementation with indicators, progress bars, and loading hierarchies

This commit is contained in:
VinnyNC 2025-09-29 23:30:14 -04:00
parent 60a5cbb576
commit b67f14bb9b
4 changed files with 791 additions and 7 deletions

View file

@ -494,10 +494,17 @@ All utilities follow the established pattern of using CSS custom properties from
- **Mobile Responsive**: Full responsive design with mobile-specific positioning and touch-friendly interactions
- **Api Integration**: New Notifications API (show(), success(), error(), warning(), info()) with configuration options
#### 4.3.2: Loading States
- [ ] Implement loading indicators
- [ ] Add progress bars for long operations
- [ ] Create loading state hierarchies
#### 4.3.2: Loading States - COMPLETED 9/29/2025
- [x] Implement loading indicators
- [x] Add progress bars for long operations
- [x] Create loading state hierarchies
**Notes:** Comprehensive loading state management system implemented:
- **Loading State Manager**: New `LoadingStateManager` module with hierarchical loading state tracking, queue management, and async operation handling
- **Loading Indicators**: Button loading states, overlay spinners, and contextual loading components throughout the UI
- **Progress Bars**: Video loading progress, chat refresh progress, and stream quality checks with indeterminate and determinate progress bars
- **Loading State Hierarchies**: Parent-child loading relationships with automatic show/hide content, skeleton loading for dynamic content, and progressive loading patterns
- **UX Enhancements**: Smooth transitions between loading and loaded states, accessibility support with screen reader announcements, and mobile-optimized loading experiences
### Sub-task 4.4: Gesture and Touch Interactions

View file

@ -152,6 +152,7 @@
function sendMessage() {
const messageInput = document.getElementById('messageInput');
const nicknameInput = document.getElementById('nickname');
const sendButton = document.querySelector('.chat-input button') || document.querySelector('#messageInput').nextElementSibling;
const nicknameErrorElement = document.getElementById('nicknameError');
const messageErrorElement = document.getElementById('messageError');
@ -177,6 +178,9 @@
return;
}
// Show loading state on send button
const loadingContext = window.LoadingStates?.showButtonLoading(sendButton, 'Sending...');
// All validation passed - send message via API
API.sendMessage(nickname, message)
.then(data => {
@ -208,6 +212,12 @@
AppLogger.error('Error sending message:', error);
updateValidationUI('messageInput', messageErrorElement, false, 'Failed to send message. Please try again.');
UIControls.updateConnectionStatus(false);
})
.finally(() => {
// End loading state
if (loadingContext) {
loadingContext.end(true);
}
});
}

766
assets/js/loading-states.js Normal file
View file

@ -0,0 +1,766 @@
/**
* Loading States Manager
* Manages loading indicators, progress bars, and loading hierarchies throughout the UI
* Provides comprehensive loading state tracking for async operations
*/
(function() {
'use strict';
AppModules.require('notifications');
AppModules.register('loading-states');
// Loading state tracking
const loadingStates = new Map();
const loadingHierarchy = new Map(); // Parent-child relationships
const loadingQueue = [];
// Configuration
const config = {
maxConcurrentLoadings: 5,
defaultFadeDelay: 300,
skeletonTransitionDuration: 200,
progressUpdateInterval: 50
};
// Loading state manager class
class LoadingStateManager {
constructor() {
this.activeLoadings = new Set();
this.loadingElements = new WeakMap();
this.progressTrackers = new Map();
}
/**
* Start a loading operation
* @param {string} id - Unique loading identifier
* @param {Object} options - Loading options
* @returns {LoadingContext} Loading context for management
*/
start(id, options = {}) {
const context = new LoadingContext(id, options);
this.activeLoadings.add(id);
loadingStates.set(id, context);
context.start();
// Check queue if at capacity
if (this.activeLoadings.size >= config.maxConcurrentLoadings) {
this.processQueue();
}
this.log('Started loading:', id);
return context;
}
/**
* End a loading operation
* @param {string} id - Loading identifier
* @param {boolean} success - Whether loading was successful
*/
end(id, success = true) {
const context = loadingStates.get(id);
if (!context) return;
context.end(success);
this.activeLoadings.delete(id);
loadingStates.delete(id);
// Process next in queue
this.processQueue();
this.log('Ended loading:', id, success ? 'success' : 'failed');
}
/**
* Update loading progress
* @param {string} id - Loading identifier
* @param {number} progress - Progress (0-100)
*/
updateProgress(id, progress) {
const context = loadingStates.get(id);
if (context) {
context.updateProgress(progress);
}
}
/**
* Process loading queue
*/
processQueue() {
while (this.activeLoadings.size < config.maxConcurrentLoadings && loadingQueue.length > 0) {
const queuedItem = loadingQueue.shift();
queuedItem.resolve();
}
}
/**
* Add to loading hierarchy
* @param {string} parentId - Parent loading ID
* @param {string} childId - Child loading ID
*/
addToHierarchy(parentId, childId) {
if (!loadingHierarchy.has(parentId)) {
loadingHierarchy.set(parentId, new Set());
}
loadingHierarchy.get(parentId).add(childId);
}
/**
* Get loading context by ID
* @param {string} id - Loading identifier
* @returns {LoadingContext|null}
*/
get(id) {
return loadingStates.get(id) || null;
}
/**
* Get all active loading contexts
* @returns {Array<LoadingContext>}
*/
getAllActive() {
return Array.from(this.activeLoadings).map(id => loadingStates.get(id));
}
/**
* Check if any loading is active
* @returns {boolean}
*/
isAnyLoading() {
return this.activeLoadings.size > 0;
}
/**
* Check if specific loading is active
* @param {string} id - Loading identifier
* @returns {boolean}
*/
isLoading(id) {
return this.activeLoadings.has(id);
}
log(...args) {
if (window.AppConfig?.debug?.loadingStates) {
console.log('[LoadingStates]', ...args);
}
}
}
// Loading context class for individual loading operations
class LoadingContext {
constructor(id, options = {}) {
this.id = id;
this.options = {
type: options.type || 'overlay', // overlay, button, inline, skeleton
element: options.element || null,
text: options.text || 'Loading...',
showProgress: options.showProgress || false,
cancellable: options.cancellable || false,
timeout: options.timeout || 30000, // Default 30 seconds
hierarchy: options.hierarchy || null,
...options
};
this.startTime = null;
this.endTime = null;
this.progress = 0;
this.isActive = false;
this.timeoutId = null;
// Create loading element if needed
if (!this.options.element) {
this.options.element = this.createLoadingElement();
}
}
start() {
this.startTime = Date.now();
this.isActive = true;
// Show loading UI
this.show();
// Set timeout fail-safe
if (this.options.timeout > 0) {
this.timeoutId = setTimeout(() => {
if (this.isActive) {
this.end(false);
AppLogger.warn(`Loading operation ${this.id} timed out after ${this.options.timeout}ms`);
}
}, this.options.timeout);
}
// Add to hierarchy if specified
if (this.options.hierarchy) {
loadingStateManager.addToHierarchy(this.options.hierarchy, this.id);
}
}
end(success = true) {
if (!this.isActive) return;
this.endTime = Date.now();
this.isActive = false;
// Clear timeout
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
// Hide loading UI
this.hide(success);
// Log duration
const duration = this.endTime - this.startTime;
loadingStateManager.log(`Loading ${this.id} completed in ${duration}ms`);
}
updateProgress(progress) {
this.progress = Math.max(0, Math.min(100, progress));
this.updateProgressUI();
// Trigger progress event
this.trigger('progress', { progress: this.progress });
}
show() {
const element = this.options.element;
if (!element) return;
// Add loading class
element.classList.add('loading');
// Handle different loading types
switch (this.options.type) {
case 'button':
this.showButtonLoading(element);
break;
case 'skeleton':
this.showSkeletonLoading(element);
break;
case 'inline':
this.showInlineLoading(element);
break;
case 'overlay':
default:
this.showOverlayLoading(element);
break;
}
// Add to DOM if not already present
if (!element.parentElement && element !== document.body) {
element.style.display = 'block';
}
// Trigger show event
this.trigger('show');
}
hide(success = true) {
const element = this.options.element;
if (!element) return;
// Handle different loading types
switch (this.options.type) {
case 'button':
this.hideButtonLoading(element, success);
break;
case 'skeleton':
this.hideSkeletonLoading(element, success);
break;
case 'inline':
this.hideInlineLoading(element, success);
break;
case 'overlay':
default:
this.hideOverlayLoading(element, success);
break;
}
// Remove loading class after transition
setTimeout(() => {
element.classList.remove('loading');
}, config.defaultFadeDelay);
// Trigger hide event
this.trigger('hide', { success });
}
createLoadingElement() {
const element = document.createElement('div');
element.className = `loading-state loading-state--${this.options.type}`;
element.setAttribute('data-loading-id', this.id);
element.setAttribute('aria-live', 'polite');
element.setAttribute('aria-label', this.options.text);
switch (this.options.type) {
case 'button':
element.innerHTML = `
<span class="loading-spinner spinner-border spinner-border-sm"></span>
<span class="loading-text">${this.options.text}</span>
`;
break;
case 'skeleton':
element.innerHTML = this.createSkeletonHTML();
break;
case 'inline':
element.innerHTML = `
<div class="loading-inline">
<span class="loading-spinner"></span>
<span class="loading-text">${this.options.text}</span>
</div>
`;
break;
case 'overlay':
default:
element.innerHTML = `
<div class="loading-overlay">
<div class="loading-content">
<div class="loading-spinner"></div>
${this.options.showProgress ? '<div class="progress-bar"><div class="progress-fill"></div></div>' : ''}
<div class="loading-text">${this.options.text}</div>
</div>
</div>
`;
break;
}
return element;
}
createSkeletonHTML() {
// Create skeleton based on element content
return `
<div class="skeleton-container">
<div class="skeleton skeleton-card">
<div class="skeleton-header">
<div class="skeleton-circle"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-line--short"></div>
</div>
</div>
<div class="skeleton-body">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
`;
}
showButtonLoading(button) {
// Store original content
const originalHTML = button.innerHTML;
this.loadingElements.set(button, { originalHTML });
// Add loading spinner to button
const loadingContent = `
<span class="loading-spinner spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
`;
// Keep button text but add spinner
const textSpan = button.querySelector('.loading-text') || document.createElement('span');
textSpan.className = 'loading-text';
textSpan.textContent = this.options.text;
button.innerHTML = loadingContent + textSpan.outerHTML;
button.disabled = true;
button.setAttribute('aria-busy', 'true');
}
hideButtonLoading(button, success) {
const stored = this.loadingElements.get(button);
if (stored) {
// Restore original content or keep success state
setTimeout(() => {
button.innerHTML = stored.originalHTML;
button.disabled = false;
button.removeAttribute('aria-busy');
if (success) {
button.classList.add('btn-success-pulse');
setTimeout(() => button.classList.remove('btn-success-pulse'), 1000);
}
}, config.defaultFadeDelay);
}
}
showSkeletonLoading(element) {
// Add skeleton overlay
element.classList.add('skeleton-loading');
// Find content areas and replace with skeletons
const contentAreas = element.querySelectorAll('[data-skeleton]');
contentAreas.forEach(area => {
const skeletonType = area.dataset.skeleton || 'line';
area.innerHTML = this.createSkeletonForType(skeletonType);
});
// If no skeleton areas defined, add general skeleton
if (contentAreas.length === 0) {
element.innerHTML = this.createSkeletonHTML();
}
}
hideSkeletonLoading(element, success) {
element.classList.remove('skeleton-loading');
// Fade out skeletons
const skeletons = element.querySelectorAll('.skeleton');
skeletons.forEach(skeleton => {
skeleton.classList.add('skeleton-exit');
setTimeout(() => skeleton.remove(), config.skeletonTransitionDuration);
});
}
showOverlayLoading(element) {
// Position overlay
const overlay = element.querySelector('.loading-overlay');
if (overlay) {
overlay.style.display = 'flex';
element.classList.add('has-loading-overlay');
}
}
hideOverlayLoading(element, success) {
const overlay = element.querySelector('.loading-overlay');
if (overlay) {
overlay.style.display = 'none';
element.classList.remove('has-loading-overlay');
}
}
showInlineLoading(element) {
// Just show the inline loading content
element.style.display = 'inline-block';
}
hideInlineLoading(element, success) {
element.style.display = 'none';
}
updateProgressUI() {
if (!this.options.showProgress) return;
const progressFill = this.options.element.querySelector('.progress-fill');
if (progressFill) {
progressFill.style.width = `${this.progress}%`;
}
const progressText = this.options.element.querySelector('.progress-text');
if (progressText) {
progressText.textContent = `${Math.round(this.progress)}%`;
}
}
createSkeletonForType(type) {
switch (type) {
case 'circle':
return '<div class="skeleton skeleton-circle"></div>';
case 'rectangle':
return '<div class="skeleton skeleton-rectangle"></div>';
case 'text':
return '<div class="skeleton skeleton-line"></div>';
case 'title':
return '<div class="skeleton skeleton-line skeleton-line--title"></div>';
default:
return '<div class="skeleton skeleton-line"></div>';
}
}
trigger(eventName, data = {}) {
// Trigger loading events
const event = new CustomEvent(`loading:${eventName}`, {
detail: {
id: this.id,
context: this,
...data
}
});
document.dispatchEvent(event);
}
}
// Global loading state manager instance
const loadingStateManager = new LoadingStateManager();
// API Functions
const API = {
/**
* Start a loading operation
*/
startLoading(id, options) {
return loadingStateManager.start(id, options);
},
/**
* End a loading operation
*/
endLoading(id, success = true) {
loadingStateManager.end(id, success);
},
/**
* Update loading progress
*/
updateProgress(id, progress) {
loadingStateManager.updateProgress(id, progress);
},
/**
* Check if loading is active
*/
isLoading(id) {
return loadingStateManager.isLoading(id);
},
/**
* Check if any loading is active
*/
isAnyLoading() {
return loadingStateManager.isAnyLoading();
},
/**
* Create button loading state
*/
showButtonLoading(button, text = 'Loading...') {
const loadingId = `button-${Date.now()}-${Math.random()}`;
const context = loadingStateManager.start(loadingId, {
type: 'button',
element: button,
text: text,
showProgress: false
});
button.setAttribute('data-loading-id', loadingId);
return context;
},
/**
* Show page-level loading overlay
*/
showPageLoading(text = 'Loading...') {
return loadingStateManager.start('page-loading', {
type: 'overlay',
element: document.body,
text: text,
hierarchy: 'global'
});
},
/**
* Show section loading with skeleton
*/
showSectionLoading(element, text = 'Loading...') {
const loadingId = `section-${Date.now()}`;
return loadingStateManager.start(loadingId, {
type: 'skeleton',
element: element,
text: text
});
},
/**
* Show content loading overlay
*/
showContentLoading(container, text = 'Loading...') {
const loadingId = `content-${Date.now()}`;
const overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = `
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text">${text}</div>
</div>
`;
container.appendChild(overlay);
container.classList.add('has-loading-overlay');
return loadingStateManager.start(loadingId, {
type: 'overlay',
element: container,
text: text,
customOverlay: true
});
},
/**
* Show video loading progress
*/
showVideoLoading(progress = null) {
return loadingStateManager.start('video-loading', {
type: 'overlay',
element: document.getElementById('videoSection'),
text: 'Loading video...',
showProgress: progress !== null
});
},
/**
* Show chat refresh loading
*/
showChatRefreshLoading() {
const chatSection = document.getElementById('chatSection');
return API.showSectionLoading(chatSection, 'Refreshing chat...');
},
/**
* Create loading queue for sequential operations
*/
createLoadingQueue() {
return {
queue: [],
add(operation, options = {}) {
const promise = new Promise((resolve, reject) => {
this.queue.push({
operation,
options,
resolve,
reject
});
});
return promise;
},
process(concurrency = 1) {
return Promise.all(
Array.from({ length: concurrency }, () => this.processNext())
);
},
async processNext() {
if (this.queue.length === 0) return;
const item = this.queue.shift();
try {
const result = await item.operation();
item.resolve(result);
return result;
} catch (error) {
item.reject(error);
throw error;
}
}
};
}
};
// Export API
window.LoadingStates = API;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Add global loading state CSS if not present
if (!document.getElementById('loading-states-styles')) {
const style = document.createElement('style');
style.id = 'loading-states-styles';
style.textContent = `
/* Loading States Styles */
.loading-state {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(2px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
transition: opacity 0.3s ease;
}
.loading-content {
text-align: center;
padding: 1rem;
}
.loading-text {
margin-top: 0.5rem;
font-weight: 500;
color: var(--dodgers-blue);
}
.has-loading-overlay {
position: relative;
}
.skeleton-loading .skeleton {
animation: skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.skeleton-loading .skeleton-exit {
opacity: 0;
transition: opacity 0.2s ease;
}
.btn-success-pulse {
animation: pulse 0.5s ease-in-out;
background-color: var(--dodgers-blue) !important;
}
.loading-inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/* Progress bar in loading states */
.loading-content .progress-bar {
margin-top: 1rem;
width: 200px;
height: 4px;
background-color: var(--dodgers-gray-200);
border-radius: 2px;
overflow: hidden;
}
.loading-content .progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--dodgers-blue), var(--dodgers-red));
width: 0%;
transition: width 0.3s ease;
}
/* Mobile optimizations */
@media (max-width: 767px) {
.loading-overlay {
padding: 0.5rem;
}
.loading-content .progress-bar {
width: 150px;
}
}
/* Accessibility */
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
`;
document.head.appendChild(style);
}
AppLogger.log('Loading States module initialized');
});
AppLogger.log('Loading States module loaded');
})();

View file

@ -416,9 +416,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
<button class="video-player__toggle-dashboard-btn" data-action="toggle-dashboard" aria-expanded="false" aria-label="Toggle dashboard" title="Toggle Dashboard">
<span class="icon-dashboard icon-sm"></span> Dashboard
</button>
<button class="stream-stats__refresh-btn" data-action="manual-refresh" title="Manual Stream Refresh" aria-label="Refresh stream">
<span class="icon-refresh icon-sm"></span> Refresh
</button>
<button class="stream-stats__refresh-btn" id="refreshBtn" data-action="manual-refresh" title="Manual Stream Refresh" aria-label="Refresh stream">
<span class="icon-refresh icon-sm"></span> Refresh
</button>
<select class="video-player__quality-selector" id="qualitySelector" style="display:none;" aria-label="Video quality">
<option value="auto">Auto Quality</option>
<option value="1080p">1080p</option>
@ -537,5 +537,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
<script defer src="assets/js/chat.js?v=1.4.4"></script>
<script defer src="assets/js/video-player.js?v=1.4.4"></script>
<script defer src="assets/js/notifications.js?v=1.4.4"></script>
<script defer src="assets/js/loading-states.js?v=1.4.4"></script>
</body>
</html>