Skip to content

Commit 839d90f

Browse files
committed
chore(SlotAttributePropagationController): add tests + docs
1 parent 1f8e1df commit 839d90f

8 files changed

Lines changed: 836 additions & 4 deletions

File tree

2nd-gen/packages/core/controllers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ export {
4444
type PlacementOptions,
4545
type VirtualTrigger,
4646
} from './placement-controller/index.js';
47+
export {
48+
SlotAttributePropagationController,
49+
type SlotAttributePropagationControllerOptions,
50+
} from './slot-attribute-propagation/index.js';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
2+
import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks';
3+
4+
import * as Stories from './stories/slot-attribute-propagation.stories';
5+
6+
<Meta of={Stories} />
7+
8+
<DocsHeader />
9+
10+
## Usage
11+
12+
`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).
13+
14+
### Basic usage
15+
16+
1. Construct the controller in the host's constructor, passing `attribute`, `getValue`, and optionally `slotName` and `selector`.
17+
2. The controller propagates automatically in `hostUpdated()` whenever the value returned by `getValue` changes, including on first render.
18+
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.
19+
20+
```typescript
21+
import { LitElement, html } from 'lit';
22+
import { property } from 'lit/decorators.js';
23+
import { SlotAttributePropagationController } from '@spectrum-web-components/core/controllers/slot-attribute-propagation.js';
24+
25+
class MyBase extends LitElement {
26+
@property({ type: String, reflect: true })
27+
public size: MySize = 'm';
28+
29+
private readonly sizePropagation = new SlotAttributePropagationController(
30+
this,
31+
{
32+
attribute: 'size',
33+
getValue: () => this.size,
34+
slotName: 'actions',
35+
}
36+
);
37+
38+
protected handleActionsSlotChange(): void {
39+
this.sizePropagation.propagate();
40+
}
41+
42+
protected override render() {
43+
return html`
44+
<slot name="actions" @slotchange=${this.handleActionsSlotChange}></slot>
45+
`;
46+
}
47+
}
48+
```
49+
50+
## Options
51+
52+
### Default slot
53+
54+
Omit `slotName` to target the default (unnamed) slot. Every element assigned to it receives the attribute.
55+
56+
<Canvas of={Stories.DefaultSlot} />
57+
58+
### Named slot
59+
60+
Set `slotName` to target a specific named slot. Elements assigned to other slots on the same host, including the default slot, are left untouched.
61+
62+
<Canvas of={Stories.NamedSlot} />
63+
64+
### Selector filtering
65+
66+
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. There's no existing production consumer of this option yet; it's demonstrated here for the option's own sake.
67+
68+
<Canvas of={Stories.Selector} />
69+
70+
## Behaviors
71+
72+
### Propagation timing
73+
74+
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.
75+
76+
### Propagates to dynamically added elements
77+
78+
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.
79+
80+
<Canvas of={Stories.DynamicSlotting} />
81+
82+
### Disconnect and reconnect
83+
84+
`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).
85+
86+
## Accessibility
87+
88+
### Features
89+
90+
`SlotAttributePropagationController` sets a plain DOM attribute on assigned elements via `setAttribute`. 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.
91+
92+
### Best practices
93+
94+
- Use this controller for presentation-oriented attributes that slotted children are expected to read declaratively, such as `size` or a visual `variant`.
95+
- 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.
96+
- Always wire the targeted slot's `slotchange` event to `propagate()` so elements added after first render aren't missed.
97+
98+
<Canvas of={Stories.Accessibility} />
99+
100+
## API
101+
102+
### Methods
103+
104+
| Member | Description |
105+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
106+
| `propagate()` | Propagates the current value (from `getValue()`) to all matching assigned elements immediately, bypassing the no-op guard. Call from the host's `slotchange` handler. |
107+
108+
### Constructor options (`SlotAttributePropagationControllerOptions`)
109+
110+
| Option | Type | Description |
111+
| ----------- | -------------- | ------------------------------------------------------------------------------------------------------------- |
112+
| `attribute` | `string` | The attribute name to propagate to assigned elements. |
113+
| `getValue` | `() => string` | Returns the current value to propagate. |
114+
| `slotName` | `string` | Named slot to target. Omit for the default (unnamed) slot. |
115+
| `selector` | `string` | CSS selector used to filter assigned elements before propagating. Omit to propagate to all assigned elements. |
116+
117+
<DocsFooter />
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { css, html, LitElement, type TemplateResult } from 'lit';
14+
import { customElement } from 'lit/decorators.js';
15+
16+
import { SlotAttributePropagationController } from '../index.js';
17+
18+
declare global {
19+
interface HTMLElementTagNameMap {
20+
'demo-slot-propagation-default': DemoSlotPropagationDefault;
21+
'demo-slot-propagation-named': DemoSlotPropagationNamed;
22+
'demo-slot-propagation-selector': DemoSlotPropagationSelector;
23+
}
24+
}
25+
26+
// ─────────────────────────────────────────
27+
// DEFAULT (UNNAMED) SLOT DEMO HOST
28+
// ─────────────────────────────────────────
29+
30+
/**
31+
* @internal
32+
*
33+
* Storybook-only host demonstrating {@link SlotAttributePropagationController}
34+
* targeting the default (unnamed) slot, mirroring `ButtonGroupBase`'s usage:
35+
* no `slotName` and no `selector`, so every assigned element receives the
36+
* `size` attribute.
37+
*/
38+
@customElement('demo-slot-propagation-default')
39+
export class DemoSlotPropagationDefault extends LitElement {
40+
static override styles = css`
41+
:host {
42+
display: flex;
43+
gap: 8px;
44+
flex-wrap: wrap;
45+
}
46+
`;
47+
48+
static override properties = {
49+
size: { type: String },
50+
};
51+
52+
declare size: string;
53+
54+
constructor() {
55+
super();
56+
this.size = 'm';
57+
}
58+
59+
private readonly _sizePropagation = new SlotAttributePropagationController(
60+
this,
61+
{
62+
attribute: 'size',
63+
getValue: () => this.size,
64+
}
65+
);
66+
67+
/** Re-propagates to elements assigned after the first render. */
68+
private _handleSlotchange(): void {
69+
this._sizePropagation.propagate();
70+
}
71+
72+
protected override render(): TemplateResult {
73+
return html`
74+
<slot @slotchange=${this._handleSlotchange}></slot>
75+
`;
76+
}
77+
}
78+
79+
// ─────────────────────────────
80+
// NAMED SLOT DEMO HOST
81+
// ─────────────────────────────
82+
83+
/**
84+
* @internal
85+
*
86+
* Storybook-only host demonstrating {@link SlotAttributePropagationController}
87+
* targeting a named slot, mirroring `IllustratedMessageBase`'s usage:
88+
* `slotName: 'actions'` so only elements assigned to the `actions` slot
89+
* receive the `size` attribute. A default slot is also rendered to prove
90+
* elements assigned there are left untouched.
91+
*/
92+
@customElement('demo-slot-propagation-named')
93+
export class DemoSlotPropagationNamed extends LitElement {
94+
static override styles = css`
95+
:host {
96+
display: flex;
97+
flex-direction: column;
98+
gap: 8px;
99+
}
100+
.actions {
101+
display: flex;
102+
gap: 8px;
103+
flex-wrap: wrap;
104+
}
105+
`;
106+
107+
static override properties = {
108+
size: { type: String },
109+
};
110+
111+
declare size: string;
112+
113+
constructor() {
114+
super();
115+
this.size = 'm';
116+
}
117+
118+
private readonly _sizePropagation = new SlotAttributePropagationController(
119+
this,
120+
{
121+
attribute: 'size',
122+
getValue: () => this.size,
123+
slotName: 'actions',
124+
}
125+
);
126+
127+
/** Re-propagates to elements assigned to the `actions` slot after the first render. */
128+
private _handleActionsSlotchange(): void {
129+
this._sizePropagation.propagate();
130+
}
131+
132+
protected override render(): TemplateResult {
133+
return html`
134+
<slot></slot>
135+
<div class="actions">
136+
<slot
137+
name="actions"
138+
@slotchange=${this._handleActionsSlotchange}
139+
></slot>
140+
</div>
141+
`;
142+
}
143+
}
144+
145+
// ────────────────────────────────
146+
// SELECTOR-FILTERED DEMO HOST
147+
// ────────────────────────────────
148+
149+
/**
150+
* @internal
151+
*
152+
* Storybook-only host demonstrating the {@link SlotAttributePropagationController}
153+
* `selector` option: only default-slot elements matching `.target` receive
154+
* the `variant` attribute; sibling elements without that class are left
155+
* untouched.
156+
*/
157+
@customElement('demo-slot-propagation-selector')
158+
export class DemoSlotPropagationSelector extends LitElement {
159+
static override styles = css`
160+
:host {
161+
display: flex;
162+
gap: 8px;
163+
flex-wrap: wrap;
164+
}
165+
`;
166+
167+
static override properties = {
168+
variant: { type: String },
169+
};
170+
171+
declare variant: string;
172+
173+
constructor() {
174+
super();
175+
this.variant = 'accent';
176+
}
177+
178+
private readonly _variantPropagation = new SlotAttributePropagationController(
179+
this,
180+
{
181+
attribute: 'variant',
182+
getValue: () => this.variant,
183+
selector: '.target',
184+
}
185+
);
186+
187+
/** Re-propagates to matching elements assigned after the first render. */
188+
private _handleSlotchange(): void {
189+
this._variantPropagation.propagate();
190+
}
191+
192+
protected override render(): TemplateResult {
193+
return html`
194+
<slot @slotchange=${this._handleSlotchange}></slot>
195+
`;
196+
}
197+
}

0 commit comments

Comments
 (0)