From 71603720c7a7f87f3c0be4e18c0644d5aa9375cf Mon Sep 17 00:00:00 2001 From: mariohamann Date: Fri, 23 Jan 2026 14:11:26 +0100 Subject: [PATCH 1/2] feat: add custom attributes --- .../src/internal/custom-attributes.test.ts | 157 +++++++++++++++ .../src/internal/custom-attributes.ts | 184 ++++++++++++++++++ .../components/src/internal/solid-element.ts | 47 +++++ packages/components/src/solid-components.ts | 3 + .../packages/components/Custom Attributes.mdx | 146 ++++++++++++++ 5 files changed, 537 insertions(+) create mode 100644 packages/components/src/internal/custom-attributes.test.ts create mode 100644 packages/components/src/internal/custom-attributes.ts create mode 100644 packages/docs/src/stories/packages/components/Custom Attributes.mdx diff --git a/packages/components/src/internal/custom-attributes.test.ts b/packages/components/src/internal/custom-attributes.test.ts new file mode 100644 index 0000000000..33a406dbe9 --- /dev/null +++ b/packages/components/src/internal/custom-attributes.test.ts @@ -0,0 +1,157 @@ +import '../../dist/solid-components'; +import { expect, fixture, html } from '@open-wc/testing'; +import sinon from 'sinon'; +import type SdButton from '../components/button/button'; + +describe('CustomAttributesController', () => { + describe('single object format', () => { + it('should apply a single attribute to the base element', async () => { + const el = await fixture(html` + Button + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement).to.exist; + expect(baseElement!.getAttribute('aria-label')).to.equal('Test Label'); + }); + + it('should apply multiple attributes from a single object', async () => { + const el = await fixture(html` + Button + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-expanded')).to.equal('false'); + expect(baseElement!.getAttribute('aria-haspopup')).to.equal('true'); + }); + }); + + describe('array of objects format', () => { + it('should apply attributes from an array of objects', async () => { + const el = await fixture(html` + Button + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-expanded')).to.equal('false'); + expect(baseElement!.getAttribute('aria-haspopup')).to.equal('true'); + }); + }); + + describe('targeted queries', () => { + it('should apply attributes to elements matching part query', async () => { + const el = await fixture(html` + + Button + + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-label')).to.equal('Targeted Label'); + }); + + it('should apply base attributes and targeted attributes together', async () => { + const el = await fixture(html` + + Button + + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-expanded')).to.equal('false'); + expect(baseElement!.getAttribute('aria-label')).to.equal('Targeted'); + }); + }); + + describe('setCustomAttributes method', () => { + it('should apply attributes programmatically with a single object', async () => { + const el = await fixture(html`Button`); + + el.setCustomAttributes({ 'aria-label': 'Programmatic Label' }); + await el.updateComplete; + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-label')).to.equal('Programmatic Label'); + }); + + it('should apply attributes programmatically with an array', async () => { + const el = await fixture(html`Button`); + + el.setCustomAttributes([{ 'aria-expanded': 'true' }, { 'aria-pressed': 'false' }]); + await el.updateComplete; + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-expanded')).to.equal('true'); + expect(baseElement!.getAttribute('aria-pressed')).to.equal('false'); + }); + }); + + describe('attribute updates', () => { + it('should clear old attributes when value changes', async () => { + const el = await fixture(html` + Button + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-label')).to.equal('Old Label'); + + el.setAttribute('custom-attributes', '{"aria-expanded": "true"}'); + await el.updateComplete; + + expect(baseElement!.getAttribute('aria-label')).to.be.null; + expect(baseElement!.getAttribute('aria-expanded')).to.equal('true'); + }); + + it('should clear all attributes when value is removed', async () => { + const el = await fixture(html` + Button + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('aria-label')).to.equal('Test Label'); + + el.removeAttribute('custom-attributes'); + await el.updateComplete; + + expect(baseElement!.getAttribute('aria-label')).to.be.null; + }); + }); + + describe('error handling', () => { + it('should handle invalid JSON gracefully', async () => { + const consoleErrorSpy = sinon.spy(console, 'error'); + + const el = await fixture(html` Button `); + + expect(consoleErrorSpy.calledOnce).to.be.true; + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + // Should not have any custom attributes applied + expect(baseElement!.getAttribute('aria-label')).to.be.null; + + consoleErrorSpy.restore(); + }); + + it('should handle null/empty values', async () => { + const el = await fixture(html`Button`); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + // Should render without errors + expect(baseElement).to.exist; + }); + }); + + describe('non-aria attributes', () => { + it('should allow setting any attribute, not just aria-*', async () => { + const el = await fixture(html` + Button + `); + + const baseElement = el.shadowRoot!.querySelector('[part~="base"]'); + expect(baseElement!.getAttribute('data-testid')).to.equal('my-button'); + expect(baseElement!.getAttribute('role')).to.equal('switch'); + }); + }); +}); diff --git a/packages/components/src/internal/custom-attributes.ts b/packages/components/src/internal/custom-attributes.ts new file mode 100644 index 0000000000..7512f353fb --- /dev/null +++ b/packages/components/src/internal/custom-attributes.ts @@ -0,0 +1,184 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +/** + * Represents a single attribute to set on an element. + */ +export interface CustomAttribute { + [key: string]: string; +} + +/** + * Represents a targeted attribute configuration for a specific element. + */ +export interface TargetedCustomAttributes { + query: string; + attributes: CustomAttribute[]; +} + +/** + * The value type for the custom-attributes property. + * Can be: + * - A single object: `{"aria-label": "hello"}` + * - An array of objects: `[{"aria-role": "switch"}, {"aria-checked": "true"}]` + * - An array with targeted queries: `[{"aria-role": "switch"}, {"query": "summary", "attributes": [{"aria-label": "hello"}]}]` + */ +export type CustomAttributesValue = CustomAttribute | (CustomAttribute | TargetedCustomAttributes)[] | null; + +/** + * Type guard to check if an item is a targeted custom attributes configuration. + */ +function isTargetedCustomAttributes( + item: CustomAttribute | TargetedCustomAttributes +): item is TargetedCustomAttributes { + return 'query' in item && 'attributes' in item && Array.isArray((item as TargetedCustomAttributes).attributes); +} + +/** + * Custom converter for the custom-attributes property. + * Handles JSON parsing and validation. + */ +export const customAttributesConverter = { + fromAttribute(value: string | null): CustomAttributesValue { + if (!value) return null; + try { + return JSON.parse(value) as CustomAttributesValue; + } catch (error) { + console.error('Error parsing custom-attributes:', error); + return null; + } + }, + toAttribute(value: CustomAttributesValue): string | null { + if (!value) return null; + return JSON.stringify(value); + } +}; + +/** + * This controller handles reflecting custom attributes to elements inside the shadow DOM. + * + * It works by parsing the `custom-attributes` property which should be a JSON value containing + * attributes to set on internal elements. By default, attributes are set on the element with + * `part="base"`. Targeted queries can be used to set attributes on specific elements. + * + * @example + * ```html + * + * + * + * + * + * + * + * + * ``` + */ +export class CustomAttributesController implements ReactiveController { + host: ReactiveControllerHost & HTMLElement; + private _appliedAttributes: Map> = new Map(); + + constructor(host: ReactiveControllerHost & HTMLElement) { + this.host = host; + host.addController(this); + } + + hostConnected(): void { + // Initial application will happen after first render via hostUpdated + } + + hostUpdated(): void { + this.applyCustomAttributes(); + } + + hostDisconnected(): void { + this.clearAllAttributes(); + } + + /** + * Gets the current custom-attributes value from the host. + */ + private getCustomAttributesValue(): CustomAttributesValue { + // Access the property directly from the host + return (this.host as unknown as { customAttributes: CustomAttributesValue }).customAttributes; + } + + /** + * Applies custom attributes to the appropriate elements in the shadow DOM. + */ + applyCustomAttributes(): void { + const value = this.getCustomAttributesValue(); + + // Clear previously applied attributes first + this.clearAllAttributes(); + + if (!value) return; + + const shadowRoot = this.host.shadowRoot; + if (!shadowRoot) return; + + // Normalize to array format + const items: (CustomAttribute | TargetedCustomAttributes)[] = Array.isArray(value) ? value : [value]; + + // Separate base attributes and targeted attributes + const baseAttributes: CustomAttribute[] = []; + const targetedConfigs: TargetedCustomAttributes[] = []; + + for (const item of items) { + if (isTargetedCustomAttributes(item)) { + targetedConfigs.push(item); + } else { + baseAttributes.push(item); + } + } + + // Apply base attributes to part="base" element + if (baseAttributes.length > 0) { + const baseElement = shadowRoot.querySelector('[part~="base"]'); + if (baseElement) { + this.applyAttributesToElement(baseElement, baseAttributes); + } + } + + // Apply targeted attributes + for (const config of targetedConfigs) { + // Support both part names and CSS selectors + let targetElement = shadowRoot.querySelector(`[part~="${config.query}"]`); + if (!targetElement) { + targetElement = shadowRoot.querySelector(config.query); + } + if (targetElement) { + this.applyAttributesToElement(targetElement, config.attributes); + } + } + } + + /** + * Applies a list of attribute objects to an element. + */ + private applyAttributesToElement(element: Element, attributes: CustomAttribute[]): void { + const appliedSet = this._appliedAttributes.get(element) || new Set(); + + for (const attrObj of attributes) { + for (const [name, value] of Object.entries(attrObj)) { + element.setAttribute(name, value); + appliedSet.add(name); + } + } + + this._appliedAttributes.set(element, appliedSet); + } + + /** + * Clears all previously applied attributes. + */ + private clearAllAttributes(): void { + for (const [element, attributes] of this._appliedAttributes) { + for (const attr of attributes) { + element.removeAttribute(attr); + } + } + this._appliedAttributes.clear(); + } +} diff --git a/packages/components/src/internal/solid-element.ts b/packages/components/src/internal/solid-element.ts index 48fe3f0c50..251b37d78b 100644 --- a/packages/components/src/internal/solid-element.ts +++ b/packages/components/src/internal/solid-element.ts @@ -1,4 +1,5 @@ import { cssVar, parseDuration } from './animate'; +import { CustomAttributesController, customAttributesConverter, type CustomAttributesValue } from './custom-attributes'; import { LitElement, unsafeCSS } from 'lit'; import { property } from 'lit/decorators.js'; @@ -15,6 +16,29 @@ export default class SolidElement extends LitElement { /** The element's language. */ @property() lang: string; + /** + * Custom attributes to reflect to internal elements. + * Accepts a JSON object or array of objects with attributes to set. + * By default, attributes are applied to the element with `part="base"`. + * Use targeted queries to apply attributes to specific elements. + * + * @example + * ```html + * + * + * + * + * + * + * + * + * ``` + */ + @property({ attribute: 'custom-attributes', converter: customAttributesConverter }) + customAttributes: CustomAttributesValue = null; + + private _customAttributesController = new CustomAttributesController(this); + protected onThemeChange?(e: CustomEvent<{ theme: string }>): void; connectedCallback(): void { @@ -127,6 +151,29 @@ export default class SolidElement extends LitElement { const processor = Object.keys(tokenProcessors).find(token => name.startsWith(token)); return (tokenProcessors[processor ?? name]?.(value) as T) ?? (value as T) ?? fallback; } + + /** + * Sets custom attributes programmatically. + * This is an alternative to setting the `custom-attributes` attribute directly. + * + * @example + * ```js + * const button = document.querySelector('sd-button'); + * button.setCustomAttributes({ 'aria-label': 'hello' }); + * + * // Or with multiple attributes + * button.setCustomAttributes([{ 'aria-expanded': 'false' }, { 'aria-haspopup': 'true' }]); + * + * // Or with targeted queries + * details.setCustomAttributes([ + * { 'aria-role': 'switch' }, + * { query: 'summary', attributes: [{ 'aria-label': 'toggle' }] } + * ]); + * ``` + */ + setCustomAttributes(value: CustomAttributesValue): void { + this.customAttributes = value; + } } export interface SolidFormControl extends SolidElement { diff --git a/packages/components/src/solid-components.ts b/packages/components/src/solid-components.ts index 443168a95a..1f5ac00d9a 100644 --- a/packages/components/src/solid-components.ts +++ b/packages/components/src/solid-components.ts @@ -63,3 +63,6 @@ export { default as SdThemeListener } from './components/theme-listener/theme-li export * from './utilities/icon-library'; export * from './utilities/localize'; export * from './utilities/autocomplete-config'; + +// Types for custom attributes +export type { CustomAttribute, TargetedCustomAttributes, CustomAttributesValue } from './internal/custom-attributes'; diff --git a/packages/docs/src/stories/packages/components/Custom Attributes.mdx b/packages/docs/src/stories/packages/components/Custom Attributes.mdx new file mode 100644 index 0000000000..e07ae512d5 --- /dev/null +++ b/packages/docs/src/stories/packages/components/Custom Attributes.mdx @@ -0,0 +1,146 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Custom Attributes + +Solid components support reflecting custom attributes to internal shadow DOM elements. This is particularly useful for accessibility scenarios where you need to set ARIA attributes on the actual interactive element inside a component. + +## The Problem + +Web components encapsulate their internal structure in shadow DOM. When you set an attribute like `aria-expanded` on a Solid component, it stays on the outer host element rather than the interactive element inside: + +```html + + +``` + +For accessibility tools to work correctly, these attributes often need to be on the actual interactive element (button, link, input, etc.) inside the shadow DOM. + +## Solution: custom-attributes + +The `custom-attributes` property allows you to specify attributes that should be reflected to internal elements. By default, attributes are applied to the element with `part="base"`, which is typically the main interactive element. + +### Single Attribute + +Set a single attribute using a JSON object: + +```html +× +``` + +### Multiple Attributes + +Set multiple attributes using either a single object or an array of objects: + +```html + + Menu + + + Menu +``` + +### Targeted Elements + +For components with multiple interactive elements, you can target specific elements using queries. The query can be either a part name or a CSS selector: + +```html + + + Section Title + Content goes here... + +``` + +In this example: + +- `{"aria-label": "Section controls"}` is applied to the base element +- `{"query": "summary", "attributes": [...]}` is applied to the element with `part="summary"` + +## Programmatic API + +You can also set custom attributes programmatically using the `setCustomAttributes()` method: + +```html +Menu + + +``` + +## Common Use Cases + +### Accessible Toggle Buttons + +```html + + Toggle Panel + + + + +``` + +### Links with Descriptive Labels + +```html + Read more +``` + +### Menu Buttons + +```html + Options +``` + +## Attribute Lifecycle + +- When `custom-attributes` changes, previously applied attributes are automatically removed before new ones are applied +- When `custom-attributes` is removed or set to `null`, all previously applied custom attributes are cleared +- Invalid JSON is handled gracefully with a console error, and no attributes are applied + +## TypeScript Support + +Types are exported for use with the programmatic API: + +```ts +import type { CustomAttribute, TargetedCustomAttributes, CustomAttributesValue } from '@solid-design-system/components'; + +// CustomAttribute: { [key: string]: string } +// TargetedCustomAttributes: { query: string; attributes: CustomAttribute[] } +// CustomAttributesValue: CustomAttribute | (CustomAttribute | TargetedCustomAttributes)[] | null +``` + +## Notes + +- Any attribute can be set, not just ARIA attributes. However, this feature is primarily intended for accessibility attributes. +- For `aria-hidden`, consider whether it should be on the host element or the internal element based on your use case. Setting `aria-hidden` on the host element hides the entire component from assistive technologies. +- The default target is the element with `part="base"`. Most Solid components use this convention for their main interactive element. From acb06bc1e9651cb479bada3931d4349efcfd01e3 Mon Sep 17 00:00:00 2001 From: mariohamann Date: Fri, 23 Jan 2026 15:41:53 +0100 Subject: [PATCH 2/2] fix --- packages/components/src/internal/solid-element.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/internal/solid-element.ts b/packages/components/src/internal/solid-element.ts index 251b37d78b..7f535a14fe 100644 --- a/packages/components/src/internal/solid-element.ts +++ b/packages/components/src/internal/solid-element.ts @@ -37,13 +37,13 @@ export default class SolidElement extends LitElement { @property({ attribute: 'custom-attributes', converter: customAttributesConverter }) customAttributes: CustomAttributesValue = null; - private _customAttributesController = new CustomAttributesController(this); - protected onThemeChange?(e: CustomEvent<{ theme: string }>): void; connectedCallback(): void { super.connectedCallback(); + void new CustomAttributesController(this); + if (!this.onThemeChange) return; this.renderRoot.addEventListener('sd-theme-change', this.onThemeChange.bind(this)); }