/** * 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; }