Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
418 lines
11 KiB
JavaScript
418 lines
11 KiB
JavaScript
/**
|
|
* DsComponentBase - Base class for all design system components
|
|
*
|
|
* Provides standardized:
|
|
* - Component lifecycle (connectedCallback, disconnectedCallback, attributeChangedCallback)
|
|
* - Standard attributes (variant, size, disabled, loading, aria-* attributes)
|
|
* - Standard methods (focus(), blur())
|
|
* - Theme change handling
|
|
* - Accessibility features (WCAG 2.1 AA)
|
|
* - Event emission patterns (ds-* namespaced events)
|
|
*
|
|
* All Web Components should extend this class to ensure API consistency.
|
|
*
|
|
* Usage:
|
|
* class DsButton extends DsComponentBase {
|
|
* static get observedAttributes() {
|
|
* return [...super.observedAttributes(), 'type'];
|
|
* }
|
|
* }
|
|
*/
|
|
|
|
import StylesheetManager from '../core/stylesheet-manager.js';
|
|
|
|
export class DsComponentBase extends HTMLElement {
|
|
/**
|
|
* Standard observed attributes all components should support
|
|
* Subclasses should extend this list with component-specific attributes
|
|
*/
|
|
static get observedAttributes() {
|
|
return [
|
|
// State management
|
|
'disabled',
|
|
'loading',
|
|
// Accessibility
|
|
'aria-label',
|
|
'aria-disabled',
|
|
'aria-expanded',
|
|
'aria-hidden',
|
|
'aria-pressed',
|
|
'aria-selected',
|
|
'aria-invalid',
|
|
'aria-describedby',
|
|
'aria-labelledby',
|
|
// Focus management
|
|
'tabindex'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Initialize component
|
|
* Subclasses should call super.constructor()
|
|
*/
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: 'open' });
|
|
|
|
// Initialize standard properties
|
|
this._disabled = false;
|
|
this._loading = false;
|
|
this._initialized = false;
|
|
this._cleanup = [];
|
|
this._themeObserver = null;
|
|
this._resizeObserver = null;
|
|
}
|
|
|
|
/**
|
|
* Called when component is inserted into DOM
|
|
* Loads stylesheets, syncs attributes, and renders
|
|
*/
|
|
async connectedCallback() {
|
|
try {
|
|
// Attach stylesheets
|
|
await StylesheetManager.attachStyles(this.shadowRoot);
|
|
|
|
// Sync HTML attributes to JavaScript properties
|
|
this._syncAttributesToProperties();
|
|
|
|
// Initialize theme observer for dark/light mode changes
|
|
this._initializeThemeObserver();
|
|
|
|
// Allow subclass to setup event listeners
|
|
this.setupEventListeners?.();
|
|
|
|
// Initial render
|
|
this._initialized = true;
|
|
this.render?.();
|
|
|
|
// Emit connected event for testing/debugging
|
|
this.emit('ds-component-connected', {
|
|
component: this.constructor.name
|
|
});
|
|
} catch (error) {
|
|
console.error(`[${this.constructor.name}] Error in connectedCallback:`, error);
|
|
this.emit('ds-component-error', { error: error.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when component is removed from DOM
|
|
* Cleanup event listeners and observers
|
|
*/
|
|
disconnectedCallback() {
|
|
// Allow subclass to cleanup
|
|
this.cleanupEventListeners?.();
|
|
|
|
// Remove theme observer
|
|
if (this._themeObserver) {
|
|
window.removeEventListener('theme-changed', this._themeObserver);
|
|
this._themeObserver = null;
|
|
}
|
|
|
|
// Disconnect resize observer if present
|
|
if (this._resizeObserver) {
|
|
this._resizeObserver.disconnect();
|
|
this._resizeObserver = null;
|
|
}
|
|
|
|
// Cleanup all tracked listeners
|
|
this._cleanup.forEach(({ element, event, handler }) => {
|
|
element.removeEventListener(event, handler);
|
|
});
|
|
this._cleanup = [];
|
|
}
|
|
|
|
/**
|
|
* Called when observed attributes change
|
|
* Subclasses can override but should call super.attributeChangedCallback()
|
|
*/
|
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
if (!this._initialized || oldValue === newValue) return;
|
|
|
|
// Handle standard attributes
|
|
switch (name) {
|
|
case 'disabled':
|
|
this._disabled = newValue !== null;
|
|
this._updateAccessibility();
|
|
break;
|
|
case 'loading':
|
|
this._loading = newValue !== null;
|
|
break;
|
|
case 'aria-label':
|
|
case 'aria-disabled':
|
|
case 'aria-expanded':
|
|
case 'aria-hidden':
|
|
case 'aria-pressed':
|
|
case 'aria-selected':
|
|
case 'aria-invalid':
|
|
this._updateAccessibility();
|
|
break;
|
|
case 'tabindex':
|
|
// Update tabindex if changed
|
|
this.setAttribute('tabindex', newValue || '0');
|
|
break;
|
|
}
|
|
|
|
// Re-render component
|
|
this.render?.();
|
|
}
|
|
|
|
/**
|
|
* Sync HTML attributes to JavaScript properties
|
|
* @private
|
|
*/
|
|
_syncAttributesToProperties() {
|
|
this._disabled = this.hasAttribute('disabled');
|
|
this._loading = this.hasAttribute('loading');
|
|
|
|
// Ensure accessible tabindex
|
|
if (!this.hasAttribute('tabindex')) {
|
|
this.setAttribute('tabindex', this._disabled ? '-1' : '0');
|
|
} else if (this._disabled && this.getAttribute('tabindex') !== '-1') {
|
|
this.setAttribute('tabindex', '-1');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize theme observer to listen for dark/light mode changes
|
|
* @private
|
|
*/
|
|
_initializeThemeObserver() {
|
|
this._themeObserver = () => {
|
|
// Re-render when theme changes
|
|
this.render?.();
|
|
};
|
|
|
|
window.addEventListener('theme-changed', this._themeObserver);
|
|
}
|
|
|
|
/**
|
|
* Update accessibility attributes based on component state
|
|
* @private
|
|
*/
|
|
_updateAccessibility() {
|
|
// Update aria-disabled to match disabled state
|
|
this.setAttribute('aria-disabled', this._disabled);
|
|
|
|
// Ensure proper tab order when disabled
|
|
if (this._disabled) {
|
|
this.setAttribute('tabindex', '-1');
|
|
} else if (this.getAttribute('tabindex') === '-1') {
|
|
this.setAttribute('tabindex', '0');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Standard properties with getters/setters
|
|
*/
|
|
|
|
get disabled() { return this._disabled; }
|
|
set disabled(value) {
|
|
this._disabled = !!value;
|
|
value ? this.setAttribute('disabled', '') : this.removeAttribute('disabled');
|
|
}
|
|
|
|
get loading() { return this._loading; }
|
|
set loading(value) {
|
|
this._loading = !!value;
|
|
value ? this.setAttribute('loading', '') : this.removeAttribute('loading');
|
|
}
|
|
|
|
get ariaLabel() { return this.getAttribute('aria-label'); }
|
|
set ariaLabel(value) {
|
|
value ? this.setAttribute('aria-label', value) : this.removeAttribute('aria-label');
|
|
}
|
|
|
|
get ariaDescribedBy() { return this.getAttribute('aria-describedby'); }
|
|
set ariaDescribedBy(value) {
|
|
value ? this.setAttribute('aria-describedby', value) : this.removeAttribute('aria-describedby');
|
|
}
|
|
|
|
/**
|
|
* Standard methods for focus management
|
|
*/
|
|
|
|
focus(options) {
|
|
// Find first focusable element in shadow DOM
|
|
const focusable = this.shadowRoot.querySelector('button, input, [tabindex]');
|
|
if (focusable) {
|
|
focusable.focus(options);
|
|
}
|
|
}
|
|
|
|
blur() {
|
|
const focused = this.shadowRoot.activeElement;
|
|
if (focused && typeof focused.blur === 'function') {
|
|
focused.blur();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit custom event (ds-* namespaced)
|
|
* @param {string} eventName - Event name (without 'ds-' prefix)
|
|
* @param {object} detail - Event detail object
|
|
* @returns {boolean} Whether event was not prevented
|
|
*/
|
|
emit(eventName, detail = {}) {
|
|
const event = new CustomEvent(`ds-${eventName}`, {
|
|
detail,
|
|
composed: true, // Bubble out of shadow DOM
|
|
bubbles: true, // Standard bubbling
|
|
cancelable: true // Allow preventDefault()
|
|
});
|
|
|
|
return this.dispatchEvent(event);
|
|
}
|
|
|
|
/**
|
|
* Add event listener with automatic cleanup
|
|
* Listener is automatically removed in disconnectedCallback()
|
|
* @param {HTMLElement} element - Element to listen on
|
|
* @param {string} event - Event name
|
|
* @param {Function} handler - Event handler
|
|
* @param {object} [options] - Event listener options
|
|
*/
|
|
addEventListener(element, event, handler, options = false) {
|
|
element.addEventListener(event, handler, options);
|
|
this._cleanup.push({ element, event, handler });
|
|
}
|
|
|
|
/**
|
|
* Render method stub - override in subclass
|
|
* Called on initialization and on attribute changes
|
|
*/
|
|
render() {
|
|
// Override in subclass
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners - override in subclass
|
|
* Called in connectedCallback after render
|
|
*/
|
|
setupEventListeners() {
|
|
// Override in subclass
|
|
}
|
|
|
|
/**
|
|
* Cleanup event listeners - override in subclass
|
|
* Called in disconnectedCallback
|
|
*/
|
|
cleanupEventListeners() {
|
|
// Override in subclass
|
|
}
|
|
|
|
/**
|
|
* Get computed CSS variable value
|
|
* @param {string} varName - CSS variable name (with or without --)
|
|
* @returns {string} CSS variable value
|
|
*/
|
|
getCSSVariable(varName) {
|
|
const name = varName.startsWith('--') ? varName : `--${varName}`;
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
|
|
/**
|
|
* Check if component is in dark mode
|
|
* @returns {boolean}
|
|
*/
|
|
isDarkMode() {
|
|
return document.documentElement.classList.contains('dark') ||
|
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
}
|
|
|
|
/**
|
|
* Debounce function execution
|
|
* @param {Function} fn - Function to debounce
|
|
* @param {number} delay - Delay in milliseconds
|
|
* @returns {Function} Debounced function
|
|
*/
|
|
debounce(fn, delay = 300) {
|
|
let timeoutId;
|
|
return (...args) => {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Throttle function execution
|
|
* @param {Function} fn - Function to throttle
|
|
* @param {number} limit - Time limit in milliseconds
|
|
* @returns {Function} Throttled function
|
|
*/
|
|
throttle(fn, limit = 300) {
|
|
let inThrottle;
|
|
return (...args) => {
|
|
if (!inThrottle) {
|
|
fn.apply(this, args);
|
|
inThrottle = true;
|
|
setTimeout(() => (inThrottle = false), limit);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wait for an event
|
|
* @param {string} eventName - Event name to wait for
|
|
* @param {number} [timeout] - Optional timeout in milliseconds
|
|
* @returns {Promise} Resolves with event detail
|
|
*/
|
|
waitForEvent(eventName, timeout = null) {
|
|
return new Promise((resolve, reject) => {
|
|
const handler = (e) => {
|
|
this.removeEventListener(eventName, handler);
|
|
clearTimeout(timeoutId);
|
|
resolve(e.detail);
|
|
};
|
|
|
|
this.addEventListener(eventName, handler);
|
|
|
|
let timeoutId;
|
|
if (timeout) {
|
|
timeoutId = setTimeout(() => {
|
|
this.removeEventListener(eventName, handler);
|
|
reject(new Error(`Event '${eventName}' did not fire within ${timeout}ms`));
|
|
}, timeout);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get HTML structure for rendering in shadow DOM
|
|
* Useful for preventing repeated string concatenation
|
|
* @param {string} html - HTML template
|
|
* @param {object} [data] - Data for template interpolation
|
|
* @returns {string} Rendered HTML
|
|
*/
|
|
renderTemplate(html, data = {}) {
|
|
return html.replace(/\{\{(\w+)\}\}/g, (match, key) => data[key] ?? match);
|
|
}
|
|
|
|
/**
|
|
* Static helper to create component with attributes
|
|
* @param {object} attrs - Attributes to set
|
|
* @returns {HTMLElement} Component instance
|
|
*/
|
|
static create(attrs = {}) {
|
|
const element = document.createElement(this.name.replace(/([A-Z])/g, '-$1').toLowerCase());
|
|
Object.entries(attrs).forEach(([key, value]) => {
|
|
if (value === true) {
|
|
element.setAttribute(key, '');
|
|
} else if (value !== false && value !== null && value !== undefined) {
|
|
element.setAttribute(key, value);
|
|
}
|
|
});
|
|
return element;
|
|
}
|
|
}
|
|
|
|
// Export for module systems
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { DsComponentBase };
|
|
}
|
|
|
|
// Make available globally
|
|
if (typeof window !== 'undefined') {
|
|
window.DsComponentBase = DsComponentBase;
|
|
}
|