/**
* @file base-component.js
* @summary Base class for design system Web Components.
* @description
* Provides common functionality for all design system components including
* shadow DOM setup, event handling, and attribute management.
*
* @abstract
*/
class BaseComponent extends HTMLElement {
/**
* Creates a new base component.
* @param {Object} options - Configuration options
* @param {string} options.template - HTML template string
* @param {string} options.display - CSS display value for :host
* @param {Array<string>} options.observedAttributes - Attributes to observe
* @param {Object} options.attributeHandlers - Attribute change handlers
* @param {Array<string>} options.events - Events to re-dispatch
* @param {string} options.targetSelector - CSS selector for the target element
*/
constructor(options = {}) {
super();
// ARIA config defaults
const ariaConfig = options.ariaConfig || {};
this.ariaConfig = {
requiredAriaAttributes: ariaConfig.requiredAriaAttributes || [],
staticAriaAttributes: ariaConfig.staticAriaAttributes || {},
dynamicAriaAttributes: ariaConfig.dynamicAriaAttributes || [],
...ariaConfig
};
// Merge ARIA attributes into observedAttributes
const ariaObserved = [
...this.ariaConfig.dynamicAriaAttributes || [],
...this.ariaConfig.requiredAriaAttributes || []
];
this.options = {
display: options.display || 'block',
observedAttributes: Array.from(new Set([...(options.observedAttributes || []), ...ariaObserved])),
attributeHandlers: { ...(options.attributeHandlers || {}) },
events: options.events || [],
targetSelector: options.targetSelector || null,
template: options.template,
};
// Add ARIA attribute handlers
this.addAriaAttributeHandlers();
this.setupShadowDOM();
this.setupARIA();
this.setupEventListeners();
}
/**
* Sets up the shadow DOM with the provided template.
*/
setupShadowDOM() {
const shadowRoot = this.attachShadow({ mode: 'open' });
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: ${this.options.display};
}
</style>
${this.options.template || '<slot></slot>'}
`;
shadowRoot.appendChild(template.content.cloneNode(true));
// Store reference to target element if selector is provided
if (this.options.targetSelector) {
this.targetElement = shadowRoot.querySelector(this.options.targetSelector);
}
}
/**
* Sets up event listeners to re-dispatch events from the host element.
*/
setupEventListeners() {
if (!this.options.events.length || !this.targetElement) return;
this.options.events.forEach(eventType => {
this.targetElement.addEventListener(eventType, (event) => {
const newEvent = new Event(eventType, {
bubbles: true,
composed: true,
cancelable: true
});
// Copy relevant properties for form events
if (eventType === 'input' || eventType === 'change') {
// Don't try to set read-only properties in test environment
try {
Object.defineProperty(newEvent, 'target', { value: this, writable: false });
Object.defineProperty(newEvent, 'currentTarget', { value: this, writable: false });
} catch (e) {
// Ignore errors in test environment where properties are read-only
}
}
this.dispatchEvent(newEvent);
});
});
}
/**
* Called when one of the component's observed attributes is added, removed, or changed.
* @param {string} name - The name of the attribute that changed.
* @param {string|null} oldValue - The attribute's old value.
* @param {string|null} newValue - The attribute's new value.
*/
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return; // No change
// Handle ARIA attributes first
if ((this.ariaConfig.dynamicAriaAttributes || []).includes(name) || (this.ariaConfig.requiredAriaAttributes || []).includes(name)) {
// Check if this is a static ARIA attribute and if so, only warn once and skip validation
if (this.ariaConfig.staticAriaAttributes && this.ariaConfig.staticAriaAttributes[name]) {
const handler = BaseComponent.createAriaAttributeHandler(name);
handler.call(this, newValue);
// Do not call validateAndWarnARIA for static attributes
return;
}
const handler = BaseComponent.createAriaAttributeHandler(name);
handler.call(this, newValue);
// Validate ARIA attributes after they're applied
this.validateAndWarnARIA(name, newValue);
}
// Handle other attributes
const handler = this.options.attributeHandlers[name];
if (handler) {
handler.call(this, newValue);
}
}
/**
* Called when the element is connected to the DOM.
* Applies initial attributes and ensures styles are applied.
*/
connectedCallback() {
// Set display style directly on the host (safe here)
this.style.display = this.options.display;
// Force a reflow to ensure styles are applied
this.offsetHeight;
// Apply initial attributes
this.options.observedAttributes.forEach(attr => {
this.attributeChangedCallback(attr, null, this.getAttribute(attr));
});
// Delay ARIA validation to ensure text content is available
setTimeout(() => {
this.warnMissingARIA();
}, 0);
}
/**
* Creates a standard attribute handler for boolean attributes.
* @param {string} propertyName - The property name to set
* @param {string} attributeName - The attribute name to check
* @returns {Function} The attribute handler function
*/
static createBooleanHandler(propertyName, attributeName) {
return function(newValue) {
if (this.targetElement) {
this.targetElement[propertyName] = this.hasAttribute(attributeName);
}
};
}
/**
* Creates a standard attribute handler for string attributes.
* @param {string} propertyName - The property name to set
* @param {string} defaultValue - Default value if attribute is null
* @returns {Function} The attribute handler function
*/
static createStringHandler(propertyName, defaultValue = '') {
return function(newValue) {
if (this.targetElement) {
this.targetElement[propertyName] = newValue || defaultValue;
}
};
}
/**
* Creates a standard attribute handler for setAttribute.
* @param {string} attributeName - The attribute name to set
* @returns {Function} The attribute handler function
*/
static createSetAttributeHandler(attributeName) {
return function(newValue) {
if (this.targetElement) {
if (newValue === null) {
this.targetElement.removeAttribute(attributeName);
} else {
this.targetElement.setAttribute(attributeName, newValue);
}
}
};
}
/**
* Creates a standard getter/setter pair for a property.
* @param {string} propertyName - The property name
* @returns {Object} Object with get and set functions
*/
createPropertyAccessor(propertyName) {
return {
get: () => this.targetElement?.[propertyName],
set: (val) => {
if (this.targetElement) {
this.targetElement[propertyName] = val;
}
}
};
}
setupARIA() {
// Apply static ARIA attributes
if (this.targetElement && this.ariaConfig.staticAriaAttributes) {
Object.entries(this.ariaConfig.staticAriaAttributes).forEach(([attr, value]) => {
this.targetElement.setAttribute(attr, value);
});
}
}
addAriaAttributeHandlers() {
if (!this.options.attributeHandlers) this.options.attributeHandlers = {};
const allAria = [
...(this.ariaConfig.dynamicAriaAttributes || []),
...(this.ariaConfig.requiredAriaAttributes || [])
];
allAria.forEach(attr => {
if (!this.options.attributeHandlers[attr]) {
this.options.attributeHandlers[attr] = BaseComponent.createAriaAttributeHandler(attr);
}
});
}
static createAriaAttributeHandler(attributeName) {
return function(newValue) {
// Ensure targetElement is available
if (!this.targetElement) {
this.targetElement = this.shadowRoot?.querySelector(this.options.targetSelector);
}
if (this.targetElement) {
// Don't override static attributes
if (this.ariaConfig.staticAriaAttributes && this.ariaConfig.staticAriaAttributes[attributeName]) {
const staticValue = this.ariaConfig.staticAriaAttributes[attributeName];
// Only warn if trying to set a different value
if (newValue !== null && newValue !== staticValue) {
console.warn(`[${this.constructor.name}] Cannot override static ARIA attribute '${attributeName}' with value '${newValue}'. Static value '${staticValue}' will be preserved.`);
}
return;
}
if (newValue === null || newValue === undefined) {
this.targetElement.removeAttribute(attributeName);
} else {
this.targetElement.setAttribute(attributeName, newValue);
}
}
};
}
static createAriaPropertyHandler(propertyName) {
return {
get() { return this.targetElement?.getAttribute(propertyName); },
set(val) {
if (this.targetElement) {
if (val === null || val === undefined) {
this.targetElement.removeAttribute(propertyName);
} else {
this.targetElement.setAttribute(propertyName, val);
}
}
}
};
}
static createAriaStateHandler(stateName) {
return function(newValue) {
if (this.targetElement) {
if (newValue === null || newValue === undefined) {
this.targetElement.removeAttribute(stateName);
} else {
this.targetElement.setAttribute(stateName, newValue);
}
}
};
}
validateAriaTokens(attributeName, value, allowedTokens) {
if (!allowedTokens.includes(value)) {
return `Invalid value '${value}' for ${attributeName}. Allowed: ${allowedTokens.join(', ')}`;
}
return null;
}
checkAriaReferences(attributeName, value) {
if (!value) return null;
const ids = value.split(/\s+/);
for (const id of ids) {
if (!document.getElementById(id)) {
return `Element referenced by ${attributeName} ('${id}') does not exist in the document.`;
}
}
return null;
}
validateARIA() {
const errors = [];
// Check required ARIA attributes
(this.ariaConfig.requiredAriaAttributes || []).forEach(attr => {
if (!this.hasAttribute(attr) && !this.targetElement?.hasAttribute(attr)) {
errors.push(`Missing required ARIA attribute: ${attr}`);
}
});
// Validate ARIA tokens (if any)
if (this.ariaConfig.tokenValidation) {
Object.entries(this.ariaConfig.tokenValidation).forEach(([attr, allowedTokens]) => {
const val = this.getAttribute(attr) || this.targetElement?.getAttribute(attr);
if (val && !allowedTokens.includes(val)) {
errors.push(this.validateAriaTokens(attr, val, allowedTokens));
}
});
}
// Validate ARIA references
(this.ariaConfig.referenceAttributes || []).forEach(attr => {
const val = this.getAttribute(attr) || this.targetElement?.getAttribute(attr);
const refError = this.checkAriaReferences(attr, val);
if (refError) errors.push(refError);
});
return errors;
}
warnMissingARIA() {
const errors = this.validateARIA();
errors.forEach(msg => {
console.warn(`[${this.constructor.name}] ARIA validation: ${msg}`);
});
}
/**
* Defines which attributes the component observes for changes.
* @returns {Array<string>} An array of attribute names to observe.
*/
static get observedAttributes() {
// This will be overridden by subclasses, but provide a default
// that includes common ARIA attributes
return [
'aria-label',
'aria-describedby',
'aria-pressed',
'aria-expanded',
'aria-haspopup',
'aria-controls',
'aria-current',
'aria-live',
'aria-atomic',
'aria-relevant',
'aria-busy',
'aria-dropeffect',
'aria-grabbed',
'aria-activedescendant',
'aria-colcount',
'aria-colindex',
'aria-colspan',
'aria-level',
'aria-multiline',
'aria-multiselectable',
'aria-orientation',
'aria-readonly',
'aria-required',
'aria-rowcount',
'aria-rowindex',
'aria-rowspan',
'aria-selected',
'aria-setsize',
'aria-sort',
'aria-valuemax',
'aria-valuemin',
'aria-valuenow',
'aria-valuetext'
];
}
/**
* Validates and warns about ARIA issues for a specific attribute
* @param {string} attributeName - The name of the attribute being validated
* @param {string|null} value - The value of the attribute
*/
validateAndWarnARIA(attributeName, value) {
const errors = [];
// Validate token values
if (this.ariaConfig.tokenValidation && this.ariaConfig.tokenValidation[attributeName]) {
const allowedTokens = this.ariaConfig.tokenValidation[attributeName];
if (value && !allowedTokens.includes(value)) {
errors.push(this.validateAriaTokens(attributeName, value, allowedTokens));
}
}
// Validate references
if ((this.ariaConfig.referenceAttributes || []).includes(attributeName)) {
const refError = this.checkAriaReferences(attributeName, value);
if (refError) errors.push(refError);
}
// Warn about any errors
errors.forEach(msg => {
console.warn(`[${this.constructor.name}] ARIA validation: ${msg}`);
});
}
}
// Export for use in other components
export default BaseComponent;