fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled

- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

@@ -0,0 +1,256 @@
/**
* 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<string>|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<string>|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 `
<div class="skeleton-container" style="width: ${width}; height: ${height}px; padding: 1.5rem;">
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text skeleton-text--80" style="margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text skeleton-text--60" style="margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text skeleton-text--80"></div>
</div>
`;
}
/**
* Create fragment-specific skeletons
*
* @param {Array<string>} fragments - Fragment names
* @returns {string} Skeleton HTML
*/
createFragmentSkeletons(fragments) {
return fragments.map(fragmentName => `
<div class="skeleton-fragment" data-fragment="${fragmentName}">
<div class="skeleton skeleton-text skeleton-text--full" style="margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text skeleton-text--80"></div>
</div>
`).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();

View File

@@ -0,0 +1,347 @@
/**
* Error Boundary for LiveComponents
*
* Provides automatic error handling, retry mechanisms, and error recovery
* for LiveComponent operations.
*/
// Note: LiveComponentError type is defined in types/livecomponent.d.ts
// This is a runtime implementation, so we don't need to import the type
export class ErrorBoundary {
constructor(liveComponentManager) {
this.manager = liveComponentManager;
this.retryStrategies = new Map();
this.errorHandlers = new Map();
this.maxRetries = 3;
this.retryDelays = [1000, 2000, 5000]; // Progressive backoff
}
/**
* Handle error from component action
*
* @param {string} componentId - Component ID
* @param {string} action - Action method name
* @param {Error|LiveComponentError} error - Error object
* @param {Object} context - Additional context
* @returns {Promise<boolean>} True if error was handled, false otherwise
*/
async handleError(componentId, action, error, context = {}) {
console.error(`[ErrorBoundary] Error in ${componentId}.${action}:`, error);
// Convert to standardized error format
const standardizedError = this.standardizeError(error, componentId, action);
// Check for custom error handler
const handler = this.errorHandlers.get(componentId);
if (handler) {
try {
const handled = await handler(standardizedError, context);
if (handled) {
return true;
}
} catch (handlerError) {
console.error('[ErrorBoundary] Error handler failed:', handlerError);
}
}
// Check if error is retryable
if (this.isRetryable(standardizedError)) {
const retried = await this.retryOperation(componentId, action, context, standardizedError);
if (retried) {
return true;
}
}
// Show error to user
this.showError(componentId, standardizedError);
// Dispatch error event
this.dispatchErrorEvent(componentId, standardizedError);
return false;
}
/**
* Standardize error format
*
* @param {Error|LiveComponentError|Object} error - Error object
* @param {string} componentId - Component ID
* @param {string} action - Action method name
* @returns {LiveComponentError} Standardized error
*/
standardizeError(error, componentId, action) {
// If already standardized
if (error && typeof error === 'object' && 'code' in error && 'message' in error) {
return {
code: error.code,
message: error.message,
details: error.details || {},
componentId: error.componentId || componentId,
action: error.action || action,
timestamp: error.timestamp || Date.now()
};
}
// If Error object
if (error instanceof Error) {
return {
code: 'INTERNAL_ERROR',
message: error.message,
details: {
stack: error.stack,
name: error.name
},
componentId,
action,
timestamp: Date.now()
};
}
// If string
if (typeof error === 'string') {
return {
code: 'INTERNAL_ERROR',
message: error,
componentId,
action,
timestamp: Date.now()
};
}
// Default
return {
code: 'INTERNAL_ERROR',
message: 'An unknown error occurred',
details: { original: error },
componentId,
action,
timestamp: Date.now()
};
}
/**
* Check if error is retryable
*
* @param {LiveComponentError} error - Standardized error
* @returns {boolean} True if error is retryable
*/
isRetryable(error) {
const retryableCodes = [
'RATE_LIMIT_EXCEEDED',
'STATE_CONFLICT',
'INTERNAL_ERROR'
];
return retryableCodes.includes(error.code);
}
/**
* Retry operation with exponential backoff
*
* @param {string} componentId - Component ID
* @param {string} action - Action method name
* @param {Object} context - Operation context
* @param {LiveComponentError} error - Error that occurred
* @returns {Promise<boolean>} True if retry succeeded
*/
async retryOperation(componentId, action, context, error) {
const retryKey = `${componentId}:${action}`;
const retryCount = this.retryStrategies.get(retryKey) || 0;
if (retryCount >= this.maxRetries) {
console.warn(`[ErrorBoundary] Max retries exceeded for ${retryKey}`);
this.retryStrategies.delete(retryKey);
return false;
}
// Calculate delay (progressive backoff)
const delay = this.retryDelays[retryCount] || this.retryDelays[this.retryDelays.length - 1];
console.log(`[ErrorBoundary] Retrying ${retryKey} in ${delay}ms (attempt ${retryCount + 1}/${this.maxRetries})`);
// Update retry count
this.retryStrategies.set(retryKey, retryCount + 1);
// Wait before retry
await new Promise(resolve => setTimeout(resolve, delay));
try {
// Retry the operation
const result = await this.manager.executeAction(
componentId,
action,
context.params || {},
context.fragments || null
);
// Success - clear retry count
this.retryStrategies.delete(retryKey);
console.log(`[ErrorBoundary] Retry succeeded for ${retryKey}`);
return true;
} catch (retryError) {
// Retry failed - will be handled by next retry or error handler
console.warn(`[ErrorBoundary] Retry failed for ${retryKey}:`, retryError);
return false;
}
}
/**
* Show error to user
*
* @param {string} componentId - Component ID
* @param {LiveComponentError} error - Standardized error
*/
showError(componentId, error) {
const config = this.manager.components.get(componentId);
if (!config) return;
// Remove existing error
const existingError = config.element.querySelector('.livecomponent-error-boundary');
if (existingError) {
existingError.remove();
}
// Create error element
const errorEl = document.createElement('div');
errorEl.className = 'livecomponent-error-boundary';
errorEl.style.cssText = `
padding: 1rem;
background: #fee;
color: #c00;
border: 1px solid #faa;
border-radius: 4px;
margin-bottom: 1rem;
`;
// Error message
const messageEl = document.createElement('div');
messageEl.style.fontWeight = 'bold';
messageEl.textContent = error.message;
errorEl.appendChild(messageEl);
// Error code (if not generic)
if (error.code !== 'INTERNAL_ERROR') {
const codeEl = document.createElement('div');
codeEl.style.fontSize = '0.875rem';
codeEl.style.marginTop = '0.5rem';
codeEl.style.color = '#666';
codeEl.textContent = `Error Code: ${error.code}`;
errorEl.appendChild(codeEl);
}
// Retry button (if retryable)
if (this.isRetryable(error)) {
const retryBtn = document.createElement('button');
retryBtn.textContent = 'Retry';
retryBtn.style.cssText = `
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #c00;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
retryBtn.addEventListener('click', async () => {
errorEl.remove();
await this.retryOperation(componentId, error.action || '', {}, error);
});
errorEl.appendChild(retryBtn);
}
// Close button
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.cssText = `
float: right;
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #c00;
`;
closeBtn.addEventListener('click', () => {
errorEl.remove();
});
errorEl.appendChild(closeBtn);
// Insert at top of component
config.element.insertAdjacentElement('afterbegin', errorEl);
// Auto-remove after 10 seconds
setTimeout(() => {
if (errorEl.parentNode) {
errorEl.remove();
}
}, 10000);
}
/**
* Dispatch error event
*
* @param {string} componentId - Component ID
* @param {LiveComponentError} error - Standardized error
*/
dispatchErrorEvent(componentId, error) {
// Dispatch custom DOM event
const event = new CustomEvent('livecomponent:error', {
detail: {
componentId,
error
},
bubbles: true
});
const config = this.manager.components.get(componentId);
if (config) {
config.element.dispatchEvent(event);
}
// Also dispatch on document
document.dispatchEvent(event);
}
/**
* Register custom error handler for component
*
* @param {string} componentId - Component ID
* @param {Function} handler - Error handler function
*/
registerErrorHandler(componentId, handler) {
if (typeof handler !== 'function') {
throw new Error('Error handler must be a function');
}
this.errorHandlers.set(componentId, handler);
}
/**
* Clear error handler for component
*
* @param {string} componentId - Component ID
*/
clearErrorHandler(componentId) {
this.errorHandlers.delete(componentId);
}
/**
* Clear retry strategy for component/action
*
* @param {string} componentId - Component ID
* @param {string} action - Action method name
*/
clearRetryStrategy(componentId, action) {
const retryKey = `${componentId}:${action}`;
this.retryStrategies.delete(retryKey);
}
/**
* Reset all retry strategies
*/
resetRetryStrategies() {
this.retryStrategies.clear();
}
}

View File

@@ -226,7 +226,9 @@ export class LazyComponentLoader {
// Copy state to element
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
// Use StateSerializer for type-safe state handling
const stateJson = JSON.stringify(data.state);
config.element.dataset.state = stateJson;
}
// Initialize as regular LiveComponent

View File

@@ -0,0 +1,347 @@
/**
* LiveComponent UI Helper
*
* Provides unified API for UI components (Dialogs, Modals, Notifications)
* integrated with LiveComponents.
*/
import { UIManager } from '../ui/UIManager.js';
export class LiveComponentUIHelper {
constructor(liveComponentManager) {
this.manager = liveComponentManager;
this.uiManager = UIManager;
this.activeDialogs = new Map(); // componentId → dialog instances
this.activeNotifications = new Map(); // componentId → notification instances
}
/**
* Show dialog from LiveComponent action
*
* @param {string} componentId - Component ID
* @param {Object} options - Dialog options
* @returns {Object} Dialog instance
*/
showDialog(componentId, options = {}) {
const {
title = '',
content = '',
size = 'medium',
buttons = [],
closeOnBackdrop = true,
closeOnEscape = true,
onClose = null,
onConfirm = null
} = options;
// Create dialog content
const dialogContent = this.createDialogContent(title, content, buttons, {
onClose,
onConfirm
});
// Show modal via UIManager
const modal = this.uiManager.open('modal', {
content: dialogContent,
className: `livecomponent-dialog livecomponent-dialog--${size}`,
onClose: () => {
if (onClose) {
onClose();
}
this.activeDialogs.delete(componentId);
}
});
// Store reference
this.activeDialogs.set(componentId, modal);
return modal;
}
/**
* Show confirmation dialog
*
* @param {string} componentId - Component ID
* @param {Object} options - Confirmation options
* @returns {Promise<boolean>} Promise resolving to true if confirmed
*/
showConfirm(componentId, options = {}) {
return new Promise((resolve) => {
const {
title = 'Confirm',
message = 'Are you sure?',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmClass = 'btn-primary',
cancelClass = 'btn-secondary'
} = options;
const buttons = [
{
text: cancelText,
class: cancelClass,
action: () => {
this.closeDialog(componentId);
resolve(false);
}
},
{
text: confirmText,
class: confirmClass,
action: () => {
this.closeDialog(componentId);
resolve(true);
}
}
];
this.showDialog(componentId, {
title,
content: `<p>${message}</p>`,
buttons,
size: 'small'
});
});
}
/**
* Show alert dialog
*
* @param {string} componentId - Component ID
* @param {Object} options - Alert options
*/
showAlert(componentId, options = {}) {
const {
title = 'Alert',
message = '',
buttonText = 'OK',
type = 'info' // info, success, warning, error
} = options;
const buttons = [
{
text: buttonText,
class: `btn-${type}`,
action: () => {
this.closeDialog(componentId);
}
}
];
return this.showDialog(componentId, {
title,
content: `<p class="alert-message alert-message--${type}">${message}</p>`,
buttons,
size: 'small'
});
}
/**
* Close dialog
*
* @param {string} componentId - Component ID
*/
closeDialog(componentId) {
const dialog = this.activeDialogs.get(componentId);
if (dialog && typeof dialog.close === 'function') {
dialog.close();
this.activeDialogs.delete(componentId);
}
}
/**
* Show notification
*
* @param {string} componentId - Component ID
* @param {Object} options - Notification options
*/
showNotification(componentId, options = {}) {
const {
message = '',
type = 'info', // info, success, warning, error
duration = 5000,
position = 'top-right',
action = null
} = options;
// Create notification element
const notification = document.createElement('div');
notification.className = `livecomponent-notification livecomponent-notification--${type} livecomponent-notification--${position}`;
notification.setAttribute('role', 'alert');
notification.setAttribute('aria-live', 'polite');
notification.innerHTML = `
<div class="notification-content">
<span class="notification-message">${message}</span>
${action ? `<button class="notification-action">${action.text}</button>` : ''}
<button class="notification-close" aria-label="Close">×</button>
</div>
`;
// Add styles
notification.style.cssText = `
position: fixed;
${position.includes('top') ? 'top' : 'bottom'}: 1rem;
${position.includes('left') ? 'left' : 'right'}: 1rem;
max-width: 400px;
padding: 1rem;
background: ${this.getNotificationColor(type)};
color: white;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 10000;
opacity: 0;
transform: translateY(${position.includes('top') ? '-20px' : '20px'});
transition: opacity 0.3s ease, transform 0.3s ease;
`;
// Add to DOM
document.body.appendChild(notification);
// Animate in
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
});
// Setup close button
const closeBtn = notification.querySelector('.notification-close');
closeBtn.addEventListener('click', () => {
this.hideNotification(notification);
});
// Setup action button
if (action) {
const actionBtn = notification.querySelector('.notification-action');
actionBtn.addEventListener('click', () => {
if (action.handler) {
action.handler();
}
this.hideNotification(notification);
});
}
// Auto-hide after duration
if (duration > 0) {
setTimeout(() => {
this.hideNotification(notification);
}, duration);
}
// Store reference
this.activeNotifications.set(componentId, notification);
return notification;
}
/**
* Hide notification
*
* @param {HTMLElement|string} notificationOrComponentId - Notification element or component ID
*/
hideNotification(notificationOrComponentId) {
const notification = typeof notificationOrComponentId === 'string'
? this.activeNotifications.get(notificationOrComponentId)
: notificationOrComponentId;
if (!notification) {
return;
}
// Animate out
notification.style.opacity = '0';
notification.style.transform = `translateY(${notification.classList.contains('livecomponent-notification--top') ? '-20px' : '20px'})`;
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
// Remove from map
for (const [componentId, notif] of this.activeNotifications.entries()) {
if (notif === notification) {
this.activeNotifications.delete(componentId);
break;
}
}
}, 300);
}
/**
* Create dialog content HTML
*
* @param {string} title - Dialog title
* @param {string} content - Dialog content
* @param {Array} buttons - Button configurations
* @param {Object} callbacks - Callback functions
* @returns {string} HTML content
*/
createDialogContent(title, content, buttons, callbacks) {
const buttonsHtml = buttons.map((btn, index) => {
const btnClass = btn.class || 'btn-secondary';
const btnAction = btn.action || (() => {});
return `<button class="btn ${btnClass}" data-dialog-action="${index}">${btn.text || 'Button'}</button>`;
}).join('');
return `
<div class="livecomponent-dialog-content">
${title ? `<div class="dialog-header"><h3>${title}</h3></div>` : ''}
<div class="dialog-body">${content}</div>
${buttons.length > 0 ? `<div class="dialog-footer">${buttonsHtml}</div>` : ''}
</div>
`;
}
/**
* Get notification color by type
*
* @param {string} type - Notification type
* @returns {string} Color value
*/
getNotificationColor(type) {
const colors = {
info: '#3b82f6',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
};
return colors[type] || colors.info;
}
/**
* Show loading dialog
*
* @param {string} componentId - Component ID
* @param {string} message - Loading message
* @returns {Object} Dialog instance
*/
showLoadingDialog(componentId, message = 'Loading...') {
return this.showDialog(componentId, {
title: '',
content: `
<div class="loading-dialog">
<div class="spinner"></div>
<p>${message}</p>
</div>
`,
size: 'small',
buttons: [],
closeOnBackdrop: false,
closeOnEscape: false
});
}
/**
* Cleanup all UI components for component
*
* @param {string} componentId - Component ID
*/
cleanup(componentId) {
// Close dialogs
this.closeDialog(componentId);
// Hide notifications
this.hideNotification(componentId);
}
}

View File

@@ -0,0 +1,307 @@
/**
* 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 = `
<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);
}
}

View File

@@ -0,0 +1,180 @@
/**
* Request Deduplication for LiveComponents
*
* Prevents duplicate requests for the same action with the same parameters.
* Useful for preventing race conditions and reducing server load.
*/
export class RequestDeduplicator {
constructor() {
this.pendingRequests = new Map();
this.requestCache = new Map();
this.cacheTimeout = 1000; // 1 second cache
}
/**
* Generate request key for deduplication
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @returns {string} Request key
*/
generateKey(componentId, method, params) {
// Sort params for consistent key generation
const sortedParams = Object.keys(params)
.sort()
.reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
const paramsString = JSON.stringify(sortedParams);
return `${componentId}:${method}:${paramsString}`;
}
/**
* Check if request is already pending
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @returns {Promise|null} Pending request promise or null
*/
getPendingRequest(componentId, method, params) {
const key = this.generateKey(componentId, method, params);
return this.pendingRequests.get(key) || null;
}
/**
* Register pending request
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @param {Promise} promise - Request promise
* @returns {Promise} Request promise
*/
registerPendingRequest(componentId, method, params, promise) {
const key = this.generateKey(componentId, method, params);
// Store pending request
this.pendingRequests.set(key, promise);
// Clean up when request completes
promise
.then(() => {
this.pendingRequests.delete(key);
})
.catch(() => {
this.pendingRequests.delete(key);
});
return promise;
}
/**
* Check if request result is cached
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @returns {Object|null} Cached result or null
*/
getCachedResult(componentId, method, params) {
const key = this.generateKey(componentId, method, params);
const cached = this.requestCache.get(key);
if (!cached) {
return null;
}
// Check if cache is still valid
const now = Date.now();
if (now - cached.timestamp > this.cacheTimeout) {
this.requestCache.delete(key);
return null;
}
return cached.result;
}
/**
* Cache request result
*
* @param {string} componentId - Component ID
* @param {string} method - Action method name
* @param {Object} params - Action parameters
* @param {Object} result - Request result
*/
cacheResult(componentId, method, params, result) {
const key = this.generateKey(componentId, method, params);
this.requestCache.set(key, {
result,
timestamp: Date.now()
});
// Clean up old cache entries
this.cleanupCache();
}
/**
* Clean up expired cache entries
*/
cleanupCache() {
const now = Date.now();
for (const [key, cached] of this.requestCache.entries()) {
if (now - cached.timestamp > this.cacheTimeout) {
this.requestCache.delete(key);
}
}
}
/**
* Clear all pending requests
*/
clearPendingRequests() {
this.pendingRequests.clear();
}
/**
* Clear all cached results
*/
clearCache() {
this.requestCache.clear();
}
/**
* Clear requests and cache for specific component
*
* @param {string} componentId - Component ID
*/
clearComponent(componentId) {
// Clear pending requests
for (const [key] of this.pendingRequests.entries()) {
if (key.startsWith(`${componentId}:`)) {
this.pendingRequests.delete(key);
}
}
// Clear cache
for (const [key] of this.requestCache.entries()) {
if (key.startsWith(`${componentId}:`)) {
this.requestCache.delete(key);
}
}
}
/**
* Get statistics
*
* @returns {Object} Statistics
*/
getStats() {
return {
pendingRequests: this.pendingRequests.size,
cachedResults: this.requestCache.size
};
}
}

View File

@@ -0,0 +1,124 @@
/**
* Shared Configuration between PHP and JavaScript
*
* Provides unified configuration management for LiveComponents
* that can be synchronized between server and client.
*/
export class SharedConfig {
constructor() {
this.config = {
// Request settings
requestTimeout: 30000, // 30 seconds
retryAttempts: 3,
retryDelays: [1000, 2000, 5000], // Progressive backoff
// State settings
stateVersioning: true,
stateValidation: true,
// Performance settings
requestDeduplication: true,
requestCacheTimeout: 1000, // 1 second
batchFlushDelay: 50, // 50ms
// Error handling
errorBoundary: true,
autoRetry: true,
showErrorUI: true,
// DevTools
devToolsEnabled: false
};
}
/**
* Load configuration from server
*
* @param {Object} serverConfig - Configuration from server
*/
loadFromServer(serverConfig) {
if (serverConfig && typeof serverConfig === 'object') {
this.config = {
...this.config,
...serverConfig
};
}
}
/**
* Load configuration from DOM
*
* Looks for data-livecomponent-config attribute on document or body
*/
loadFromDOM() {
const configElement = document.querySelector('[data-livecomponent-config]') || document.body;
const configJson = configElement.dataset.livecomponentConfig;
if (configJson) {
try {
const parsed = JSON.parse(configJson);
this.loadFromServer(parsed);
} catch (error) {
console.warn('[SharedConfig] Failed to parse config from DOM:', error);
}
}
}
/**
* Get configuration value
*
* @param {string} key - Configuration key
* @param {*} defaultValue - Default value if key not found
* @returns {*} Configuration value
*/
get(key, defaultValue = null) {
return this.config[key] ?? defaultValue;
}
/**
* Set configuration value
*
* @param {string} key - Configuration key
* @param {*} value - Configuration value
*/
set(key, value) {
this.config[key] = value;
}
/**
* Get all configuration
*
* @returns {Object} Full configuration object
*/
getAll() {
return { ...this.config };
}
/**
* Merge configuration
*
* @param {Object} newConfig - Configuration to merge
*/
merge(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
}
// Create singleton instance
export const sharedConfig = new SharedConfig();
// Auto-load from DOM on initialization
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
sharedConfig.loadFromDOM();
});
} else {
sharedConfig.loadFromDOM();
}
}

View File

@@ -0,0 +1,245 @@
/**
* State Serializer for LiveComponents
*
* Provides type-safe state serialization/deserialization with versioning
* and validation.
*/
/**
* Serialize component state
*
* @param {LiveComponentState} state - Component state
* @returns {string} Serialized state JSON
*/
export function serializeState(state) {
// Validate state structure
if (!state || typeof state !== 'object') {
throw new Error('State must be an object');
}
if (!state.id || typeof state.id !== 'string') {
throw new Error('State must have a valid id');
}
if (!state.component || typeof state.component !== 'string') {
throw new Error('State must have a valid component name');
}
if (typeof state.version !== 'number' || state.version < 1) {
throw new Error('State must have a valid version number');
}
// Ensure data is an object
const data = state.data || {};
if (typeof data !== 'object' || Array.isArray(data)) {
throw new Error('State data must be an object');
}
// Create serializable state
const serializableState = {
id: state.id,
component: state.component,
data: data,
version: state.version
};
// Serialize to JSON
try {
return JSON.stringify(serializableState);
} catch (error) {
throw new Error(`Failed to serialize state: ${error.message}`);
}
}
/**
* Deserialize component state
*
* @param {string} json - Serialized state JSON
* @returns {LiveComponentState} Deserialized state
*/
export function deserializeState(json) {
if (typeof json !== 'string') {
throw new Error('State JSON must be a string');
}
if (json.trim() === '') {
// Return empty state
return {
id: '',
component: '',
data: {},
version: 1
};
}
try {
const parsed = JSON.parse(json);
// Validate structure
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid state structure');
}
// Extract state data
const id = parsed.id || '';
const component = parsed.component || '';
const data = parsed.data || {};
const version = typeof parsed.version === 'number' ? parsed.version : 1;
// Validate data is an object
if (typeof data !== 'object' || Array.isArray(data)) {
throw new Error('State data must be an object');
}
return {
id,
component,
data,
version
};
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON format: ${error.message}`);
}
throw error;
}
}
/**
* Create state diff (changes between two states)
*
* @param {LiveComponentState} oldState - Previous state
* @param {LiveComponentState} newState - New state
* @returns {Object} State diff
*/
export function createStateDiff(oldState, newState) {
const diff = {
changed: false,
added: {},
removed: {},
modified: {},
versionChange: newState.version - oldState.version
};
const oldData = oldState.data || {};
const newData = newState.data || {};
// Find added keys
for (const key in newData) {
if (!(key in oldData)) {
diff.added[key] = newData[key];
diff.changed = true;
}
}
// Find removed keys
for (const key in oldData) {
if (!(key in newData)) {
diff.removed[key] = oldData[key];
diff.changed = true;
}
}
// Find modified keys
for (const key in newData) {
if (key in oldData) {
const oldValue = oldData[key];
const newValue = newData[key];
// Deep comparison for objects
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
diff.modified[key] = {
old: oldValue,
new: newValue
};
diff.changed = true;
}
}
}
return diff;
}
/**
* Merge state changes
*
* @param {LiveComponentState} baseState - Base state
* @param {Object} changes - State changes to apply
* @returns {LiveComponentState} Merged state
*/
export function mergeStateChanges(baseState, changes) {
const baseData = baseState.data || {};
const mergedData = { ...baseData, ...changes };
return {
...baseState,
data: mergedData,
version: baseState.version + 1
};
}
/**
* Validate state structure
*
* @param {LiveComponentState} state - State to validate
* @returns {boolean} True if valid
*/
export function validateState(state) {
if (!state || typeof state !== 'object') {
return false;
}
if (!state.id || typeof state.id !== 'string') {
return false;
}
if (!state.component || typeof state.component !== 'string') {
return false;
}
if (typeof state.version !== 'number' || state.version < 1) {
return false;
}
if (state.data && (typeof state.data !== 'object' || Array.isArray(state.data))) {
return false;
}
return true;
}
/**
* Get state from DOM element
*
* @param {HTMLElement} element - Component element
* @returns {LiveComponentState|null} State or null if not found
*/
export function getStateFromElement(element) {
const stateJson = element.dataset.state;
if (!stateJson) {
return null;
}
try {
return deserializeState(stateJson);
} catch (error) {
console.error('[StateSerializer] Failed to deserialize state from element:', error);
return null;
}
}
/**
* Set state on DOM element
*
* @param {HTMLElement} element - Component element
* @param {LiveComponentState} state - State to set
*/
export function setStateOnElement(element, state) {
if (!validateState(state)) {
throw new Error('Invalid state structure');
}
const stateJson = serializeState(state);
element.dataset.state = stateJson;
}

View File

@@ -0,0 +1,388 @@
/**
* Tooltip Manager for LiveComponents
*
* Provides tooltip functionality for LiveComponent elements with:
* - Automatic positioning
* - Validation error tooltips
* - Accessibility support
* - Smooth animations
* - Multiple positioning strategies
*/
export class TooltipManager {
constructor() {
this.tooltips = new Map(); // element → tooltip element
this.activeTooltip = null;
this.config = {
delay: 300, // ms before showing tooltip
hideDelay: 100, // ms before hiding tooltip
maxWidth: 300, // px
offset: 8, // px offset from element
animationDuration: 200, // ms
zIndex: 10000
};
}
/**
* Initialize tooltip for element
*
* @param {HTMLElement} element - Element to attach tooltip to
* @param {Object} options - Tooltip options
*/
init(element, options = {}) {
// Get tooltip content from data attributes or options
const content = options.content ||
element.dataset.tooltip ||
element.getAttribute('title') ||
element.getAttribute('aria-label') ||
'';
if (!content) {
return; // No tooltip content
}
// Remove native title attribute to prevent default tooltip
if (element.hasAttribute('title')) {
element.dataset.originalTitle = element.getAttribute('title');
element.removeAttribute('title');
}
// Get positioning
const position = options.position ||
element.dataset.tooltipPosition ||
'top';
// Setup event listeners
this.setupEventListeners(element, content, position, options);
}
/**
* Setup event listeners for tooltip
*
* @param {HTMLElement} element - Element
* @param {string} content - Tooltip content
* @param {string} position - Position (top, bottom, left, right)
* @param {Object} options - Additional options
*/
setupEventListeners(element, content, position, options) {
let showTimeout;
let hideTimeout;
const showTooltip = () => {
clearTimeout(hideTimeout);
showTimeout = setTimeout(() => {
this.show(element, content, position, options);
}, this.config.delay);
};
const hideTooltip = () => {
clearTimeout(showTimeout);
hideTimeout = setTimeout(() => {
this.hide(element);
}, this.config.hideDelay);
};
// Mouse events
element.addEventListener('mouseenter', showTooltip);
element.addEventListener('mouseleave', hideTooltip);
element.addEventListener('focus', showTooltip);
element.addEventListener('blur', hideTooltip);
// Touch events (for mobile)
element.addEventListener('touchstart', showTooltip, { passive: true });
element.addEventListener('touchend', hideTooltip, { passive: true });
// Store cleanup function
element._tooltipCleanup = () => {
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
element.removeEventListener('mouseenter', showTooltip);
element.removeEventListener('mouseleave', hideTooltip);
element.removeEventListener('focus', showTooltip);
element.removeEventListener('blur', hideTooltip);
element.removeEventListener('touchstart', showTooltip);
element.removeEventListener('touchend', hideTooltip);
};
}
/**
* Show tooltip
*
* @param {HTMLElement} element - Element
* @param {string} content - Tooltip content
* @param {string} position - Position
* @param {Object} options - Additional options
*/
show(element, content, position, options = {}) {
// Hide existing tooltip
if (this.activeTooltip) {
this.hide();
}
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = 'livecomponent-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.setAttribute('aria-hidden', 'false');
tooltip.textContent = content;
// Apply styles
tooltip.style.cssText = `
position: absolute;
max-width: ${this.config.maxWidth}px;
padding: 0.5rem 0.75rem;
background: #1f2937;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
z-index: ${this.config.zIndex};
pointer-events: none;
opacity: 0;
transition: opacity ${this.config.animationDuration}ms ease;
word-wrap: break-word;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
// Add to DOM
document.body.appendChild(tooltip);
// Position tooltip
this.positionTooltip(tooltip, element, position);
// Animate in
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
// Store references
this.tooltips.set(element, tooltip);
this.activeTooltip = tooltip;
// Set aria-describedby on element
const tooltipId = `tooltip-${Date.now()}`;
tooltip.id = tooltipId;
element.setAttribute('aria-describedby', tooltipId);
}
/**
* Position tooltip relative to element
*
* @param {HTMLElement} tooltip - Tooltip element
* @param {HTMLElement} element - Target element
* @param {string} position - Position (top, bottom, left, right)
*/
positionTooltip(tooltip, element, position) {
const rect = element.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const offset = this.config.offset;
let top, left;
switch (position) {
case 'top':
top = rect.top + scrollY - tooltipRect.height - offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
break;
case 'bottom':
top = rect.bottom + scrollY + offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
break;
case 'left':
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
left = rect.left + scrollX - tooltipRect.width - offset;
break;
case 'right':
top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2);
left = rect.right + scrollX + offset;
break;
default:
top = rect.top + scrollY - tooltipRect.height - offset;
left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
}
// Keep tooltip within viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Horizontal adjustment
if (left < scrollX) {
left = scrollX + 8;
} else if (left + tooltipRect.width > scrollX + viewportWidth) {
left = scrollX + viewportWidth - tooltipRect.width - 8;
}
// Vertical adjustment
if (top < scrollY) {
top = scrollY + 8;
} else if (top + tooltipRect.height > scrollY + viewportHeight) {
top = scrollY + viewportHeight - tooltipRect.height - 8;
}
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
}
/**
* Hide tooltip
*
* @param {HTMLElement} element - Element (optional, hides active tooltip if not provided)
*/
hide(element = null) {
const tooltip = element ? this.tooltips.get(element) : this.activeTooltip;
if (!tooltip) {
return;
}
// Animate out
tooltip.style.opacity = '0';
// Remove after animation
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
// Remove aria-describedby
if (element) {
element.removeAttribute('aria-describedby');
}
// Clean up references
if (element) {
this.tooltips.delete(element);
}
if (this.activeTooltip === tooltip) {
this.activeTooltip = null;
}
}, this.config.animationDuration);
}
/**
* Show validation error tooltip
*
* @param {HTMLElement} element - Form element
* @param {string} message - Error message
*/
showValidationError(element, message) {
// Remove existing error tooltip
this.hideValidationError(element);
// Show error tooltip with error styling
const tooltip = document.createElement('div');
tooltip.className = 'livecomponent-tooltip livecomponent-tooltip--error';
tooltip.setAttribute('role', 'alert');
tooltip.textContent = message;
tooltip.style.cssText = `
position: absolute;
max-width: ${this.config.maxWidth}px;
padding: 0.5rem 0.75rem;
background: #dc2626;
color: white;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.4;
z-index: ${this.config.zIndex};
pointer-events: none;
opacity: 0;
transition: opacity ${this.config.animationDuration}ms ease;
word-wrap: break-word;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
`;
document.body.appendChild(tooltip);
this.positionTooltip(tooltip, element, 'top');
// Animate in
requestAnimationFrame(() => {
tooltip.style.opacity = '1';
});
// Store reference
element._validationTooltip = tooltip;
// Add error class to element
element.classList.add('livecomponent-error');
}
/**
* Hide validation error tooltip
*
* @param {HTMLElement} element - Form element
*/
hideValidationError(element) {
const tooltip = element._validationTooltip;
if (!tooltip) {
return;
}
// Animate out
tooltip.style.opacity = '0';
setTimeout(() => {
if (tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
delete element._validationTooltip;
element.classList.remove('livecomponent-error');
}, this.config.animationDuration);
}
/**
* Initialize all tooltips in component
*
* @param {HTMLElement} container - Component container
*/
initComponent(container) {
// Find all elements with tooltip attributes
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
elements.forEach(element => {
this.init(element);
});
}
/**
* Cleanup tooltips for component
*
* @param {HTMLElement} container - Component container
*/
cleanupComponent(container) {
const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]');
elements.forEach(element => {
// Hide tooltip
this.hide(element);
// Cleanup event listeners
if (element._tooltipCleanup) {
element._tooltipCleanup();
delete element._tooltipCleanup;
}
// Restore original title
if (element.dataset.originalTitle) {
element.setAttribute('title', element.dataset.originalTitle);
delete element.dataset.originalTitle;
}
});
}
/**
* Update configuration
*
* @param {Object} newConfig - New configuration
*/
updateConfig(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
}
// Create singleton instance
export const tooltipManager = new TooltipManager();

View File

@@ -20,6 +20,9 @@ import { ComponentFileUploader } from './ComponentFileUploader.js';
import { FileUploadWidget } from './FileUploadWidget.js';
import { optimisticStateManager } from './OptimisticStateManager.js';
import { accessibilityManager } from './AccessibilityManager.js';
import { ErrorBoundary } from './ErrorBoundary.js';
import { RequestDeduplicator } from './RequestDeduplicator.js';
import * as StateSerializer from './StateSerializer.js';
class LiveComponentManager {
constructor() {
@@ -42,6 +45,30 @@ class LiveComponentManager {
// DevTools Integration
this.devTools = null; // Will be set by DevTools when available
// Error Handling
this.errorBoundary = new ErrorBoundary(this);
// Request Deduplication
this.requestDeduplicator = new RequestDeduplicator();
// Shared Configuration
this.config = sharedConfig;
// Tooltip Manager
this.tooltipManager = tooltipManager;
// Action Loading Manager
this.actionLoadingManager = actionLoadingManager;
// UI Helper
this.uiHelper = new LiveComponentUIHelper(this);
// Loading State Manager
this.loadingStateManager = new LoadingStateManager(
this.actionLoadingManager,
optimisticStateManager
);
}
/**
@@ -140,6 +167,9 @@ class LiveComponentManager {
// Setup accessibility features
this.setupAccessibility(componentId, element);
// Initialize tooltips for component
this.tooltipManager.initComponent(element);
console.log(`[LiveComponent] Initialized: ${componentId}`);
}
@@ -282,9 +312,9 @@ class LiveComponentManager {
this.setupFileUploadHandlers(config.element);
}
// Update state
// Update state using StateSerializer
if (state) {
config.element.dataset.state = JSON.stringify(state);
StateSerializer.setStateOnElement(config.element, state);
}
// Restore focus after update
@@ -614,18 +644,46 @@ class LiveComponentManager {
return;
}
// Check for pending duplicate request
const pendingRequest = this.requestDeduplicator.getPendingRequest(componentId, method, params);
if (pendingRequest) {
console.log(`[LiveComponent] Deduplicating request: ${componentId}.${method}`);
return await pendingRequest;
}
// Check for cached result
const cachedResult = this.requestDeduplicator.getCachedResult(componentId, method, params);
if (cachedResult) {
console.log(`[LiveComponent] Using cached result: ${componentId}.${method}`);
return cachedResult;
}
let operationId = null;
const startTime = performance.now();
try {
// Get current state from element
const stateJson = config.element.dataset.state || '{}';
const stateWrapper = JSON.parse(stateJson);
// Show loading state (uses LoadingStateManager for configurable indicators)
const loadingConfig = this.loadingStateManager.getConfig(componentId);
this.loadingStateManager.showLoading(componentId, config.element, {
fragments,
type: loadingConfig.type,
optimistic: true // Check optimistic UI first
});
// Create request promise
const requestPromise = (async () => {
try {
// Get current state from element using StateSerializer
const stateWrapper = StateSerializer.getStateFromElement(config.element) || {
id: componentId,
component: '',
data: {},
version: 1
};
// Extract actual state data from wrapper format
// Wrapper format: {id, component, data, version}
// Server expects just the data object
const state = stateWrapper.data || stateWrapper;
const state = stateWrapper.data || {};
// Apply optimistic update for immediate UI feedback
// This updates the UI before server confirmation
@@ -741,9 +799,12 @@ class LiveComponentManager {
this.setupActionHandlers(config.element);
}
// Update component state
// Re-initialize tooltips after DOM update
this.tooltipManager.initComponent(config.element);
// Update component state using StateSerializer
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
StateSerializer.setStateOnElement(config.element, data.state);
}
// Handle server events
@@ -753,29 +814,50 @@ class LiveComponentManager {
console.log(`[LiveComponent] Action executed: ${componentId}.${method}`, data);
// Log successful action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
// Log successful action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, true);
} catch (error) {
console.error(`[LiveComponent] Action failed:`, error);
// Cache successful result
this.requestDeduplicator.cacheResult(componentId, method, params, data);
// Log failed action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
// Hide loading state
this.loadingStateManager.hideLoading(componentId);
// Rollback optimistic update on error
if (operationId) {
const snapshot = optimisticStateManager.getSnapshot(componentId);
if (snapshot) {
config.element.dataset.state = JSON.stringify(snapshot);
optimisticStateManager.clearPendingOperations(componentId);
optimisticStateManager.clearSnapshot(componentId);
return data;
} catch (error) {
console.error(`[LiveComponent] Action failed:`, error);
// Log failed action to DevTools
const endTime = performance.now();
this.logActionToDevTools(componentId, method, params, startTime, endTime, false, error.message);
// Rollback optimistic update on error
if (operationId) {
const snapshot = optimisticStateManager.getSnapshot(componentId);
if (snapshot) {
StateSerializer.setStateOnElement(config.element, snapshot);
optimisticStateManager.clearPendingOperations(componentId);
optimisticStateManager.clearSnapshot(componentId);
}
}
}
this.handleError(componentId, error);
}
// Hide loading state on error
this.loadingStateManager.hideLoading(componentId);
// Handle error via ErrorBoundary
await this.errorBoundary.handleError(componentId, method, error, {
params,
fragments
});
throw error;
}
})();
// Register pending request for deduplication
return this.requestDeduplicator.registerPendingRequest(componentId, method, params, requestPromise);
}
/**
@@ -1154,9 +1236,9 @@ class LiveComponentManager {
this.setupFileUploadHandlers(config.element);
}
// Update component state
// Update component state using StateSerializer
if (data.state) {
config.element.dataset.state = JSON.stringify(data.state);
StateSerializer.setStateOnElement(config.element, data.state);
}
// Handle server events
@@ -1260,6 +1342,22 @@ class LiveComponentManager {
// Cleanup accessibility features
this.accessibilityManager.cleanup(componentId);
// Cleanup error handler
this.errorBoundary.clearErrorHandler(componentId);
// Cleanup request deduplication
this.requestDeduplicator.clearComponent(componentId);
// Cleanup tooltips
this.tooltipManager.cleanupComponent(config.element);
// Hide any active loading states
this.loadingStateManager.hideLoading(componentId);
this.loadingStateManager.clearConfig(componentId);
// Cleanup UI components
this.uiHelper.cleanup(componentId);
// Remove from registry
this.components.delete(componentId);
@@ -1413,9 +1511,9 @@ class LiveComponentManager {
this.setupActionHandlers(config.element);
}
// Update state
// Update state using StateSerializer
if (result.state) {
config.element.dataset.state = JSON.stringify(result.state);
StateSerializer.setStateOnElement(config.element, result.state);
}
// Dispatch events
@@ -1584,4 +1682,11 @@ export { ComponentPlayground };
export { ComponentFileUploader } from './ComponentFileUploader.js';
export { FileUploadWidget } from './FileUploadWidget.js';
export { ChunkedUploader } from './ChunkedUploader.js';
// Export UI integration modules
export { tooltipManager } from './TooltipManager.js';
export { actionLoadingManager } from './ActionLoadingManager.js';
export { LiveComponentUIHelper } from './LiveComponentUIHelper.js';
export { LoadingStateManager } from './LoadingStateManager.js';
export default LiveComponent;