Skip to content
Open
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
4 changes: 4 additions & 0 deletions 2nd-gen/packages/core/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,7 @@ export {
type PlacementOptions,
type VirtualTrigger,
} from './placement-controller/index.js';
export {
SlotAttributePropagationController,
type SlotAttributePropagationControllerOptions,
} from './slot-attribute-propagation/index.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks';

import * as Stories from './stories/slot-attribute-propagation.stories';

<Meta of={Stories} />

<DocsHeader packageName="slot-attribute-propagation" />

## Usage

`SlotAttributePropagationController` keeps a slot's assigned elements in sync with a host attribute, so consuming components don't each re-implement the same slot-observation logic. It's used by `ButtonGroup` (propagating `size` to the default slot) and `IllustratedMessage` (propagating `size` to the named `actions` slot).

### Basic usage

1. Construct the controller in the host's constructor, passing `attribute`, `getValue`, and optionally `slotName` and `selector`.
2. The controller propagates automatically in `hostUpdated()` whenever the value returned by `getValue` changes, including on first render.
3. Wire the host's `slotchange` event on the targeted slot to call `propagate()`, so elements assigned after the first render (for example, content added dynamically) still receive the attribute. `slotchange` does not itself trigger a Lit update, so this call is required.

```typescript
import { LitElement, html } from 'lit';
import { property } from 'lit/decorators.js';
import { SlotAttributePropagationController } from '@spectrum-web-components/core/controllers/slot-attribute-propagation.js';

class MyBase extends LitElement {
@property({ type: String, reflect: true })
public size: MySize = 'm';

private readonly sizePropagation = new SlotAttributePropagationController(
this,
{
attribute: 'size',
getValue: () => this.size,
slotName: 'actions',
}
);

protected handleActionsSlotChange(): void {
this.sizePropagation.propagate();
}

protected override render() {
return html`
<slot name="actions" @slotchange=${this.handleActionsSlotChange}></slot>
`;
}
}
```

`getValue` can also return `null` for attributes that are only sometimes present on the host; see "Optional attributes" under Behaviors below.

## Options

### Default slot

Omit `slotName` to target the default (unnamed) slot. Every element assigned to it receives the attribute.

<Canvas of={Stories.DefaultSlot} />

### Named slot

Set `slotName` to target a specific named slot. Elements assigned to other slots on the same host, including the default slot, are left untouched.

<Canvas of={Stories.NamedSlot} />

### Selector filtering

Set `selector` to a CSS selector to propagate the attribute only to assigned elements that match it. Elements in the same slot that don't match are left untouched.

<Canvas of={Stories.Selector} />

## Behaviors

### Propagation timing

The controller propagates the current value to all matching assigned elements on first render and again in `hostUpdated()` any time the value returned by `getValue` differs from the value tracked at the last propagation. If the value hasn't changed, `hostUpdated()` is a no-op: the controller does not touch assigned elements on every unrelated host update.

### Optional attributes

`getValue` can return `null` for attributes that are only sometimes present on the host. When it does, the controller removes the attribute from assigned elements via `removeAttribute` instead of setting it to an empty string. This is the mechanism to use for attributes that are genuinely optional, such as `invalid`, rather than always-present attributes like `size`.

<Canvas of={Stories.OptionalAttributeRemoval} />

### Propagates to dynamically added elements

Elements assigned to the slot after the first render (for example, appended to the host's light DOM at runtime) don't trigger a Lit update on their own, so they aren't covered by the automatic `hostUpdated()` propagation. Call `propagate()` from the host's `slotchange` handler to cover them.

<Canvas of={Stories.DynamicSlotting} />

### Disconnect and reconnect

`hostDisconnected()` clears the internally tracked value. If the host reconnects and updates again with the _same_ value it had before disconnecting, the controller still re-propagates, because the internal guard has no prior value to compare against. This keeps propagation correct after a host is moved in the DOM (which disconnects and reconnects it).

## Accessibility

### Features

`SlotAttributePropagationController` sets a plain DOM attribute on assigned elements via `setAttribute`, or removes it via `removeAttribute` when `getValue` returns `null`. It does not manage ARIA relationships, roles, or attributes on its own: it will propagate whatever attribute name it's configured with, including an ARIA attribute, but the consuming component is responsible for deciding whether that's an appropriate way to convey that particular relationship.

### Best practices

- Use this controller for presentation-oriented attributes that slotted children are expected to read declaratively, such as `size` or a visual `variant`.
- Prefer explicit ARIA wiring (for example `aria-describedby` set directly on the relevant elements) over propagating ARIA attributes through this controller, since assigned elements may have their own ARIA semantics that a blanket propagation could override unexpectedly.
- Always wire the targeted slot's `slotchange` event to `propagate()` so elements added after first render aren't missed.

<Canvas of={Stories.Accessibility} />

## API

@rubencarvalho rubencarvalho Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was reviewing the existing API contract and then i checked the controller implementation and noticed one thing, let me know what you think about both getValue supporting string | null, whereas null removes the attribute? Since this is a general "agnostic controller", we may need to propagate optional attributes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took a look at this in b109cca. Let me know what you think ✨


### Methods

| Member | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `propagate()` | Propagates the current value (from `getValue()`) to all matching assigned elements immediately, without waiting for `hostUpdated()`. Also updates the internally tracked value, so a subsequent `hostUpdated()` with the same value is a no-op. Call from the host's `slotchange` handler. |

### Constructor options (`SlotAttributePropagationControllerOptions`)

| Option | Type | Description |
| ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------- |
| `attribute` | `string` | The attribute name to propagate to assigned elements. |
| `getValue` | `() => string \| null` | Returns the current value to propagate. Return `null` to remove the attribute from assigned elements. |
| `slotName` | `string` | Named slot to target. Omit for the default (unnamed) slot. |
| `selector` | `string` | CSS selector used to filter assigned elements before propagating. Omit to propagate to all assigned elements. |

<DocsFooter />
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,13 @@ import type { ReactiveController, ReactiveElement } from 'lit';
export interface SlotAttributePropagationControllerOptions {
/** The attribute name to propagate to assigned elements. */
attribute: string;
/** Returns the current value to propagate. */
getValue: () => string;

/**
* Returns the current value to propagate. Return `null` to remove the
* attribute from assigned elements, for attributes that are only
* sometimes present on the host.
*/
getValue: () => string | null;
/** Named slot to target. Omit for the default (unnamed) slot. */
slotName?: string;

Expand All @@ -43,6 +48,10 @@ export interface SlotAttributePropagationControllerOptions {
* value returned by `getValue` changes. Call `propagate()` from the host's
* `slotchange` handler to cover elements inserted after the first render.
*
* `getValue` may return `null` for attributes that are only sometimes present
* on the host; the controller removes the attribute from assigned elements
* rather than setting it to an empty string.
*
* @example
* ```ts
* class MyBase extends SpectrumElement {
Expand All @@ -64,7 +73,7 @@ export interface SlotAttributePropagationControllerOptions {
export class SlotAttributePropagationController implements ReactiveController {
private readonly _host: ReactiveElement;
private readonly _options: SlotAttributePropagationControllerOptions;
private _previousValue?: string;
private _previousValue?: string | null;

constructor(
host: ReactiveElement,
Expand All @@ -82,30 +91,44 @@ export class SlotAttributePropagationController implements ReactiveController {
public hostUpdated(): void {
const value = this._options.getValue();
if (value !== this._previousValue) {
this._previousValue = value;
this._propagateToSlot(value);
}
}

/**
* Propagates the current value to all matching assigned elements.
* Call this from the host's `slotchange` event handler.
* Propagates the current value to all matching assigned elements
* immediately, updating the value tracked for the `hostUpdated()` no-op
* guard so it isn't repeated once the pending update runs. Call this from
* the host's `slotchange` event handler.
*/
public propagate(): void {
this._propagateToSlot(this._options.getValue());
}

private _propagateToSlot(value: string): void {
private _propagateToSlot(value: string | null): void {
const slot = this._resolveSlot();
if (!slot) {
return;
}
// Tracked here (not just in hostUpdated()) so a propagate() call from a
// slotchange handler also updates the no-op guard; otherwise a
// subsequently-scheduled hostUpdated() with the same value would repeat
// the same setAttribute()/removeAttribute() sweep over every target. Set
// only once the slot resolves: if a call (from either hostUpdated() or
// propagate()) can't resolve the slot yet, the value must not be marked
// as applied, or a slot that resolves later with an unchanged value
// would be silently skipped forever.
this._previousValue = value;
const assigned = slot.assignedElements({ flatten: true });
const targets = this._options.selector
? assigned.filter((el) => el.matches(this._options.selector!))
: assigned;
for (const el of targets) {
el.setAttribute(this._options.attribute, value);
if (value === null) {
el.removeAttribute(this._options.attribute);
} else {
el.setAttribute(this._options.attribute, value);
}
}
}

Expand Down
Loading
Loading