Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions packages/components/src/internal/custom-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -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<SdButton>(html`
<sd-button custom-attributes='{"aria-label": "Test Label"}'>Button</sd-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<SdButton>(html`
<sd-button custom-attributes='{"aria-expanded": "false", "aria-haspopup": "true"}'>Button</sd-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<SdButton>(html`
<sd-button custom-attributes='[{"aria-expanded": "false"}, {"aria-haspopup": "true"}]'>Button</sd-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<SdButton>(html`
<sd-button custom-attributes='[{"query": "base", "attributes": [{"aria-label": "Targeted Label"}]}]'>
Button
</sd-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<SdButton>(html`
<sd-button
custom-attributes='[{"aria-expanded": "false"}, {"query": "base", "attributes": [{"aria-label": "Targeted"}]}]'
>
Button
</sd-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<SdButton>(html`<sd-button>Button</sd-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<SdButton>(html`<sd-button>Button</sd-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<SdButton>(html`
<sd-button custom-attributes='{"aria-label": "Old Label"}'>Button</sd-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<SdButton>(html`
<sd-button custom-attributes='{"aria-label": "Test Label"}'>Button</sd-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<SdButton>(html` <sd-button custom-attributes="not valid json">Button</sd-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<SdButton>(html`<sd-button custom-attributes="">Button</sd-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<SdButton>(html`
<sd-button custom-attributes='{"data-testid": "my-button", "role": "switch"}'>Button</sd-button>
`);

const baseElement = el.shadowRoot!.querySelector('[part~="base"]');
expect(baseElement!.getAttribute('data-testid')).to.equal('my-button');
expect(baseElement!.getAttribute('role')).to.equal('switch');
});
});
});
184 changes: 184 additions & 0 deletions packages/components/src/internal/custom-attributes.ts
Original file line number Diff line number Diff line change
@@ -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
* <!-- Set aria-label on the base element -->
* <sd-button custom-attributes='{"aria-label": "hello"}'></sd-button>
*
* <!-- Set multiple attributes on the base element -->
* <sd-button custom-attributes='[{"aria-role": "switch"}, {"aria-checked": "true"}]'></sd-button>
*
* <!-- Set attributes on specific elements using queries -->
* <sd-details custom-attributes='[
* {"aria-role": "switch"},
* {"query": "summary", "attributes": [{"aria-label": "toggle"}]}
* ]'></sd-details>
* ```
*/
export class CustomAttributesController implements ReactiveController {
host: ReactiveControllerHost & HTMLElement;
private _appliedAttributes: Map<Element, Set<string>> = 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();
}
}
Loading
Loading