mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
439 lines (394 loc) • 14.5 kB
JavaScript
/**
* StatusComponent - Generic status display component
*
* This component handles status badges, progress indicators, and state visualization.
* It's designed to be highly customizable and reusable across different MCP servers
* that need to display various status information.
*
* Features:
* - Customizable status mappings and styles
* - Progress indicators with animation
* - Icon support for enhanced visual feedback
* - Built-in accessibility support
* - Multiple display modes (badge, pill, block)
* - Color-coded status categories
* - Tooltip support for detailed descriptions
*
* Usage Example:
* const status = new StatusComponent(element, {
* status: 'active',
* progress: 75,
* description: 'Task is currently running'
* }, {
* status: {
* showIcon: true,
* showProgress: true,
* mode: 'badge',
* statusMap: {
* 'custom': { class: 'status-custom', icon: '⭐', label: 'Custom Status' }
* }
* }
* });
*/
class StatusComponent extends BaseComponent {
/**
* Constructor for StatusComponent
* @param {HTMLElement} element - The DOM element to attach this component to
* @param {Object|string} data - Status data (can be object with status/progress or simple string)
* @param {Object} config - Configuration object
*/
constructor(element, data = {}, config = {}) {
// Component-specific configuration with sensible defaults
const componentConfig = {
showIcon: true,
showProgress: false,
showDescription: true,
mode: 'badge', // 'badge' | 'pill' | 'block' | 'minimal'
size: 'medium', // 'small' | 'medium' | 'large'
animated: true,
clickable: false,
statusMap: {}, // Custom status mappings override defaults
progressConfig: {
showPercentage: true,
animated: true,
height: '4px',
showStripes: false
},
accessibility: {
includeAriaLabel: true,
includeLiveRegion: false
},
...config.status
};
super(element, data, config);
this.componentConfig = componentConfig;
this.progressAnimationFrame = null;
// Initialize component after configuration is set up
this.init();
}
/**
* Render the status component
* This creates the complete status display with optional progress
*/
render() {
if (this.isDestroyed) return;
try {
// Normalize data to object format
const statusData = this.normalizeStatusData(this.data);
this.element.innerHTML = this.html`
<div class="component component-status mode-${this.componentConfig.mode} size-${this.componentConfig.size}">
${this.trustedHtml(this.renderStatus(statusData))}
${this.componentConfig.showProgress && statusData.progress !== undefined ?
this.trustedHtml(this.renderProgress(statusData)) : ''}
${this.componentConfig.showDescription && statusData.description ?
this.trustedHtml(this.renderDescription(statusData)) : ''}
</div>
`;
// Animate progress if enabled
if (this.componentConfig.showProgress && this.componentConfig.progressConfig.animated) {
this.animateProgress();
}
} catch (error) {
this.log('ERROR', `Failed to render status: ${error.message}`);
this.element.innerHTML = this.html`
<div class="component component-status">
<span class="status-badge status-error">
${this.componentConfig.showIcon ? '❌' : ''} Error
</span>
</div>
`;
}
}
/**
* Normalize input data to consistent object format
* @param {Object|string} data - Raw input data
* @returns {Object} Normalized status data
*/
normalizeStatusData(data) {
// Handle string input
if (typeof data === 'string') {
return { status: data };
}
// Handle object input
if (data && typeof data === 'object') {
return {
status: data.status || 'unknown',
progress: data.progress,
description: data.description,
timestamp: data.timestamp,
metadata: data.metadata
};
}
// Fallback for invalid input
return { status: 'unknown' };
}
/**
* Render the main status badge
* @param {Object} statusData - Normalized status data
* @returns {string} HTML string for status badge
*/
renderStatus(statusData) {
const config = this.getStatusConfig(statusData.status);
const isClickable = this.componentConfig.clickable;
const ariaLabel = this.componentConfig.accessibility.includeAriaLabel ?
`aria-label="${config.description || config.label}"` : '';
const role = isClickable ? 'role="button" tabindex="0"' : '';
return `
<span class="status-badge ${config.class} ${isClickable ? 'status-clickable' : ''}"
title="${config.description || config.label}"
data-status="${statusData.status}"
${ariaLabel}
${role}>
${this.componentConfig.showIcon && config.icon ?
`<span class="status-icon">${config.icon}</span>` : ''}
<span class="status-label">${config.label}</span>
${statusData.timestamp ?
`<span class="status-timestamp">${this.formatTimestamp(statusData.timestamp)}</span>` : ''}
</span>
`;
}
/**
* Get configuration for a specific status
* @param {string} status - Status value
* @returns {Object} Status configuration object
*/
getStatusConfig(status) {
// Check for custom status mappings first
if (this.componentConfig.statusMap[status]) {
return {
...this.getDefaultStatusConfig(status),
...this.componentConfig.statusMap[status]
};
}
return this.getDefaultStatusConfig(status);
}
/**
* Get default status configuration
* @param {string} status - Status value
* @returns {Object} Default status configuration
*/
getDefaultStatusConfig(status) {
const defaults = {
'active': {
class: 'status-active',
icon: '✅',
label: 'Active',
description: 'Currently active and running'
},
'inactive': {
class: 'status-inactive',
icon: '⏸️',
label: 'Inactive',
description: 'Currently inactive or paused'
},
'pending': {
class: 'status-pending',
icon: '⏳',
label: 'Pending',
description: 'Waiting to be processed'
},
'running': {
class: 'status-running',
icon: '🏃',
label: 'Running',
description: 'Currently executing'
},
'completed': {
class: 'status-completed',
icon: '✅',
label: 'Completed',
description: 'Successfully completed'
},
'failed': {
class: 'status-failed',
icon: '❌',
label: 'Failed',
description: 'Execution failed'
},
'cancelled': {
class: 'status-cancelled',
icon: '🚫',
label: 'Cancelled',
description: 'Operation was cancelled'
},
'scheduled': {
class: 'status-scheduled',
icon: '📅',
label: 'Scheduled',
description: 'Scheduled for future execution'
},
'paused': {
class: 'status-paused',
icon: '⏸️',
label: 'Paused',
description: 'Temporarily paused'
}
};
return defaults[status] || {
class: 'status-unknown',
icon: '❓',
label: this.sanitize(String(status)),
description: `Status: ${this.sanitize(String(status))}`
};
}
/**
* Render progress indicator
* @param {Object} statusData - Status data with progress
* @returns {string} HTML string for progress indicator
*/
renderProgress(statusData) {
const progress = this.sanitizeProgress(statusData.progress);
const config = this.componentConfig.progressConfig;
return `
<div class="status-progress" style="height: ${config.height}">
<div class="progress-track ${config.showStripes ? 'progress-striped' : ''}">
<div class="progress-fill"
data-progress="${progress}"
style="width: 0%"
role="progressbar"
aria-valuenow="${progress}"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
${config.showPercentage ?
`<span class="progress-percentage">${progress}%</span>` : ''}
</div>
`;
}
/**
* Sanitize progress value
* @param {any} progress - Raw progress value
* @returns {number} Sanitized progress (0-100)
*/
sanitizeProgress(progress) {
const num = parseFloat(progress);
if (isNaN(num)) return 0;
return Math.max(0, Math.min(100, Math.round(num)));
}
/**
* Render description text
* @param {Object} statusData - Status data with description
* @returns {string} HTML string for description
*/
renderDescription(statusData) {
return `
<div class="status-description">
${statusData.description}
</div>
`;
}
/**
* Format timestamp for display
* @param {string|number|Date} timestamp - Raw timestamp
* @returns {string} Formatted timestamp
*/
formatTimestamp(timestamp) {
try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) return '';
// Show relative time for recent timestamps
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
// Fallback to formatted date
return date.toLocaleDateString();
} catch (error) {
return '';
}
}
/**
* Animate progress bar to target value
*/
animateProgress() {
const progressFill = this.element.querySelector('.progress-fill');
if (!progressFill) return;
const targetProgress = parseFloat(progressFill.dataset.progress || 0);
const startTime = performance.now();
const duration = 800; // Animation duration in ms
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Use easing for smooth animation
const easedProgress = this.easeOutQuart(progress);
const currentWidth = targetProgress * easedProgress;
progressFill.style.width = `${currentWidth}%`;
if (progress < 1) {
this.progressAnimationFrame = requestAnimationFrame(animate);
}
};
this.progressAnimationFrame = requestAnimationFrame(animate);
}
/**
* Easing function for progress animation
* @param {number} t - Progress (0 to 1)
* @returns {number} Eased progress
*/
easeOutQuart(t) {
return 1 - Math.pow(1 - t, 4);
}
/**
* Bind component-specific events
*/
bindEvents() {
super.bindEvents();
// Handle clickable status badges
if (this.componentConfig.clickable) {
this.on('click', '.status-clickable', (e) => {
this.handleStatusClick(e);
});
this.on('keydown', '.status-clickable', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleStatusClick(e);
}
});
}
}
/**
* Handle status badge click
* @param {Event} e - Click event
*/
handleStatusClick(e) {
const statusBadge = e.currentTarget;
const status = statusBadge.dataset.status;
// Emit custom event for parent components to handle
const customEvent = new CustomEvent('statusClick', {
detail: { status, element: statusBadge },
bubbles: true
});
this.element.dispatchEvent(customEvent);
}
/**
* Update component with new data
* @param {Object|string} newData - New status data
*/
update(newData) {
// Cancel any running animations
if (this.progressAnimationFrame) {
cancelAnimationFrame(this.progressAnimationFrame);
this.progressAnimationFrame = null;
}
super.update(newData);
}
/**
* Clean up resources when component is destroyed
*/
destroy() {
// Cancel any running animations
if (this.progressAnimationFrame) {
cancelAnimationFrame(this.progressAnimationFrame);
this.progressAnimationFrame = null;
}
super.destroy();
}
}
// Make the component available globally for tests and framework
if (typeof global !== 'undefined') {
global.StatusComponent = StatusComponent;
} else if (typeof window !== 'undefined') {
window.StatusComponent = StatusComponent;
}