From 02db50dc5a93f4278294927eed6ecc1b46306f84 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 11 Jun 2026 15:34:12 -0400 Subject: [PATCH 01/32] feat(core): init poc selection controller --- .claude/settings.json | 5 + 2nd-gen/packages/core/controllers/index.ts | 9 + .../controllers/selection-controller/index.ts | 21 + .../selection-controller.mdx | 305 ++++++ .../src/selection-controller.ts | 610 +++++++++++ .../stories/demo-hosts.ts | 969 ++++++++++++++++++ .../stories/selection-controller.stories.ts | 461 +++++++++ 7 files changed, 2380 insertions(+) create mode 100644 .claude/settings.json create mode 100644 2nd-gen/packages/core/controllers/selection-controller/index.ts create mode 100644 2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx create mode 100644 2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts create mode 100644 2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts create mode 100644 2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..4326985ea57 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(npx tsc *)", "Bash(echo \"Exit code: $?\")"] + } +} diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index a8e2892842d..3990494a51a 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -39,3 +39,12 @@ export { type PlacementOptions, type VirtualTrigger, } from './placement-controller/index.js'; +export { + deepestSelectionItemContaining, + SelectionController, + selectionControllerChange, + type SelectionControllerChangeDetail, + type SelectionControllerConfirmDetail, + type SelectionControllerOptions, + type SelectionMode, +} from './selection-controller/index.js'; diff --git a/2nd-gen/packages/core/controllers/selection-controller/index.ts b/2nd-gen/packages/core/controllers/selection-controller/index.ts new file mode 100644 index 00000000000..bc1bc28cdc6 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/index.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export { + deepestSelectionItemContaining, + SelectionController, + selectionControllerChange, + type SelectionControllerChangeDetail, + type SelectionControllerConfirmDetail, + type SelectionControllerOptions, + type SelectionMode, +} from './src/selection-controller.js'; diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx new file mode 100644 index 00000000000..2d9ec45f283 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -0,0 +1,305 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks'; + +import * as SelectionControllerStories from './stories/selection-controller.stories'; + + + + + +## What it does + +### Modes + +`SelectionController` supports three selection modes that can be set in the constructor and changed at runtime via `setOptions`: + +- **`single`**: at most one item is selected at a time. Clicking the active item has no effect. Use for tab lists, mandatory radio groups, and exclusive category filters. +- **`single-toggle`**: at most one item is selected at a time. Clicking the active item deselects it (clears the selection). Use for optional ratings, toggleable accordion headers, and filters where clearing is valid. +- **`multiple`**: any number of items may be selected; each click toggles one item independently. Use for multiselect listboxes, checkboxes, and tag filters. + +### Events and callbacks + +- **`swc-selection-controller-change`** (`selectionControllerChange`): dispatched on the host (bubbles, composed) with `{ selectedItems, addedItems, removedItems }` whenever the selection changes. +- **`onSelectionChange`**: optional callback with the same payload. +- **`confirmSelectionChange`**: optional gate; return `false` to abort a pending transition before any DOM mutators run. + +### Programmatic API + +- **`setSelectedItem(item | null)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only). +- **`toggleItem(item)`**: toggle using the current mode rules. +- **`selectAll()`**: select all eligible items (multiple mode only). +- **`clearAll()`**: deselect all items (single-toggle and multiple modes). +- **`getSelectedItems()`**: returns the current selection as an ordered array. +- **`isSelected(item)`**: check whether a specific item is selected. + +## Basic usage + +1. Construct the controller in your element's `constructor`, passing `getItems`, `selectItem`, `deselectItem`, and `mode`. +2. Ensure `getItems` returns live `HTMLElement` references from `this.renderRoot` or slotted content. +3. After the first render, call **`refresh()`** from `firstUpdated` so the initial selection state is applied. +4. Provide appropriate **roles**, **ARIA attributes**, and **labels** on the host and items — the controller does not set them. +5. Pair with **`FocusgroupNavigationController`** when the composite also needs roving `tabindex`, arrow keys, Home/End, or focus memory. + +```typescript +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { + FocusgroupNavigationController, + SelectionController, +} from '@spectrum-web-components/core/controllers'; + +@customElement('my-listbox') +export class MyListbox extends LitElement { + private items: HTMLElement[] = []; + + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: false, + getItems: () => this.items, + }); + + private readonly selection = new SelectionController(this, { + getItems: () => this.items, + selectItem: (el) => el.setAttribute('aria-selected', 'true'), + deselectItem: (el) => el.setAttribute('aria-selected', 'false'), + mode: 'multiple', + keydownActivation: true, + }); + + protected override firstUpdated(): void { + this.items = Array.from( + this.renderRoot.querySelectorAll('[role="option"]') + ); + this.navigation.refresh(); + this.selection.refresh(); + } + + protected override render() { + return html` +
+
Item A
+
Item B
+
Item C
+
+ `; + } +} +``` + +## Behaviors + +### `single` mode + +In `single` mode, at most one item is selected at a time. Clicking the active item has no effect. Arrow keys (via `FocusgroupNavigationController`) move focus without changing selection; press **Enter** or **Space** to select the focused item when `keydownActivation` is `true`. + +```typescript +private readonly selection = new SelectionController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-star]')), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + mode: 'single', + keydownActivation: true, +}); +``` + + + +### `single-toggle` mode + +In `single-toggle` mode, clicking the active item deselects it (clears the selection). Clicking a different item replaces the selection. This is useful for optional ratings and accordion headers where all panels may legitimately be closed. + +```typescript +private readonly selection = new SelectionController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-star]')), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + mode: 'single-toggle', + keydownActivation: true, +}); +``` + + + +### `multiple` mode: listbox with `selectAll` and `clearAll` + +In `multiple` mode, each click independently toggles one item. Call `selectAll()` to select all eligible items and `clearAll()` to deselect all of them. Pair with `FocusgroupNavigationController` for keyboard navigation; set `keydownActivation: true` so **Enter** / **Space** toggle items. + +```typescript +private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: false, + getItems: () => this.options, +}); + +private readonly selection = new SelectionController(this, { + getItems: () => this.options, + selectItem: (el) => el.setAttribute('aria-selected', 'true'), + deselectItem: (el) => el.setAttribute('aria-selected', 'false'), + mode: 'multiple', + keydownActivation: true, +}); + +// "Select all" button: +this.selection.selectAll(); + +// "Clear" button: +this.selection.clearAll(); +``` + + + +### Accordion: runtime mode switch via `setOptions` + +Call `setOptions({ mode })` to switch modes at runtime without reconstructing the controller. This accordion starts in `single` mode; the buttons switch it to `single-toggle` or `multiple` on the fly. When switching from `multiple` back to a single-item mode while more than one panel is open, all but the first are closed automatically and a change event is dispatched for the deselected items. + +```typescript +// Initial setup: +private readonly accordionSelection = new SelectionController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-accordion]')), + selectItem: (header) => { + header.setAttribute('aria-expanded', 'true'); + this.togglePanel(header.dataset.accordion, true); + }, + deselectItem: (header) => { + header.setAttribute('aria-expanded', 'false'); + this.togglePanel(header.dataset.accordion, false); + }, + mode: 'single', +}); + +// Switching modes later (e.g. from a user action): +this.accordionSelection.setOptions({ mode: 'multiple' }); +``` + + + +### Tablist: `FocusgroupNavigationController` + `SelectionController` + +For a manual-activation tab list, pair `FocusgroupNavigationController` (arrow keys, roving `tabindex`, Home/End) with `SelectionController` (`mode: 'single'`, `keydownActivation: true`). Arrow keys only move focus; **Enter** or **Space** selects and activates the focused tab. + +```typescript +import { + FocusgroupNavigationController, + SelectionController, +} from '@spectrum-web-components/core/controllers'; + +private tabButtons: HTMLButtonElement[] = []; + +private readonly tabNavigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => this.tabButtons, +}); + +private readonly tabSelection = new SelectionController(this, { + getItems: () => this.tabButtons, + selectItem: (tab) => { + tab.setAttribute('aria-selected', 'true'); + this.showPanelForTab(tab); + }, + deselectItem: (tab) => { + tab.setAttribute('aria-selected', 'false'); + this.hidePanelForTab(tab); + }, + mode: 'single', + keydownActivation: true, + defaultToFirstSelectable: true, +}); +``` + + + +## Accessibility + +### Features + +The `SelectionController` implements several accessibility features: + +#### Pointer interaction + +Capture-phase `click` listener resolves the deepest eligible item in the composed event path. Primary clicks only (button 0, no modifier keys). Disabled and `aria-disabled="true"` items are never toggled. + +#### Keyboard interaction + +With `keydownActivation: true`, capture-phase `keydown` for **Enter** / **Space** applies the current mode rules to the deepest eligible item on the event path. Modifier keys (Ctrl, Meta, Shift, Alt) and `event.repeat` are ignored. + +The controller does **not** implement roving `tabindex`, arrow keys, Home/End, or programmatic `focus()`. Add `FocusgroupNavigationController` when the composite needs those behaviors. + +#### ARIA management + +The controller does not read or set ARIA attributes itself — it calls your `selectItem` / `deselectItem` callbacks and leaves ARIA management to you. Keep `aria-selected`, `aria-checked`, `aria-expanded`, and related attributes in sync inside those two callbacks. + +For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host element so screen readers announce the correct interaction model. + +### Best practices + +- Always provide `role` and `aria-label` or `aria-labelledby` on the host and items; the controller does not set these. +- Use `aria-selected` for `option` and `tab` roles; `aria-checked` for `radio` and `menuitemradio` roles; `aria-expanded` for accordion headers. +- For multiselect listboxes, set `aria-multiselectable="true"` on the host. +- Use `aria-disabled="true"` instead of native `disabled` when items should remain focusable but not selectable. +- Call `refresh()` after any DOM change that adds, removes, or hides items. +- Pair with `FocusgroupNavigationController` for composites that also need arrow-key focus movement. + +## API + +### Methods + +| Member | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes and calls `refresh()`. | +| `refresh()` | Remove stale selections and optionally select the first item (`defaultToFirstSelectable`). | +| `getSelectedItems()` | Returns the current selection as an ordered array. | +| `isSelected(item)` | Returns `true` when `item` is currently selected. | +| `getMode()` | Returns the current `SelectionMode`. | +| `setSelectedItem(item \| null)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes). | +| `toggleItem(item)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | +| `selectAll()` | Select all eligible items. Multiple mode only; returns `false` otherwise. | +| `clearAll()` | Deselect all items. Single-toggle and multiple modes; returns `false` in `single` mode. | +| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) and calls `refresh()`. | +| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | + +### Events + +The controller dispatches **`swc-selection-controller-change`** (`selectionControllerChange`) on the host. The event bubbles and is composed. + +```typescript +import { selectionControllerChange } from '@spectrum-web-components/core/controllers'; + +host.addEventListener(selectionControllerChange, (event) => { + const { selectedItems, addedItems, removedItems } = event.detail; + console.log('Selected:', selectedItems); +}); +``` + +### Options + +| Option | Type | Default | Description | +| -------------------------- | ------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | +| `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | +| `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | +| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | +| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | +| `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the eligible item on the event path. Pair with `FocusgroupNavigationController` for arrow keys. | +| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Callback with the same payload as the change event. | +| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Return `false` to abort the transition before mutators run. | + +## Appendix + +### Choosing between `RadioController` and `SelectionController` + +`RadioController` is designed for strict single-selection patterns and provides a `selectionBinding` feature for pairing with a string key (as used by `swc-tabs`). Use it when you need those advanced key-binding features. + +`SelectionController` covers all three modes (`single`, `single-toggle`, `multiple`) in one controller and lets you switch modes at runtime via `setOptions`. Use it when you need runtime mode switching or multiple selection. + +### See also + +- [Focusgroup navigation controller](../?path=/docs/controllers-focus-group-navigation-controller--docs) +- [APG listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) +- [APG accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) +- [APG tab pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) + + diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts new file mode 100644 index 00000000000..028bb706937 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -0,0 +1,610 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ReactiveController, ReactiveElement } from 'lit'; + +// ───────────────────────── +// TYPES +// ───────────────────────── + +/** + * Controls how the roster handles selection. + * + * - **`single`**: at most one item selected at a time; clicking the active item has no effect. + * - **`single-toggle`**: at most one item selected at a time; clicking the active item deselects it. + * - **`multiple`**: any number of items may be selected; clicking an item toggles it. + */ +export type SelectionMode = 'single' | 'single-toggle' | 'multiple'; + +/** + * Options for {@link SelectionController}. + */ +export type SelectionControllerOptions = { + /** + * Returns the current participant set. Items outside the host subtree (shadow-inclusive) + * are ignored. + */ + getItems: () => HTMLElement[]; + + /** + * Invoked when {@link item} enters the selected state. + * + * @param item - Item becoming selected. + */ + selectItem: (item: HTMLElement) => void; + + /** + * Invoked when {@link item} leaves the selected state. + * + * @param item - Item becoming deselected. + */ + deselectItem: (item: HTMLElement) => void; + + /** + * Controls how clicks change the selection. + * + * - **`single`** (default): at most one item; clicking the active item has no effect. + * - **`single-toggle`**: at most one item; clicking the active item deselects it. + * - **`multiple`**: any number; clicking an item toggles it. + */ + mode?: SelectionMode; + + /** + * When **`true`**, asserts the first eligible item after **`refresh`** when nothing is selected. + * Only meaningful in `single` and `single-toggle` modes. + */ + defaultToFirstSelectable?: boolean; + + /** + * When **`true`**, **Enter** or **Space** on an eligible item toggles it using the current + * **`mode`** rules. Pair with **`FocusgroupNavigationController`** for arrow-key focus moves; + * this controller does not implement roving **`tabindex`** or arrow navigation. + */ + keydownActivation?: boolean; + + /** Optional callback mirroring {@link selectionControllerChange}. */ + onSelectionChange?: (detail: SelectionControllerChangeDetail) => void; + + /** + * Called **before** **`selectItem`** / **`deselectItem`** run for the proposed transition. + * Return **`false`** to abort — mutators do not run, the selection stays unchanged, and no + * change event is dispatched. Omit for unconditional commits. + */ + confirmSelectionChange?: ( + detail: SelectionControllerConfirmDetail + ) => boolean; +}; + +/** + * Name of the bubbling composed `CustomEvent` dispatched when the selection changes. + */ +export const selectionControllerChange = 'swc-selection-controller-change'; + +/** + * `detail` object for {@link selectionControllerChange}. + */ +export type SelectionControllerChangeDetail = { + /** Current selection after the change. */ + selectedItems: HTMLElement[]; + /** Items that became selected in this transition. */ + addedItems: HTMLElement[]; + /** Items that became deselected in this transition. */ + removedItems: HTMLElement[]; +}; + +/** + * Payload for {@link SelectionControllerOptions.confirmSelectionChange}. + */ +export type SelectionControllerConfirmDetail = { + /** Selection that would result if the transition is committed. */ + candidateItems: HTMLElement[]; + /** Selection before this transition. */ + priorItems: HTMLElement[]; +}; + +/** + * Returns the deepest entry from {@link Event.composedPath} that participates in {@link items}. + * + * @param event - Interaction bubbling through shadow roots. + * @param items - Pre-filtered collection (eligible slice). + */ +export function deepestSelectionItemContaining( + event: Event, + items: HTMLElement[] +): HTMLElement | null { + const set = new Set(items); + for (const node of event.composedPath()) { + if (node instanceof HTMLElement && set.has(node)) { + return node; + } + } + return null; +} + +/** + * Manages item selection across a set of sibling elements in three modes: **`single`**, + * **`single-toggle`**, and **`multiple`**. You supply **`getItems`** (who participates), + * **`selectItem`** / **`deselectItem`** (how DOM or ARIA reflects state), and **`mode`** + * (selection behavior). + * + * - **`single`**: one item may be selected; clicking the selected item has no effect. + * - **`single-toggle`**: one item may be selected; clicking the selected item deselects it. + * - **`multiple`**: any number of items may be selected; clicks toggle individual items. + * + * This controller handles capture-phase **`click`** and optional capture-phase **`keydown`** + * (**Enter** / **Space**) when **`keydownActivation`** is **`true`**. Pair with + * **`FocusgroupNavigationController`** when the composite also needs roving **`tabindex`**, + * arrow keys, Home/End, or focus memory; this controller does not implement those behaviors. + * + * Call **`setOptions({ mode })`** at any time to switch modes without reconstructing the + * controller. When switching from **`multiple`** to a single-item mode and more than one item + * is currently selected, all but the first item are deselected. + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ + * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ + */ +export class SelectionController implements ReactiveController { + private readonly host: ReactiveElement; + + private options: Omit< + Required< + Pick< + SelectionControllerOptions, + | 'getItems' + | 'selectItem' + | 'deselectItem' + | 'mode' + | 'defaultToFirstSelectable' + | 'keydownActivation' + > + >, + never + > & + Pick< + SelectionControllerOptions, + 'onSelectionChange' | 'confirmSelectionChange' + >; + + private selectedItems: Set = new Set(); + + private keydownListenerAttached = false; + + private readonly handleClickCapture = (event: MouseEvent): void => { + if (event.button !== 0) { + return; + } + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + const items = this.getEligibleItems(); + const hit = deepestSelectionItemContaining(event, items); + if (!hit || this.isDisabledParticipant(hit)) { + return; + } + this.applyClickOrKey(hit); + }; + + private readonly handleKeyDownCapture = (event: KeyboardEvent): void => { + if (!this.options.keydownActivation) { + return; + } + if (event.code !== 'Enter' && event.code !== 'Space') { + return; + } + if (event.repeat) { + return; + } + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + const items = this.getEligibleItems(); + const hit = deepestSelectionItemContaining(event, items); + if (!hit || this.isDisabledParticipant(hit)) { + return; + } + event.preventDefault(); + this.applyClickOrKey(hit); + }; + + constructor(host: ReactiveElement, options: SelectionControllerOptions) { + this.host = host; + this.options = { + getItems: options.getItems, + selectItem: options.selectItem, + deselectItem: options.deselectItem, + mode: options.mode ?? 'single', + defaultToFirstSelectable: options.defaultToFirstSelectable ?? false, + keydownActivation: options.keydownActivation ?? false, + onSelectionChange: options.onSelectionChange, + confirmSelectionChange: options.confirmSelectionChange, + }; + + host.addController(this); + } + + /** Returns a snapshot of the current selection (ordered by insertion). */ + public getSelectedItems(): HTMLElement[] { + return Array.from(this.selectedItems); + } + + /** Returns **`true`** when {@link item} is currently selected. */ + public isSelected(item: HTMLElement): boolean { + return this.selectedItems.has(item); + } + + /** Returns the current selection mode. */ + public getMode(): SelectionMode { + return this.options.mode; + } + + /** + * Merges option deltas, normalizes selection for a mode change, and calls {@link refresh}. + * + * When switching from **`multiple`** to a single-item mode with more than one item selected, + * only the first selected item is retained. + */ + public setOptions(partial: Partial): void { + const prevMode = this.options.mode; + this.options = { + ...this.options, + ...partial, + mode: partial.mode ?? this.options.mode, + defaultToFirstSelectable: + typeof partial.defaultToFirstSelectable === 'boolean' + ? partial.defaultToFirstSelectable + : this.options.defaultToFirstSelectable, + keydownActivation: + typeof partial.keydownActivation === 'boolean' + ? partial.keydownActivation + : this.options.keydownActivation, + getItems: partial.getItems ?? this.options.getItems, + selectItem: partial.selectItem ?? this.options.selectItem, + deselectItem: partial.deselectItem ?? this.options.deselectItem, + onSelectionChange: + partial.onSelectionChange ?? this.options.onSelectionChange, + confirmSelectionChange: + partial.confirmSelectionChange ?? this.options.confirmSelectionChange, + }; + + const nextMode = this.options.mode; + const wasSingle = prevMode === 'single' || prevMode === 'single-toggle'; + const isNowSingle = nextMode === 'single' || nextMode === 'single-toggle'; + + if (!wasSingle && isNowSingle && this.selectedItems.size > 1) { + const [first] = this.selectedItems; + const toRemove = Array.from(this.selectedItems).slice(1); + const candidate = first ? [first] : []; + this.applySelectionTransition(candidate, toRemove); + } + + this.syncKeydownActivationListener(); + this.refresh(); + } + + /** + * Asserts {@link item} as the selection in single / single-toggle modes, or adds it to the + * set in multiple mode. Disabled or ineligible items are rejected (returns **`false`**). + * + * Passing **`null`** clears the selection; only works when the mode allows an empty selection + * (**`single-toggle`** or **`multiple`**). + */ + public setSelectedItem(item: HTMLElement | null): boolean { + if (item === null) { + if (this.options.mode === 'single') { + return false; + } + return this.clearAll(); + } + + if ( + !this.getEligibleItems().includes(item) || + this.isDisabledParticipant(item) + ) { + return false; + } + + if (this.options.mode === 'multiple') { + if (this.selectedItems.has(item)) { + return true; + } + const next = [...Array.from(this.selectedItems), item]; + return this.applySelectionTransition(next, []); + } + + const prev = Array.from(this.selectedItems); + const toRemove = prev.filter((el) => el !== item); + return this.applySelectionTransition([item], toRemove); + } + + /** + * Toggles {@link item}: selects it when not selected, deselects when selected (mode allowing). + * + * - **`single`**: selects {@link item} (deselects previous); clicking the active item has no + * effect (returns **`false`**). + * - **`single-toggle`**: selects {@link item} when deselected, deselects when selected. + * - **`multiple`**: always toggles. + * + * @returns **`false`** when the item is ineligible or when the mode disallows the operation. + */ + public toggleItem(item: HTMLElement): boolean { + if ( + !this.getEligibleItems().includes(item) || + this.isDisabledParticipant(item) + ) { + return false; + } + + const isSelected = this.selectedItems.has(item); + + if (isSelected) { + if (this.options.mode === 'single') { + return false; + } + const next = Array.from(this.selectedItems).filter((el) => el !== item); + return this.applySelectionTransition(next, [item]); + } else { + if (this.options.mode === 'multiple') { + const next = [...Array.from(this.selectedItems), item]; + return this.applySelectionTransition(next, []); + } + const prev = Array.from(this.selectedItems); + const toRemove = prev.filter((el) => el !== item); + return this.applySelectionTransition([item], toRemove); + } + } + + /** + * Selects all eligible items. Only meaningful in **`multiple`** mode; returns **`false`** in + * single-item modes (does not throw). + */ + public selectAll(): boolean { + if (this.options.mode !== 'multiple') { + return false; + } + const eligible = this.getEligibleItems(); + const toAdd = eligible.filter((el) => !this.selectedItems.has(el)); + if (toAdd.length === 0) { + return true; + } + const next = [...Array.from(this.selectedItems), ...toAdd]; + return this.applySelectionTransition(next, []); + } + + /** + * Deselects all items. Works in **`single-toggle`** and **`multiple`** modes. In **`single`** + * mode, returns **`false`** and leaves selection unchanged. + */ + public clearAll(): boolean { + if (this.options.mode === 'single') { + return false; + } + if (this.selectedItems.size === 0) { + return true; + } + const toRemove = Array.from(this.selectedItems); + return this.applySelectionTransition([], toRemove); + } + + /** + * Re-applies bookkeeping after structural changes: removes stale selections, and selects the + * first eligible item when **`defaultToFirstSelectable`** is **`true`** and nothing is selected + * (single-item modes only). + */ + public refresh(): void { + const eligible = this.getEligibleItems(); + const stale = Array.from(this.selectedItems).filter( + (el) => !el.isConnected || !eligible.includes(el) + ); + + if (stale.length > 0) { + const next = Array.from(this.selectedItems).filter( + (el) => !stale.includes(el) + ); + this.applySelectionTransition(next, stale); + } + + const isSingle = + this.options.mode === 'single' || this.options.mode === 'single-toggle'; + if ( + isSingle && + this.options.defaultToFirstSelectable && + this.selectedItems.size === 0 && + eligible.length > 0 + ) { + this.applySelectionTransition([eligible[0]], []); + } + } + + public hostConnected(): void { + this.host.addEventListener('click', this.handleClickCapture, true); + this.syncKeydownActivationListener(); + this.refresh(); + } + + public hostDisconnected(): void { + this.host.removeEventListener('click', this.handleClickCapture, true); + if (this.keydownListenerAttached) { + this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = false; + } + } + + // ───────────────────────── + // PRIVATE + // ───────────────────────── + + /** Applies the mode-appropriate selection logic for a pointer click or Enter/Space key. */ + private applyClickOrKey(hit: HTMLElement): void { + const mode = this.options.mode; + const isSelected = this.selectedItems.has(hit); + + if (mode === 'single') { + if (isSelected) { + return; + } + const prev = Array.from(this.selectedItems); + this.applySelectionTransition([hit], prev); + } else if (mode === 'single-toggle') { + if (isSelected) { + this.applySelectionTransition([], [hit]); + } else { + const prev = Array.from(this.selectedItems); + this.applySelectionTransition([hit], prev); + } + } else { + if (isSelected) { + const next = Array.from(this.selectedItems).filter((el) => el !== hit); + this.applySelectionTransition(next, [hit]); + } else { + const next = [...Array.from(this.selectedItems), hit]; + this.applySelectionTransition(next, []); + } + } + } + + /** + * Runs **`confirmSelectionChange`** (if any), applies mutators, updates internal state, and + * dispatches the change event. + */ + private applySelectionTransition( + next: HTMLElement[], + removedItems: HTMLElement[] + ): boolean { + const priorItems = Array.from(this.selectedItems); + const addedItems = next.filter((el) => !this.selectedItems.has(el)); + + if (addedItems.length === 0 && removedItems.length === 0) { + return true; + } + + if (this.options.confirmSelectionChange) { + const ok = this.options.confirmSelectionChange({ + candidateItems: next, + priorItems, + }); + if (!ok) { + return false; + } + } + + this.applyMutators(next); + this.selectedItems = new Set(next); + + this.dispatchChange({ selectedItems: next, addedItems, removedItems }); + return true; + } + + /** + * Walks every scoped raw item and calls **`selectItem`** / **`deselectItem`** to align DOM + * with **`next`**. + */ + private applyMutators(next: HTMLElement[]): void { + const nextSet = new Set(next); + for (const item of this.getScopedRawItems()) { + if (nextSet.has(item)) { + this.options.selectItem(item); + } else { + this.options.deselectItem(item); + } + } + } + + private dispatchChange(detail: SelectionControllerChangeDetail): void { + this.options.onSelectionChange?.(detail); + this.host.dispatchEvent( + new CustomEvent( + selectionControllerChange, + { + bubbles: true, + composed: true, + detail, + } + ) + ); + } + + private syncKeydownActivationListener(): void { + if (!this.host.isConnected) { + return; + } + const want = this.options.keydownActivation; + if (want && !this.keydownListenerAttached) { + this.host.addEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = true; + } else if (!want && this.keydownListenerAttached) { + this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = false; + } + } + + private isNodeWithinHostScope(node: Node | null): boolean { + if (!node) { + return false; + } + const rootHost = this.host; + let current: Node | null = node; + while (current) { + if (current === rootHost) { + return true; + } + const parent: Node | null = current.parentNode; + if (parent) { + current = parent; + } else if (current instanceof ShadowRoot) { + current = current.host; + } else { + return false; + } + } + return false; + } + + private getScopedRawItems(): HTMLElement[] { + return this.options + .getItems() + .filter((el) => this.isNodeWithinHostScope(el)); + } + + private isDisabledParticipant(participant: HTMLElement): boolean { + if ('disabled' in participant) { + if ((participant as HTMLButtonElement).disabled) { + return true; + } + } + return participant.getAttribute('aria-disabled') === 'true'; + } + + private isSelectableItem(participant: HTMLElement): boolean { + if (!participant.isConnected) { + return false; + } + if (participant.hasAttribute('inert') || participant.closest('[inert]')) { + return false; + } + const style = getComputedStyle(participant); + if ( + style.visibility === 'hidden' || + style.display === 'none' || + participant.hidden + ) { + return false; + } + if (this.isDisabledParticipant(participant)) { + return false; + } + return true; + } + + private getEligibleItems(): HTMLElement[] { + return this.getScopedRawItems().filter((el) => this.isSelectableItem(el)); + } +} diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts new file mode 100644 index 00000000000..1dc1dfd1727 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts @@ -0,0 +1,969 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { css, html, LitElement, type TemplateResult } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import { FocusgroupNavigationController } from '../../focusgroup-navigation-controller/index.js'; +import { + SelectionController, + type SelectionControllerChangeDetail, + type SelectionMode, +} from '../index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'demo-selection-star-single': DemoSelectionStarSingle; + 'demo-selection-star-toggle': DemoSelectionStarToggle; + 'demo-selection-accordion': DemoSelectionAccordion; + 'demo-selection-listbox': DemoSelectionListbox; + 'demo-selection-tabs': DemoSelectionTabs; + } +} + +// ───────────────────────────────────────────── +// Shared star-rating chrome +// ───────────────────────────────────────────── + +const starRatingStyles = css` + :host { + display: block; + } + [role='radiogroup'] { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 22rem; + padding: 0.75rem; + border-radius: 8px; + border: 1px solid var(--spectrum-gray-300, #d3d3d3); + font: + 0.95rem system-ui, + sans-serif; + } + #rating-label { + font-weight: 600; + } + .hint { + margin: 0; + font-size: 0.85rem; + color: var(--spectrum-gray-700, #464646); + } + .stars { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + align-items: center; + } + .stars button { + display: inline-grid; + place-items: center; + box-sizing: border-box; + inline-size: 2.75rem; + block-size: 2.75rem; + padding: 0.35rem; + border: none; + border-radius: 6px; + background: transparent; + color: var(--spectrum-gray-500, #8f8f8f); + cursor: pointer; + } + .stars button:hover { + color: var(--spectrum-gray-800, #2c2c2c); + background: var(--spectrum-gray-100, #f1f1f1); + } + .stars button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .stars button[aria-checked='true'] { + color: var(--spectrum-orange-900, #b14c00); + background: var(--spectrum-orange-300, #ffb02e); + } + .stars button[aria-checked='true']:hover { + color: var(--spectrum-orange-900, #8a3b00); + background: var(--spectrum-orange-400, #ffa037); + } + .stars button svg { + inline-size: 1.85rem; + block-size: 1.85rem; + flex-shrink: 0; + } +`; + +const starSvg = html` + +`; + +// ───────────────────────────────────────────── +// Star rating — single mode (no toggle) +// ───────────────────────────────────────────── + +/** + * Five-star rating using **`mode: 'single'`**. Clicking the active star has no effect; a new + * star replaces the current selection. + * + * @internal + */ +@customElement('demo-selection-star-single') +export class DemoSelectionStarSingle extends LitElement { + static override styles = starRatingStyles; + + private readonly stars = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-star-single]' + ) + ), + }); + + private readonly selection = new SelectionController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-star-single]' + ) + ), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + mode: 'single', + defaultToFirstSelectable: false, + keydownActivation: true, + }); + + protected override firstUpdated(): void { + this.stars.refresh(); + this.selection.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+
Rating (single — no deselect)
+

+ Click a star to select it. Clicking the active star again has no + effect because + mode + is + 'single' + . +

+
+ ${[1, 2, 3, 4, 5].map((value) => { + const label = value === 1 ? `${value} star` : `${value} stars`; + return html` + + `; + })} +
+
+ `; + } +} + +// ───────────────────────────────────────────── +// Star rating — single-toggle mode +// ───────────────────────────────────────────── + +/** + * Five-star rating using **`mode: 'single-toggle'`**. Clicking the active star deselects it + * (clears the rating). Clicking a different star replaces the selection. + * + * @internal + */ +@customElement('demo-selection-star-toggle') +export class DemoSelectionStarToggle extends LitElement { + static override styles = starRatingStyles; + + private readonly stars = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-star-toggle]' + ) + ), + }); + + private readonly selection = new SelectionController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-star-toggle]' + ) + ), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + mode: 'single-toggle', + keydownActivation: true, + }); + + protected override firstUpdated(): void { + this.stars.refresh(); + this.selection.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+
Rating (single-toggle — can deselect)
+

+ Click the active star to clear the rating. + mode + is + 'single-toggle' + . +

+
+ ${[1, 2, 3, 4, 5].map((value) => { + const label = value === 1 ? `${value} star` : `${value} stars`; + return html` + + `; + })} +
+
+ `; + } +} + +// ───────────────────────────────────────────── +// Accordion with runtime mode switcher +// ───────────────────────────────────────────── + +const accordionStyles = css` + :host { + display: block; + font: + 0.95rem system-ui, + sans-serif; + } + .controls { + display: flex; + gap: 0.5rem; + margin-block-end: 0.75rem; + align-items: center; + flex-wrap: wrap; + } + .controls span { + font-size: 0.85rem; + color: var(--spectrum-gray-700, #464646); + } + .mode-btn { + font: inherit; + font-size: 0.85rem; + padding: 0.3rem 0.75rem; + border: 1px solid var(--spectrum-gray-400, #b9b9b9); + border-radius: 999px; + background: transparent; + cursor: pointer; + transition: background 0.12s; + } + .mode-btn:hover { + background: var(--spectrum-gray-100, #f1f1f1); + } + .mode-btn[aria-pressed='true'] { + background: var(--spectrum-blue-800, #0265dc); + border-color: var(--spectrum-blue-800, #0265dc); + color: white; + } + .mode-btn:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .accordion { + max-width: 28rem; + border: 1px solid var(--spectrum-gray-300, #cbcbcb); + border-radius: 6px; + overflow: clip; + } + article { + border-block-end: 1px solid var(--spectrum-gray-200, #e6e6e6); + } + article:last-of-type { + border-block-end: none; + } + button.trigger { + display: flex; + width: 100%; + gap: 0.5rem; + align-items: center; + justify-content: space-between; + font: inherit; + padding: 0.75rem 1rem; + border: none; + background: white; + cursor: pointer; + text-align: start; + } + button.trigger:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: -2px; + } + button.trigger span.chevron::before { + content: ''; + inline-size: 0.65rem; + block-size: 0.65rem; + border-inline-end: 2px solid currentColor; + border-block-end: 2px solid currentColor; + display: inline-block; + rotate: -45deg; + translate: 0 0.1rem; + } + button[aria-expanded='true'] span.chevron::before { + rotate: 135deg; + translate: 0 -0.1rem; + } + .region { + padding: 0.75rem 1rem 1rem; + background: var(--spectrum-gray-75, #fafafa); + border-block-start: 1px solid var(--spectrum-gray-200, #e6e6e6); + } + .region[hidden] { + display: none; + } + .accordion-heading { + margin: 0; + font: inherit; + font-weight: 600; + } +`; + +const ACCORDION_ITEMS = [ + { + key: 'general', + heading: 'General', + copy: 'In single mode, one panel stays open and cannot be closed by clicking its own header. Switch to single-toggle to allow closing the open panel, or multiple to open several at once.', + }, + { + key: 'appearance', + heading: 'Appearance', + copy: 'In single-toggle mode, clicking the open header closes its panel. Clicking a different header closes the current panel and opens the new one.', + }, + { + key: 'content', + heading: 'Content', + copy: 'In multiple mode, each header toggles its own panel independently. Several panels can be open simultaneously.', + }, + { + key: 'support', + heading: 'Support', + copy: 'Use setOptions({ mode }) to switch modes at runtime — no need to reconstruct the controller. The selection is normalized automatically when switching from multiple to a single-item mode.', + }, +] as const; + +type AccordionKey = (typeof ACCORDION_ITEMS)[number]['key']; + +/** + * Accordion that can switch between **`single`**, **`single-toggle`**, and **`multiple`** modes + * at runtime using **`setOptions`**. The mode-selector buttons call + * **`this.accordionSelection.setOptions({ mode })`** directly. + * + * @internal + */ +@customElement('demo-selection-accordion') +export class DemoSelectionAccordion extends LitElement { + static override styles = accordionStyles; + + @state() private mode: SelectionMode = 'single'; + + private readonly accordionSelection = new SelectionController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll('[data-accordion]') + ), + selectItem: (header) => { + header.setAttribute('aria-expanded', 'true'); + this.togglePanel(header.dataset.accordion as AccordionKey, true); + }, + deselectItem: (header) => { + header.setAttribute('aria-expanded', 'false'); + this.togglePanel(header.dataset.accordion as AccordionKey, false); + }, + mode: 'single', + }); + + private togglePanel(key: AccordionKey, open: boolean): void { + const panel = this.renderRoot.querySelector( + `[data-panel="${key}"]` + ); + if (panel) { + panel.hidden = !open; + } + } + + protected override firstUpdated(): void { + this.accordionSelection.refresh(); + } + + private setMode(newMode: SelectionMode): void { + this.mode = newMode; + this.accordionSelection.setOptions({ mode: newMode }); + } + + protected override render(): TemplateResult { + const modes: SelectionMode[] = ['single', 'single-toggle', 'multiple']; + return html` +
+ Mode: + ${modes.map( + (m) => html` + + ` + )} +
+
+ ${ACCORDION_ITEMS.map( + ({ key, heading, copy }, ordinal) => html` +
+

+ +

+
+ ${copy} +
+
+ ` + )} +
+ `; + } +} + +// ───────────────────────────────────────────── +// Listbox — multiple mode +// ───────────────────────────────────────────── + +const listboxStyles = css` + :host { + display: block; + font: + 0.95rem system-ui, + sans-serif; + } + .wrapper { + max-width: 22rem; + } + .listbox-label { + font-weight: 600; + margin-block-end: 0.5rem; + id: listbox-label; + } + [role='listbox'] { + border: 1px solid var(--spectrum-gray-300, #d3d3d3); + border-radius: 6px; + padding: 0.25rem; + background: var(--spectrum-gray-50, #fff); + min-block-size: 8rem; + } + [role='option'] { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.65rem; + border-radius: 4px; + cursor: pointer; + user-select: none; + } + [role='option']:focus { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: -2px; + } + [role='option']:hover { + background: var(--spectrum-gray-100, #f1f1f1); + } + [role='option'][aria-selected='true'] { + background: var(--spectrum-blue-100, #e0f0ff); + color: var(--spectrum-blue-900, #014380); + font-weight: 600; + } + .checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.1rem; + block-size: 1.1rem; + border: 2px solid currentColor; + border-radius: 3px; + flex-shrink: 0; + background: transparent; + } + [aria-selected='true'] .checkbox { + background: var(--spectrum-blue-800, #0265dc); + border-color: var(--spectrum-blue-800, #0265dc); + color: white; + } + .checkbox svg { + inline-size: 0.75rem; + block-size: 0.75rem; + } + .actions { + display: flex; + gap: 0.5rem; + margin-block-start: 0.5rem; + } + .action-btn { + font: inherit; + font-size: 0.85rem; + padding: 0.3rem 0.75rem; + border: 1px solid var(--spectrum-gray-400, #b9b9b9); + border-radius: 4px; + background: transparent; + cursor: pointer; + } + .action-btn:hover { + background: var(--spectrum-gray-100, #f1f1f1); + } + .action-btn:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .count { + margin-block-start: 0.5rem; + font-size: 0.85rem; + color: var(--spectrum-gray-700, #464646); + } +`; + +const LISTBOX_OPTIONS = [ + { key: 'photoshop', label: 'Photoshop' }, + { key: 'illustrator', label: 'Illustrator' }, + { key: 'indesign', label: 'InDesign' }, + { key: 'premiere', label: 'Premiere Pro' }, + { key: 'aftereffects', label: 'After Effects' }, + { key: 'xd', label: 'Adobe XD' }, + { key: 'lightroom', label: 'Lightroom' }, +] as const; + +const checkmarkSvg = html` + +`; + +/** + * Multi-select listbox pairing **`FocusgroupNavigationController`** (arrow keys, roving + * **`tabindex`**) with **`SelectionController`** (**`mode: 'multiple'`**, **`keydownActivation: + * true`** for **Enter** / **Space** toggle). "Select all" and "Clear" call **`selectAll()`** and + * **`clearAll()`**. + * + * @internal + */ +@customElement('demo-selection-listbox') +export class DemoSelectionListbox extends LitElement { + static override styles = listboxStyles; + + @state() private selectedCount = 0; + + private options: HTMLElement[] = []; + + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: false, + getItems: () => this.options, + }); + + private readonly selection = new SelectionController(this, { + getItems: () => this.options, + selectItem: (el) => { + el.setAttribute('aria-selected', 'true'); + }, + deselectItem: (el) => { + el.setAttribute('aria-selected', 'false'); + }, + mode: 'multiple', + keydownActivation: true, + onSelectionChange: (detail: SelectionControllerChangeDetail) => { + this.selectedCount = detail.selectedItems.length; + }, + }); + + protected override firstUpdated(): void { + this.options = Array.from( + this.renderRoot.querySelectorAll('[data-option]') + ); + this.navigation.refresh(); + this.selection.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+
Creative Cloud apps
+
+ ${LISTBOX_OPTIONS.map( + ({ key, label }) => html` +
+ + ${label} +
+ ` + )} +
+
+ + +
+

+ ${this.selectedCount === 0 + ? 'No apps selected' + : `${this.selectedCount} app${this.selectedCount === 1 ? '' : 's'} selected`} +

+
+ `; + } +} + +// ───────────────────────────────────────────── +// Tabs — pairing with FocusgroupNavigationController +// ───────────────────────────────────────────── + +const tabStyles = css` + :host { + display: block; + max-width: 28rem; + font: + 0.95rem system-ui, + sans-serif; + } + [role='tablist'] { + display: flex; + gap: 0.25rem; + padding: 0.35rem; + border-radius: 8px; + border: 1px solid var(--spectrum-gray-300, #d3d3d3); + background: var(--spectrum-gray-75, #fafafa); + } + [role='tab'] { + flex: 1 1 auto; + margin: 0; + padding: 0.5rem 0.65rem; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + font: inherit; + cursor: pointer; + } + [role='tab']:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + [role='tab'][aria-selected='true'] { + background: var(--spectrum-gray-200, #e6e6e6); + border-color: var(--spectrum-gray-400, #b1b1b1); + font-weight: 600; + } + .panels { + margin-block-start: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid var(--spectrum-gray-300, #d3d3d3); + } + [role='tabpanel'] { + margin: 0; + } + [role='tabpanel'][hidden] { + display: none; + } + .hint { + margin: 0 0 0.65rem; + font-size: 0.82rem; + color: var(--spectrum-gray-700, #464646); + } +`; + +/** + * Manual-activation tablist pairing **`FocusgroupNavigationController`** (arrow keys, roving + * **`tabindex`**) with **`SelectionController`** (**`mode: 'single'`**, **`keydownActivation: + * true`** for **Enter** / **Space** activation). + * + * @internal + */ +@customElement('demo-selection-tabs') +export class DemoSelectionTabs extends LitElement { + static override styles = tabStyles; + + private tabButtons: HTMLButtonElement[] = []; + + private readonly tabNavigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => this.tabButtons, + }); + + private readonly tabSelection = new SelectionController(this, { + getItems: () => this.tabButtons, + selectItem: (tab) => { + tab.setAttribute('aria-selected', 'true'); + const key = tab.dataset.tab!; + const panel = this.renderRoot.querySelector( + `[data-tab-panel="${key}"]` + ); + panel?.removeAttribute('hidden'); + }, + deselectItem: (tab) => { + tab.setAttribute('aria-selected', 'false'); + const key = tab.dataset.tab!; + const panel = this.renderRoot.querySelector( + `[data-tab-panel="${key}"]` + ); + panel?.setAttribute('hidden', ''); + }, + mode: 'single', + keydownActivation: true, + defaultToFirstSelectable: true, + }); + + protected override firstUpdated(): void { + this.tabButtons = Array.from( + this.renderRoot.querySelectorAll('[data-tab]') + ); + this.tabNavigation.refresh(); + this.tabSelection.refresh(); + } + + protected override render(): TemplateResult { + return html` +

+ Arrow keys move focus via + FocusgroupNavigationController + ; press + Enter + or + Space + to select via + SelectionController + ( + mode: 'single' + ). Pointer also works. +

+
+ + + +
+
+ + + +
+ `; + } +} + +// ───────────────────────────────────────────── +// Overview / playground host +// ───────────────────────────────────────────── + +declare global { + interface HTMLElementTagNameMap { + 'demo-selection-overview': DemoSelectionOverview; + } +} + +/** + * Overview host for the Playground story — renders a five-star rating with + * **`mode: 'single-toggle'`** so the active star can be cleared. + * + * @internal + */ +@customElement('demo-selection-overview') +export class DemoSelectionOverview extends LitElement { + static override styles = starRatingStyles; + + private readonly stars = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-star-overview]' + ) + ), + }); + + private readonly selection = new SelectionController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-star-overview]' + ) + ), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + mode: 'single-toggle', + keydownActivation: true, + }); + + protected override firstUpdated(): void { + this.stars.refresh(); + this.selection.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+
Rating
+
+ ${[1, 2, 3, 4, 5].map((value) => { + const label = value === 1 ? `${value} star` : `${value} stars`; + return html` + + `; + })} +
+
+ `; + } +} diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts new file mode 100644 index 00000000000..4d06e46b5c9 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts @@ -0,0 +1,461 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import type { Meta, StoryObj } from '@storybook/web-components'; + +import './demo-hosts.js'; + +// ──────────────── +// METADATA +// ──────────────── + +/** + * `SelectionController` manages item selection in three modes: **`single`** (one item, + * no toggle), **`single-toggle`** (one item, click to deselect), and **`multiple`** (any + * number of items). You supply `getItems` (who participates), `selectItem` / `deselectItem` + * (how DOM or ARIA reflects state), and `mode` (selection behavior). Switch modes at runtime + * via `setOptions({ mode })` — the controller normalizes selection automatically. + * + * Use [FocusgroupNavigationController](../?path=/docs/controllers-focus-group-navigation-controller--docs) + * separately when the composite also needs roving `tabindex`, arrow keys, or focus memory. + * + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ | APG listbox pattern} + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ | APG accordion pattern} + */ +const meta: Meta = { + title: 'Controllers/Selection controller', + component: 'demo-selection-overview', + parameters: { + docs: { + subtitle: + 'Single, single-toggle, and multiple selection in one controller — switch modes at runtime via setOptions.', + canvas: { sourceState: 'none' }, + }, + }, + tags: ['migrated', 'controller'], + render: () => html` + + `, +}; + +export default meta; + +type Story = StoryObj; + +// ────────────────────────── +// PLAYGROUND STORY +// ────────────────────────── + +export const Playground: Story = { + tags: ['autodocs', 'dev'], +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + tags: ['overview'], +}; + +// ────────────────────────── +// USAGE STORIES +// ────────────────────────── + +/** + * ## Anatomy of a `SelectionController` + * + * The `SelectionController` constructor takes two arguments: + * + * - `host: ReactiveElement` — the Lit host element + * - `options: SelectionControllerOptions` — configuration + * - `getItems: () => HTMLElement[]` — returns the current participant set + * - `selectItem: (item: HTMLElement) => void` — called when an item enters the selected state + * - `deselectItem: (item: HTMLElement) => void` — called when an item leaves the selected state + * - `mode?: SelectionMode` — `'single'` (default), `'single-toggle'`, or `'multiple'` + * - `defaultToFirstSelectable?: boolean` — select the first eligible item after `refresh` when nothing is selected (single-item modes only) + * - `keydownActivation?: boolean` — when `true`, **Enter** / **Space** toggles items using the current mode rules; pair with `FocusgroupNavigationController` for arrow keys + * - `onSelectionChange?: (detail) => void` — called when the selection changes + * - `confirmSelectionChange?: (detail) => boolean` — return `false` to abort the transition + * + * ```typescript + * import { SelectionController } from '@spectrum-web-components/core/controllers'; + * + * private readonly selection = new SelectionController(this, { + * getItems: () => [...], + * selectItem: (el) => el.setAttribute('aria-selected', 'true'), + * deselectItem: (el) => el.setAttribute('aria-selected', 'false'), + * mode: 'multiple', + * keydownActivation: true, + * }); + * ``` + * + * ### `getItems` + * + * Pass a **function** (not a static array) that returns the current list of `HTMLElement` nodes + * that should be selectable — for example every `[role="option"]` inside `this.renderRoot`. + * + * The controller automatically filters out items that are: + * + * - Outside the reactive host's subtree (light DOM children and shadow descendants; nodes + * outside the host are ignored) + * - Disconnected, `[inert]`, `hidden`, or `display: none` / `visibility: hidden` + * - Native **`disabled`** or **`aria-disabled="true"`** + * + * Call **`selection.refresh()`** after the shadow tree exists and whenever the set of items + * changes — typically from `firstUpdated` and from `updated` when templates or slot content changes. + * + * ```typescript + * protected override firstUpdated(): void { + * this.selection.refresh(); + * } + * ``` + * + * ### `selectItem` and `deselectItem` + * + * Whenever the selection changes, the controller walks every scoped raw item from `getItems` and: + * + * - calls **`selectItem`** on each item in the new selection + * - calls **`deselectItem`** on each item outside the new selection + * + * Your callbacks should only update that element — for example set or remove attributes, toggle + * classes, or sync related nodes (such as a sibling panel's `[hidden]` flag). + * + * ```typescript + * selectItem: (item) => item.setAttribute('aria-selected', 'true'), + * deselectItem: (item) => item.setAttribute('aria-selected', 'false'), + * ``` + * + * ### Switching modes at runtime + * + * Call **`setOptions({ mode })`** to switch modes without reconstructing the controller. When + * switching from **`multiple`** to a single-item mode with more than one item selected, only the + * first selected item is retained — a change event fires for the deselected items. + * + * ```typescript + * this.selection.setOptions({ mode: 'single-toggle' }); + * ``` + */ +export const Usage: Story = { + tags: ['usage', 'description-only'], + parameters: { 'section-order': 0 }, +}; + +/** + * ## Examples + * ### `single` mode — selecting without deselect + * + * Use **`mode: 'single'`** when exactly one item should always be active and clicking the + * active item should do nothing. Good for tab lists and exclusive category filters. + * + * ```typescript + * private readonly selection = new SelectionController(this, { + * getItems: () => + * Array.from(this.renderRoot.querySelectorAll('[data-star]')), + * selectItem: (star) => star.setAttribute('aria-checked', 'true'), + * deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + * mode: 'single', + * keydownActivation: true, + * }); + * ``` + */ +export const UsageExampleSingle: Story = { + name: 'Example: single mode', + tags: ['usage'], + parameters: { 'section-order': 1 }, + render: () => html` + + `, +}; + +/** + * ### `single-toggle` mode — selecting and deselecting + * + * Use **`mode: 'single-toggle'`** when at most one item should be selected and the selection + * should be clearable by clicking the active item again. Good for optional ratings, filters, + * or accordion headers that should allow all panels to be closed. + * + * ```typescript + * private readonly selection = new SelectionController(this, { + * getItems: () => + * Array.from(this.renderRoot.querySelectorAll('[data-star]')), + * selectItem: (star) => star.setAttribute('aria-checked', 'true'), + * deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + * mode: 'single-toggle', + * keydownActivation: true, + * }); + * ``` + */ +export const UsageExampleSingleToggle: Story = { + name: 'Example: single-toggle mode', + tags: ['usage'], + parameters: { 'section-order': 2 }, + render: () => html` + + `, +}; + +/** + * ### `multiple` mode — independent toggles + * + * Use **`mode: 'multiple'`** when any number of items may be selected simultaneously, each + * toggled independently. Combine **`selectAll()`** and **`clearAll()`** with UI buttons. + * + * ```typescript + * private readonly selection = new SelectionController(this, { + * getItems: () => this.options, + * selectItem: (el) => el.setAttribute('aria-selected', 'true'), + * deselectItem: (el) => el.setAttribute('aria-selected', 'false'), + * mode: 'multiple', + * keydownActivation: true, + * }); + * + * // In response to a "Select all" button: + * this.selection.selectAll(); + * + * // In response to a "Clear" button: + * this.selection.clearAll(); + * ``` + */ +export const UsageExampleMultiple: Story = { + name: 'Example: multiple mode', + tags: ['usage'], + parameters: { 'section-order': 3 }, + render: () => html` + + `, +}; + +/** + * ### Switching modes at runtime + * + * Call **`setOptions({ mode })`** on the controller to change the selection mode without + * reconstructing it. This accordion starts in **`single`** mode; the buttons let you switch + * to **`single-toggle`** or **`multiple`** and see how behavior changes immediately. + * + * - **`single`**: only one panel open, cannot close it by clicking its header again + * - **`single-toggle`**: only one panel open, clicking the open header closes it + * - **`multiple`**: each header toggles its own panel independently + * + * When switching from **`multiple`** back to a single-item mode while more than one panel is + * open, all but the first are closed automatically. + * + * ```typescript + * // Triggered by a user action or external API: + * this.accordionSelection.setOptions({ mode: 'single-toggle' }); + * ``` + */ +export const UsageExampleRuntimeModeSwitch: Story = { + name: 'Example: switching modes at runtime', + tags: ['usage'], + parameters: { 'section-order': 4 }, + render: () => html` + + `, +}; + +/** + * ### Pairing with `FocusgroupNavigationController` + * + * For a manual-activation tab list, pair **`FocusgroupNavigationController`** (arrow keys, + * roving **`tabindex`**, Home/End) with **`SelectionController`** (**`mode: 'single'`**, + * **`keydownActivation: true`** for **Enter** / **Space** selection). Keep selection state + * (`aria-selected`, panels) in **`selectItem`** / **`deselectItem`**; do not manage `tabindex` + * there. + * + * ```typescript + * import { + * FocusgroupNavigationController, + * SelectionController, + * } from '@spectrum-web-components/core/controllers'; + * + * private readonly tabNavigation = new FocusgroupNavigationController(this, { + * direction: 'horizontal', + * wrap: true, + * getItems: () => this.tabButtons, + * }); + * + * private readonly tabSelection = new SelectionController(this, { + * getItems: () => this.tabButtons, + * selectItem: (tab) => { + * tab.setAttribute('aria-selected', 'true'); + * this.showPanelForTab(tab); + * }, + * deselectItem: (tab) => { + * tab.setAttribute('aria-selected', 'false'); + * this.hidePanelForTab(tab); + * }, + * mode: 'single', + * keydownActivation: true, + * defaultToFirstSelectable: true, + * }); + * ``` + */ +export const UsageExampleTabsWithFocusgroup: Story = { + name: 'Example: tablist with FocusgroupNavigationController', + tags: ['usage'], + parameters: { 'section-order': 5 }, + render: () => html` + + `, +}; + +// ────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────── + +export const SingleModeRating: Story = { + name: 'single mode: no deselect on re-click', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { docs: { disable: true } }, +}; + +export const SingleToggleModeRating: Story = { + name: 'single-toggle mode: click active item to deselect', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { docs: { disable: true } }, +}; + +export const MultipleListbox: Story = { + name: 'multiple mode: listbox with selectAll and clearAll', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { docs: { disable: true } }, +}; + +export const AccordionModeSwitch: Story = { + name: 'Accordion: runtime mode switch via setOptions', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { docs: { disable: true } }, +}; + +export const TablistWithFocusgroup: Story = { + name: 'Tablist: FocusgroupNavigationController + SelectionController', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { docs: { disable: true } }, +}; + +// ────────────────────────────── +// API STORY +// ────────────────────────────── + +/** + * ### Methods + * + * | Member | Description | + * |---|---| + * | `setOptions(partial)` | Merge new options, normalize selection for mode changes, and reapply. | + * | `refresh()` | Remove stale selections and optionally select the first item (`defaultToFirstSelectable`). | + * | `getSelectedItems()` | Returns the current selection as an ordered array. | + * | `isSelected(item)` | Returns `true` when `item` is currently selected. | + * | `getMode()` | Returns the current `SelectionMode`. | + * | `setSelectedItem(item \| null)` | Select one item (single/single-toggle) or add to multiple selection; pass `null` to clear (single-toggle and multiple only). | + * | `toggleItem(item)` | Toggle an item using the current mode rules. | + * | `selectAll()` | Select all eligible items (multiple mode only; returns `false` otherwise). | + * | `clearAll()` | Deselect all items (single-toggle and multiple modes; returns `false` in single mode). | + * + * ### Events + * + * The controller dispatches **`swc-selection-controller-change`** (`selectionControllerChange`) + * on the host with `detail: { selectedItems, addedItems, removedItems }`. The event bubbles and + * is composed. + * + * ```typescript + * import { selectionControllerChange } from + * '@spectrum-web-components/core/controllers'; + * + * host.addEventListener(selectionControllerChange, (event) => { + * const { selectedItems, addedItems, removedItems } = event.detail; + * console.log('Selected:', selectedItems); + * }); + * ``` + * + * ### Options + * + * | Option | Type | Default | Description | + * |---|---|---|---| + * | `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | + * | `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | + * | `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | + * | `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | + * | `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | + * | `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the focused eligible item. Pair with `FocusgroupNavigationController` for arrow keys. | + * | `onSelectionChange` | `(detail) => void` | — | Callback on selection change. | + * | `confirmSelectionChange` | `(detail) => boolean` | — | Return `false` to abort the transition before mutators run. | + */ +export const API: Story = { + tags: ['api', 'description-only'], +}; + +// ──────────────────────────────── +// ACCESSIBILITY STORIES +// ──────────────────────────────── + +/** + * ### Pointer and eligibility + * + * **`SelectionController`** resolves primary clicks with **`deepestSelectionItemContaining`**. + * Disabled and **`aria-disabled="true"`** items are never toggled and do not receive selection + * changes from pointer interaction. + * + * ### Keyboard and focus + * + * **`SelectionController`** does not implement roving **`tabindex`**, arrow keys, Home/End, or + * programmatic **`focus()`**. Add **`FocusgroupNavigationController`** when the composite needs + * those behaviors. + * + * With **`keydownActivation: true`**, the controller listens for **Enter** and **Space** on the + * host (capture phase) and applies the current mode rules to the deepest eligible item on the + * event path. + * + * ### ARIA + * + * Keep `aria-selected`, `aria-checked`, `aria-expanded`, and related attributes in sync inside + * your **`selectItem`** / **`deselectItem`** callbacks; the controller does not infer state from + * the DOM. For a multiselect listbox, also set **`aria-multiselectable="true"`** on the host. + * + * ### `confirmSelectionChange` + * + * When you need to run a cancelable host event **before** DOM mutators run, use + * **`confirmSelectionChange`**. Return **`false`** to abort the transition; the selection stays + * unchanged and no change event is dispatched. + */ +export const Accessibility: Story = { + tags: ['a11y', 'description-only'], +}; + +/** + * ### See also + * + * - [Focusgroup navigation controller](../?path=/docs/controllers-focus-group-navigation-controller--docs) + * - [APG listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) + * - [APG accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) + * - [APG tab pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) + */ +export const Appendix: Story = { + tags: ['description-only', 'appendix'], +}; From dc92ec0c0e784ce133fe0d3cc1abd1746442fa84 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Thu, 11 Jun 2026 15:56:56 -0400 Subject: [PATCH 02/32] feat(core): fixed storybook and updated docs --- .../selection-controller.mdx | 72 ++++++------ .../stories/demo-hosts.ts | 4 + .../stories/selection-controller.stories.ts | 109 +----------------- 3 files changed, 39 insertions(+), 146 deletions(-) diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx index 2d9ec45f283..6f7035776db 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -176,42 +176,6 @@ this.accordionSelection.setOptions({ mode: 'multiple' }); -### Tablist: `FocusgroupNavigationController` + `SelectionController` - -For a manual-activation tab list, pair `FocusgroupNavigationController` (arrow keys, roving `tabindex`, Home/End) with `SelectionController` (`mode: 'single'`, `keydownActivation: true`). Arrow keys only move focus; **Enter** or **Space** selects and activates the focused tab. - -```typescript -import { - FocusgroupNavigationController, - SelectionController, -} from '@spectrum-web-components/core/controllers'; - -private tabButtons: HTMLButtonElement[] = []; - -private readonly tabNavigation = new FocusgroupNavigationController(this, { - direction: 'horizontal', - wrap: true, - getItems: () => this.tabButtons, -}); - -private readonly tabSelection = new SelectionController(this, { - getItems: () => this.tabButtons, - selectItem: (tab) => { - tab.setAttribute('aria-selected', 'true'); - this.showPanelForTab(tab); - }, - deselectItem: (tab) => { - tab.setAttribute('aria-selected', 'false'); - this.hidePanelForTab(tab); - }, - mode: 'single', - keydownActivation: true, - defaultToFirstSelectable: true, -}); -``` - - - ## Accessibility ### Features @@ -289,11 +253,41 @@ host.addEventListener(selectionControllerChange, (event) => { ## Appendix -### Choosing between `RadioController` and `SelectionController` +### Pairing with `FocusgroupNavigationController` + +`SelectionController` handles what is selected; it does not manage keyboard focus movement. For composites that need roving `tabindex`, arrow keys, Home/End, or focus memory, pair it with `FocusgroupNavigationController`. A common pattern is a manual-activation tab list: `FocusgroupNavigationController` moves focus with arrow keys while `SelectionController` (`mode: 'single'`, `keydownActivation: true`) activates the focused tab on **Enter** or **Space**. + +```typescript +import { + FocusgroupNavigationController, + SelectionController, +} from '@spectrum-web-components/core/controllers'; + +private tabButtons: HTMLButtonElement[] = []; + +private readonly tabNavigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => this.tabButtons, +}); -`RadioController` is designed for strict single-selection patterns and provides a `selectionBinding` feature for pairing with a string key (as used by `swc-tabs`). Use it when you need those advanced key-binding features. +private readonly tabSelection = new SelectionController(this, { + getItems: () => this.tabButtons, + selectItem: (tab) => { + tab.setAttribute('aria-selected', 'true'); + this.showPanelForTab(tab); + }, + deselectItem: (tab) => { + tab.setAttribute('aria-selected', 'false'); + this.hidePanelForTab(tab); + }, + mode: 'single', + keydownActivation: true, + defaultToFirstSelectable: true, +}); +``` -`SelectionController` covers all three modes (`single`, `single-toggle`, `multiple`) in one controller and lets you switch modes at runtime via `setOptions`. Use it when you need runtime mode switching or multiple selection. + ### See also diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts index 1dc1dfd1727..3246b33d2d5 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts @@ -554,6 +554,10 @@ const listboxStyles = css` .checkbox svg { inline-size: 0.75rem; block-size: 0.75rem; + visibility: hidden; + } + [aria-selected='true'] .checkbox svg { + visibility: visible; } .actions { display: flex; diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts index 4d06e46b5c9..4f397a2d88a 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts @@ -57,7 +57,7 @@ type Story = StoryObj; // ────────────────────────── export const Playground: Story = { - tags: ['autodocs', 'dev'], + tags: ['dev'], }; // ────────────────────────── @@ -320,7 +320,6 @@ export const SingleModeRating: Story = { `, tags: ['behaviors'], - parameters: { docs: { disable: true } }, }; export const SingleToggleModeRating: Story = { @@ -329,7 +328,6 @@ export const SingleToggleModeRating: Story = { `, tags: ['behaviors'], - parameters: { docs: { disable: true } }, }; export const MultipleListbox: Story = { @@ -338,7 +336,6 @@ export const MultipleListbox: Story = { `, tags: ['behaviors'], - parameters: { docs: { disable: true } }, }; export const AccordionModeSwitch: Story = { @@ -347,7 +344,6 @@ export const AccordionModeSwitch: Story = { `, tags: ['behaviors'], - parameters: { docs: { disable: true } }, }; export const TablistWithFocusgroup: Story = { @@ -355,107 +351,6 @@ export const TablistWithFocusgroup: Story = { render: () => html` `, - tags: ['behaviors'], - parameters: { docs: { disable: true } }, + tags: ['appendix'], }; -// ────────────────────────────── -// API STORY -// ────────────────────────────── - -/** - * ### Methods - * - * | Member | Description | - * |---|---| - * | `setOptions(partial)` | Merge new options, normalize selection for mode changes, and reapply. | - * | `refresh()` | Remove stale selections and optionally select the first item (`defaultToFirstSelectable`). | - * | `getSelectedItems()` | Returns the current selection as an ordered array. | - * | `isSelected(item)` | Returns `true` when `item` is currently selected. | - * | `getMode()` | Returns the current `SelectionMode`. | - * | `setSelectedItem(item \| null)` | Select one item (single/single-toggle) or add to multiple selection; pass `null` to clear (single-toggle and multiple only). | - * | `toggleItem(item)` | Toggle an item using the current mode rules. | - * | `selectAll()` | Select all eligible items (multiple mode only; returns `false` otherwise). | - * | `clearAll()` | Deselect all items (single-toggle and multiple modes; returns `false` in single mode). | - * - * ### Events - * - * The controller dispatches **`swc-selection-controller-change`** (`selectionControllerChange`) - * on the host with `detail: { selectedItems, addedItems, removedItems }`. The event bubbles and - * is composed. - * - * ```typescript - * import { selectionControllerChange } from - * '@spectrum-web-components/core/controllers'; - * - * host.addEventListener(selectionControllerChange, (event) => { - * const { selectedItems, addedItems, removedItems } = event.detail; - * console.log('Selected:', selectedItems); - * }); - * ``` - * - * ### Options - * - * | Option | Type | Default | Description | - * |---|---|---|---| - * | `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | - * | `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | - * | `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | - * | `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | - * | `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | - * | `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the focused eligible item. Pair with `FocusgroupNavigationController` for arrow keys. | - * | `onSelectionChange` | `(detail) => void` | — | Callback on selection change. | - * | `confirmSelectionChange` | `(detail) => boolean` | — | Return `false` to abort the transition before mutators run. | - */ -export const API: Story = { - tags: ['api', 'description-only'], -}; - -// ──────────────────────────────── -// ACCESSIBILITY STORIES -// ──────────────────────────────── - -/** - * ### Pointer and eligibility - * - * **`SelectionController`** resolves primary clicks with **`deepestSelectionItemContaining`**. - * Disabled and **`aria-disabled="true"`** items are never toggled and do not receive selection - * changes from pointer interaction. - * - * ### Keyboard and focus - * - * **`SelectionController`** does not implement roving **`tabindex`**, arrow keys, Home/End, or - * programmatic **`focus()`**. Add **`FocusgroupNavigationController`** when the composite needs - * those behaviors. - * - * With **`keydownActivation: true`**, the controller listens for **Enter** and **Space** on the - * host (capture phase) and applies the current mode rules to the deepest eligible item on the - * event path. - * - * ### ARIA - * - * Keep `aria-selected`, `aria-checked`, `aria-expanded`, and related attributes in sync inside - * your **`selectItem`** / **`deselectItem`** callbacks; the controller does not infer state from - * the DOM. For a multiselect listbox, also set **`aria-multiselectable="true"`** on the host. - * - * ### `confirmSelectionChange` - * - * When you need to run a cancelable host event **before** DOM mutators run, use - * **`confirmSelectionChange`**. Return **`false`** to abort the transition; the selection stays - * unchanged and no change event is dispatched. - */ -export const Accessibility: Story = { - tags: ['a11y', 'description-only'], -}; - -/** - * ### See also - * - * - [Focusgroup navigation controller](../?path=/docs/controllers-focus-group-navigation-controller--docs) - * - [APG listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) - * - [APG accordion pattern](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) - * - [APG tab pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) - */ -export const Appendix: Story = { - tags: ['description-only', 'appendix'], -}; From 72e363fc4f69fe622b42868c18a9fed9a75d8dcc Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 11:07:09 -0400 Subject: [PATCH 03/32] feat(tabs): implemented focus group navigation and selection controllers --- .../core/components/tabs/Tabs.base.ts | 364 +++++++----------- 2nd-gen/packages/swc/components/tabs/Tabs.ts | 2 - 2 files changed, 142 insertions(+), 224 deletions(-) diff --git a/2nd-gen/packages/core/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index 8041cd482c0..619fa7e706b 100644 --- a/2nd-gen/packages/core/components/tabs/Tabs.base.ts +++ b/2nd-gen/packages/core/components/tabs/Tabs.base.ts @@ -12,6 +12,12 @@ import { PropertyValues } from 'lit'; import { property, state } from 'lit/decorators.js'; +import { + focusgroupNavigationActiveChange, + type FocusgroupNavigationActiveChangeDetail, + FocusgroupNavigationController, + SelectionController, +} from '@spectrum-web-components/core/controllers/index.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; import { @@ -241,6 +247,80 @@ export abstract class TabsBase extends SpectrumElement { */ private _tabs: TabLike[] = []; + /** + * @internal + * + * When `true`, the next `confirmSelectionChange` call returns `true` + * without dispatching a `change` event. Used to sync the + * SelectionController's internal state from external property changes + * without raising a user-facing event. + */ + private _suppressSelectionChangeEvent = false; + + // ────────────────────────────── + // REACTIVE CONTROLLERS + // ────────────────────────────── + + /** + * @internal + * + * Manages roving tabindex and arrow-key / Home / End focus movement + * within the tab list. Direction is kept in sync with `this.direction` + * via `setOptions` in `willUpdate`. Disabled tabs remain in the + * navigation sequence (per APG) but are not activatable. + * + * `getItems` returns an empty array when the container is disabled so + * all tabs lose their tab stop, matching the `aria-disabled` tablist + * behavior. + */ + private readonly _navigation = new FocusgroupNavigationController(this, { + direction: this._direction, + wrap: true, + getItems: () => (this.disabled ? [] : (this._tabs as HTMLElement[])), + }); + + /** + * @internal + * + * Manages click and Enter/Space selection within the tab list. + * `selectItem` / `deselectItem` update each tab element's `selected` + * property; `onSelectionChange` keeps the public `selected` (tab-id) + * string in sync; `confirmSelectionChange` dispatches the cancelable + * `change` event so consumers may veto a selection. + * + * `keydownActivation: true` handles manual-mode Enter/Space. In + * automatic mode, pressing Enter/Space on the focused tab is a no-op + * because the tab is already selected (`single` mode ignores + * re-clicks on the active item). + * + * `getItems` returns an empty array when the container is disabled, + * which prevents any selection interaction while disabled. + */ + private readonly _selection = new SelectionController(this, { + getItems: () => (this.disabled ? [] : (this._tabs as HTMLElement[])), + selectItem: (item) => { + (item as TabLike).selected = true; + }, + deselectItem: (item) => { + (item as TabLike).selected = false; + }, + mode: 'single', + keydownActivation: true, + onSelectionChange: ({ selectedItems }) => { + const newTab = selectedItems[0] as TabLike | undefined; + const newId = newTab?.tabId ?? ''; + if (this.selected !== newId) { + this.selected = newId; + } + }, + confirmSelectionChange: () => { + if (this._suppressSelectionChangeEvent) { + return true; + } + return this.dispatchEvent(new Event('change', { cancelable: true })); + }, + }); + /** * Whether an assigned node is treated as a tab. `role="tab"` is set in * each tab's `firstUpdated`, so `slotchange` may run before that — accept @@ -258,15 +338,19 @@ export abstract class TabsBase extends SpectrumElement { /** * Called by the concrete class when the default slot's content - * changes. Rebuilds the internal tab list and syncs selection - * state. + * changes. Rebuilds the internal tab list, syncs the SelectionController + * to the current `selected` value, and refreshes keyboard navigation. */ protected handleTabSlotChange(event: Event): void { const slot = event.target as HTMLSlotElement; this._tabs = slot .assignedElements() .filter(TabsBase.isTabSlotNode) as TabLike[]; - this.updateCheckedState(); + + // Refresh navigation first so its eligible-items cache is populated + // before _syncSelectionController calls setActiveItem. + this._navigation.refresh(); + this._syncSelectionController(); this.updateSelectionIndicator(); } @@ -282,227 +366,52 @@ export abstract class TabsBase extends SpectrumElement { } /** - * Click handler bound to the tablist wrapper in the concrete - * template. Activates the clicked tab. - */ - protected handleClick(event: Event): void { - if (this.disabled) { - return; - } - - const target = event - .composedPath() - .find((el) => (el as TabLike).parentElement === this) as - | TabLike - | undefined; - - if (!target || target.disabled) { - return; - } - - this.selectTarget(target); - } - - /** - * Full keyboard handler per WAI-ARIA APG Tabs pattern. - * - * **Horizontal:** Left/Right navigate; Up/Down ignored. - * **Vertical:** Up/Down navigate; Left/Right ignored. - * **RTL:** Left/Right swap in `dir="rtl"`. - * **Wrapping:** Navigation wraps from last to first and vice versa. - * **Disabled tabs:** Disabled tabs receive focus via arrows but - * are not activatable by Enter/Space. - * **Automatic activation:** When `keyboard-activation` is `automatic`, - * selection follows focus on arrow key navigation. - * **Home/End:** Jump to first/last tab. - */ - protected handleKeyDown(event: KeyboardEvent): void { - if (this.disabled) { - return; - } - - const { code } = event; - - if (code === 'Enter' || code === 'Space') { - event.preventDefault(); - const target = event.target as TabLike | null; - if (target && !target.disabled) { - this.selectTarget(target); - } - return; - } - - const delta = this.getNavigationDelta(code); - if (delta !== null) { - event.preventDefault(); - this.focusByDelta(delta); - return; - } - - if (code === 'Home') { - event.preventDefault(); - this.focusTabAtIndex(0); - return; - } - - if (code === 'End') { - event.preventDefault(); - this.focusTabAtIndex(this._tabs.length - 1); - return; - } - } - - /** - * Maps a keyboard code to a navigation delta (+1 or -1) based - * on orientation and text direction. Returns `null` when the key - * does not apply to the current orientation. - */ - private getNavigationDelta(code: string): number | null { - const isRtl = this.dir === 'rtl'; - - if (this._direction === 'horizontal') { - if (code === 'ArrowRight') { - return isRtl ? -1 : 1; - } - if (code === 'ArrowLeft') { - return isRtl ? 1 : -1; - } - return null; - } - - // Vertical: Up/Down navigate; Left/Right have no effect. - if (code === 'ArrowDown') { - return 1; - } - if (code === 'ArrowUp') { - return -1; - } - return null; - } - - /** - * Moves focus by `delta` positions from the currently focused tab, - * wrapping around the list. All tabs (including disabled) receive - * focus per APG. In automatic activation mode, the newly focused tab - * is also selected. + * Syncs the SelectionController's internal `selectedItems` set and the + * navigation controller's roving tab stop to match `this.selected` without + * dispatching a `change` event. Called when `selected` changes externally + * and when the tab list is rebuilt. */ - private focusByDelta(delta: number): void { + private _syncSelectionController(): void { if (!this._tabs.length) { return; } - const root = this.getRootNode() as Document | ShadowRoot; - const current = this._tabs.indexOf(root.activeElement as TabLike); - const start = current === -1 ? 0 : current; - const nextIndex = this.wrapIndex(start + delta); + const selectedTab = this._tabs.find((t) => t.tabId === this.selected); - this.focusTabAtIndex(nextIndex); + if (selectedTab) { + this._suppressSelectionChangeEvent = true; + this._selection.setSelectedItem(selectedTab as HTMLElement); + this._suppressSelectionChangeEvent = false; + // Also sync the roving tab stop so focus() and Tab re-entry target + // the selected tab rather than the first item in the list. + this._navigation.setActiveItem(selectedTab as HTMLElement); + } else if (this.selected) { + // No tab with this id — reset the public property. + this.selected = ''; + } } /** - * Focuses the tab at the given index and, when in automatic - * activation mode, also selects it. Selection runs before `focus()` - * so `change` listeners observe the updated value before focus moves. + * Handles `focusgroupNavigationActiveChange` events dispatched by + * `_navigation`. In automatic activation mode, selects the newly + * focused tab (unless it is disabled) and dispatches `change`. */ - private focusTabAtIndex(index: number): void { - if (!this._tabs.length) { + private readonly _handleNavigationActiveChange = (event: Event): void => { + if (this._keyboardActivation !== 'automatic') { return; } - - const clamped = this.wrapIndex(index); - const tab = this._tabs[clamped]; - if (!tab) { + const { activeElement } = ( + event as CustomEvent + ).detail; + if (!activeElement) { return; } - - if (this._keyboardActivation === 'automatic' && !tab.disabled) { - this.selectTarget(tab); - } - - this.setRovingTabindex(tab); - tab.focus(); - } - - /** - * Wraps an index into the valid range `[0, tabs.length)`. - */ - private wrapIndex(index: number): number { - const len = this._tabs.length; - return ((index % len) + len) % len; - } - - /** - * Updates roving tabindex so only the given tab has - * `tabindex="0"` and all others have `tabindex="-1"`. - */ - private setRovingTabindex(activeTab: TabLike): void { - for (const tab of this._tabs) { - tab.tabIndex = tab === activeTab ? 0 : -1; - } - } - - /** - * Attempts to select the given tab element. Dispatches a cancelable - * `change` event — if the consumer calls `preventDefault()`, the - * selection reverts to the previous value. - */ - private selectTarget(target: TabLike): void { - const id = target.tabId; - if (!id) { + const tab = activeElement as TabLike; + if (tab.disabled) { return; } - - const previous = this.selected; - this.selected = id; - - const applyDefault = this.dispatchEvent( - new Event('change', { - cancelable: true, - }) - ); - - if (!applyDefault) { - this.selected = previous; - } - } - - /** - * Synchronizes the `selected` attribute and roving tabindex on - * each child tab to match the container's `selected` value. - * Ensures at least one tab has `tabindex="0"` for Tab-key entry - * when the container is not disabled. When the container is - * disabled, all tabs get `tabindex="-1"` to prevent Tab-key - * entry into the tab list. - */ - private updateCheckedState(): void { - let hasTabStop = false; - - for (const tab of this._tabs) { - tab.selected = false; - } - - if (this.selected) { - const currentTab = this._tabs.find((el) => el.tabId === this.selected); - - if (currentTab) { - currentTab.selected = true; - if (!this.disabled) { - this.setRovingTabindex(currentTab); - hasTabStop = true; - } - } else { - this.selected = ''; - } - } - - if (this.disabled) { - for (const tab of this._tabs) { - tab.tabIndex = -1; - } - } else if (!hasTabStop && this._tabs.length) { - this._tabs[0].tabIndex = 0; - } - } + this._selection.setSelectedItem(activeElement); + }; /** * Wires up cross-element ARIA relationships between tabs and @@ -591,7 +500,7 @@ export abstract class TabsBase extends SpectrumElement { ) as TabLike | null; if (selectedChild) { - this.selectTarget(selectedChild); + this.selected = selectedChild.tabId; } } @@ -599,7 +508,7 @@ export abstract class TabsBase extends SpectrumElement { if (changes.has('selected')) { if (this._tabs.length) { - this.updateCheckedState(); + this._syncSelectionController(); } const previousValue = changes.get('selected') as string | undefined; @@ -625,16 +534,29 @@ export abstract class TabsBase extends SpectrumElement { } if (changes.has('disabled') && this._tabs.length) { - this.updateCheckedState(); + // getItems already returns [] when disabled; refreshing both + // controllers re-applies roving tabindex accordingly. + this._navigation.refresh(); + this._selection.refresh(); + // After re-enabling, restore the roving tab stop to the selected tab + // (refresh() defaults to items[0] when no item has tabindex=0). + if (!this.disabled) { + this._syncSelectionController(); + } } if (changes.has('direction')) { + this._navigation.setOptions({ direction: this.direction }); this.updateSelectionIndicator(); } } public override connectedCallback(): void { super.connectedCallback(); + this.addEventListener( + focusgroupNavigationActiveChange, + this._handleNavigationActiveChange + ); window.addEventListener('resize', this.updateSelectionIndicator); if ('fonts' in document) { document.fonts.addEventListener( @@ -649,6 +571,10 @@ export abstract class TabsBase extends SpectrumElement { } public override disconnectedCallback(): void { + this.removeEventListener( + focusgroupNavigationActiveChange, + this._handleNavigationActiveChange + ); window.removeEventListener('resize', this.updateSelectionIndicator); if ('fonts' in document) { document.fonts.removeEventListener( @@ -662,23 +588,17 @@ export abstract class TabsBase extends SpectrumElement { } /** - * Focuses the selected tab, or the first tab when none is selected yet. - * Slotted tabs live in the light DOM; this is more reliable than relying - * only on shadow `delegatesFocus` across browsers and test harnesses. + * Focuses the tab that currently has the roving tab stop (`tabindex="0"`), + * falling back to `super.focus()` when no tab is in the tab order. */ public override focus(options?: FocusOptions): void { if (this.disabled) { return; } - const selectedTab = this._tabs.find((tab) => tab.selected); - if (selectedTab) { - (selectedTab as HTMLElement).focus(options); - return; - } - - if (this._tabs.length) { - (this._tabs[0] as HTMLElement).focus(options); + const activeItem = this._navigation.getActiveItem(); + if (activeItem) { + activeItem.focus(options); return; } diff --git a/2nd-gen/packages/swc/components/tabs/Tabs.ts b/2nd-gen/packages/swc/components/tabs/Tabs.ts index a1da4349b78..aba78a82a76 100644 --- a/2nd-gen/packages/swc/components/tabs/Tabs.ts +++ b/2nd-gen/packages/swc/components/tabs/Tabs.ts @@ -71,8 +71,6 @@ export class Tabs extends TabsBase { this.direction === 'vertical' ? 'vertical' : undefined )} aria-disabled=${ifDefined(this.disabled ? 'true' : undefined)} - @click=${this.handleClick} - @keydown=${this.handleKeyDown} >
Date: Fri, 12 Jun 2026 11:19:58 -0400 Subject: [PATCH 04/32] chore: added changeset --- .changeset/metal-badgers-see.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/metal-badgers-see.md diff --git a/.changeset/metal-badgers-see.md b/.changeset/metal-badgers-see.md new file mode 100644 index 00000000000..07b9da2feae --- /dev/null +++ b/.changeset/metal-badgers-see.md @@ -0,0 +1,5 @@ +--- +'@adobe/spectrum-wc': patch +--- + +Add `SelectionController`, a Lit `ReactiveController` that manages single- and multi-selectable groups of items for tabs, accordions, radio groups, swatch groups with selectable swatches, button groups that function as radio groups, menus, and pickers. From 19f38f5626f58c748e9eac9c87997141c1a02ad3 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 11:36:53 -0400 Subject: [PATCH 05/32] test(core): added tests for selection controller --- .../test/selection-controller.test.ts | 519 ++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts diff --git a/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts new file mode 100644 index 00000000000..554fbc6d746 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts @@ -0,0 +1,519 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { + selectionControllerChange, + type SelectionControllerChangeDetail, +} from '@spectrum-web-components/core/controllers/index.js'; + +import '../stories/demo-hosts.js'; + +import { getComponent } from '../../../../swc/utils/test-utils.js'; +import selectionMeta, { + AccordionModeSwitch, + MultipleListbox, + SingleModeRating, + SingleToggleModeRating, + TablistWithFocusgroup, +} from '../stories/selection-controller.stories.js'; + +/** + * Dispatches a keydown with both `key` and `code` set. The SelectionController + * checks `event.code` for Enter/Space activation, so both must be present. + */ +function activate( + target: HTMLElement, + code: 'Enter' | 'Space' = 'Enter' +): void { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key: code === 'Space' ? ' ' : code, + code, + bubbles: true, + composed: true, + cancelable: true, + }) + ); +} + +/** + * Dispatches a keydown with `key` only, for FocusgroupNavigationController + * arrow key tests (which check `event.key`, not `event.code`). + */ +function arrowKey(target: HTMLElement, key: string): void { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + }) + ); +} + +/** Returns the currently focused element inside the host's shadow root. */ +function shadowFocused(host: HTMLElement): HTMLElement | null { + const active = host.shadowRoot?.activeElement; + return active instanceof HTMLElement ? active : null; +} + +export default { + ...selectionMeta, + title: 'Controllers/Selection controller/Tests', + parameters: { + ...selectionMeta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// Single mode — clicking the selected item has no effect +// ────────────────────────────────────────────────────────────── + +export const SingleModeRatingTest: Story = { + ...SingleModeRating, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-selection-star-single' + ); + const root = host.shadowRoot!; + const stars = Array.from( + root.querySelectorAll('[data-star-single]') + ); + expect(stars.length).toBe(5); + + await step('initially no star is selected', async () => { + for (const star of stars) { + expect(star.getAttribute('aria-checked')).toBe('false'); + } + }); + + await step( + 'clicking star 2 selects it and leaves others deselected', + async () => { + stars[1].click(); + expect(stars[1].getAttribute('aria-checked')).toBe('true'); + for (let i = 0; i < 5; i++) { + if (i !== 1) { + expect(stars[i].getAttribute('aria-checked')).toBe('false'); + } + } + } + ); + + await step( + 'clicking the already-selected star again has no effect (single mode)', + async () => { + stars[1].click(); + expect(stars[1].getAttribute('aria-checked')).toBe('true'); + } + ); + + await step('clicking a different star replaces the selection', async () => { + stars[3].click(); + expect(stars[3].getAttribute('aria-checked')).toBe('true'); + expect(stars[1].getAttribute('aria-checked')).toBe('false'); + }); + + await step('Enter key selects the focused star', async () => { + stars[0].focus(); + activate(stars[0], 'Enter'); + expect(stars[0].getAttribute('aria-checked')).toBe('true'); + expect(stars[3].getAttribute('aria-checked')).toBe('false'); + }); + + await step('Space key selects the focused star', async () => { + stars[4].focus(); + activate(stars[4], 'Space'); + expect(stars[4].getAttribute('aria-checked')).toBe('true'); + expect(stars[0].getAttribute('aria-checked')).toBe('false'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Single-toggle mode — clicking the selected item clears it +// ────────────────────────────────────────────────────────────── + +export const SingleToggleModeRatingTest: Story = { + ...SingleToggleModeRating, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-selection-star-toggle' + ); + const root = host.shadowRoot!; + const stars = Array.from( + root.querySelectorAll('[data-star-toggle]') + ); + expect(stars.length).toBe(5); + + await step('clicking star 3 selects it', async () => { + stars[2].click(); + expect(stars[2].getAttribute('aria-checked')).toBe('true'); + for (let i = 0; i < 5; i++) { + if (i !== 2) { + expect(stars[i].getAttribute('aria-checked')).toBe('false'); + } + } + }); + + await step( + 'clicking the active star again clears the selection', + async () => { + stars[2].click(); + for (const star of stars) { + expect(star.getAttribute('aria-checked')).toBe('false'); + } + } + ); + + await step('clicking star 2 selects it', async () => { + stars[1].click(); + expect(stars[1].getAttribute('aria-checked')).toBe('true'); + }); + + await step('clicking a different star replaces the selection', async () => { + stars[4].click(); + expect(stars[4].getAttribute('aria-checked')).toBe('true'); + expect(stars[1].getAttribute('aria-checked')).toBe('false'); + }); + + await step('Space on the active star clears the selection', async () => { + stars[4].focus(); + activate(stars[4], 'Space'); + for (const star of stars) { + expect(star.getAttribute('aria-checked')).toBe('false'); + } + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Multiple mode — independent toggles, selectAll, clearAll +// ────────────────────────────────────────────────────────────── + +export const MultipleListboxTest: Story = { + ...MultipleListbox, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-selection-listbox' + ); + const root = host.shadowRoot!; + const options = Array.from( + root.querySelectorAll('[data-option]') + ); + expect(options.length).toBe(7); + + const [selectAllBtn, clearBtn] = Array.from( + root.querySelectorAll('.action-btn') + ); + + await step('initially all options are deselected', async () => { + for (const option of options) { + expect(option.getAttribute('aria-selected')).toBe('false'); + } + }); + + await step('clicking an option selects it independently', async () => { + options[0].click(); + expect(options[0].getAttribute('aria-selected')).toBe('true'); + expect(options[1].getAttribute('aria-selected')).toBe('false'); + }); + + await step('clicking another option adds it to the selection', async () => { + options[2].click(); + expect(options[0].getAttribute('aria-selected')).toBe('true'); + expect(options[2].getAttribute('aria-selected')).toBe('true'); + expect(options[1].getAttribute('aria-selected')).toBe('false'); + }); + + await step('clicking a selected option deselects it (toggle)', async () => { + options[0].click(); + expect(options[0].getAttribute('aria-selected')).toBe('false'); + expect(options[2].getAttribute('aria-selected')).toBe('true'); + }); + + await step('selectAll selects every option', async () => { + selectAllBtn.click(); + for (const option of options) { + expect(option.getAttribute('aria-selected')).toBe('true'); + } + }); + + await step('clearAll deselects every option', async () => { + clearBtn.click(); + for (const option of options) { + expect(option.getAttribute('aria-selected')).toBe('false'); + } + }); + + await step( + 'selectionControllerChange event fires with correct detail', + async () => { + let receivedDetail: SelectionControllerChangeDetail | null = null; + const handler = (( + event: CustomEvent + ) => { + receivedDetail = event.detail; + }) as EventListener; + + host.addEventListener(selectionControllerChange, handler); + options[1].click(); + + expect(receivedDetail).toBeTruthy(); + expect(receivedDetail!.selectedItems).toContain(options[1]); + expect(receivedDetail!.addedItems).toContain(options[1]); + expect(receivedDetail!.removedItems.length).toBe(0); + expect(receivedDetail!.selectedItems.length).toBe(1); + + host.removeEventListener(selectionControllerChange, handler); + } + ); + + await step( + 'selectionControllerChange event bubbles and is composed', + async () => { + let captured = false; + const handler = ((event: CustomEvent) => { + expect(event.bubbles).toBe(true); + expect(event.composed).toBe(true); + captured = true; + }) as EventListener; + + canvasElement.addEventListener(selectionControllerChange, handler); + options[3].click(); + expect(captured).toBe(true); + canvasElement.removeEventListener(selectionControllerChange, handler); + } + ); + + await step( + 'Enter key toggles a focused option in multiple mode', + async () => { + options[0].focus(); + const wasBefore = options[0].getAttribute('aria-selected'); + activate(options[0] as HTMLElement, 'Enter'); + const isNow = options[0].getAttribute('aria-selected'); + expect(isNow).not.toBe(wasBefore); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Runtime mode switch via setOptions +// ────────────────────────────────────────────────────────────── + +export const AccordionModeSwitchTest: Story = { + ...AccordionModeSwitch, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-selection-accordion' + ); + const root = host.shadowRoot!; + + const trigger = (key: string): HTMLButtonElement => + root.querySelector(`[data-accordion="${key}"]`)!; + const panel = (key: string): HTMLElement => + root.querySelector(`[data-panel="${key}"]`)!; + const modeBtn = (label: string): HTMLButtonElement => { + const btn = Array.from( + root.querySelectorAll('.mode-btn') + ).find((b) => b.textContent?.trim() === label); + expect(btn).toBeTruthy(); + return btn!; + }; + + await step( + 'after mount all panels are hidden (refresh deselects all)', + async () => { + expect(trigger('general').getAttribute('aria-expanded')).toBe('false'); + expect(trigger('appearance').getAttribute('aria-expanded')).toBe( + 'false' + ); + expect(panel('general').hidden).toBe(true); + expect(panel('appearance').hidden).toBe(true); + } + ); + + await step('clicking the General trigger opens its panel', async () => { + trigger('general').click(); + expect(trigger('general').getAttribute('aria-expanded')).toBe('true'); + expect(panel('general').hidden).toBe(false); + }); + + await step( + 'single mode: clicking a different trigger closes General and opens Appearance', + async () => { + trigger('appearance').click(); + expect(trigger('appearance').getAttribute('aria-expanded')).toBe( + 'true' + ); + expect(panel('appearance').hidden).toBe(false); + expect(trigger('general').getAttribute('aria-expanded')).toBe('false'); + expect(panel('general').hidden).toBe(true); + } + ); + + await step( + 'switching to single-toggle mode retains the current open panel', + async () => { + modeBtn('single-toggle').click(); + expect(trigger('appearance').getAttribute('aria-expanded')).toBe( + 'true' + ); + expect(panel('appearance').hidden).toBe(false); + } + ); + + await step( + 'single-toggle mode: clicking the open trigger closes it', + async () => { + trigger('appearance').click(); + expect(trigger('appearance').getAttribute('aria-expanded')).toBe( + 'false' + ); + expect(panel('appearance').hidden).toBe(true); + } + ); + + await step( + 'switching to multiple mode: multiple panels can be open simultaneously', + async () => { + modeBtn('multiple').click(); + + trigger('content').click(); + trigger('support').click(); + + expect(trigger('content').getAttribute('aria-expanded')).toBe('true'); + expect(panel('content').hidden).toBe(false); + expect(trigger('support').getAttribute('aria-expanded')).toBe('true'); + expect(panel('support').hidden).toBe(false); + } + ); + + await step( + 'switching back to single retains only the first selected panel', + async () => { + modeBtn('single').click(); + + const openCount = [ + 'general', + 'appearance', + 'content', + 'support', + ].filter( + (key) => trigger(key).getAttribute('aria-expanded') === 'true' + ).length; + expect(openCount).toBe(1); + + expect(trigger('content').getAttribute('aria-expanded')).toBe('true'); + expect(trigger('support').getAttribute('aria-expanded')).toBe('false'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Tablist: FocusgroupNavigationController + SelectionController +// ────────────────────────────────────────────────────────────── + +export const TablistWithFocusgroupTest: Story = { + ...TablistWithFocusgroup, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-selection-tabs' + ); + const root = host.shadowRoot!; + + const tab = (key: string): HTMLButtonElement => + root.querySelector(`[data-tab="${key}"]`)!; + const tabPanel = (key: string): HTMLElement => + root.querySelector(`[data-tab-panel="${key}"]`)!; + + await step( + 'defaultToFirstSelectable auto-selects the first tab on mount', + async () => { + expect(tab('layers').getAttribute('aria-selected')).toBe('true'); + expect(tab('adjustments').getAttribute('aria-selected')).toBe('false'); + expect(tab('export').getAttribute('aria-selected')).toBe('false'); + expect(tabPanel('layers').hasAttribute('hidden')).toBe(false); + expect(tabPanel('adjustments').hasAttribute('hidden')).toBe(true); + expect(tabPanel('export').hasAttribute('hidden')).toBe(true); + } + ); + + await step( + 'ArrowRight moves focus without changing the selection (manual activation)', + async () => { + tab('layers').focus(); + arrowKey(tab('layers'), 'ArrowRight'); + + expect(shadowFocused(host)?.getAttribute('data-tab')).toBe( + 'adjustments' + ); + expect(tab('adjustments').getAttribute('aria-selected')).toBe('false'); + expect(tab('layers').getAttribute('aria-selected')).toBe('true'); + } + ); + + await step('Enter on the focused tab selects it', async () => { + const focused = shadowFocused(host) as HTMLButtonElement; + expect(focused.getAttribute('data-tab')).toBe('adjustments'); + + activate(focused, 'Enter'); + + expect(tab('adjustments').getAttribute('aria-selected')).toBe('true'); + expect(tabPanel('adjustments').hasAttribute('hidden')).toBe(false); + expect(tab('layers').getAttribute('aria-selected')).toBe('false'); + expect(tabPanel('layers').hasAttribute('hidden')).toBe(true); + }); + + await step('Space on a focused tab selects it', async () => { + arrowKey(tab('adjustments'), 'ArrowRight'); + const focused = shadowFocused(host) as HTMLButtonElement; + expect(focused.getAttribute('data-tab')).toBe('export'); + + activate(focused, 'Space'); + + expect(tab('export').getAttribute('aria-selected')).toBe('true'); + expect(tabPanel('export').hasAttribute('hidden')).toBe(false); + expect(tab('adjustments').getAttribute('aria-selected')).toBe('false'); + }); + + await step('pointer click directly selects the target tab', async () => { + tab('layers').click(); + + expect(tab('layers').getAttribute('aria-selected')).toBe('true'); + expect(tabPanel('layers').hasAttribute('hidden')).toBe(false); + expect(tab('export').getAttribute('aria-selected')).toBe('false'); + expect(tabPanel('export').hasAttribute('hidden')).toBe(true); + }); + + await step('ArrowLeft wraps from first tab to last', async () => { + tab('layers').focus(); + arrowKey(tab('layers'), 'ArrowLeft'); + + expect(shadowFocused(host)?.getAttribute('data-tab')).toBe('export'); + expect(tab('layers').getAttribute('aria-selected')).toBe('true'); + }); + }, +}; From f9e89ef84c84d912e6360ec0b5afcb8583ad61d6 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 12:00:34 -0400 Subject: [PATCH 06/32] chore(rules): updates to stories documentation rules to expand on controller stories --- .ai/rules/stories-documentation.md | 223 +++++++++++++++++++++++++++-- .ai/rules/stories-format.md | 94 ++++++++++-- 2 files changed, 301 insertions(+), 16 deletions(-) diff --git a/.ai/rules/stories-documentation.md b/.ai/rules/stories-documentation.md index 86fa1c58768..55ce0335723 100644 --- a/.ai/rules/stories-documentation.md +++ b/.ai/rules/stories-documentation.md @@ -25,11 +25,12 @@ Long-form documentation lives in the per-unit MDX file at the unit's root, not i ### File template +#### Component / pattern MDX template + ```mdx import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import { DocsFooter, DocsHeader } from '../../.storybook/blocks'; // components // or '../../../.storybook/blocks' for patterns -// or '../../../swc/.storybook/blocks' for controllers import * as Stories from './stories/.stories'; @@ -80,8 +81,101 @@ import * as Stories from './stories/.stories'; ``` +#### Controller MDX template + +Controllers do not expose a visual DOM surface, so their MDX uses different top-level sections. The section headings `## What it does`, `## Basic usage`, `## Behaviors`, `## Accessibility`, `## API`, and `## Appendix` are the controller equivalents of the component section order. The `## API` section is **hand-authored** (controllers opt out of `` via `'controller'` in `meta.tags`). + +````mdx +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks'; + +import * as Stories from './stories/.stories'; + + + + + +## What it does + +### [Feature group 1] + +- Bullet describing behavior A (e.g. "Collapses the tab sequence to one tab stop") +- Bullet describing behavior B + +### [Feature group 2] + +... + +## Basic usage + +1. Construct the controller in your element's `constructor`, passing required options. +2. Ensure `getItems` returns live `HTMLElement` references from `this.renderRoot`. +3. Call `refresh()` from `firstUpdated` once shadow DOM exists. +4. Pair with other controllers when needed (e.g. `FocusgroupNavigationController`). + +```typescript +// Minimal constructor + firstUpdated setup example +``` +```` + +## Behaviors + +### [Story display name] + +...prose... + + + +## Accessibility + +### Features + +...keyboard navigation, ARIA management, etc.... + +### Best practices + +... + + + +## API + +### Methods + +| Member | Description | +| ----------- | ---------------------------------- | +| `refresh()` | Re-query items and re-apply state. | + +### Events + +The controller dispatches **`event-name`** on the host. The event bubbles and is composed. + +```typescript +import { eventConstant } from '@spectrum-web-components/core/controllers'; + +host.addEventListener(eventConstant, (event) => { ... }); +``` + +### Options + +| Option | Type | Default | Description | +| ---------- | --------------------- | ---------- | ----------- | +| `getItems` | `() => HTMLElement[]` | (required) | ... | + +## Appendix + +### See also + +- [Related controller](../?path=/docs/controllers-related--docs) +- [APG pattern link](https://www.w3.org/WAI/ARIA/apg/...) + + +``` + ### Canonical section order +#### Components and patterns + `DocumentTemplate.mdx` (the fallback template for units without a per-unit MDX) renders sections in this same order, so converted units and unconverted units sit side-by-side without surprises: 1. **Anatomy** — `hideTitle` semantics: no per-story `###` heading @@ -96,15 +190,31 @@ import * as Stories from './stories/.stories'; 10. **Appendix** — `hideTitle` semantics; prose-only. 11. **Feedback** — rendered by ``. +#### Controllers + +Controller MDX uses a different section order and different top-level headings because controllers have no visual DOM surface: + +1. **`## What it does`** — bullet-list description of what the controller provides (navigation, selection, positioning, etc.) +2. **`## Basic usage`** — numbered steps: constructor setup, `getItems` contract, `refresh()` lifecycle, pairing guidance, code snippet +3. **`## Behaviors`** — `### Story display name` + prose + `` for each `'behaviors'`-tagged story +4. **`## Accessibility`** — `### Features` and `### Best practices` subheadings; `` +5. **`## API`** — hand-authored; three subsections: `### Methods` (table), `### Events` (import snippet + description), `### Options` (table) +6. **`## Appendix`** — prose-only; relationship to other controllers, "See also" links +7. **Feedback** — rendered by `` + +Sections `## Anatomy`, `## Options`, `## States` do **not** appear in pure utility controllers because there is no user-facing DOM surface to describe. Exceptions: a controller with configurable options that have visual effects (e.g., `HoverController`) may include `## Options` and `## States` sections. + ### Genre-specific notes -| Topic | Component | Internal | Pattern | Controller | -| ------------------------------------ | --------------------------- | --------------------------------- | ------------------------------ | ---------------------------------------------------- | -| MDX path | `components//.mdx` | `components//.internal.mdx` | `patterns///.mdx` | `controllers//.mdx` | -| Blocks import path | `'../../.storybook/blocks'` | `'../../.storybook/blocks'` | `'../../../.storybook/blocks'` | `'../../../swc/.storybook/blocks'` | -| Meta tag for `DocsFooter` API branch | `'migrated'` | n/a | n/a | `'controller'` (omits ``) | -| Anatomy section | required (DOM-bearing) | required if applicable | required (DOM-bearing) | omitted (controllers have no DOM) | -| Production build inclusion | yes | no (`.internal.mdx` excluded) | yes | yes (dev) — core docs excluded from production build | +| Topic | Component | Internal | Pattern | Controller | +| ------------------------------------ | --------------------------- | --------------------------------- | ------------------------------ | ---------------------------------------------- | +| MDX path | `components//.mdx` | `components//.internal.mdx` | `patterns///.mdx` | `controllers//.mdx` | +| Blocks import path | `'../../.storybook/blocks'` | `'../../.storybook/blocks'` | `'../../../.storybook/blocks'` | `'../../../swc/.storybook/blocks'` | +| Meta tag for `DocsFooter` API branch | `'migrated'` | n/a | n/a | `'controller'` (omits ``) | +| Top-level section headings | `Anatomy`, `Options`, etc. | `Anatomy`, `Options`, etc. | `Anatomy`, `Options`, etc. | See controller canonical section order above | +| Anatomy section | required (DOM-bearing) | required if applicable | required (DOM-bearing) | omitted (controllers have no DOM surface) | +| API section | auto by `DocsFooter` | auto by `DocsFooter` | auto by `DocsFooter` | hand-authored `## API` before `` | +| Production build inclusion | yes | no (`.internal.mdx` excluded) | yes | yes (dev) — core docs excluded from prod build | ### Per-story title rules @@ -160,6 +270,103 @@ Organize MDX into these sections (skip sections that don't apply): The remaining sections of this rule describe the **prose content** for each section. Author that prose inside the per-unit MDX, not as JSDoc above stories. +## Controller-specific MDX sections + +The following sections apply **only to controllers** (`core/controllers//.mdx`). Skip these when authoring component or pattern MDX. + +### `## What it does` + +A concise feature inventory for the controller: what it provides and what it explicitly does **not** provide. Use bullet groups with `###` subheadings for logical clusters: + +```md +## What it does + +### Navigation + +- Collapses a composite widget to a single Tab stop via roving `tabindex`. +- Arrow keys move focus according to `direction`. +- `Home` / `End` jump to the first or last item. + +### Configuration + +- `wrap`: end wraps to start. +- `skipDisabled`: disabled items excluded from arrow movement. + +### Programmatic API + +- `setActiveItem(element)`: sets the roving tab stop without calling `focus()`. +``` + +### `## Basic usage` + +A numbered integration checklist followed by a minimal code example. Always include: + +1. Where to construct (in `constructor`) +2. What `getItems` must return (live references from `this.renderRoot`) +3. When to call `refresh()` (from `firstUpdated`, and after DOM changes) +4. What the host must provide (ARIA roles, labels — the controller does not set these) +5. Which controller to pair with when relevant + +```md +## Basic usage + +1. Construct the controller in your element's `constructor`. +2. Ensure `getItems` returns live `HTMLElement` references from `this.renderRoot`. +3. Call `refresh()` from `firstUpdated` once shadow DOM exists. +4. Provide ARIA roles and labels on the host; the controller does not set them. +5. Pair with `FocusgroupNavigationController` when the composite also needs arrow keys. + +\`\`\`typescript +private readonly selection = new SelectionController(this, { +getItems: () => Array.from(this.renderRoot.querySelectorAll('[role="option"]')), +selectItem: (el) => el.setAttribute('aria-selected', 'true'), +deselectItem: (el) => el.setAttribute('aria-selected', 'false'), +mode: 'multiple', +keydownActivation: true, +}); + +protected override firstUpdated(): void { +this.selection.refresh(); +} +\`\`\` +``` + +### Hand-authored `## API` section + +Controllers opt out of the auto-generated `` via `'controller'` in `meta.tags`. The `## API` section is therefore written by hand before ``. Use three fixed subsections: + +```md +## API + +### Methods + +| Member | Description | +| --------------------- | ---------------------------------------------------------- | +| `refresh()` | Re-query items and re-apply state. Call after DOM changes. | +| `setOptions(partial)` | Merge option updates. | + +### Events + +The controller dispatches **`swc-example-change`** (`exampleChange`) on the host with +`detail: { ... }`. The event bubbles and is composed. + +\`\`\`typescript +import { exampleChange } from '@spectrum-web-components/core/controllers'; + +host.addEventListener(exampleChange, (event) => { +console.log(event.detail); +}); +\`\`\` + +### Options + +| Option | Type | Default | Description | +| ---------- | --------------------- | ---------- | ------------------------ | +| `getItems` | `() => HTMLElement[]` | (required) | Current participant set. | +``` + +If the controller dispatches no events, omit `### Events` and state that explicitly. If the controller has no public methods beyond `refresh()`, list only `refresh()` in the table. + ## Helpers section **Purpose**: Organize shared code, label mappings, and utilities used across multiple stories. diff --git a/.ai/rules/stories-format.md b/.ai/rules/stories-format.md index 2519ec9652f..17d72acc641 100644 --- a/.ai/rules/stories-format.md +++ b/.ai/rules/stories-format.md @@ -42,6 +42,8 @@ Component-specific `.usage.mdx` files are no longer used. Units without a per-un ### Stories file (`.stories.ts`) +#### Component / pattern section order + Required structure with visual separators between sections: 1. **Copyright header** (lines 1-11) @@ -56,6 +58,22 @@ Required structure with visual separators between sections: 10. **BEHAVIORS STORIES** - Built-in functionality (if applicable) 11. **ACCESSIBILITY STORIES** - A11y demonstration +#### Controller section order + +Controllers do not have `ANATOMY STORIES`, `OPTIONS STORIES`, or `STATES STORIES` sections (no visual DOM surface). The typical controller story file structure is: + +1. **Copyright header** (lines 1-11) +2. **Imports** +3. **METADATA** - Meta object with `tags: ['migrated', 'controller']` +4. **HELPERS** - Shared utilities (if needed) +5. **PLAYGROUND STORY** - tags: `['dev']` +6. **OVERVIEW STORY** - tags: `['overview']` +7. **USAGE STORIES** _(optional)_ - tags: `['usage']` / `['usage', 'description-only']` with `'section-order'` parameter; JSDoc code examples above each export (see [Usage stories in controllers](#usage-stories-in-controllers)) +8. **BEHAVIORS STORIES** - tags: `['behaviors']` +9. **ACCESSIBILITY STORIES** - tags: `['a11y']` + +A controller may also include `OPTIONS STORIES` and `STATES STORIES` if it has configurable behaviors with visible differences (e.g., `HoverController` with `disabled` and `manual` states). + #### Visual separators ```typescript @@ -300,6 +318,64 @@ Section ordering is hand-authored in each component's per-component MDX file (`< Do not use a `section-order` parameter on stories. The previous `section-order` workaround is retired now that MDX is the source of truth for documentation layout. +**Controller exception:** Some existing `core/controllers` stories (notably `selection-controller.stories.ts`) use the `'usage'` tag, `'description-only'` tag, and `'section-order'` parameter on usage-example stories. These are a preserved documentation pattern for controllers that ship inline code examples alongside the story canvas. Do **not** remove these stories or tags to satisfy a lint error. If a lint error occurs on a different story in the same file (e.g. an `'a11y'` story without a `` reference in the MDX), fix only that story. See the [Usage stories in controllers](#usage-stories-in-controllers) section below. + +## Usage stories in controllers + +Some `core/controllers` stories use a legacy inline-documentation pattern where `'usage'`-tagged exports carry JSDoc code examples. This is distinct from the component pattern (no JSDoc above story exports). Preserve these stories when they exist. + +### When to use + +Use this pattern **only in `core/controllers`** when the controller's primary audience is engineers integrating it into a component, and inline code snippets alongside a live demo are more useful than prose alone in MDX. + +### Pattern + +````typescript +// ────────────────────────── +// USAGE STORIES +// ────────────────────────── + +/** + * ## Anatomy of a `MyController` + * + * Constructor signature, options, and lifecycle description. + * + * ```typescript + * // Example setup code + * ``` + */ +export const Usage: Story = { + tags: ['usage', 'description-only'], + parameters: { 'section-order': 0 }, +}; + +/** + * ### Single mode example + * + * Explanation of when to use this mode. + * + * ```typescript + * // Mode-specific code + * ``` + */ +export const UsageExampleSingle: Story = { + name: 'Example: single mode', + tags: ['usage'], + parameters: { 'section-order': 1 }, + render: () => html` + + `, +}; +```` + +### Rules specific to controller usage stories + +- The JSDoc above each usage story export **is** authored (exception to the no-story-JSDoc rule). +- `'description-only'` on the header story means it has no canvas — prose only. +- `'section-order'` controls display order within the Usage section in `DocumentTemplate.mdx`. +- The demo custom element rendered by usage stories lives in `stories/demo-hosts.ts` alongside the stories file. +- Do not add `` to the controller MDX — usage stories render through `DocumentTemplate.mdx` via the `'usage'` tag, not through the hand-authored MDX page. + ## Tags ### Required tags @@ -320,7 +396,9 @@ Do not use a `section-order` parameter on stories. The previous `section-order` - `'upcoming'` - Story demonstrates a feature or variant that is not yet available -> The previous `'description-only'` tag is retired. Prose-only sections live in the per-component MDX (`.mdx`) as Markdown without a `` reference, not as story exports. +> The `'description-only'` tag is retired **for components and patterns**. Prose-only sections live in the per-unit MDX as Markdown without a `` reference, not as story exports. +> +> **Controller exception:** `'usage'` and `'description-only'` remain valid in `core/controllers` stories for the usage-example pattern. See [Usage stories in controllers](#usage-stories-in-controllers). ### Exclusion tags @@ -662,10 +740,10 @@ See `asset.stories.ts` for complete examples. ### ❌ Don't -- Add JSDoc above any individual story export (Playground, Overview, or any other) -- Use the `'usage'` tag for new units (deprecated) -- Use the `'description-only'` tag (retired — prose-only sections live in MDX) -- Use the `'section-order'` parameter (retired — section order is hand-authored in MDX) +- Add JSDoc above any individual story export (Playground, Overview, or any other) — **except** usage-example stories in `core/controllers` (see [Usage stories in controllers](#usage-stories-in-controllers)) +- Use the `'usage'` tag for new **component or pattern** units (deprecated for those genres) +- Use the `'description-only'` tag for **components or patterns** (retired — prose-only sections live in MDX) +- Use the `'section-order'` parameter for **components or patterns** (retired — section order is hand-authored in MDX) - Use `tags: ['autodocs', 'dev']` on a unit that has a per-unit MDX file (creates a duplicate Docs entry — use `['dev']`) - Omit `subtitle` in meta parameters - Use placeholder text @@ -696,9 +774,9 @@ See `asset.stories.ts` for complete examples. - [ ] Behaviors: `['behaviors']` tag (if applicable) - [ ] Accessibility: `['a11y']` tag (prose lives in MDX) - [ ] Static colors: three-story or combined-story pattern with `staticColorsDemo` (if applicable) -- [ ] No story-level JSDoc comments above any `export const` -- [ ] No `section-order` parameter on any story -- [ ] No `description-only` tag on any story +- [ ] No story-level JSDoc comments above any `export const` (exception: usage-example stories in `core/controllers`) +- [ ] No `section-order` parameter on any story (exception: usage-example stories in `core/controllers`) +- [ ] No `description-only` tag on any story (exception: usage-example stories in `core/controllers`) - [ ] All stories accessible with meaningful content - [ ] Image assets: use `picsum.photos` with static IDs (if applicable) - [ ] Per-unit MDX file exists at the unit root and references each section-tagged story via `` (see `.ai/rules/stories-documentation.md`) From eef6543ebdb8ebff208786aa8df39ac084812552 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 12:28:45 -0400 Subject: [PATCH 07/32] fix(core): fixed failing tests for tabs using focus group navigation controller --- .../src/focusgroup-navigation-controller.ts | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts index 57c601a6546..ca243bfb535 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts @@ -839,18 +839,22 @@ export class FocusgroupNavigationController implements ReactiveController { const isGrid = this.options.direction === 'grid'; const rows = isGrid ? this.getRows(items) : null; + // Fall back to event.code when event.key is empty (synthetic test events). + // Real browser events always populate event.key, so this never affects + // numpad disambiguation (e.g. Numpad6 NumLock-off sets key='ArrowRight'). + const key = event.key || event.code; if ( isGrid && event.ctrlKey && !event.metaKey && - (event.key === 'Home' || event.key === 'End') + (key === 'Home' || key === 'End') ) { if (rows!.length > 0) { const firstRow = rows![0]; const lastRow = rows![rows!.length - 1]; const boundary = - event.key === 'Home' + key === 'Home' ? (firstRow?.[0] ?? null) : (lastRow?.[lastRow.length - 1] ?? null); if (boundary && boundary !== target) { @@ -866,14 +870,11 @@ export class FocusgroupNavigationController implements ReactiveController { } const pageMagnitude = this.getEffectivePageMagnitude(); - if ( - pageMagnitude !== null && - (event.key === 'PageUp' || event.key === 'PageDown') - ) { + if (pageMagnitude !== null && (key === 'PageUp' || key === 'PageDown')) { const pageNext = this.navigatePage( items, target, - event.key === 'PageDown' ? pageMagnitude : -pageMagnitude, + key === 'PageDown' ? pageMagnitude : -pageMagnitude, rows ); if (pageNext && pageNext !== target) { @@ -888,16 +889,16 @@ export class FocusgroupNavigationController implements ReactiveController { switch (this.options.direction) { case 'horizontal': - next = this.navigateLinear(items, target, event.key, 'horizontal', rtl); + next = this.navigateLinear(items, target, key, 'horizontal', rtl); break; case 'vertical': - next = this.navigateLinear(items, target, event.key, 'vertical', rtl); + next = this.navigateLinear(items, target, key, 'vertical', rtl); break; case 'both': - next = this.navigateBothAxes(items, target, event.key, rtl); + next = this.navigateBothAxes(items, target, key, rtl); break; case 'grid': - next = this.navigateGrid(target, event.key, rtl, rows!); + next = this.navigateGrid(target, key, rtl, rows!); break; default: break; @@ -909,7 +910,7 @@ export class FocusgroupNavigationController implements ReactiveController { return; } - if (event.key === 'Home' || event.key === 'End') { + if (key === 'Home' || key === 'End') { if (isGrid) { // APG grid pattern: Home/End scope to the current row. // Ctrl+Home/End (entire grid) is handled above. @@ -922,9 +923,7 @@ export class FocusgroupNavigationController implements ReactiveController { return; } const boundary = - event.key === 'Home' - ? currentRow[0] - : currentRow[currentRow.length - 1]; + key === 'Home' ? currentRow[0] : currentRow[currentRow.length - 1]; if (boundary && boundary !== target) { event.preventDefault(); this.moveKeyNavigationFocusTo(boundary); @@ -933,8 +932,7 @@ export class FocusgroupNavigationController implements ReactiveController { if (items.length === 0) { return; } - const boundary = - event.key === 'Home' ? items[0] : items[items.length - 1]; + const boundary = key === 'Home' ? items[0] : items[items.length - 1]; if (boundary && boundary !== target) { event.preventDefault(); this.moveKeyNavigationFocusTo(boundary); From 176566261805bae3ba09fbb702da4f7ac105f23e Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 17:51:10 -0400 Subject: [PATCH 08/32] docs(core): test fixes for selection controller --- .../src/focusgroup-navigation-controller.ts | 15 ++++++++++++++- .../selection-controller/stories/demo-hosts.ts | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts index ca243bfb535..1b744fa5a79 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts @@ -280,6 +280,13 @@ export class FocusgroupNavigationController implements ReactiveController { */ private cachedRows: HTMLElement[][] | null = null; + /** + * Snapshot of the last non-empty raw item list. Used to reset `tabIndex` + * when `getItems()` returns `[]` (e.g. the host signals it is disabled), + * since `getRawItems()` would also return `[]` in that state. + */ + private lastKnownItems: HTMLElement[] = []; + // ───────────────────────── // PUBLIC API // ───────────────────────── @@ -334,9 +341,14 @@ export class FocusgroupNavigationController implements ReactiveController { this.cachedRows = null; const items = this.getEligibleItems(); if (items.length === 0) { - for (const el of this.getRawItems()) { + // getRawItems() calls getItems() which may return [] when the host + // signals it is disabled. Fall back to the last known item list so + // all managed items get tabIndex=-1 even when getItems() is empty. + const rawItems = this.getRawItems(); + for (const el of rawItems.length > 0 ? rawItems : this.lastKnownItems) { el.tabIndex = -1; } + this.lastKnownItems = []; this.lastFocused = null; if (this.previousActive !== null) { this.previousActive = null; @@ -346,6 +358,7 @@ export class FocusgroupNavigationController implements ReactiveController { return; } + this.lastKnownItems = this.getRawItems(); const preferred = (this.options.memory && this.lastFocused && diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts index 3246b33d2d5..427b9434467 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts @@ -426,8 +426,18 @@ export class DemoSelectionAccordion extends LitElement { } } - protected override firstUpdated(): void { - this.accordionSelection.refresh(); + protected override updated(): void { + for (const { key } of ACCORDION_ITEMS) { + const btn = this.renderRoot.querySelector( + `[data-accordion="${key}"]` + ); + if (!btn) { + continue; + } + const isOpen = this.accordionSelection.isSelected(btn); + btn.setAttribute('aria-expanded', String(isOpen)); + this.togglePanel(key as AccordionKey, isOpen); + } } private setMode(newMode: SelectionMode): void { @@ -455,7 +465,7 @@ export class DemoSelectionAccordion extends LitElement {
${ACCORDION_ITEMS.map( - ({ key, heading, copy }, ordinal) => html` + ({ key, heading, copy }) => html`

From 19de94282ae54ff84eb8fe20712e536fe1a1b8da Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 19:54:05 -0400 Subject: [PATCH 09/32] fix(core): fixing another failing a11y test --- .../selection-controller/src/selection-controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 028bb706937..f94dff83cc8 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -401,8 +401,12 @@ export class SelectionController implements ReactiveController { */ public refresh(): void { const eligible = this.getEligibleItems(); + // An item is stale when it is disconnected, or when the eligible list is + // non-empty and does not include it. When eligible is empty (e.g. the host + // signals it is disabled via getItems()→[]), only disconnected items are + // removed — connected selections are preserved so aria-selected stays true. const stale = Array.from(this.selectedItems).filter( - (el) => !el.isConnected || !eligible.includes(el) + (el) => !el.isConnected || (eligible.length > 0 && !eligible.includes(el)) ); if (stale.length > 0) { From 2cc8a6ac0ba5352d77f897536e2b9e494a6074cb Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 10:07:14 -0400 Subject: [PATCH 10/32] docs(core): fix selection controller stories --- .../stories/selection-controller.stories.ts | 1 - 2nd-gen/packages/swc/package.json | 8 ++++---- .../packages/tools/vite-global-elements-css/package.json | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts index 4f397a2d88a..4ef337e99ad 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts @@ -353,4 +353,3 @@ export const TablistWithFocusgroup: Story = { `, tags: ['appendix'], }; - diff --git a/2nd-gen/packages/swc/package.json b/2nd-gen/packages/swc/package.json index 60c552cfecc..dc8ebdc037d 100644 --- a/2nd-gen/packages/swc/package.json +++ b/2nd-gen/packages/swc/package.json @@ -27,14 +27,14 @@ "types": "./dist/*.css.d.ts", "default": "./dist/*.css" }, - "./components/*.js": { - "types": "./dist/components/*.d.ts", - "default": "./dist/components/*.js" - }, "./components/*": { "types": "./dist/components/*/index.d.ts", "import": "./dist/components/*/index.js" }, + "./components/*.js": { + "types": "./dist/components/*.d.ts", + "default": "./dist/components/*.js" + }, "./global-button.css": { "default": "./dist/global-button.css" }, diff --git a/2nd-gen/packages/tools/vite-global-elements-css/package.json b/2nd-gen/packages/tools/vite-global-elements-css/package.json index f161dd0382b..f37c1522513 100644 --- a/2nd-gen/packages/tools/vite-global-elements-css/package.json +++ b/2nd-gen/packages/tools/vite-global-elements-css/package.json @@ -19,11 +19,11 @@ }, "type": "module", "main": "index.js", - "types": "index.d.ts", "scripts": { "test": "vitest --run", "test:watch": "vitest --watch" }, + "types": "index.d.ts", "dependencies": { "postcss": "8.5.9" }, From 032b4eefa2355c350f9631ac626754f65e9f6cdf Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 10:54:59 -0400 Subject: [PATCH 11/32] fix(core): enforcing a selection with disabled host with radio type --- .../core/components/tabs/Tabs.base.ts | 26 ++- .../src/selection-controller.ts | 29 +++- 2nd-gen/packages/swc/.storybook/preview.ts | 11 +- .../swc/stylesheets/global/global-button.css | 152 +++++------------- 4 files changed, 100 insertions(+), 118 deletions(-) diff --git a/2nd-gen/packages/core/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index 619fa7e706b..231bdefff77 100644 --- a/2nd-gen/packages/core/components/tabs/Tabs.base.ts +++ b/2nd-gen/packages/core/components/tabs/Tabs.base.ts @@ -293,11 +293,13 @@ export abstract class TabsBase extends SpectrumElement { * because the tab is already selected (`single` mode ignores * re-clicks on the active item). * - * `getItems` returns an empty array when the container is disabled, - * which prevents any selection interaction while disabled. + * `getItems` always returns the full tab list so the controller can + * track selection and apply `defaultToFirstSelectable` even when the + * container is disabled. User interaction is blocked via + * `confirmSelectionChange` when `disabled` is `true`. */ private readonly _selection = new SelectionController(this, { - getItems: () => (this.disabled ? [] : (this._tabs as HTMLElement[])), + getItems: () => this._tabs as HTMLElement[], selectItem: (item) => { (item as TabLike).selected = true; }, @@ -305,6 +307,7 @@ export abstract class TabsBase extends SpectrumElement { (item as TabLike).selected = false; }, mode: 'single', + defaultToFirstSelectable: true, keydownActivation: true, onSelectionChange: ({ selectedItems }) => { const newTab = selectedItems[0] as TabLike | undefined; @@ -317,6 +320,9 @@ export abstract class TabsBase extends SpectrumElement { if (this._suppressSelectionChangeEvent) { return true; } + if (this.disabled) { + return false; + } return this.dispatchEvent(new Event('change', { cancelable: true })); }, }); @@ -350,7 +356,14 @@ export abstract class TabsBase extends SpectrumElement { // Refresh navigation first so its eligible-items cache is populated // before _syncSelectionController calls setActiveItem. this._navigation.refresh(); + // Sync any externally-set selection before refresh so a pre-selected tab + // wins over the defaultToFirstSelectable default. this._syncSelectionController(); + // Refresh without firing a change event: populates lastKnownRawItems and + // applies defaultToFirstSelectable when no tab was pre-selected. + this._suppressSelectionChangeEvent = true; + this._selection.refresh(); + this._suppressSelectionChangeEvent = false; this.updateSelectionIndicator(); } @@ -534,10 +547,13 @@ export abstract class TabsBase extends SpectrumElement { } if (changes.has('disabled') && this._tabs.length) { - // getItems already returns [] when disabled; refreshing both - // controllers re-applies roving tabindex accordingly. + // Refreshing both controllers re-applies roving tabindex accordingly. this._navigation.refresh(); + // Suppress change events: toggling disabled must not look like a + // user-initiated selection change to consumers. + this._suppressSelectionChangeEvent = true; this._selection.refresh(); + this._suppressSelectionChangeEvent = false; // After re-enabling, restore the roving tab stop to the selected tab // (refresh() defaults to items[0] when no item has tabindex=0). if (!this.disabled) { diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index f94dff83cc8..6e4141ce3ac 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -176,6 +176,13 @@ export class SelectionController implements ReactiveController { private selectedItems: Set = new Set(); + /** + * Snapshot of the last non-empty raw item list. Used to support + * `defaultToFirstSelectable` and stale-item cleanup when `getItems()` + * returns `[]` (e.g. the host signals it is disabled). + */ + private lastKnownRawItems: HTMLElement[] = []; + private keydownListenerAttached = false; private readonly handleClickCapture = (event: MouseEvent): void => { @@ -400,7 +407,14 @@ export class SelectionController implements ReactiveController { * (single-item modes only). */ public refresh(): void { - const eligible = this.getEligibleItems(); + // Call getScopedRawItems() once and derive eligible from it so we avoid a + // redundant getItems() call inside getEligibleItems(). + const rawItems = this.getScopedRawItems(); + if (rawItems.length > 0) { + this.lastKnownRawItems = rawItems; + } + const eligible = rawItems.filter((el) => this.isSelectableItem(el)); + // An item is stale when it is disconnected, or when the eligible list is // non-empty and does not include it. When eligible is empty (e.g. the host // signals it is disabled via getItems()→[]), only disconnected items are @@ -421,10 +435,17 @@ export class SelectionController implements ReactiveController { if ( isSingle && this.options.defaultToFirstSelectable && - this.selectedItems.size === 0 && - eligible.length > 0 + this.selectedItems.size === 0 ) { - this.applySelectionTransition([eligible[0]], []); + // For radio types one item must be selected even when the host is disabled. + // Use the eligible list when available; fall back to the last known raw + // items when getItems() returns [] (host disabled) so the first item + // is still selected. + const candidates = + eligible.length > 0 ? eligible : this.lastKnownRawItems; + if (candidates.length > 0) { + this.applySelectionTransition([candidates[0]], []); + } } } diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index e6bf6551b9b..01c380051bf 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -260,6 +260,7 @@ const preview = { 'Spectrum SWC migration', 'Anti patterns', 'Property order quick reference', + 'Stylesheets', ], 'TypeScript', [ @@ -336,7 +337,10 @@ const preview = { 'Rendering and styling migration analysis', ], 'Action group', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Action menu', [ 'Accessibility migration analysis', @@ -379,6 +383,11 @@ const preview = { ], 'Color field', ['Rendering and styling migration analysis'], + 'Color handle', + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Color loupe', [ 'Accessibility migration analysis', diff --git a/2nd-gen/packages/swc/stylesheets/global/global-button.css b/2nd-gen/packages/swc/stylesheets/global/global-button.css index e0e323f0091..9ffa05dc385 100644 --- a/2nd-gen/packages/swc/stylesheets/global/global-button.css +++ b/2nd-gen/packages/swc/stylesheets/global/global-button.css @@ -16,84 +16,66 @@ @layer swc-global-elements {.swc-Button, .swc-Button * { box-sizing: border-box; -} - -.swc-Button { +}.swc-Button { -webkit-tap-highlight-color: transparent; display: inline-flex; position: relative; - gap: var(--swc-button-gap, token("text-to-visual-100")); align-items: center; justify-content: center; + margin: 0; + text-transform: none; + text-decoration: none; + border-style: solid; + overflow: visible; + user-select: none; + --_swc-button-border-width: token("border-width-200"); + --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); + + gap: var(--swc-button-gap, token("text-to-visual-100")); max-inline-size: var(--swc-button-max-inline-size, inherit); min-block-size: var(--_swc-button-min-block-size); padding-block: calc(var(--swc-button-padding-vertical, token("component-padding-vertical-100")) - var(--_swc-button-border-width)); padding-inline: calc(var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100")) - var(--_swc-button-border-width)); - margin: 0; font-family: token("sans-serif-font"); font-size: var(--swc-button-font-size, token("font-size-100")); font-weight: token("bold-font-weight"); line-height: round(down, calc(token("line-height-100") * 1em), 1px); color: var(--swc-button-content-color-default, token("gray-25")); - text-transform: none; - text-decoration: none; background-color: var(--swc-button-background-color-default, token("neutral-background-color-default")); border-color: var(--swc-button-border-color-default, transparent); - border-style: solid; border-width: var(--_swc-button-border-width); border-radius: var(--swc-button-border-radius, calc(var(--_swc-button-min-block-size) / 2)); - overflow: visible; - user-select: none; transition-timing-function: token("animation-ease-in-out"); transition-duration: token("animation-duration-100"); transition-property: outline, border-color, color, background-color, transform; - - --_swc-button-border-width: token("border-width-200"); - --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); -} - -.swc-Button:focus-visible { +}.swc-Button:focus-visible { + outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); + outline-offset: token("focus-indicator-gap"); color: var(--swc-button-content-color-focus, token("gray-25")); background-color: var(--swc-button-background-color-focus, token("neutral-background-color-key-focus")); border-color: var(--swc-button-border-color-focus, transparent); - outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); - outline-offset: token("focus-indicator-gap"); -} - -.swc-Button:disabled:is(*, :hover) { +}.swc-Button:disabled:is(*, :hover) { color: var(--swc-button-content-color-disabled, token("disabled-content-color")); background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); border-color: var(--swc-button-border-color-disabled, transparent); transform: none; -} - -.swc-Button--disabled .swc-Button-icon { +}.swc-Button--disabled .swc-Button-icon { pointer-events: none; -} - -.swc-Button:hover { +}.swc-Button:hover { color: var(--swc-button-content-color-hover, token("gray-25")); background-color: var(--swc-button-background-color-hover, token("neutral-background-color-hover")); border-color: var(--swc-button-border-color-hover, transparent); -} - -.swc-Button:active { +}.swc-Button:active { color: var(--swc-button-content-color-down, token("gray-25")); background-color: var(--swc-button-background-color-down, token("neutral-background-color-down")); border-color: var(--swc-button-border-color-down, transparent); transform: var(--swc-button-down-state-transform, perspective(var(--_swc-button-min-block-size)) translate3d(0, 0, token("component-size-difference-down"))); will-change: transform; -} - -.swc-Button-label { +}.swc-Button-label { text-align: center; -} - -.swc-Button--hasIcon .swc-Button-label { +}.swc-Button--hasIcon .swc-Button-label { text-align: start; -} - -.swc-Button-icon, +}.swc-Button-icon, .swc-Button-pendingSpinner { --_swc-button-icon-size: var(--swc-button-icon-size, token("workflow-icon-size-100")); --_swc-button-icon-block-size: var(--swc-button-icon-block-size, var(--_swc-button-icon-size)); @@ -104,14 +86,10 @@ block-size: var(--_swc-button-icon-block-size); margin-block: calc((var(--_swc-button-icon-block-size) - 1lh) / 2 * -1); margin-inline-start: calc(var(--swc-button-edge-to-visual, token("component-pill-edge-to-visual-100")) - var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100"))); -} - -.swc-Button-icon { +}.swc-Button-icon { color: inherit; fill: currentcolor; -} - -.swc-Button--sizeS { +}.swc-Button--sizeS { --swc-button-min-block-size: token("component-height-75"); --swc-button-font-size: token("font-size-75"); --swc-button-gap: token("text-to-visual-75"); @@ -120,9 +98,7 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-75"); --swc-button-padding-vertical: token("component-padding-vertical-75"); --swc-button-icon-size: token("workflow-icon-size-75"); -} - -.swc-Button--sizeL { +}.swc-Button--sizeL { --swc-button-min-block-size: token("component-height-200"); --swc-button-font-size: token("font-size-200"); --swc-button-gap: token("text-to-visual-200"); @@ -131,9 +107,7 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-200"); --swc-button-padding-vertical: token("component-padding-vertical-200"); --swc-button-icon-size: token("workflow-icon-size-200"); -} - -.swc-Button--sizeXl { +}.swc-Button--sizeXl { --swc-button-min-block-size: token("component-height-300"); --swc-button-font-size: token("font-size-300"); --swc-button-gap: token("text-to-visual-300"); @@ -142,9 +116,7 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-300"); --swc-button-padding-vertical: token("component-padding-vertical-300"); --swc-button-icon-size: token("workflow-icon-size-300"); -} - -.swc-Button--accent { +}.swc-Button--accent { --swc-button-content-color-default: token("white"); --swc-button-content-color-hover: token("white"); --swc-button-content-color-down: token("white"); @@ -153,9 +125,7 @@ --swc-button-background-color-hover: token("accent-background-color-hover"); --swc-button-background-color-down: token("accent-background-color-down"); --swc-button-background-color-focus: token("accent-background-color-key-focus"); -} - -.swc-Button--negative { +}.swc-Button--negative { --swc-button-content-color-default: token("white"); --swc-button-content-color-hover: token("white"); --swc-button-content-color-down: token("white"); @@ -164,9 +134,7 @@ --swc-button-background-color-hover: token("negative-background-color-hover"); --swc-button-background-color-down: token("negative-background-color-down"); --swc-button-background-color-focus: token("negative-background-color-key-focus"); -} - -.swc-Button--secondary { +}.swc-Button--secondary { --swc-button-content-color-default: token("neutral-content-color-default"); --swc-button-content-color-hover: token("neutral-content-color-hover"); --swc-button-content-color-down: token("neutral-content-color-down"); @@ -175,9 +143,7 @@ --swc-button-background-color-hover: token("gray-200"); --swc-button-background-color-down: token("gray-200"); --swc-button-background-color-focus: token("gray-200"); -} - -.swc-Button--primary.swc-Button--outline { +}.swc-Button--primary.swc-Button--outline { --swc-button-background-color-default: transparent; --swc-button-background-color-hover: token("gray-100"); --swc-button-background-color-down: token("gray-100"); @@ -192,9 +158,7 @@ --swc-button-content-color-focus: token("neutral-content-color-key-focus"); --swc-button-background-color-disabled: transparent; --swc-button-border-color-disabled: token("disabled-border-color"); -} - -.swc-Button--secondary.swc-Button--outline { +}.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: transparent; --swc-button-background-color-hover: token("gray-100"); --swc-button-background-color-down: token("gray-100"); @@ -205,9 +169,7 @@ --swc-button-border-color-focus: token("gray-400"); --swc-button-background-color-disabled: transparent; --swc-button-border-color-disabled: token("disabled-border-color"); -} - -.swc-Button--staticWhite { +}.swc-Button--staticWhite { --swc-button-focus-indicator-color: token("static-white-focus-indicator-color"); --swc-button-background-color-default: token("transparent-white-800"); --swc-button-background-color-hover: token("transparent-white-900"); @@ -220,9 +182,7 @@ --swc-button-background-color-disabled: token("disabled-static-white-background-color"); --swc-button-border-color-disabled: transparent; --swc-button-content-color-disabled: token("disabled-static-white-content-color"); -} - -.swc-Button--staticWhite.swc-Button--secondary { +}.swc-Button--staticWhite.swc-Button--secondary { --swc-button-background-color-default: token("transparent-white-100"); --swc-button-background-color-hover: token("transparent-white-200"); --swc-button-background-color-down: token("transparent-white-200"); @@ -231,9 +191,7 @@ --swc-button-content-color-hover: token("transparent-white-900"); --swc-button-content-color-down: token("transparent-white-900"); --swc-button-content-color-focus: token("transparent-white-900"); -} - -.swc-Button--staticWhite.swc-Button--outline { +}.swc-Button--staticWhite.swc-Button--outline { --swc-button-background-color-default: token("transparent-white-25"); --swc-button-background-color-hover: token("transparent-white-100"); --swc-button-background-color-down: token("transparent-white-100"); @@ -249,9 +207,7 @@ --swc-button-background-color-disabled: token("transparent-white-25"); --swc-button-border-color-disabled: token("disabled-static-white-border-color"); --swc-button-content-color-disabled: token("disabled-static-white-content-color"); -} - -.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { +}.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: token("transparent-white-25"); --swc-button-background-color-hover: token("transparent-white-100"); --swc-button-background-color-down: token("transparent-white-100"); @@ -260,9 +216,7 @@ --swc-button-border-color-hover: token("transparent-white-400"); --swc-button-border-color-down: token("transparent-white-400"); --swc-button-border-color-focus: token("transparent-white-400"); -} - -.swc-Button--staticBlack { +}.swc-Button--staticBlack { --swc-button-focus-indicator-color: token("static-black-focus-indicator-color"); --swc-button-background-color-default: token("transparent-black-800"); --swc-button-background-color-hover: token("transparent-black-900"); @@ -275,9 +229,7 @@ --swc-button-background-color-disabled: token("disabled-static-black-background-color"); --swc-button-border-color-disabled: transparent; --swc-button-content-color-disabled: token("disabled-static-black-content-color"); -} - -.swc-Button--staticBlack.swc-Button--secondary { +}.swc-Button--staticBlack.swc-Button--secondary { --swc-button-background-color-default: token("transparent-black-100"); --swc-button-background-color-hover: token("transparent-black-200"); --swc-button-background-color-down: token("transparent-black-200"); @@ -286,9 +238,7 @@ --swc-button-content-color-hover: token("transparent-black-900"); --swc-button-content-color-down: token("transparent-black-900"); --swc-button-content-color-focus: token("transparent-black-900"); -} - -.swc-Button--staticBlack.swc-Button--outline { +}.swc-Button--staticBlack.swc-Button--outline { --swc-button-background-color-default: token("transparent-black-25"); --swc-button-background-color-hover: token("transparent-black-100"); --swc-button-background-color-down: token("transparent-black-100"); @@ -304,9 +254,7 @@ --swc-button-background-color-disabled: token("transparent-black-25"); --swc-button-border-color-disabled: token("disabled-static-black-border-color"); --swc-button-content-color-disabled: token("disabled-static-black-content-color"); -} - -.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { +}.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: token("transparent-black-25"); --swc-button-background-color-hover: token("transparent-black-100"); --swc-button-background-color-down: token("transparent-black-100"); @@ -315,40 +263,28 @@ --swc-button-border-color-hover: token("transparent-black-400"); --swc-button-border-color-down: token("transparent-black-400"); --swc-button-border-color-focus: token("transparent-black-400"); -} - -.swc-Button--iconOnly { +}.swc-Button--iconOnly { --swc-button-border-radius: token("corner-radius-full"); --swc-button-gap: 0; padding-inline: calc(var(--swc-button-edge-to-visual-only, token("component-pill-edge-to-visual-only-100")) - var(--_swc-button-border-width)); -} - -.swc-Button--iconOnly .swc-Button-icon { +}.swc-Button--iconOnly .swc-Button-icon { --swc-button-edge-to-visual: 0; align-self: center; -} - -.swc-Button--truncate .swc-Button-label { +}.swc-Button--truncate .swc-Button-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.swc-Button--justified { +}.swc-Button--justified { flex-grow: 1; justify-self: stretch; inline-size: 100%; -} - -@media (prefers-reduced-motion: reduce) { +}@media (prefers-reduced-motion: reduce) { .swc-Button { transition-duration: 0ms; } } -} - -.swc-Button { +}.swc-Button { all: revert-layer !important; } From 5719c93a1f040b31713a27b3f3cf7f47962c4c4e Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 11:37:52 -0400 Subject: [PATCH 12/32] revert(global-button): remove accidental CSS reformatting Co-Authored-By: Claude Sonnet 4.6 --- .../swc/stylesheets/global/global-button.css | 158 ++++++++++++------ 1 file changed, 109 insertions(+), 49 deletions(-) diff --git a/2nd-gen/packages/swc/stylesheets/global/global-button.css b/2nd-gen/packages/swc/stylesheets/global/global-button.css index 9ffa05dc385..147a9757140 100644 --- a/2nd-gen/packages/swc/stylesheets/global/global-button.css +++ b/2nd-gen/packages/swc/stylesheets/global/global-button.css @@ -16,66 +16,80 @@ @layer swc-global-elements {.swc-Button, .swc-Button * { box-sizing: border-box; -}.swc-Button { +} + +.swc-Button { -webkit-tap-highlight-color: transparent; display: inline-flex; position: relative; + gap: var(--swc-button-gap, token("text-to-visual-100")); align-items: center; justify-content: center; - margin: 0; - text-transform: none; - text-decoration: none; - border-style: solid; - overflow: visible; - user-select: none; - --_swc-button-border-width: token("border-width-200"); - --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); - - gap: var(--swc-button-gap, token("text-to-visual-100")); max-inline-size: var(--swc-button-max-inline-size, inherit); min-block-size: var(--_swc-button-min-block-size); padding-block: calc(var(--swc-button-padding-vertical, token("component-padding-vertical-100")) - var(--_swc-button-border-width)); padding-inline: calc(var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100")) - var(--_swc-button-border-width)); + margin: 0; font-family: token("sans-serif-font"); font-size: var(--swc-button-font-size, token("font-size-100")); font-weight: token("bold-font-weight"); line-height: round(down, calc(token("line-height-100") * 1em), 1px); color: var(--swc-button-content-color-default, token("gray-25")); + text-transform: none; + text-decoration: none; background-color: var(--swc-button-background-color-default, token("neutral-background-color-default")); border-color: var(--swc-button-border-color-default, transparent); + border-style: solid; border-width: var(--_swc-button-border-width); border-radius: var(--swc-button-border-radius, calc(var(--_swc-button-min-block-size) / 2)); + overflow: visible; + user-select: none; transition-timing-function: token("animation-ease-in-out"); transition-duration: token("animation-duration-100"); transition-property: outline, border-color, color, background-color, transform; -}.swc-Button:focus-visible { - outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); - outline-offset: token("focus-indicator-gap"); + + --_swc-button-border-width: token("border-width-200"); + --_swc-button-min-block-size: var(--swc-button-min-block-size, token("component-height-100")); +} + +.swc-Button:focus-visible { color: var(--swc-button-content-color-focus, token("gray-25")); background-color: var(--swc-button-background-color-focus, token("neutral-background-color-key-focus")); border-color: var(--swc-button-border-color-focus, transparent); -}.swc-Button:disabled:is(*, :hover) { - color: var(--swc-button-content-color-disabled, token("disabled-content-color")); - background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); - border-color: var(--swc-button-border-color-disabled, transparent); - transform: none; -}.swc-Button--disabled .swc-Button-icon { - pointer-events: none; -}.swc-Button:hover { + outline: token("focus-indicator-thickness") solid var(--swc-button-focus-indicator-color, token("focus-indicator-color")); + outline-offset: token("focus-indicator-gap"); +} + +.swc-Button:hover { color: var(--swc-button-content-color-hover, token("gray-25")); background-color: var(--swc-button-background-color-hover, token("neutral-background-color-hover")); border-color: var(--swc-button-border-color-hover, transparent); -}.swc-Button:active { +} + +.swc-Button:active { color: var(--swc-button-content-color-down, token("gray-25")); background-color: var(--swc-button-background-color-down, token("neutral-background-color-down")); border-color: var(--swc-button-border-color-down, transparent); transform: var(--swc-button-down-state-transform, perspective(var(--_swc-button-min-block-size)) translate3d(0, 0, token("component-size-difference-down"))); will-change: transform; -}.swc-Button-label { +} + +.swc-Button:disabled:is(*, :hover) { + color: var(--swc-button-content-color-disabled, token("disabled-content-color")); + background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); + border-color: var(--swc-button-border-color-disabled, transparent); + transform: none; +} + +.swc-Button-label { text-align: center; -}.swc-Button--hasIcon .swc-Button-label { +} + +.swc-Button--hasIcon .swc-Button-label { text-align: start; -}.swc-Button-icon, +} + +.swc-Button-icon, .swc-Button-pendingSpinner { --_swc-button-icon-size: var(--swc-button-icon-size, token("workflow-icon-size-100")); --_swc-button-icon-block-size: var(--swc-button-icon-block-size, var(--_swc-button-icon-size)); @@ -86,10 +100,14 @@ block-size: var(--_swc-button-icon-block-size); margin-block: calc((var(--_swc-button-icon-block-size) - 1lh) / 2 * -1); margin-inline-start: calc(var(--swc-button-edge-to-visual, token("component-pill-edge-to-visual-100")) - var(--swc-button-edge-to-text, token("component-pill-edge-to-text-100"))); -}.swc-Button-icon { +} + +.swc-Button-icon { color: inherit; fill: currentcolor; -}.swc-Button--sizeS { +} + +.swc-Button--sizeS { --swc-button-min-block-size: token("component-height-75"); --swc-button-font-size: token("font-size-75"); --swc-button-gap: token("text-to-visual-75"); @@ -98,7 +116,9 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-75"); --swc-button-padding-vertical: token("component-padding-vertical-75"); --swc-button-icon-size: token("workflow-icon-size-75"); -}.swc-Button--sizeL { +} + +.swc-Button--sizeL { --swc-button-min-block-size: token("component-height-200"); --swc-button-font-size: token("font-size-200"); --swc-button-gap: token("text-to-visual-200"); @@ -107,7 +127,9 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-200"); --swc-button-padding-vertical: token("component-padding-vertical-200"); --swc-button-icon-size: token("workflow-icon-size-200"); -}.swc-Button--sizeXl { +} + +.swc-Button--sizeXl { --swc-button-min-block-size: token("component-height-300"); --swc-button-font-size: token("font-size-300"); --swc-button-gap: token("text-to-visual-300"); @@ -116,7 +138,9 @@ --swc-button-edge-to-visual-only: token("component-pill-edge-to-visual-only-300"); --swc-button-padding-vertical: token("component-padding-vertical-300"); --swc-button-icon-size: token("workflow-icon-size-300"); -}.swc-Button--accent { +} + +.swc-Button--accent { --swc-button-content-color-default: token("white"); --swc-button-content-color-hover: token("white"); --swc-button-content-color-down: token("white"); @@ -125,7 +149,9 @@ --swc-button-background-color-hover: token("accent-background-color-hover"); --swc-button-background-color-down: token("accent-background-color-down"); --swc-button-background-color-focus: token("accent-background-color-key-focus"); -}.swc-Button--negative { +} + +.swc-Button--negative { --swc-button-content-color-default: token("white"); --swc-button-content-color-hover: token("white"); --swc-button-content-color-down: token("white"); @@ -134,7 +160,9 @@ --swc-button-background-color-hover: token("negative-background-color-hover"); --swc-button-background-color-down: token("negative-background-color-down"); --swc-button-background-color-focus: token("negative-background-color-key-focus"); -}.swc-Button--secondary { +} + +.swc-Button--secondary { --swc-button-content-color-default: token("neutral-content-color-default"); --swc-button-content-color-hover: token("neutral-content-color-hover"); --swc-button-content-color-down: token("neutral-content-color-down"); @@ -143,7 +171,9 @@ --swc-button-background-color-hover: token("gray-200"); --swc-button-background-color-down: token("gray-200"); --swc-button-background-color-focus: token("gray-200"); -}.swc-Button--primary.swc-Button--outline { +} + +.swc-Button--primary.swc-Button--outline { --swc-button-background-color-default: transparent; --swc-button-background-color-hover: token("gray-100"); --swc-button-background-color-down: token("gray-100"); @@ -158,7 +188,9 @@ --swc-button-content-color-focus: token("neutral-content-color-key-focus"); --swc-button-background-color-disabled: transparent; --swc-button-border-color-disabled: token("disabled-border-color"); -}.swc-Button--secondary.swc-Button--outline { +} + +.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: transparent; --swc-button-background-color-hover: token("gray-100"); --swc-button-background-color-down: token("gray-100"); @@ -169,7 +201,9 @@ --swc-button-border-color-focus: token("gray-400"); --swc-button-background-color-disabled: transparent; --swc-button-border-color-disabled: token("disabled-border-color"); -}.swc-Button--staticWhite { +} + +.swc-Button--staticWhite { --swc-button-focus-indicator-color: token("static-white-focus-indicator-color"); --swc-button-background-color-default: token("transparent-white-800"); --swc-button-background-color-hover: token("transparent-white-900"); @@ -182,7 +216,9 @@ --swc-button-background-color-disabled: token("disabled-static-white-background-color"); --swc-button-border-color-disabled: transparent; --swc-button-content-color-disabled: token("disabled-static-white-content-color"); -}.swc-Button--staticWhite.swc-Button--secondary { +} + +.swc-Button--staticWhite.swc-Button--secondary { --swc-button-background-color-default: token("transparent-white-100"); --swc-button-background-color-hover: token("transparent-white-200"); --swc-button-background-color-down: token("transparent-white-200"); @@ -191,7 +227,9 @@ --swc-button-content-color-hover: token("transparent-white-900"); --swc-button-content-color-down: token("transparent-white-900"); --swc-button-content-color-focus: token("transparent-white-900"); -}.swc-Button--staticWhite.swc-Button--outline { +} + +.swc-Button--staticWhite.swc-Button--outline { --swc-button-background-color-default: token("transparent-white-25"); --swc-button-background-color-hover: token("transparent-white-100"); --swc-button-background-color-down: token("transparent-white-100"); @@ -207,7 +245,9 @@ --swc-button-background-color-disabled: token("transparent-white-25"); --swc-button-border-color-disabled: token("disabled-static-white-border-color"); --swc-button-content-color-disabled: token("disabled-static-white-content-color"); -}.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { +} + +.swc-Button--staticWhite.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: token("transparent-white-25"); --swc-button-background-color-hover: token("transparent-white-100"); --swc-button-background-color-down: token("transparent-white-100"); @@ -216,7 +256,9 @@ --swc-button-border-color-hover: token("transparent-white-400"); --swc-button-border-color-down: token("transparent-white-400"); --swc-button-border-color-focus: token("transparent-white-400"); -}.swc-Button--staticBlack { +} + +.swc-Button--staticBlack { --swc-button-focus-indicator-color: token("static-black-focus-indicator-color"); --swc-button-background-color-default: token("transparent-black-800"); --swc-button-background-color-hover: token("transparent-black-900"); @@ -229,7 +271,9 @@ --swc-button-background-color-disabled: token("disabled-static-black-background-color"); --swc-button-border-color-disabled: transparent; --swc-button-content-color-disabled: token("disabled-static-black-content-color"); -}.swc-Button--staticBlack.swc-Button--secondary { +} + +.swc-Button--staticBlack.swc-Button--secondary { --swc-button-background-color-default: token("transparent-black-100"); --swc-button-background-color-hover: token("transparent-black-200"); --swc-button-background-color-down: token("transparent-black-200"); @@ -238,7 +282,9 @@ --swc-button-content-color-hover: token("transparent-black-900"); --swc-button-content-color-down: token("transparent-black-900"); --swc-button-content-color-focus: token("transparent-black-900"); -}.swc-Button--staticBlack.swc-Button--outline { +} + +.swc-Button--staticBlack.swc-Button--outline { --swc-button-background-color-default: token("transparent-black-25"); --swc-button-background-color-hover: token("transparent-black-100"); --swc-button-background-color-down: token("transparent-black-100"); @@ -254,7 +300,9 @@ --swc-button-background-color-disabled: token("transparent-black-25"); --swc-button-border-color-disabled: token("disabled-static-black-border-color"); --swc-button-content-color-disabled: token("disabled-static-black-content-color"); -}.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { +} + +.swc-Button--staticBlack.swc-Button--secondary.swc-Button--outline { --swc-button-background-color-default: token("transparent-black-25"); --swc-button-background-color-hover: token("transparent-black-100"); --swc-button-background-color-down: token("transparent-black-100"); @@ -263,28 +311,40 @@ --swc-button-border-color-hover: token("transparent-black-400"); --swc-button-border-color-down: token("transparent-black-400"); --swc-button-border-color-focus: token("transparent-black-400"); -}.swc-Button--iconOnly { +} + +.swc-Button--iconOnly { --swc-button-border-radius: token("corner-radius-full"); --swc-button-gap: 0; padding-inline: calc(var(--swc-button-edge-to-visual-only, token("component-pill-edge-to-visual-only-100")) - var(--_swc-button-border-width)); -}.swc-Button--iconOnly .swc-Button-icon { +} + +.swc-Button--iconOnly .swc-Button-icon { --swc-button-edge-to-visual: 0; align-self: center; -}.swc-Button--truncate .swc-Button-label { +} + +.swc-Button--truncate .swc-Button-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -}.swc-Button--justified { +} + +.swc-Button--justified { flex-grow: 1; justify-self: stretch; inline-size: 100%; -}@media (prefers-reduced-motion: reduce) { +} + +@media (prefers-reduced-motion: reduce) { .swc-Button { transition-duration: 0ms; } } -}.swc-Button { +} + +.swc-Button { all: revert-layer !important; } From ff795b8fa5908f63b66f866188b4a594506df75b Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 11:44:21 -0400 Subject: [PATCH 13/32] revert(claude): remove accidentally committed settings file Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 4326985ea57..00000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "permissions": { - "allow": ["Bash(npx tsc *)", "Bash(echo \"Exit code: $?\")"] - } -} From 95624e8178454e7d3fc42b7aaa72b1b8be2910fe Mon Sep 17 00:00:00 2001 From: Stephanie Eckles Date: Fri, 12 Jun 2026 15:57:42 -0500 Subject: [PATCH 14/32] docs(css, ai): document process and features of shared style directories (#6403) * feat(*): rename /shared to /_lit-styles * docs(css, ai): document process and features of shared style directories --- .ai/rules/code-conformance.md | 2 + .ai/skills/migration-prep/SKILL.md | 4 + .ai/skills/migration-styling/SKILL.md | 13 ++ .../swc/components/color-loupe/ColorLoupe.ts | 2 +- .../packages/swc/components/meter/Meter.ts | 2 +- .../packages/swc/components/meter/meter.css | 2 +- .../opacity-checkerboard.internal.mdx | 6 +- .../opacity-checkerboard.internal.stories.ts | 2 +- .../linear-progress-base.css | 0 .../opacity-checkerboard.css | 0 2nd-gen/packages/swc/vite.config.ts | 6 +- .../12_tools-vs-packages.md | 5 +- .../02_style-guide/01_css/01_component-css.md | 2 + .../02_style-guide/01_css/07_stylesheets.md | 195 ++++++++++++++++++ .../02_style-guide/01_css/README.md | 1 + CONTRIBUTOR-DOCS/02_style-guide/README.md | 1 + .../03_components/meter/migration-plan.md | 6 +- .../accessibility-migration-analysis.md | 2 +- .../opacity-checkerboard/migration-plan.md | 20 +- 19 files changed, 244 insertions(+), 27 deletions(-) rename 2nd-gen/packages/swc/stylesheets/{shared => _lit-styles}/linear-progress-base.css (100%) rename 2nd-gen/packages/swc/stylesheets/{shared => _lit-styles}/opacity-checkerboard.css (100%) create mode 100644 CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md diff --git a/.ai/rules/code-conformance.md b/.ai/rules/code-conformance.md index 28c3404b144..9b09053d131 100644 --- a/.ai/rules/code-conformance.md +++ b/.ai/rules/code-conformance.md @@ -61,6 +61,7 @@ Reference: [Linting tools](../../CONTRIBUTOR-DOCS/02_style-guide/03_linting-tool - [Spectrum CSS to SWC migration](../../CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md) - [Styling anti-patterns](../../CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md) - [Property order quick reference](../../CONTRIBUTOR-DOCS/02_style-guide/01_css/06_property-order-quick-reference.md) +- [Non-component stylesheets](../../CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md) — applies when the changed file is in `swc/stylesheets/` rather than a component package **What to check:** @@ -71,6 +72,7 @@ Reference: [Linting tools](../../CONTRIBUTOR-DOCS/02_style-guide/03_linting-tool - Forced-colors media query is present and correct (if applicable) - High-contrast and other media queries are sorted to the bottom of the file - No hard-coded values where design tokens are available +- For files in `swc/stylesheets/`: placement, index registration, generated file conventions, and `_lit-styles/` import patterns match [Non-component stylesheets](../../CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md) ## Test files diff --git a/.ai/skills/migration-prep/SKILL.md b/.ai/skills/migration-prep/SKILL.md index c44e4d18f6c..0da87e82770 100644 --- a/.ai/skills/migration-prep/SKILL.md +++ b/.ai/skills/migration-prep/SKILL.md @@ -83,6 +83,8 @@ During discovery, explicitly check whether the component should: - extend from another 2nd-gen component or shared base that is already planned or in progress - be migrated before another component that depends on it - wait on a prerequisite component or shared base to avoid duplicated work or conflicting APIs +- share structural CSS patterns with existing or in-flight components — check `2nd-gen/packages/swc/stylesheets/_lit-styles/` for existing shared fragments and note any that this component should consume; if no fragment exists yet but the pattern is real, flag whether it should be extracted as part of this migration or a coordinated one. See [Non-component stylesheets](../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md#shared-lit-css-fragments-_lit-styles) for what qualifies +- need a global element stylesheet counterpart — check whether `stylesheets/global/global-[component].css` should be created as part of this migration. If yes, note whether the component CSS will need `@global-exclude` fences and whether the global stylesheet is in scope for this migration cycle. See [Non-component stylesheets](../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md#global-element-styles-global) for the authoring options Use the status table, existing component analyses, and source relationships to make these dependency and ordering calls explicit in the plan. @@ -221,6 +223,7 @@ Pause and actively discuss with the user when you find any of the following: - A 1st-gen behavior that appears confusing, inconsistent, or not worth carrying forward - Multiple plausible component boundaries, such as one component vs several - A component dependency or extension relationship changes the recommended migration order +- This component shares structural CSS patterns with another component, suggesting a `_lit-styles/` fragment should be created or consumed — flag the opportunity, name the abstraction, and note whether extraction affects migration order or requires coordination - Breaking changes that may be justified now to avoid a worse migration later - Inconsistencies between source materials that change the recommended API or behavior - Missing information that prevents a confident recommendation @@ -235,6 +238,7 @@ Do not treat the following as implicitly approved, even if you can make a strong - whether another component should extend from this one - whether migration order should change because of a dependency relationship - whether shared logic should be extracted before this migration proceeds +- whether a shared `_lit-styles/` CSS fragment should be created or consumed, which fragment name to use, and whether that work needs to happen before or alongside this migration - whether a major dependency concern should remain separate rather than being unified For these cases: diff --git a/.ai/skills/migration-styling/SKILL.md b/.ai/skills/migration-styling/SKILL.md index 6e2a643498b..d24380dbedb 100644 --- a/.ai/skills/migration-styling/SKILL.md +++ b/.ai/skills/migration-styling/SKILL.md @@ -66,6 +66,19 @@ Common case: confirming that subcomponent class names follow the single-hyphen s If a rename is needed, make the template change first, confirm the component still renders correctly in Storybook, then write the CSS. +**Step 3.5 — Check for shared CSS.** If the component shares structural CSS with one or more other components (for example, a shared bar/track layout), check `2nd-gen/packages/swc/stylesheets/_lit-styles/` for an existing shared fragment before duplicating rules. Files in `_lit-styles/` are Lit CSS fragments composed into component `static styles` arrays; they reach inside shadow roots and are never emitted as standalone CSS. To use one, import it as a JS module in the component's TypeScript file — do **not** use a CSS `@import` statement: + +```ts +import sharedStyles from '../../stylesheets/_lit-styles/[name].css'; +import styles from './[component].css'; + +public static override get styles(): CSSResultArray { + return [sharedStyles, styles]; +} +``` + +If no fragment exists yet but the shared pattern is real, create a new file in `_lit-styles/` and wire it up in all consuming components. Do not put `_lit-styles/` files in `core/` — they depend on `--swc-*` design tokens, which live in the `swc/` layer. See [Non-component stylesheets](../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md#adding-a-shared-lit-css-fragment) for full guidance. + **Step 4 — Execute the phase.** Follow **[Phase 5: Styling](../../../CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md#phase-5-styling)** in the washing machine workflow doc — it covers what to do, what to check, common problems, and the quality gate for this phase. **Step 5 — Document exposed custom properties.** After writing the CSS, add a `@cssprop` JSDoc tag to the SWC component class (`2nd-gen/packages/swc/components/[component]/[Component].ts`) for every exposed `--swc-*` property. Place all `@cssprop` tags on the primary SWC class export (not the core base class). Each tag should name the property and give a one-line description of what it controls, including its default token where relevant. diff --git a/2nd-gen/packages/swc/components/color-loupe/ColorLoupe.ts b/2nd-gen/packages/swc/components/color-loupe/ColorLoupe.ts index 0bebe7471fa..47ae64d337d 100644 --- a/2nd-gen/packages/swc/components/color-loupe/ColorLoupe.ts +++ b/2nd-gen/packages/swc/components/color-loupe/ColorLoupe.ts @@ -15,7 +15,7 @@ import { styleMap } from 'lit/directives/style-map.js'; import { ColorLoupeBase } from '@spectrum-web-components/core/components/color-loupe'; -import opacityCheckerboardStyles from '../../stylesheets/shared/opacity-checkerboard.css'; +import opacityCheckerboardStyles from '../../stylesheets/_lit-styles/opacity-checkerboard.css'; import styles from './color-loupe.css'; /** diff --git a/2nd-gen/packages/swc/components/meter/Meter.ts b/2nd-gen/packages/swc/components/meter/Meter.ts index 150bada97ac..11e3ed867f2 100644 --- a/2nd-gen/packages/swc/components/meter/Meter.ts +++ b/2nd-gen/packages/swc/components/meter/Meter.ts @@ -20,7 +20,7 @@ import { type MeterVariant, } from '@spectrum-web-components/core/components/meter'; -import sharedStyles from '../../stylesheets/shared/linear-progress-base.css'; +import sharedStyles from '../../stylesheets/_lit-styles/linear-progress-base.css'; import styles from './meter.css'; /** diff --git a/2nd-gen/packages/swc/components/meter/meter.css b/2nd-gen/packages/swc/components/meter/meter.css index b5b78007a20..559164df9f6 100644 --- a/2nd-gen/packages/swc/components/meter/meter.css +++ b/2nd-gen/packages/swc/components/meter/meter.css @@ -13,7 +13,7 @@ /* * Meter-specific styles. Shared structural rules (bar/track/fill, * sizes, side-label layout, static colors, forced-colors) live in - * `swc/stylesheets/shared/linear-progress-base.css` and consume + * `swc/stylesheets/_lit-styles/linear-progress-base.css` and consume * `--swc-linear-progress-*` custom properties. This file assigns * the variant fill colors via the same exposed surface. */ diff --git a/2nd-gen/packages/swc/components/opacity-checkerboard/opacity-checkerboard.internal.mdx b/2nd-gen/packages/swc/components/opacity-checkerboard/opacity-checkerboard.internal.mdx index 56f8541c792..addb2d0b9ae 100644 --- a/2nd-gen/packages/swc/components/opacity-checkerboard/opacity-checkerboard.internal.mdx +++ b/2nd-gen/packages/swc/components/opacity-checkerboard/opacity-checkerboard.internal.mdx @@ -9,7 +9,7 @@ import * as Stories from './stories/opacity-checkerboard.internal.stories'; ## Anatomy -The opacity checkerboard is a shared CSS utility, not a custom element. It lives at `stylesheets/shared/opacity-checkerboard.css` as an importable lit `css` fragment. A consuming component imports it directly and adds it to its `styles` array: +The opacity checkerboard is a shared CSS utility, not a custom element. It lives at `stylesheets/_lit-styles/opacity-checkerboard.css` as an importable lit `css` fragment. A consuming component imports it directly and adds it to its `styles` array: Apply `.swc-OpacityCheckerboard` to a decorative element rendered behind transparent or semi-transparent content. The consuming component is responsible for keeping that element out of the accessibility tree (for example `aria-hidden="true"`). @@ -17,14 +17,14 @@ Apply `.swc-OpacityCheckerboard` to a decorative element rendered behind transpa ## Usage -1. Import the CSS file from `@spectrum-web-components//stylesheets/shared/opacity-checkerboard.css`. +1. Import the CSS file from `@spectrum-web-components//stylesheets/_lit-styles/opacity-checkerboard.css`. 2. Add the styles to your styles array. 3. Add the `swc-OpacityCheckerboard` class to the `span` or `div` element where you want the checkerboard to appear, and make sure the element is `aria-hidden="true"`. ```typescript import { css, html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import opacityCheckerboardStyles from '@spectrum-web-components//stylesheets/shared/opacity-checkerboard.css'; +import opacityCheckerboardStyles from '@spectrum-web-components//stylesheets/_lit-styles/opacity-checkerboard.css'; export class DemoOpacityCheckerboardSwatch extends LitElement { /** * Adopt the shared fragment into this element's shadow root, then layer the diff --git a/2nd-gen/packages/swc/components/opacity-checkerboard/stories/opacity-checkerboard.internal.stories.ts b/2nd-gen/packages/swc/components/opacity-checkerboard/stories/opacity-checkerboard.internal.stories.ts index 9d5ffe01610..8302fe55b34 100644 --- a/2nd-gen/packages/swc/components/opacity-checkerboard/stories/opacity-checkerboard.internal.stories.ts +++ b/2nd-gen/packages/swc/components/opacity-checkerboard/stories/opacity-checkerboard.internal.stories.ts @@ -14,7 +14,7 @@ import { css, html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; -import opacityCheckerboardStyles from '../../../stylesheets/shared/opacity-checkerboard.css'; +import opacityCheckerboardStyles from '../../../stylesheets/_lit-styles/opacity-checkerboard.css'; // ───────────────────────── // DEMO HOST diff --git a/2nd-gen/packages/swc/stylesheets/shared/linear-progress-base.css b/2nd-gen/packages/swc/stylesheets/_lit-styles/linear-progress-base.css similarity index 100% rename from 2nd-gen/packages/swc/stylesheets/shared/linear-progress-base.css rename to 2nd-gen/packages/swc/stylesheets/_lit-styles/linear-progress-base.css diff --git a/2nd-gen/packages/swc/stylesheets/shared/opacity-checkerboard.css b/2nd-gen/packages/swc/stylesheets/_lit-styles/opacity-checkerboard.css similarity index 100% rename from 2nd-gen/packages/swc/stylesheets/shared/opacity-checkerboard.css rename to 2nd-gen/packages/swc/stylesheets/_lit-styles/opacity-checkerboard.css diff --git a/2nd-gen/packages/swc/vite.config.ts b/2nd-gen/packages/swc/vite.config.ts index 29a02a5048d..bf5860c3734 100644 --- a/2nd-gen/packages/swc/vite.config.ts +++ b/2nd-gen/packages/swc/vite.config.ts @@ -41,7 +41,7 @@ const postcssPlugins = [ /** * Processes stylesheets/ CSS through PostCSS and writes them to dist/. - * Skips `stylesheets/shared/**` — those files are imported into component + * Skips `stylesheets/_lit-styles/**` — those files are imported into component * `static styles` arrays via `vite-plugin-lit-css` and should not also * ship as standalone CSS in `dist/`. */ @@ -52,7 +52,7 @@ function processStylesheets(): Plugin { async closeBundle() { const processor = postcss(postcssPlugins); for (const file of glob.sync(resolve(__dirname, 'stylesheets/**/*.css'), { - ignore: [resolve(__dirname, 'stylesheets/shared/**/*.css')], + ignore: [resolve(__dirname, 'stylesheets/_lit-styles/**/*.css')], })) { const dest = resolve(__dirname, 'dist', basename(file)); const result = await processor.process(await readFile(file, 'utf-8'), { @@ -72,7 +72,7 @@ export default defineConfig({ elements: [{ component: 'button' }], }), // Standalone stylesheets under `stylesheets/` ship as their own CSS - // files via `processStylesheets`; the `shared/` subdirectory holds + // files via `processStylesheets`; the `lit-styles/` subdirectory holds // CSS that is composed into component `static styles` arrays via // `vite-plugin-lit-css`, so we leave that path for `litCss` to pick up. litCss({ diff --git a/CONTRIBUTOR-DOCS/01_contributor-guides/12_tools-vs-packages.md b/CONTRIBUTOR-DOCS/01_contributor-guides/12_tools-vs-packages.md index 4d896eef8c3..92565c534fc 100644 --- a/CONTRIBUTOR-DOCS/01_contributor-guides/12_tools-vs-packages.md +++ b/CONTRIBUTOR-DOCS/01_contributor-guides/12_tools-vs-packages.md @@ -37,7 +37,7 @@ When deciding where something lives in 2nd-gen, ask: **does it depend on the ren | **UI-less artifact** | No (same in S1 and S2) | No | `core/` | Tools/Utilities | Reactive controllers, grid logic, truncation logic, pure utility functions, DnD engine | | **Design component** | Yes | Yes | `swc/components/` | Components | Button, Card, Dialog, Badge | | **UI artifact (WC, not a design component)** | Yes (theme-dependent) | Yes | `swc/components/` (recategorize in docs) | Tools/Utilities | `sp-asset` | -| **UI artifact (non-WC)** | Yes (style/token dependent) | No | `swc/utils/`, `swc/stylesheets/`, or `swc/shared/` | Tools/Utilities | CSS utilities, typography classes, opacity checkerboard, token/style helpers | +| **UI artifact (non-WC)** | Yes (style/token dependent) | No | `swc/utils/` or `swc/stylesheets/` | Tools/Utilities | Component-replacement CSS (`typography.css`), global element styles (`global/`), shared Lit CSS fragments (`_lit-styles/`), token/style helpers | | **Build-time tooling** | No | No | `tools/` | Tools/Utilities | PostCSS plugins, token packages, VS Code extension | The key distinction: **UI-less** code goes in `core/` (it works the same regardless of Spectrum version); **UI** code that depends on Spectrum styles goes in `swc/` (even if it's a utility, not a design component). @@ -48,8 +48,7 @@ For placement within `core/`, see [packages/core/MIGRATION.md](../../2nd-gen/pac - **`core/`** — UI-less foundational code: element/, mixins/, controllers/, utils/, and components/ for base classes. No rendering-layer dependency. - **`swc/components/`** — All web components we ship, including both design components (Button, Card) and infrastructure WCs (`sp-asset`). Code location reflects what it *is*; docs categorization reflects how consumers *think about it*. -- **`swc/stylesheets/`** — Global CSS shipped outside of any web component: token imports (`tokens.css`), base application styles (`swc.css`), and typography classes (`typography.css`). These are rendering-layer dependent but not tied to a single component. -- **`swc/shared/`** — Shared rendering-layer-dependent code that is reused across multiple SWC components but is not a standalone component or stylesheet (e.g. shared templates, lit directives, or style fragments). *(Planned — does not exist on disk yet.)* +- **`swc/stylesheets/`** — CSS shipped outside of any web component. Four sub-areas: app setup files (`swc.css`, `tokens.css`), component replacements (`typography.css`, `link.css`), global element styles (`global/`), and shared Lit CSS fragments (`_lit-styles/`). See [Non-component stylesheets](../02_style-guide/01_css/07_stylesheets.md) for build treatment, authoring modes, and procedures for adding new files to each area. - **`swc/utils/`** — SWC-specific helper modules: test utilities, a11y helpers, and other non-component JS/TS that depends on the rendering layer. - **`tools/`** — Build-time and design-token tooling (swc-tokens, postcss-token, swc-vscode-token). diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md index 100fc4e49fd..70ee3e7c05f 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md @@ -42,6 +42,8 @@ The following are high-level guidelines for the CSS creation for components. +> **Scope:** This guide covers CSS authored inside web component packages — styles composed into `static styles` arrays and rendered inside shadow DOM. For CSS that lives outside components (app-level setup, global element styles, component replacements such as `typography.css`, and shared Lit CSS fragments) see [Non-component stylesheets](07_stylesheets.md). + ## Contributor TL;DR > For examples of all of these rules in practice, review [Badge](../../../2nd-gen/packages/swc/components/badge/badge.css) and [Status Light](../../../2nd-gen/packages/swc/components/status-light/status-light.css) as reference implementations, the [reference migration for Badge](04_spectrum-swc-migration.md#reference-migration-badge), and the [anti-patterns guide](05_anti-patterns.md). diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md new file mode 100644 index 00000000000..d9c97f19273 --- /dev/null +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/07_stylesheets.md @@ -0,0 +1,195 @@ + + +[CONTRIBUTOR-DOCS](../../README.md) / [Style guide](../README.md) / [2nd-Gen CSS](README.md) / Non-component stylesheets + + + +# Non-component stylesheets + + + +
+In this doc + +- [Overview](#overview) +- [Choosing a sub-area](#choosing-a-sub-area) +- [Sub-areas](#sub-areas) + - [App setup](#app-setup) + - [Component replacements](#component-replacements) + - [Global element styles (`global/`)](#global-element-styles-global) + - [Shared Lit CSS fragments (`_lit-styles/`)](#shared-lit-css-fragments-lit-styles) +- [Adding new files](#adding-new-files) + - [Adding a component replacement](#adding-a-component-replacement) + - [Adding a global element stylesheet](#adding-a-global-element-stylesheet) + - [Adding a shared Lit CSS fragment](#adding-a-shared-lit-css-fragment) + +
+ + + +## Overview + +`2nd-gen/packages/swc/stylesheets/` holds CSS that depends on the rendering layer (Spectrum tokens, theming) but is not scoped to a single component package. All files here are processed by the `swc` Vite build. + +Use this guide when adding, editing, or reviewing files in `stylesheets/`. For CSS that lives inside a component package (shadow DOM styles), see [Component CSS](01_component-css.md). + +> **Build note:** `vite-plugin-lit-css` processes every CSS file it finds unless excluded. All existing sub-areas except `_lit-styles/` are already covered by the `exclude` list in `vite.config.ts` (`./stylesheets/*.css`, `./stylesheets/global/**/*.css`). If a new subdirectory is ever added, add a corresponding glob to that list so its files are treated as standalone CSS rather than Lit CSS modules. + +## Choosing a sub-area + +| Question | Answer | Sub-area | +| --- | --- | --- | +| Does this CSS implement the full visual design for an element type that has no custom element and never will? | Yes | Root (component replacement) | +| Does this CSS style a native element globally — either as a classless baseline reset or as a class set that mirrors a custom element? | Yes | `global/` | +| Does this CSS need to reach inside shadow roots and is shared across two or more components? | Yes | `_lit-styles/` | +| Is this foundational app-level setup (token imports, base application styles)? | Yes | Root (app setup) | + +When both "component replacement" and `global/` seem to fit: a component replacement provides a complete class-based design for an element with no custom element counterpart (typography, heading classes). A `global/` file provides element-level styling in the context of the design system — either as a global reset (`global-link.css` for bare ``) or as a class set derived from a custom element (`global-button.css` paired with `swc-button`). If there is a matching custom element or a global-reset use case, use `global/`. If the stylesheet is a self-contained class vocabulary with no custom element counterpart, use root. + +## Sub-areas + +### App setup + +**Files:** `swc.css`, `tokens.css` + +Foundational stylesheets loaded once at the page/app level. They establish token imports and base application styles; `preview.ts` imports them to bootstrap Storybook. These are not typically modified during component migrations. + +**Build treatment:** processed by `processStylesheets` (PostCSS) and emitted to `dist/` as standalone CSS. + +### Component replacements + +**Files:** `typography.css`, `link.css` + +CSS utility classes for Spectrum elements that have no custom element counterpart. Authors opt in by applying BEM-style class names in the light DOM. These stylesheets are the single source of truth for their element type; there is no companion `swc-*` custom element. + +Examples: + +- `typography.css` — heading, body, code, and detail type classes +- `link.css` — variant modifier classes for `` elements + +**Build treatment:** same as app setup — processed by `processStylesheets` and emitted to `dist/` as standalone CSS. + +**Stories:** component replacements are documented in Storybook with consumer-facing stories in their component directory (for example, `components/typography/stories/`). See [Adding a component replacement](#adding-a-component-replacement). + +### Global element styles (`global/`) + +**Files:** `global-button.css`, `global-elements.css`, `global-link.css` + +Per-element light DOM styles. Loaded once globally; rules may act as "resets" for elements without extra class names, or provide BEM-style utility classes, depending on the stylesheet. These are the stylesheets consumers import when they want global element styling without a custom element. + +`global-elements.css` is a single-entry-point index that `@import`s each individual global stylesheet. Consumers can import this one file to get all global element styles. + +**Build treatment:** processed by `processStylesheets` and emitted to `dist/` as standalone CSS. + +#### Two authoring modes: + +- **Generated** — when a matching custom element exists (for example, `global-button.css` paired with `swc-button`), the file is generated by the [`vite-global-elements-css`](../../../2nd-gen/packages/tools/vite-global-elements-css/README.md) build plugin. The plugin derives the global stylesheet from the component's shadow DOM CSS via deterministic selector transformations and wraps all rules in `@layer swc-global-elements`. Generated files carry a `DO NOT EDIT` header and must be committed as stable build output. +- **Hand-authored** — when no matching custom element exists (for example, `global-link.css`), write the CSS directly. + +See the Storybook docs page at [Customization/Global Element Styling](../../../2nd-gen/packages/swc/.storybook/guides/customization/global-elements.mdx) for the consumer-facing reference on how global element styles are used. + +### Shared Lit CSS fragments (`_lit-styles/`) + +**Files:** `linear-progress-base.css`, `opacity-checkerboard.css` + +Reusable CSS fragments that are composed into one or more component's `static styles` arrays, reaching inside shadow roots. These are **not** standalone stylesheets — they are imported as JavaScript modules by the consuming component's TypeScript file. + +`_lit-styles/` files depend on `--swc-*` design tokens, which is why they live in `swc/` rather than `core/`. They encode structural rules shared across multiple components (for example, the bar/track layout used by both `meter` and `progress-bar`). They should not include variant fill colors, animations, or any rules that belong to a single component. + +**Build treatment:** processed only by `vite-plugin-lit-css`, which converts each file into a Lit `css` tagged-template module. They are **not** processed by `processStylesheets` and **not** emitted as standalone CSS in `dist/`. + +## Adding new files + +### Adding a component replacement + +Use this when the element is pure CSS (no custom element) and consumers apply it via class names. + +1. Author the CSS in `stylesheets/[name].css`. Use BEM-style class names prefixed with `swc-`. +2. Create a component directory at `components/[name]/` with: + - `stories/[name].stories.ts` — consumer-facing stories demonstrating all classes and variants + - `stories/[name].template.ts` — shared template helper + - `[name].mdx` — per-unit docs page + - `test/[name].test.ts` — tests +3. Stories for component replacements follow the same format as web component stories; the component name in `meta` will match the CSS class root rather than a custom element tag. + +Reference: `components/typography/` for a complete example. + +### Adding a global element stylesheet + +Use this when styling a native HTML element globally, either with or without a matching custom element. + +**Step 1 — Author or generate the stylesheet.** + +- **With a matching custom element:** add the component to the `elements` array in `vite.config.ts`: + + ```ts + globalElementCSS({ + elements: [ + { component: 'button' }, + { component: 'your-component' }, // add here + ], + }), + ``` + + Then add `@global-exclude` fences in the component CSS to mark blocks that have no global equivalent (for example, pending-state animations): + + ```css + /* @global-exclude: pending state requires JS runtime */ + @keyframes swc-pending-spinner-rotate { ... } + /* @global-exclude-end */ + ``` + + The plugin generates `stylesheets/global/global-[component].css` automatically at build time and during dev. Commit the generated file. + +- **Without a matching custom element:** hand-author `stylesheets/global/global-[name].css`. Wrap all rules in `@layer swc-global-elements` to match the generated file convention. + +**Step 2 — Register in the index.** + +Add an `@import` to `stylesheets/global/global-elements.css`: + +```css +@import url("./global-[name].css"); +``` + +**Step 3 — Document in Storybook.** + +Add a section to `2nd-gen/packages/swc/.storybook/guides/customization/global-elements.mdx` demonstrating the classes and their usage. Follow the existing Button Styles section as a reference. + +### Adding a shared Lit CSS fragment + +Use this when two or more components share a structural CSS pattern that should not be duplicated. + +**Step 1 — Name the fragment.** + +Choose a name that is distinct from any existing component name. The fragment name should describe the shared abstraction, not any one consumer (for example, `linear-progress` rather than `meter` or `progress-bar`). When the fragment has an associated mixin or controller in `core/`, align the name with that abstraction so the CSS and logic layers are clearly paired. This name must be used consistently in three places: + +- the filename: `_lit-styles/[name].css` +- any CSS class names within the fragment: `.swc-[Name]-*` +- any exposed custom properties: `--swc-[name]-*` + +Using a unique name makes it easy to trace where styles come from and keeps each concern distinct as more consumers are added. + +**Step 2 — Create the fragment.** + +Add the CSS file at `stylesheets/_lit-styles/[name].css`. Include only shared structural rules; do not include variant fill colors, animations, or component-specific overrides. + +**Step 3 — Import in consuming components.** + +In each consuming component's TypeScript file, import the fragment as a JavaScript module: + +```ts +import sharedStyles from '../../stylesheets/_lit-styles/[name].css'; +import styles from './[component].css'; +``` + +Then add it to the `styles()` array: + +```ts +public static override get styles(): CSSResultArray { + return [sharedStyles, styles]; +} +``` + +Do **not** use a CSS `@import` statement for `_lit-styles/` files. They are Lit CSS modules, not standalone stylesheets; a CSS-level import would break the build. + +Reference: `components/meter/Meter.ts` and `stylesheets/_lit-styles/linear-progress-base.css` for a complete example. diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/README.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/README.md index 7aac2c06737..d41d948db31 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/README.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/README.md @@ -17,6 +17,7 @@ - [Spectrum CSS to SWC Migration](04_spectrum-swc-migration.md) - [Styling Anti-Patterns (What to Avoid)](05_anti-patterns.md) - [Property order quick reference](06_property-order-quick-reference.md) +- [Non-component stylesheets](07_stylesheets.md) diff --git a/CONTRIBUTOR-DOCS/02_style-guide/README.md b/CONTRIBUTOR-DOCS/02_style-guide/README.md index 0f6548ef408..a3def59a7c3 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/README.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/README.md @@ -18,6 +18,7 @@ - [Spectrum CSS to SWC Migration](01_css/04_spectrum-swc-migration.md) - [Styling Anti-Patterns (What to Avoid)](01_css/05_anti-patterns.md) - [Property order quick reference](01_css/06_property-order-quick-reference.md) + - [Non-component stylesheets](01_css/07_stylesheets.md) - [2nd-gen TypeScript](02_typescript/README.md) - [File organization](02_typescript/01_file-organization.md) - [Class structure](02_typescript/02_class-structure.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/meter/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/meter/migration-plan.md index df4d684bb77..d5d7164fa20 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/meter/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/meter/migration-plan.md @@ -276,7 +276,7 @@ All variants (`informative` default, `positive`, `notice`, `negative`) support a No `--mod-*` properties will be exposed. New `--swc-*` component-level properties may be introduced where needed — these are additive and not breaking. See [Component Custom Property Exposure](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md#component-custom-property-exposure) for what to expose and how. -Shipped set for ``: rather than a `--swc-meter-*` set, the component exposes the shared linear-progress surface (defined in `swc/stylesheets/shared/linear-progress-base.css` and consumed by `meter.css`). These are the public custom properties: +Shipped set for ``: rather than a `--swc-meter-*` set, the component exposes the shared linear-progress surface (defined in `swc/stylesheets/_lit-styles/linear-progress-base.css` and consumed by `meter.css`). These are the public custom properties: - `--swc-linear-progress-fill-color` — overrides the variant-derived bar fill color. - `--swc-linear-progress-track-color` — overrides the bar track color. @@ -322,7 +322,7 @@ Follow the [Badge migration reference](../../02_workstreams/02_2nd-gen-component | **Mixin** | `2nd-gen/packages/core/mixins/linear-progress.mixin.ts` | `LinearProgressMixin`. The thin shared layer. Owns: typed property declarations for all shared props (`value`, `minValue`, `maxValue`, `accessibleLabel`, `valueLabel`, `formatOptions`, `labelPosition`, `staticColor`, `size`); shared type constants (`LINEAR_PROGRESS_VALID_SIZES`, `LINEAR_PROGRESS_LABEL_POSITIONS`, `LINEAR_PROGRESS_STATIC_COLORS`); `value` clamping; fill-fraction computation; locale-aware formatting via `LanguageResolutionController`; internal id generation for the `label` and `description` slot containers; `label`-slot and `description`-slot `slotchange` tracking; resolution of `aria-labelledby` / `aria-describedby` / `aria-label` values exposed as getters for the SWC render template; DEBUG-mode accessible-name warning. **No ARIA role. No indeterminate state. No rendering.** | | **Core** | `2nd-gen/packages/core/components/meter/` | `Meter.base.ts` (extends `LinearProgressMixin`), `Meter.types.ts`, `index.ts`. Owns only what is meter-specific on top of the mixin: `variant` typed property (`METER_VARIANTS` constant); any meter-specific ARIA decisions not already resolved by the mixin. **No rendering. No JSX/Lit template.** | | **SWC** | `2nd-gen/packages/swc/components/meter/` | `Meter.ts` (extends `MeterBase`), `meter.css`, `index.ts`, element registration `swc-meter`, `stories/`, `test/`, `consumer-migration-guide.mdx`. Owns: S2 rendering with the `swc-Meter` wrapper, `role="meter"` + all `aria-value*` bindings on the role element, S2 token bindings, `static-color="white"`/`static-color="black"` classes, meter-specific visual styling. Imports `linear-progress-base.css` for shared bar/track/fill and label-layout rules. | -| **Shared CSS** | `2nd-gen/packages/swc/shared/linear-progress-base.css` | Shared bar/track/fill structure, size tokens, label/value text layout (top vs side), static-color (white/black) treatment, i18n modifiers. Imported by `meter.css` and (in the future) `progress-bar.css`. Contains no variant fill colors, no indeterminate animation. | +| **Shared CSS** | `2nd-gen/packages/swc/_lit-styles/linear-progress-base.css` | Shared bar/track/fill structure, size tokens, label/value text layout (top vs side), static-color (white/black) treatment, i18n modifiers. Imported by `meter.css` and (in the future) `progress-bar.css`. Contains no variant fill colors, no indeterminate animation. | Planned rendering shape for `Meter.ts.render()`: @@ -382,7 +382,7 @@ Notes: ### Setup - [ ] Create `2nd-gen/packages/core/mixins/linear-progress.mixin.ts` with the `LinearProgressMixin` stub and its shared type constants (`LINEAR_PROGRESS_VALID_SIZES`, `LINEAR_PROGRESS_LABEL_POSITIONS`, `LINEAR_PROGRESS_STATIC_COLORS`) -- [ ] Create `2nd-gen/packages/swc/shared/linear-progress-base.css` as an empty stub (content added in Styling phase) +- [ ] Create `2nd-gen/packages/swc/_lit-styles/linear-progress-base.css` as an empty stub (content added in Styling phase) - [ ] Create `2nd-gen/packages/core/components/meter/` with `Meter.base.ts` (extends `LinearProgressMixin`), `Meter.types.ts` (`METER_VARIANTS` + `MeterVariant`), `index.ts` - [ ] Create `2nd-gen/packages/swc/components/meter/` with `Meter.ts`, `meter.css` (`@import`s `linear-progress-base.css`), `index.ts`, `stories/`, `test/` - [ ] Wire exports in `core` and `swc` `package.json` files; export `LinearProgressMixin` from core's mixins barrel diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/accessibility-migration-analysis.md index 1e69c1b5da5..d8f4b6eafab 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/accessibility-migration-analysis.md @@ -39,7 +39,7 @@ ## Overview -This doc explains how the **opacity checkerboard shared style** should work for **accessibility** in 2nd-gen. It supports **WCAG 2.2 Level AA** as the team target. The checkerboard is **not** implemented as a custom element; it ships as an importable Lit **`css`** fragment (class **`.swc-opacity-checkerboard`**) under `2nd-gen/packages/swc/stylesheets/shared/`. **Consumer documentation** for components that use the pattern must carry the guidance in this doc; the utility itself has **no** ARIA surface. +This doc explains how the **opacity checkerboard shared style** should work for **accessibility** in 2nd-gen. It supports **WCAG 2.2 Level AA** as the team target. The checkerboard is **not** implemented as a custom element; it ships as an importable Lit **`css`** fragment (class **`.swc-opacity-checkerboard`**) under `2nd-gen/packages/swc/stylesheets/_lit-styles/`. **Consumer documentation** for components that use the pattern must carry the guidance in this doc; the utility itself has **no** ARIA surface. ### Also read diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/migration-plan.md index 425f8aa8f10..ff54aa7a7d9 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/opacity-checkerboard/migration-plan.md @@ -64,7 +64,7 @@ Opacity Checkerboard is **not a component migration**. It is a **reclassificatio Must-ship work is small and almost entirely structural: -- **Ship a 2nd-gen shared CSS utility** for the checkerboard pattern at `2nd-gen/packages/swc/stylesheets/shared/` — an importable `css` style fragment. The pattern itself already works in 2nd-gen: gen-2 tokens (`--swc-opacity-checkerboard-square-*`) exist in `tokens.css`, and `color-loupe` already renders the pattern by **inlining** the rule into its own component CSS (it does not import any shared artifact). The shared fragment de-duplicates that rule for shadow-DOM consumers. +- **Ship a 2nd-gen shared CSS utility** for the checkerboard pattern at `2nd-gen/packages/swc/stylesheets/_lit-styles/` — an importable `css` style fragment. The pattern itself already works in 2nd-gen: gen-2 tokens (`--swc-opacity-checkerboard-square-*`) exist in `tokens.css`, and `color-loupe` already renders the pattern by **inlining** the rule into its own component CSS (it does not import any shared artifact). The shared fragment de-duplicates that rule for shadow-DOM consumers. - **Deprecate the 1st-gen `@spectrum-web-components/opacity-checkerboard` package** with a notice and a pointer to the 2nd-gen replacement (timeline-gated). - **Drop the `--mod-*` surface** (`--mod-opacity-checkerboard-{dark,light,size,position}`) — consistent with all 2nd-gen migrations, which do not expose `--mod-*`. - **Contributor docs stay internal-only for now**, outside the Components section, since the utility is Lit-consumption-only and not a public standalone API. @@ -75,7 +75,7 @@ Why a shared `css` fragment and not a global class: **a global stylesheet class ### Most blocking open questions -None block implementation. The deliverable form (importable `css` fragment under `swc/stylesheets/shared/`) and class name (`.swc-OpacityCheckerboard`) are decided. One non-blocking open item remains — whether the a11y workstream needs to sign off — tracked in [Scope and prerequisites](#scope-and-prerequisites). +None block implementation. The deliverable form (importable `css` fragment under `swc/stylesheets/_lit-styles/`) and class name (`.swc-OpacityCheckerboard`) are decided. One non-blocking open item remains — whether the a11y workstream needs to sign off — tracked in [Scope and prerequisites](#scope-and-prerequisites). --- @@ -177,7 +177,7 @@ Once the shared fragment exists, `color-loupe` (and future color components) sho ### User confirmation needed -**Resolved in session:** the deliverable is a shared `css` style fragment under `swc/stylesheets/shared/`. The follow-up refactor of `color-loupe` off its inline copy (A1) remains a separate, optional ticket and does not gate this work. +**Resolved in session:** the deliverable is a shared `css` style fragment under `swc/stylesheets/_lit-styles/`. The follow-up refactor of `color-loupe` off its inline copy (A1) remains a separate, optional ticket and does not gate this work. --- @@ -197,7 +197,7 @@ Once the shared fragment exists, `color-loupe` (and future color components) sho | # | What changes | 1st-gen behavior | 2nd-gen behavior | Consumer migration path | | --- | ------------ | ---------------- | ---------------- | ----------------------- | | **B1** | Drop the `--mod-*` override surface (source: 2nd-gen CSS policy — no `--mod-*` exposed). | `--mod-opacity-checkerboard-{light,dark,size,position}` override the pattern per instance. | No `--mod-*`. Pattern is driven by `--swc-*` tokens; size handled by token, not per-instance mod. | Consumers stop setting `--mod-*`; use token-driven size or component-scoped `--swc-*` where a knob is genuinely needed. | -| **B2** | Reclassify package → shared CSS utility; deprecate `@spectrum-web-components/opacity-checkerboard` (source: [Tools vs packages](../../../../CONTRIBUTOR-DOCS/01_contributor-guides/12_tools-vs-packages.md#migration-and-deprecation-for-reclassified-items)). | Standalone npm package exporting CSS-as-JS artifacts. | No standalone 2nd-gen package. Pattern shipped as an importable `css` fragment in `swc/stylesheets/shared/`. | Consumers import the 2nd-gen fragment into their `styles` array instead of the 1st-gen package. | +| **B2** | Reclassify package → shared CSS utility; deprecate `@spectrum-web-components/opacity-checkerboard` (source: [Tools vs packages](../../../../CONTRIBUTOR-DOCS/01_contributor-guides/12_tools-vs-packages.md#migration-and-deprecation-for-reclassified-items)). | Standalone npm package exporting CSS-as-JS artifacts. | No standalone 2nd-gen package. Pattern shipped as an importable `css` fragment in `swc/stylesheets/_lit-styles/`. | Consumers import the 2nd-gen fragment into their `styles` array instead of the 1st-gen package. | #### Styling and visuals @@ -279,11 +279,11 @@ The checkerboard is **decorative**. Responsibility stays with the *consuming* el | Layer | Path | Contains | | --- | --- | --- | | **Core** | N/A | No UI-less logic. The artifact depends on Spectrum styles/tokens, so nothing belongs in `core/`. | -| **SWC** | `2nd-gen/packages/swc/stylesheets/shared/` | The checkerboard CSS as an importable `css` style fragment, consumed by adding it to a component's `styles` array (reaches shadow-DOM consumers). | +| **SWC** | `2nd-gen/packages/swc/stylesheets/_lit-styles/` | The checkerboard CSS as an importable `css` style fragment, consumed by adding it to a component's `styles` array (reaches shadow-DOM consumers). | Decided form: -- Ship an importable `css` style fragment under `swc/stylesheets/shared/` (new `shared/` directory). Shadow-DOM consumers (every known consumer) include it in their `styles` array, mirroring how `color-loupe` works today but de-duplicating the rule. +- Ship an importable `css` style fragment under `swc/stylesheets/_lit-styles/` (new `_lit-styles/` directory). Shadow-DOM consumers (every known consumer) include it in their `styles` array, mirroring how `color-loupe` works today but de-duplicating the rule. - No global utility class — a global class cannot pierce shadow DOM and would not serve a single existing consumer. - Exported only as a Lit `CSSResult` (`opacityCheckerboardStyles`); no global `.css` artifact is emitted, so there is no light-DOM/document-level usage path. @@ -301,10 +301,10 @@ Decided form: ### Setup -- [x] Deliverable form decided — **shared `css` fragment in `swc/stylesheets/shared/`** -- [x] Create `2nd-gen/packages/swc/stylesheets/shared/` with `opacity-checkerboard.css` (the `css` fragment) and `index.ts` (typed `opacityCheckerboardStyles` barrel) -- [x] Wire exports in the `swc` `package.json` (`./stylesheets/shared` + `./stylesheets/shared/*`) and add the `@adobe/spectrum-wc/stylesheets` dev alias in `vite.config.ts` -- [x] Adjust build: un-exclude `stylesheets/shared/**` from `litCss`, exclude it from `processStylesheets`, add it as a lib entry (so it is lit-transformed into an importable `css` result, not emitted as a global stylesheet) +- [x] Deliverable form decided — **shared `css` fragment in `swc/stylesheets/_lit-styles/`** +- [x] Create `2nd-gen/packages/swc/stylesheets/_lit-styles/` with `opacity-checkerboard.css` (the `css` fragment) and `index.ts` (typed `opacityCheckerboardStyles` barrel) +- [x] Wire exports in the `swc` `package.json` (`./stylesheets/shared` + `./stylesheets/_lit-styles/*`) and add the `@adobe/spectrum-wc/stylesheets` dev alias in `vite.config.ts` +- [x] Adjust build: un-exclude `stylesheets/_lit-styles/**` from `litCss`, exclude it from `processStylesheets`, add it as a lib entry (so it is lit-transformed into an importable `css` result, not emitted as a global stylesheet) - [x] Check out `spectrum-css` at `spectrum-two` branch as sibling directory - [ ] ~~Create `core/components/opacity-checkerboard/`~~ — N/A (no core split) - [ ] ~~Create `swc/components/opacity-checkerboard/`~~ — N/A (no custom element) From 602ebc08b0ff8fa51d3b4735ee0104016dd387ba Mon Sep 17 00:00:00 2001 From: Nikki Massaro <5090492+nikkimk@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:10:06 -0400 Subject: [PATCH 15/32] docs(color-handle): accessbility migration-analysis (#6397) * docs(color-handle): accessbility migration-analysis * docs(color-handle): added notes from adobe a11y * docs(color-loupe): added adaptive border guidance --- .../03_components/README.md | 3 + .../accessibility-migration-analysis.md | 438 ++++++++++++++++++ ...endering-and-styling-migration-analysis.md | 29 ++ .../accessibility-migration-analysis.md | 75 ++- .../03_project-planning/README.md | 1 + 5 files changed, 536 insertions(+), 10 deletions(-) create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/accessibility-migration-analysis.md create mode 100644 CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/rendering-and-styling-migration-analysis.md diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md index 74cab56df59..dcbfb10df04 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/README.md @@ -58,6 +58,9 @@ - [Close button migration roadmap](close-button/rendering-and-styling-migration-analysis.md) - Color Field - [Color field migration roadmap](color-field/rendering-and-styling-migration-analysis.md) +- Color Handle + - [Color handle accessibility migration analysis](color-handle/accessibility-migration-analysis.md) + - [Color handle migration roadmap](color-handle/rendering-and-styling-migration-analysis.md) - Color Loupe - [Color loupe accessibility migration analysis](color-loupe/accessibility-migration-analysis.md) - [Color loupe migration checklist](color-loupe/migration-checklist.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/accessibility-migration-analysis.md new file mode 100644 index 00000000000..c49ebd46cfc --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/accessibility-migration-analysis.md @@ -0,0 +1,438 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Color Handle / Color handle accessibility migration analysis + + + +# Color handle accessibility migration analysis + + + +
+In this doc + +- [Overview](#overview) + - [Also read](#also-read) + - [What it is](#what-it-is) + - [When to use something else](#when-to-use-something-else) + - [What it is not](#what-it-is-not) + - [Related](#related) +- [ARIA and WCAG context](#aria-and-wcag-context) + - [Pattern in the APG](#pattern-in-the-apg) + - [Guidelines that apply](#guidelines-that-apply) +- [Related 1st-gen accessibility (Jira)](#related-1st-gen-accessibility-jira) +- [Recommendations: ``](#recommendations-swc-color-handle) + - [ARIA roles, states, and properties](#aria-roles-states-and-properties) + - [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues) + - [Accessibility tree expectations](#accessibility-tree-expectations) + - [Assistive technology, live regions](#assistive-technology-live-regions) + - [Keyboard and focus](#keyboard-and-focus) + - [Story and test examples](#story-and-test-examples) +- [Known 1st-gen issues](#known-1st-gen-issues) + - [Non-text contrast on focused handle chrome (WCAG 1.4.11)](#non-text-contrast-on-focused-handle-chrome-wcag-1411) +- [Implementation: adaptive dual-border algorithm](#implementation-adaptive-dual-border-algorithm) + - [Contrast utilities](#contrast-utilities) + - [Ring sampling](#ring-sampling) + - [Finding the minimum α](#finding-the-minimum-) + - [Choosing α: the two adaptive modes](#choosing--the-two-adaptive-modes) + - [Visibility verdict: the additive dual check](#visibility-verdict-the-additive-dual-check) + - [Handle SVG rendering](#handle-svg-rendering) + - [Loupe SVG rendering](#loupe-svg-rendering) + - [Page-background edge mode (strict)](#page-background-edge-mode-strict) +- [Testing](#testing) + - [Automated tests](#automated-tests) +- [Summary checklist](#summary-checklist) +- [References](#references) + +
+ + + +## Overview + +This doc explains how **`swc-color-handle`** should work for **accessibility**. It targets **WCAG 2.2 Level AA**, including full conformance for non-text contrast on handle borders via the adaptive dual-border approach specified in RSP-2021 and SDS-16402. Until **`swc-color-handle`** ships under `2nd-gen/`, use **`1st-gen/packages/color-handle/src/ColorHandle.ts`** (``) as the behavioral reference and update this spec against the real 2nd-gen source when it lands. + +### Also read + +- [Color handle migration roadmap](./rendering-and-styling-migration-analysis.md) for layout, CSS, tokens, and DOM changes. +- [Color loupe accessibility migration analysis](../color-loupe/accessibility-migration-analysis.md); the handle **hosts** the loupe when **`open`**. +- [Opacity checkerboard accessibility migration analysis](../opacity-checkerboard/accessibility-migration-analysis.md); the handle applies the checkerboard pattern on **`:host`** for transparent **`color`** values. + +### What it is + +- A **circular thumb** that marks the **current color** on a **color area**, **color slider**, or **color wheel** track. +- It shows the **picked fill** (with an **opacity checkerboard** when **`color`** is transparent), optional **inner** / **outer** borders, and an enlarged **focused** size when the **parent** color widget has keyboard focus. +- On **touch** input, it can set **`open`** so an embedded **`swc-color-loupe`** appears above the thumb so the finger does not hide the sample ([S2 color handle](https://s2.spectrum.corp.adobe.com/page/color-handle/)). + +### When to use something else + +- A **standalone** color **control** with **label**, **value**, and **keyboard** semantics ➜ use **`swc-color-field`**, **`swc-color-area`**, **`swc-color-slider`**, or **`swc-color-wheel`** (or compose them), not an isolated handle. +- A **read-only** color **preview** (swatch, thumbnail) ➜ use **`swc-swatch`** or **`swc-thumbnail`**, not a handle. + +### What it is not + +- The **primary accessible control** for choosing a color. **Name**, **role**, **value**, and **keyboard** interaction live on the **parent** color widget (hidden **`input[type="range"]`** elements and related ARIA in 1st-gen). +- A **standalone** Storybook/demo widget authors should ship in product UI without a **labeled** parent picker. + +### Related + +- **`swc-color-loupe`** ➜ [Color loupe accessibility migration analysis](../color-loupe/accessibility-migration-analysis.md). +- **Parent pickers** (each owns labeling and slider semantics) ➜ `sp-color-area`, `sp-color-slider`, `sp-color-wheel` (2nd-gen migration docs pending). + +--- + +## ARIA and WCAG context + +### Pattern in the APG + +- The [APG](https://www.w3.org/WAI/ARIA/apg/) does **not** define a standalone “color handle” widget. Treat the handle as **supporting UI** inside the [slider](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) (or **2D slider**) patterns implemented by **parent** color components. +- **1st-gen** parents expose **`input[type="range"]`** sliders with implicit **`slider`** roles, **`aria-label`**, **`aria-valuetext`**, and related properties. The handle is a **visual** thumb only. + +### Guidelines that apply + +| Idea | Plain meaning | +|------|----------------| +| [Name, role, value (WCAG 4.1.2)](https://www.w3.org/WAI/WCAG22/#name-role-value) | The **parent** picker must expose a **name** and **value**. The handle **must not** pretend to be the sole **slider**; **1st-gen** sets **no** **`role`** or **`aria-*`** on **``**. | +| [Labels or instructions (WCAG 3.3.2)](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions) | **Visible** or programmatic **labels** belong on the **parent** widget (for example **`label`** on **`sp-color-slider`**, **`label-x`** / **`label-y`** on **`sp-color-area`**). The handle does **not** supply its own label. | +| [Use of color (WCAG 1.4.1)](https://www.w3.org/TR/WCAG22/#use-of-color) | The **fill** shows **color**; meaning must **not** rely on the handle **alone**. **Text**, **hex** inputs, or **slider** **value** announcements must state what is selected. | +| [Non-text contrast (WCAG 1.4.11)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) | Handle borders and focus chrome must meet 3:1 against adjacent colors. The **adaptive dual-border** approach achieves this across the full HSV cube: a dark border at variable opacity (floor 42%, climbing until 3:1 is met) combined with a white separator, checked additively — the handle passes if either border meets 3:1 on every adjacency. See RSP-2021, SDS-16402, and Known 1st-gen issues. | +| [Non-text content (WCAG 1.1.1)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content) | The handle **fill** and **borders** are **graphical** relative to assistive tech when the **parent** already exposes **value** and **purpose**. Do **not** add a conflicting **`role="img"`** on the host unless product and a11y agree on a **redundant** name strategy. | +| [Focus appearance (WCAG 2.4.11)](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance) / [Focus visible (WCAG 2.4.7)](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible) | Keyboard users must see **which** picker has focus. **1st-gen** reflects focus with **`[focused]`** / **`:focus-visible`** **size** expansion and **border** treatment on the handle while **`outline: none`** is set on **`:host(:focus)`**; verify **2nd-gen** keeps a **visible** focus indicator aligned with parent behavior. | + +**Bottom line:** `swc-color-handle` stays role-less on the host, carries no default accessible name, and relies on parent color widgets for slider semantics. Preserve the touch loupe behavior. Implement the **adaptive dual-border** approach (RSP-2021, SDS-16402) to achieve 3:1 non-text contrast across all track colors; the essential-presentation exception for this control is superseded by the conformant solution. + +--- + +## Related 1st-gen accessibility (Jira) + +| Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary | Notes | +|------|------|-------------------|-------------------------|---------|-------| +| [SWC-1134](https://jira.corp.adobe.com/browse/SWC-1134) | Bug | Done | Working As Designed | [Accessibility] Focus indicator lacks 3:1 contrast ratio — `sp-color-area` (Anatomy section), `sp-color-handle` | **Focused** handle **ring** / **borders** on **variable** backgrounds | +| [SWC-1135](https://jira.corp.adobe.com/browse/SWC-1135) | Bug | To Do | Unresolved | [Accessibility] Visible label missing — `sp-color-area` | **Parent** owns **label**; handle is **not** standalone | +| [SWC-1132](https://jira.corp.adobe.com/browse/SWC-1132) | Bug | To Do | Unresolved | [Accessibility] Visible label missing — `sp-color-slider` (Default, Vertical section) | **Parent** owns **`label`** / **`aria-label`** on internal range input | +| [SWC-1166](https://jira.corp.adobe.com/browse/SWC-1166) | Bug | Done | Working As Designed | [Accessibility] Color alone is used to convey info — `sp-color-area` (Anatomy and Accessible Label section) | **Fill** is **visual**; **parent** **sliders** announce **values** | +| [RSP-2021](https://jira.corp.adobe.com/browse/RSP-2021) | — | — | — | Adaptive border contrast for color area handle | Specification for the dual-border adaptive opacity approach that resolves 1.4.11 on spectrum tracks; see Known 1st-gen issues | +| [SDS-16402](https://jira.corp.adobe.com/browse/SDS-16402) | — | — | — | Non-text contrast — color area handle adaptive border specification | Spectrum Design System design decision for adaptive border opacity; drives the 2nd-gen implementation | + +--- + +## Recommendations: `` + +### ARIA roles, states, and properties + +| Topic | What to do | +|-------|------------| +| **One semantic role** | The handle represents **one** thing: a **visual thumb** on a **parent** color control. **Do not** set a host **`role`** that turns it into a **different** widget (**`role="slider"`**, **`role="button"`**, etc.). **Slider** semantics stay on the **parent** pattern. If authors need a **standalone** **slider**, they must use the **parent** color component or appropriate **slider** markup, **not** a role override on **`swc-color-handle`**. | +| **Role** | **No** default **`role`** on the host (**matches 1st-gen**). | +| **Name** | **Do not** require a default **`aria-label`** on the handle. The **parent** supplies **names** on its internal **range** inputs (or future **ElementInternals** / **form field** strategy). **Standalone** handle usage in docs must include a **labeled** **parent** or be marked **visual-only** / **`aria-hidden`** on the demo host. | +| **`color` property** | **Visual only** on the handle. **Assistive technology** should hear **values** from the **parent** control, not from parsing the handle **fill**. | +| **`disabled` property** | Reflect with **`[disabled]`** (and **`pointer-events: none`** as today). **Parent** **`disabled`** should **disable** interaction for the whole picker, including the handle. | +| **`focused` property** | **Visual** state set by the **parent** when the picker has **visible focus** in the tree. **Not** an ARIA **state** on the handle host. | +| **`open` property** | Controls embedded **`swc-color-loupe`** visibility (**touch** opens in **1st-gen** via **`pointerType === 'touch'`**). **Loupe** remains **decorative**; see [Color loupe accessibility migration analysis](../color-loupe/accessibility-migration-analysis.md). | +| **S2 `showHandleFill` / `showColorLoupe`** | Map to **visual** toggles only. **Do not** map to **ARIA** **states** unless product defines a **documented** exception. | +| **Opacity checkerboard** | Applied on **`:host`** via shared **opacity checkerboard** CSS (**matches 1st-gen**). Follow [Opacity checkerboard accessibility migration analysis](../opacity-checkerboard/accessibility-migration-analysis.md): pattern is **decorative** when **parent** announces **opacity** / **color** in text. | +| **Non-text contrast (1.4.11)** | Implement the adaptive dual-border technique specified in RSP-2021 and SDS-16402. Sample a 5px ring around the handle; composite rgba(0,0,0,α) over each adjacent sample — α starts at 42% and climbs until 3:1 is met against the gradient. A white separator between the two dark borders provides an additive check: the handle passes visibility if either the dark border or the white separator meets 3:1 on every adjacency. At gradient edges, also check the dark border against the page background. This approach has been verified to pass across the full HSV cube. The essential-presentation exception recorded in SWC-1134 no longer applies; a conformant solution exists. | +| **Docs** | State that **`swc-color-handle`** is a **primitive** used **inside** color pickers, **not** a **labeled** **form control** by itself. Point authors to **parent** docs for **labels**, **keyboard**, and **values**. Cite [**essential presentations**](https://accessibility.corp.adobe.com/docs/visual_design/color/#essential-presentations) when explaining **1.4.11** limits on **focus** **chrome**. | + +### Shadow DOM and cross-root ARIA Issues + +None + +The handle does **not** use **`aria-labelledby`** / **`aria-describedby`** ID references across shadow boundaries, and it is **not** **form-associated** on its own. The embedded **`swc-color-loupe`** keeps **SVG** **`aria-hidden="true"`** per the loupe spec. + +### Accessibility tree expectations + +**Embedded in a parent color widget (supported use)** + +- Assistive technologies should **not** treat the handle as the **primary** **slider**. **1st-gen** parents place **`tabindex="0"`** on **``** only until focus moves, then forward focus to **hidden** **`input[type="range"]`** elements that carry **`aria-label`**, **`aria-valuetext`**, and related properties. +- The handle host typically appears as a **focusable** custom element **without** an **accessible name** briefly during focus forwarding; **AT** should interact with the **parent** **range** inputs for **name** and **value** (verify per browser during **manual** testing of migrated parents). +- When **`open`** is **true**, the **loupe** subtree is **decorative** (**SVG** hidden); **color** meaning still comes from the **parent** control. + +**Standalone handle (unsupported product use)** + +- A lone **``** in a story or app exposes **no** **role** and **no** **name** if focused. **Do not** document standalone handles as an accessible pattern. + +**Disabled** + +- **`[disabled]`** hides the inner fill in **1st-gen** and removes **pointer** interaction; **parent** should prevent **keyboard** adjustment as well. + +### Assistive technology, live regions + +Does not apply. The handle does **not** own **value** announcements. **Parents** update **`aria-valuetext`** on internal **range** inputs. Do **not** add **`aria-live="assertive"`** on the handle; treat **`aria-live="polite"`** as **rare** on **parent** pickers only when product docs justify it. + +### Keyboard and focus + +**Default supported use:** embedded in **`swc-color-area`**, **`swc-color-slider`**, or **`swc-color-wheel`**. + +- **Parents** may assign **`tabindex="0"`** on the handle as the **single** tab **entry** for the picker, then **forward** focus to internal **range** inputs (**matches 1st-gen** **`forwardFocus`** / **`hasVisibleFocusInTree`** pattern). +- The handle **reflects** keyboard focus **visually** via **`[focused]`** and **`:focus-visible`** (**size** expands to **`color-handle-size-key-focus`** in **1st-gen** tokens). +- **Arrow keys**, **Page Up** / **Page Down**, **Home**, and **End** are handled by the **parent**, not the handle class. +- **`swc-color-handle`** must **not** be documented as a **standalone** keyboard widget. +- When **`disabled`**, the handle must **not** remain the only **focusable** node in an otherwise **disabled** picker. + +### Story and test examples + +- **Stories** and **automated tests** should show **`swc-color-handle`** **inside** **`swc-color-area`**, **`swc-color-slider`**, or **`swc-color-wheel`**, with **labels** and **realistic** layouts, **not** as a **lone** thumb on an empty canvas (except narrow **visual** regression frames). +- **Composite** **aXe** runs must include the **parent** picker so **label** and **slider** rules apply. +- **Isolated** handle stories that pass **`to.be.accessible()`** today do **not** prove **product-ready** accessibility; treat them as **smoke** tests only. + +--- + +## Known 1st-gen issues + +### Non-text contrast on focused handle chrome (WCAG 1.4.11) + +1st-gen `` uses a static dark border at 42% opacity (with a second dark border on the focused handle) against whatever gradient color underlies the thumb. This frequently fails the 3:1 non-text contrast threshold required by WCAG 2.2 SC 1.4.11 on bright, saturated, or light gradient areas. SWC-1134 was closed "Working As Designed" under the Adobe Accessibility [essential presentations](https://accessibility.corp.adobe.com/docs/visual_design/color/#essential-presentations) rationale. + +**Resolution for 2nd-gen (RSP-2021, SDS-16402):** An adaptive dual-border approach has been specified and prototyped ([color-area-adaptive-borders.zip](https://jira.corp.adobe.com/secure/attachment/18750666/18750666_color-area-adaptive-borders.zip)) that achieves 3:1 across every color in the HSV cube without breaking the visual character of the control. The prototype demonstrates four border modes — the current static 42% opacity, a 100% static mode, and two adaptive modes (white-first and live α) — alongside live contrast readouts, keyboard- and drag-movable handles, and a drag-anywhere color area. The two adaptive modes are the reference for 2nd-gen: + +1. **Sample** the 5px ring surrounding the handle. Classify each sample as gradient adjacency or page-background adjacency (when the handle overhangs a gradient edge). +2. **Dark border check (drives α):** composite rgba(0,0,0,α) over the adjacency color, then measure contrast. Start α at the floor (42%) and increase until 3:1 is reached for the gradient adjacency. +3. **White separator check (additive):** measure contrast(#FFFFFF, adjacency). Reported but does not lower α; provides an independent pass path. +4. **Strict edge mode:** when enabled, α is the maximum needed across both gradient and page-background adjacencies, so the dark border meets 3:1 on every side at edges. +5. **Bulletproof rule:** the handle is visible if every present adjacency has either its dark border or its white separator pass 3:1. + +2nd-gen should implement adaptive border opacity following this specification. The essential-presentation exception for this control is superseded by the conformant solution in RSP-2021 and SDS-16402. + +--- + +## Implementation: adaptive dual-border algorithm + +This section translates the prototype in [color-area-adaptive-borders.zip](https://jira.corp.adobe.com/secure/attachment/18750666/18750666_color-area-adaptive-borders.zip) into the steps a 2nd-gen implementer needs to follow. The loupe rendering variant is described below in [Loupe SVG rendering](#loupe-svg-rendering); the sampling and alpha computation are shared. + +### Contrast utilities + +Use standard WCAG 2.2 relative luminance and contrast ratio. The key addition is `compositeBlackOver`, which predicts the color of a dark border rendered at opacity α over a background: + +```js +function srgbToLinear(c) { + c /= 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +function relativeLuminance([r, g, b]) { + return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b); +} +function contrastRatio(colorA, colorB) { + const la = relativeLuminance(colorA), lb = relativeLuminance(colorB); + const hi = Math.max(la, lb), lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +// Composite black at opacity α over an RGB background. +// Result: the rendered border color. +function compositeBlackOver([r, g, b], alpha) { + return [Math.round(r * (1 - alpha)), Math.round(g * (1 - alpha)), Math.round(b * (1 - alpha))]; +} +``` + +### Ring sampling + +Sample a 5 px ring around the handle center using 24 angular positions × 3 radii (inner edge, mid-ring, outer edge). Classify each sample point: + +- **Gradient sample**: the point falls inside the control boundary (color area canvas, hue ring band). Compute the gradient color at that position using the HSV model. +- **Page-background sample**: the point falls outside the boundary. Attribute it to the application background color. + +```js +// Returns { gradient: [r, g, b], pageBg: [r, g, b], pageBgWeight: 0..1 } +function sampleSurrounding(handleCx, handleCy, sampleColor, isInsideBounds, pageBg) { + const HANDLE_R_PX = 12, RING_PX = 5; + const radii = [HANDLE_R_PX, HANDLE_R_PX + RING_PX / 2, HANDLE_R_PX + RING_PX]; + const SAMPLES = 24; + let R = 0, G = 0, B = 0, gradientCount = 0, pageBgCount = 0; + for (let i = 0; i < SAMPLES; i++) { + const angle = (i / SAMPLES) * 2 * Math.PI; + for (const r of radii) { + const px = handleCx + Math.cos(angle) * r; + const py = handleCy + Math.sin(angle) * r; + if (isInsideBounds(px, py)) { + const [rv, gv, bv] = sampleColor(px, py); + R += rv; G += gv; B += bv; gradientCount++; + } else { + pageBgCount++; + } + } + } + const total = SAMPLES * radii.length; + return { + gradient: gradientCount > 0 + ? [Math.round(R / gradientCount), Math.round(G / gradientCount), Math.round(B / gradientCount)] + : sampleColor(handleCx, handleCy), + pageBg, + pageBgWeight: pageBgCount / total + }; +} +``` + +### Finding the minimum α + +Step α up from `floor` by 0.01 until the composited dark border meets the target contrast ratio against the adjacency color, or until α reaches 1.0: + +```js +// Returns the minimum α in [floor, 1.0] that makes the dark border meet the target. +function findMinAlpha(adjacency, target = 3, floor = 0.42) { + for (let a = floor; a <= 1 + 1e-9; a += 0.01) { + if (contrastRatio(compositeBlackOver(adjacency, a), adjacency) >= target) return a; + } + return 1.0; +} +``` + +Note: both arguments to `contrastRatio` are derived from the same adjacency color. The dark border is composited black over the adjacency; the reference is the adjacency itself. This self-referential check correctly models "can this dark ring be perceived against the background it sits on?" + +### Choosing α: the two adaptive modes + +Collect the adjacencies to consider (gradient always; page background when `includePageBg` is on and the handle overhangs an edge): + +```js +const WHITE = [255, 255, 255]; + +function computeAlpha(surrounding, mode, { target = 3, floor = 0.42, includePageBg = false } = {}) { + const adjacencies = (includePageBg && surrounding.pageBgWeight > 0) + ? [surrounding.gradient, surrounding.pageBg] + : [surrounding.gradient]; + + // Adaptive (live α): α climbs until the dark border meets the target + // against the worst adjacency. Straightforward; recommended baseline. + if (mode === 'adaptive-live') { + return Math.max(...adjacencies.map(a => findMinAlpha(a, target, floor))); + } + + // Adaptive (white-first): if the white separator already passes 3:1 against + // every adjacency, stay at the floor for softer, more uniform borders. + // Only escalate α when white cannot carry the load. + if (mode === 'adaptive-smart') { + const allWhitePass = adjacencies.every(a => contrastRatio(WHITE, a) >= target); + return allWhitePass + ? floor + : Math.max(...adjacencies.map(a => findMinAlpha(a, target, floor))); + } + + return floor; +} +``` + +### Visibility verdict: the additive dual check + +After computing α, confirm that the handle is visible. The check is additive: the handle passes for each adjacency if **either** the dark border **or** the white separator meets the target: + +```js +function isVisible(alpha, surrounding, target = 3) { + const check = (adj) => + contrastRatio(compositeBlackOver(adj, alpha), adj) >= target || + contrastRatio(WHITE, adj) >= target; + + if (!check(surrounding.gradient)) return false; + if (surrounding.pageBgWeight > 0 && !check(surrounding.pageBg)) return false; + return true; +} +``` + +The prototype verifies this holds for every color in the HSV cube with default settings (floor 42%, target 3:1). + +### Handle SVG rendering + +The double-border handle renders two concentric dark circles separated by a white annulus. The white gap between the rings is the separator that provides the additive path to visibility: + +```html + + + + + + +``` + +**Size on focus/press (Spectrum focus pattern):** Growing the handle is the focus indicator — no separate focus ring is drawn. + +| State | Outer radius | Inner radius | Visible diameter | +|-------|-------------|-------------|-----------------| +| Idle | 7 px | 3 px | 16 px | +| Active (focused or pressed) | 11 px | 5 px | 24 px | + +Apply a 120 ms ease transition on radius changes for the grow/shrink effect. + +### Loupe SVG rendering + +The loupe uses a different structure from the handle. Instead of two dark rings, it uses a **white outer halo** to separate itself from the gradient, and a **single inner dark border** that contrasts against the white. This avoids a visually heavy double dark border on the loupe shape. + +```html + + + + + + + + +``` + +The white separator path for the loupe is the 5 px gap between the outer and inner teardrop radii (23 px outer, 18 px inner in the prototype). + +### Page-background edge mode (strict) + +When the handle overhangs a control boundary, `pageBgWeight > 0` and some ring samples are attributed to the page background. Two behaviors: + +- **Default (non-strict):** α is computed against the gradient adjacency only. The page-background adjacency is measured and shown in diagnostics, but it does not drive α. +- **Strict:** α is the maximum needed across both gradient and page-background adjacencies, ensuring the dark border meets 3:1 on every side at edges. + +For 2nd-gen, evaluate whether strict mode should be the default based on how often handles sit at control edges in production color picker layouts. + +--- + +## Testing + +### Automated tests + +| Kind of test | What to check | +|--------------|----------------| +| **Unit (`sp-color-handle`)** | **`open`** toggles on **touch** **pointer** events; **`disabled`** blocks **pointer**; **no** **`role`** / **`aria-*`** on host (**matches 1st-gen**). | +| **Unit (parents)** | Single **tab** stop behavior; **forwardFocus** reaches **labeled** **range** inputs; **handle** **`focused`** tracks **parent** focus. | +| **aXe + Storybook** | Run on **composite** stories (**area** / **slider** / **wheel** **+** **field** **label**), not **isolated** handle-only frames for **release** gates. | +| **Contrast** | Verify that the adaptive dual-border approach meets 3:1 on handle borders at representative gradient colors and at gradient edges against the page background, per RSP-2021 and SDS-16402. | +| **Integration** | **E2E** / snapshots use **labeled** pickers; **touch** opens **loupe** without breaking **parent** **aria-valuetext**. | + +--- + +## Summary checklist + +- [ ] **2nd-gen** host sets **no** default **`role`** and **no** **`aria-label`**; **slider** semantics stay on **parent** pickers. +- [ ] **`disabled`**, **`focused`**, and **`open`** behavior matches **1st-gen** and **S2** **states** docs without inventing **ARIA** **mappings**. +- [ ] **Touch** **`open`** still drives **`swc-color-loupe`**; **loupe** doc **decisions** (**1.4.11**, **decorative** **SVG**) remain **aligned**. +- [ ] **Opacity checkerboard** on **`:host`** follows [Opacity checkerboard accessibility migration analysis](../opacity-checkerboard/accessibility-migration-analysis.md). +- [ ] **Stories** and **tests** use **labeled** **parent** pickers, not **standalone** handles, for **a11y** **gates**. +- [ ] Adaptive dual-border opacity is implemented on handle borders; 3:1 non-text contrast is verified across representative gradient colors and at gradient edges against the page background, per RSP-2021 and SDS-16402. +- [ ] **Docs** state the handle is a **primitive**; **authors** must not ship it **without** a **labeled** **parent** color widget. + +--- + +## References + +- [WCAG 2.2](https://www.w3.org/TR/WCAG22/) +- [Using ARIA (read this first)](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) +- [WAI-ARIA slider pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) +- [WCAG 2.2 Success Criterion 1.4.11: Non-text contrast (understanding)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) +- [WCAG 2.2 Success Criterion 1.4.1: Use of color](https://www.w3.org/TR/WCAG22/#use-of-color) +- [Adobe Accessibility — Color: Essential presentations](https://accessibility.corp.adobe.com/docs/visual_design/color/#essential-presentations) (Adobe internal) +- [Color handle migration roadmap](./rendering-and-styling-migration-analysis.md) +- [Color loupe accessibility migration analysis](../color-loupe/accessibility-migration-analysis.md) +- [Opacity checkerboard accessibility migration analysis](../opacity-checkerboard/accessibility-migration-analysis.md) +- [S2 color handle documentation](https://s2.spectrum.corp.adobe.com/page/color-handle/) +- [S2 color handle — Figma (Web Desktop scale)](https://www.figma.com/design/Mngz9H7WZLbrCvGQf3GnsY/S2---Web--Desktop-scale-?node-id=13065-162) +- [RSP-2021](https://jira.corp.adobe.com/browse/RSP-2021) (Adobe internal Jira — adaptive border contrast specification for color handle) +- [SDS-16402](https://jira.corp.adobe.com/browse/SDS-16402) (Adobe internal Jira — Spectrum Design System adaptive border specification) +- [color-area-adaptive-borders.zip](https://jira.corp.adobe.com/secure/attachment/18750666/18750666_color-area-adaptive-borders.zip) — interactive prototype demonstrating all four border modes with live contrast readouts diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/rendering-and-styling-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/rendering-and-styling-migration-analysis.md new file mode 100644 index 00000000000..41279886c76 --- /dev/null +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-handle/rendering-and-styling-migration-analysis.md @@ -0,0 +1,29 @@ + + +[CONTRIBUTOR-DOCS](../../../README.md) / [Project planning](../../README.md) / [Components](../README.md) / Color Handle / Color handle migration roadmap + + + +# Color handle migration roadmap + + + +
+In this doc + +- [Overview](#overview) + +
+ + + +## Overview + +This roadmap will capture **rendering**, **DOM**, **CSS**, and **token** migration for **`swc-color-handle`**. Full analysis is **in progress** as part of the [S2 color handle migration workstream](../../02_workstreams/02_2nd-gen-component-migration/01_status.md). + +Until this doc is expanded, use: + +- **1st-gen reference:** `1st-gen/packages/color-handle/src/ColorHandle.ts` and `1st-gen/packages/color-handle/src/spectrum-color-handle.css` +- **S2 design:** [Color handle (S2)](https://s2.spectrum.corp.adobe.com/page/color-handle/) and [Figma — S2 Web Desktop scale](https://www.figma.com/design/Mngz9H7WZLbrCvGQf3GnsY/S2---Web--Desktop-scale-?node-id=13065-162) +- **Related token consumers:** [Color loupe migration analysis](../color-loupe/rendering-and-styling-migration-analysis.md), [Opacity checkerboard migration roadmap](../opacity-checkerboard/rendering-and-styling-migration-analysis.md) +- **Accessibility:** [Color handle accessibility migration analysis](./accessibility-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-loupe/accessibility-migration-analysis.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-loupe/accessibility-migration-analysis.md index 9e13c836574..29d676cd042 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-loupe/accessibility-migration-analysis.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/color-loupe/accessibility-migration-analysis.md @@ -26,6 +26,10 @@ - [Story and test examples](#story-and-test-examples) - [Known 1st-gen issues](#known-1st-gen-issues) - [Non-text contrast on loupe chrome (WCAG 1.4.11)](#non-text-contrast-on-loupe-chrome-wcag-1411) +- [Implementation: loupe adaptive borders](#implementation-loupe-adaptive-borders) + - [Loupe SVG rendering](#loupe-svg-rendering) + - [When the loupe is shown](#when-the-loupe-is-shown) + - [Teardrop path construction](#teardrop-path-construction) - [Testing](#testing) - [Automated tests](#automated-tests) - [Summary checklist](#summary-checklist) @@ -37,7 +41,7 @@ ## Overview -This doc explains how **`swc-color-loupe`** should work for **accessibility**. It supports **WCAG 2.2 Level AA** as the team target, while recording **known** limitations for **non-text contrast** on the loupe chrome. Until **`swc-color-loupe`** ships, use **`1st-gen/packages/color-loupe/src/ColorLoupe.ts`** (``) as the behavioral reference. +This doc explains how **`swc-color-loupe`** should work for **accessibility**. It targets **WCAG 2.2 Level AA**, including full conformance for non-text contrast on loupe chrome via the adaptive dual-border approach specified in RSP-2021 and SDS-16402. Until **`swc-color-loupe`** ships, use **`1st-gen/packages/color-loupe/src/ColorLoupe.ts`** (``) as the behavioral reference. ### Also read @@ -60,11 +64,11 @@ This doc explains how **`swc-color-loupe`** should work for **accessibility**. I | Idea | Plain meaning | |------|----------------| -| [Non-text contrast (WCAG 1.4.11)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) | **Borders** and other **non-text** parts needed to **perceive** the loupe shape against **adjacent** colors should meet **at least 3:1** contrast. The loupe sits over **variable** content; **1st-gen** styling often **does not** meet **3:1** for the loupe **chrome**—see **Known 1st-gen issues** and [SWC-1193](https://jira.corp.adobe.com/browse/SWC-1193). | +| [Non-text contrast (WCAG 1.4.11)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) | Loupe borders and chrome must meet 3:1 against adjacent colors. The **adaptive dual-border** approach achieves this across the full HSV cube: a dark border at variable opacity (floor 42%, climbing until 3:1 is met) combined with a white separator, checked additively — chrome passes if either border meets 3:1 on every adjacency. See SWC-1193, RSP-2021, SDS-16402, and Known 1st-gen issues. | | [Non-text content (WCAG 1.1.1)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content) | **1st-gen** marks the **SVG** **`aria-hidden="true"`**—the graphic is **decorative** relative to assistive tech **if** the **parent** pattern exposes the **color** and **purpose** in text or other accessible names. | | [Use of color (WCAG 1.4.1)](https://www.w3.org/TR/WCAG22/#use-of-color) | The **loupe** shows **color**; meaning must **not** rely on the loupe **alone**. The **field** / **slider** / **hex** input must **state** what is being adjusted. | -**Bottom line:** Pair **`swc-color-loupe`** with a **fully labeled** color **workflow**. Accept **documented** **1.4.11** limitations on loupe **chrome** per **SWC-1193** unless design finds a **conformant** approach that still meets product goals. +**Bottom line:** Pair `swc-color-loupe` with a fully labeled color workflow. Implement the **adaptive dual-border** approach (RSP-2021, SDS-16402) to achieve 3:1 non-text contrast on loupe chrome across all background colors; the 1.4.11 limitation documented in SWC-1193 is resolved by the conformant solution. --- @@ -73,6 +77,8 @@ This doc explains how **`swc-color-loupe`** should work for **accessibility**. I | Jira | Type | Status (snapshot) | Resolution (snapshot) | Summary | |------|------|-------------------|-------------------------|---------| | [SWC-1193](https://jira.corp.adobe.com/browse/SWC-1193) | Bug | Done | Working As Designed | [Accessibility] Graphical object lacks 3:1 contrast ratio — `sp-color-loupe` (Color Loupe Example) | +| [RSP-2021](https://jira.corp.adobe.com/browse/RSP-2021) | — | — | — | Adaptive border contrast for color loupe — specification for the dual-border adaptive opacity approach that resolves 1.4.11 on spectrum tracks; see Known 1st-gen issues | +| [SDS-16402](https://jira.corp.adobe.com/browse/SDS-16402) | — | — | — | Non-text contrast — color loupe adaptive border specification; Spectrum Design System design decision for adaptive border opacity; drives the 2nd-gen implementation | --- @@ -82,7 +88,7 @@ This doc explains how **`swc-color-loupe`** should work for **accessibility**. I | Topic | What to do | |-------|------------| -| **Non-text contrast (1.4.11)** | Aim for **3:1** on **loupe** **borders** / **edges** vs **adjacent** UI where **feasible**. Where **variable color** constraints make **3:1** **unrealistic** for all states, record the **[SWC-1193](https://jira.corp.adobe.com/browse/SWC-1193)** **decision** and keep **audit** language aligned with SWC-1196's **[comment](https://jira.corp.adobe.com/browse/SWC-1193?focusedId=51301299&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-51301299)** on practical limits. | +| **Non-text contrast (1.4.11)** | Implement the adaptive dual-border technique specified in RSP-2021 and SDS-16402. Sample a 5px ring around the loupe chrome; composite rgba(0,0,0,α) over each adjacent sample — α starts at 42% and climbs until 3:1 is met against the gradient. A white separator between the two dark borders provides an additive check: chrome passes visibility if either the dark border or the white separator meets 3:1 on every adjacency. At gradient edges, also check the dark border against the page background. This resolves SWC-1193; the practical-limits exception is superseded by the conformant solution. | | **Docs** | State that the loupe is a **visual aid**, not a **standalone** **accessible** color **control**. Point authors to **color field** / **picker** docs for **labels**, **keyboard**, and **values**. | ### Shadow DOM and cross-root ARIA Issues @@ -109,8 +115,54 @@ None ### Non-text contrast on loupe chrome (WCAG 1.4.11) -- **1st-gen** **``** **border** / **loupe** **outline** styling often **does not** achieve the **minimum 3:1** **contrast ratio** against **adjacent** colors required by [**WCAG 2.2 Success Criterion 1.4.11** (Non-text contrast, Level AA)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast). -- **[SWC-1193](https://jira.corp.adobe.com/browse/SWC-1193)** (Adobe **internal** Jira): **Najika** notes that meeting **1.4.11** for this control **realistically** may **not** be **achievable** given how the loupe **overlays** **arbitrary** **content** and **Spectrum** **visual** **intent**. Treat that ticket as the **product** / **a11y** **decision** record for **exceptions** or **risk** **acceptance** until **design** **changes** or **new** **techniques** land. +1st-gen `` border and outline styling frequently fails the 3:1 non-text contrast threshold required by WCAG 2.2 SC 1.4.11. SWC-1193 recorded this as a case where meeting 1.4.11 may not be achievable given variable background content and Spectrum visual intent, leaving it as a practical-limits exception. + +**Resolution for 2nd-gen (RSP-2021, SDS-16402):** An adaptive dual-border approach has been specified and prototyped ([color-area-adaptive-borders.zip](https://jira.corp.adobe.com/secure/attachment/18750666/18750666_color-area-adaptive-borders.zip)) that achieves 3:1 across every color in the HSV cube. The prototype demonstrates four border modes — current static 42% opacity, 100% static, and two adaptive modes (white-first and live α) — with live contrast readouts. The two adaptive modes are the reference for 2nd-gen: + +1. **Sample** the 5px ring surrounding the loupe chrome. Classify each sample as gradient adjacency or page-background adjacency (when the loupe overhangs a gradient edge). +2. **Dark border check (drives α):** composite rgba(0,0,0,α) over the adjacency color; α starts at 42% and increases until 3:1 is reached. +3. **White separator check (additive):** measure contrast(#FFFFFF, adjacency). Does not lower α; provides an independent pass path. +4. **Bulletproof rule:** chrome is visible if every present adjacency has either its dark border or its white separator pass 3:1. + +2nd-gen should implement adaptive border opacity following this specification. The practical-limits exception recorded in SWC-1193 is superseded by the conformant solution in RSP-2021 and SDS-16402. + +--- + +## Implementation: loupe adaptive borders + +See [Color handle implementation: adaptive dual-border algorithm](../color-handle/accessibility-migration-analysis.md#implementation-adaptive-dual-border-algorithm) for the shared contrast utilities, ring sampler, alpha computation, and visibility verdict. The loupe uses the same α value computed from the handle's ring sample; it does not run its own independent sample. + +### Loupe SVG rendering + +The loupe's border structure differs from the handle. Rather than two dark rings, it uses a **white outer halo** that separates the loupe shape from the underlying gradient, and a **single inner dark border** that contrasts against the white. This keeps the loupe visually lighter than a double-dark-border approach. + +```html + + + + + + + + +``` + +**Prototype dimensions:** outer teardrop bulb radius 23 px, inner teardrop bulb radius 18 px (5 px white separator each side), overall shape 48 × 64 px visible. + +### When the loupe is shown + +The loupe appears only when the parent handle is in the active state (focused or being dragged) — matching the 1st-gen behavior where `open` is set on touch input to prevent the finger obscuring the selected color. In the inactive state, only the handle circles are rendered; the loupe SVG group is omitted. + +### Teardrop path construction + +The loupe shape is a teardrop: a circle for the bulb, tapering to a point at the bottom (the pin that touches the handle). Build it as a closed SVG path using cubic Bézier curves approximating the circular arc and a symmetric taper. The prototype constructs this with `teardropPath(tipY, bulbCy, bulbR)` — see [color-area-adaptive-borders.zip](https://jira.corp.adobe.com/secure/attachment/18750666/18750666_color-area-adaptive-borders.zip) for the reference implementation. --- @@ -122,7 +174,7 @@ None |--------------|----------------| | **Unit** | **`open`** / **`color`** do **not** break **parent** **label** associations; **SVG** **`aria-hidden`** matches implementation. | | **aXe + Storybook** | Run on **composite** stories (loupe **+** **field** / **sliders**), **not** only **isolated** loupe frames. | -| **Contrast** | Where **policy** requires, measure **loupe** **chrome** at **representative** backgrounds; **document** **gaps** **per** **SWC-1193** when **3:1** is **not** met. | +| **Contrast** | Verify that the adaptive dual-border approach meets 3:1 on loupe chrome at representative gradient colors and at gradient edges against the page background, per RSP-2021 and SDS-16402. | | **Integration** | **E2E** / **snapshots** use **realistic** **pages**: **labels**, **focus order**, and **values** on **surrounding** **controls**. | --- @@ -132,8 +184,8 @@ None - [ ] **Stories** place the loupe **in** **real** **picker** **contexts** with **accessible** **siblings**, not **only** the loupe **on** **gray** **canvas**. - [ ] **Copy** is **descriptive**; **no** **token-only** **labels** as the **sole** **accessible** **name** for **user-facing** **steps**. - [ ] **Color field** (or **equivalent**) **owns** **name**, **value**, and **keyboard**; loupe does **not** **stand** **in** for **that**. -- [ ] **SWC-1193** **position** on **1.4.11** is **reflected** in **internal** **audit** / **release** **notes** where **needed**. -- [ ] **Contrast** **measurements** for **loupe** **chrome** are **attempted** on **key** **themes**; **failures** are **known** **issues**, **not** **silent**. +- [ ] Adaptive dual-border opacity is implemented on loupe chrome; 3:1 non-text contrast is verified across representative background colors and at gradient edges, per RSP-2021 and SDS-16402. +- [ ] Contrast measurements for loupe chrome confirm the adaptive approach meets 3:1 across the HSV cube; SWC-1193 practical-limits exception is superseded. --- @@ -142,5 +194,8 @@ None - [WCAG 2.2 Success Criterion 1.4.11: Non-text contrast (understanding)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast) - [WCAG 2.2](https://www.w3.org/TR/WCAG22/) - [Using ARIA (read this first)](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/) -- [SWC-1193](https://jira.corp.adobe.com/browse/SWC-1193) (Adobe internal Jira—non-text contrast / realistic constraints for color loupe) +- [SWC-1193](https://jira.corp.adobe.com/browse/SWC-1193) (Adobe internal Jira — non-text contrast / practical-limits exception for color loupe, superseded by adaptive approach) +- [RSP-2021](https://jira.corp.adobe.com/browse/RSP-2021) (Adobe internal Jira — adaptive border contrast specification for color loupe) +- [SDS-16402](https://jira.corp.adobe.com/browse/SDS-16402) (Adobe internal Jira — Spectrum Design System adaptive border specification) +- [color-area-adaptive-borders.zip](https://jira.corp.adobe.com/secure/attachment/18750666/18750666_color-area-adaptive-borders.zip) — interactive prototype demonstrating all four border modes with live contrast readouts - [Color field migration roadmap](../color-field/rendering-and-styling-migration-analysis.md) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/README.md b/CONTRIBUTOR-DOCS/03_project-planning/README.md index 4bbf025e26c..1429190f725 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/README.md @@ -39,6 +39,7 @@ - Checkbox - Close Button - Color Field + - Color Handle - Color Loupe - Divider - Dropzone From 37f3fd88ed4c4f00c194e85a8fb8d4b4090b08b4 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 12 Jun 2026 19:54:05 -0400 Subject: [PATCH 16/32] fix(core): fixing another failing a11y test --- .../selection-controller/src/selection-controller.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 028bb706937..f94dff83cc8 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -401,8 +401,12 @@ export class SelectionController implements ReactiveController { */ public refresh(): void { const eligible = this.getEligibleItems(); + // An item is stale when it is disconnected, or when the eligible list is + // non-empty and does not include it. When eligible is empty (e.g. the host + // signals it is disabled via getItems()→[]), only disconnected items are + // removed — connected selections are preserved so aria-selected stays true. const stale = Array.from(this.selectedItems).filter( - (el) => !el.isConnected || !eligible.includes(el) + (el) => !el.isConnected || (eligible.length > 0 && !eligible.includes(el)) ); if (stale.length > 0) { From db0bffb130476a6f2b89516813bf140e6d6c1d86 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 10:07:14 -0400 Subject: [PATCH 17/32] docs(core): fix selection controller stories --- .../stories/selection-controller.stories.ts | 1 - 2nd-gen/packages/swc/package.json | 8 ++++---- .../packages/tools/vite-global-elements-css/package.json | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts index 4f397a2d88a..4ef337e99ad 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts @@ -353,4 +353,3 @@ export const TablistWithFocusgroup: Story = { `, tags: ['appendix'], }; - diff --git a/2nd-gen/packages/swc/package.json b/2nd-gen/packages/swc/package.json index 60c552cfecc..dc8ebdc037d 100644 --- a/2nd-gen/packages/swc/package.json +++ b/2nd-gen/packages/swc/package.json @@ -27,14 +27,14 @@ "types": "./dist/*.css.d.ts", "default": "./dist/*.css" }, - "./components/*.js": { - "types": "./dist/components/*.d.ts", - "default": "./dist/components/*.js" - }, "./components/*": { "types": "./dist/components/*/index.d.ts", "import": "./dist/components/*/index.js" }, + "./components/*.js": { + "types": "./dist/components/*.d.ts", + "default": "./dist/components/*.js" + }, "./global-button.css": { "default": "./dist/global-button.css" }, diff --git a/2nd-gen/packages/tools/vite-global-elements-css/package.json b/2nd-gen/packages/tools/vite-global-elements-css/package.json index f161dd0382b..f37c1522513 100644 --- a/2nd-gen/packages/tools/vite-global-elements-css/package.json +++ b/2nd-gen/packages/tools/vite-global-elements-css/package.json @@ -19,11 +19,11 @@ }, "type": "module", "main": "index.js", - "types": "index.d.ts", "scripts": { "test": "vitest --run", "test:watch": "vitest --watch" }, + "types": "index.d.ts", "dependencies": { "postcss": "8.5.9" }, From 9f358ff00998d29b471b6876d16ee69fc06ceb51 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 10:54:59 -0400 Subject: [PATCH 18/32] fix(core): enforcing a selection with disabled host with radio type --- .../core/components/tabs/Tabs.base.ts | 26 +++++++++++++---- .../src/selection-controller.ts | 29 ++++++++++++++++--- 2nd-gen/packages/swc/.storybook/preview.ts | 11 ++++++- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/2nd-gen/packages/core/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index 619fa7e706b..231bdefff77 100644 --- a/2nd-gen/packages/core/components/tabs/Tabs.base.ts +++ b/2nd-gen/packages/core/components/tabs/Tabs.base.ts @@ -293,11 +293,13 @@ export abstract class TabsBase extends SpectrumElement { * because the tab is already selected (`single` mode ignores * re-clicks on the active item). * - * `getItems` returns an empty array when the container is disabled, - * which prevents any selection interaction while disabled. + * `getItems` always returns the full tab list so the controller can + * track selection and apply `defaultToFirstSelectable` even when the + * container is disabled. User interaction is blocked via + * `confirmSelectionChange` when `disabled` is `true`. */ private readonly _selection = new SelectionController(this, { - getItems: () => (this.disabled ? [] : (this._tabs as HTMLElement[])), + getItems: () => this._tabs as HTMLElement[], selectItem: (item) => { (item as TabLike).selected = true; }, @@ -305,6 +307,7 @@ export abstract class TabsBase extends SpectrumElement { (item as TabLike).selected = false; }, mode: 'single', + defaultToFirstSelectable: true, keydownActivation: true, onSelectionChange: ({ selectedItems }) => { const newTab = selectedItems[0] as TabLike | undefined; @@ -317,6 +320,9 @@ export abstract class TabsBase extends SpectrumElement { if (this._suppressSelectionChangeEvent) { return true; } + if (this.disabled) { + return false; + } return this.dispatchEvent(new Event('change', { cancelable: true })); }, }); @@ -350,7 +356,14 @@ export abstract class TabsBase extends SpectrumElement { // Refresh navigation first so its eligible-items cache is populated // before _syncSelectionController calls setActiveItem. this._navigation.refresh(); + // Sync any externally-set selection before refresh so a pre-selected tab + // wins over the defaultToFirstSelectable default. this._syncSelectionController(); + // Refresh without firing a change event: populates lastKnownRawItems and + // applies defaultToFirstSelectable when no tab was pre-selected. + this._suppressSelectionChangeEvent = true; + this._selection.refresh(); + this._suppressSelectionChangeEvent = false; this.updateSelectionIndicator(); } @@ -534,10 +547,13 @@ export abstract class TabsBase extends SpectrumElement { } if (changes.has('disabled') && this._tabs.length) { - // getItems already returns [] when disabled; refreshing both - // controllers re-applies roving tabindex accordingly. + // Refreshing both controllers re-applies roving tabindex accordingly. this._navigation.refresh(); + // Suppress change events: toggling disabled must not look like a + // user-initiated selection change to consumers. + this._suppressSelectionChangeEvent = true; this._selection.refresh(); + this._suppressSelectionChangeEvent = false; // After re-enabling, restore the roving tab stop to the selected tab // (refresh() defaults to items[0] when no item has tabindex=0). if (!this.disabled) { diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index f94dff83cc8..6e4141ce3ac 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -176,6 +176,13 @@ export class SelectionController implements ReactiveController { private selectedItems: Set = new Set(); + /** + * Snapshot of the last non-empty raw item list. Used to support + * `defaultToFirstSelectable` and stale-item cleanup when `getItems()` + * returns `[]` (e.g. the host signals it is disabled). + */ + private lastKnownRawItems: HTMLElement[] = []; + private keydownListenerAttached = false; private readonly handleClickCapture = (event: MouseEvent): void => { @@ -400,7 +407,14 @@ export class SelectionController implements ReactiveController { * (single-item modes only). */ public refresh(): void { - const eligible = this.getEligibleItems(); + // Call getScopedRawItems() once and derive eligible from it so we avoid a + // redundant getItems() call inside getEligibleItems(). + const rawItems = this.getScopedRawItems(); + if (rawItems.length > 0) { + this.lastKnownRawItems = rawItems; + } + const eligible = rawItems.filter((el) => this.isSelectableItem(el)); + // An item is stale when it is disconnected, or when the eligible list is // non-empty and does not include it. When eligible is empty (e.g. the host // signals it is disabled via getItems()→[]), only disconnected items are @@ -421,10 +435,17 @@ export class SelectionController implements ReactiveController { if ( isSingle && this.options.defaultToFirstSelectable && - this.selectedItems.size === 0 && - eligible.length > 0 + this.selectedItems.size === 0 ) { - this.applySelectionTransition([eligible[0]], []); + // For radio types one item must be selected even when the host is disabled. + // Use the eligible list when available; fall back to the last known raw + // items when getItems() returns [] (host disabled) so the first item + // is still selected. + const candidates = + eligible.length > 0 ? eligible : this.lastKnownRawItems; + if (candidates.length > 0) { + this.applySelectionTransition([candidates[0]], []); + } } } diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index e6bf6551b9b..01c380051bf 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -260,6 +260,7 @@ const preview = { 'Spectrum SWC migration', 'Anti patterns', 'Property order quick reference', + 'Stylesheets', ], 'TypeScript', [ @@ -336,7 +337,10 @@ const preview = { 'Rendering and styling migration analysis', ], 'Action group', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Action menu', [ 'Accessibility migration analysis', @@ -379,6 +383,11 @@ const preview = { ], 'Color field', ['Rendering and styling migration analysis'], + 'Color handle', + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Color loupe', [ 'Accessibility migration analysis', From aafc2900a8e4844974995adfe937bf8ba01bd917 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 11:44:21 -0400 Subject: [PATCH 19/32] revert(claude): remove accidentally committed settings file Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 4326985ea57..00000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "permissions": { - "allow": ["Bash(npx tsc *)", "Bash(echo \"Exit code: $?\")"] - } -} From 7ec56a77157581be6d8fc8d52a66553fdefb4589 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 15 Jun 2026 12:08:49 -0400 Subject: [PATCH 20/32] chore(global-button): sync with main Co-Authored-By: Claude Sonnet 4.6 --- .../swc/stylesheets/global/global-button.css | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/2nd-gen/packages/swc/stylesheets/global/global-button.css b/2nd-gen/packages/swc/stylesheets/global/global-button.css index 147a9757140..e0e323f0091 100644 --- a/2nd-gen/packages/swc/stylesheets/global/global-button.css +++ b/2nd-gen/packages/swc/stylesheets/global/global-button.css @@ -60,6 +60,17 @@ outline-offset: token("focus-indicator-gap"); } +.swc-Button:disabled:is(*, :hover) { + color: var(--swc-button-content-color-disabled, token("disabled-content-color")); + background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); + border-color: var(--swc-button-border-color-disabled, transparent); + transform: none; +} + +.swc-Button--disabled .swc-Button-icon { + pointer-events: none; +} + .swc-Button:hover { color: var(--swc-button-content-color-hover, token("gray-25")); background-color: var(--swc-button-background-color-hover, token("neutral-background-color-hover")); @@ -74,13 +85,6 @@ will-change: transform; } -.swc-Button:disabled:is(*, :hover) { - color: var(--swc-button-content-color-disabled, token("disabled-content-color")); - background-color: var(--swc-button-background-color-disabled, token("disabled-background-color")); - border-color: var(--swc-button-border-color-disabled, transparent); - transform: none; -} - .swc-Button-label { text-align: center; } From 28707047737562c9e62f2f7c218341e7ee4376f4 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 12:08:26 -0400 Subject: [PATCH 21/32] fix(core): updated selection controller based on PR feedback --- .../src/focusgroup-navigation-controller.ts | 9 ++ .../selection-controller.mdx | 28 +++---- .../src/selection-controller.ts | 84 +++++++++++-------- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts index 1b744fa5a79..eec97909150 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts @@ -380,6 +380,11 @@ export class FocusgroupNavigationController implements ReactiveController { * @returns False if `item` is not in the current eligible item list. */ public setActiveItem(item: HTMLElement): boolean { + // Clear the eligible-items cache so this reflects the current DOM rather + // than a stale snapshot left behind by a previous refresh()/keydown/focusin + // cycle. Public entry points must not depend on caller ordering. + this.cachedEligibleItems = null; + this.cachedRows = null; const items = this.getEligibleItems(); if (!items.includes(item)) { return false; @@ -414,6 +419,10 @@ export class FocusgroupNavigationController implements ReactiveController { if (trimmed === '') { return false; } + // Same staleness concern as setActiveItem(): recompute rather than trust a + // cache populated by a previous cycle. + this.cachedEligibleItems = null; + this.cachedRows = null; const needle = trimmed.toLowerCase(); const items = this.getEligibleItems(); const match = items.find((el) => { diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx index 6f7035776db..e20a1206904 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -25,7 +25,7 @@ import * as SelectionControllerStories from './stories/selection-controller.stor ### Programmatic API -- **`setSelectedItem(item | null)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only). +- **`setSelectedItem(item | null, options?)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only). Pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event — for resyncing internal state from an external property change. - **`toggleItem(item)`**: toggle using the current mode rules. - **`selectAll()`**: select all eligible items (multiple mode only). - **`clearAll()`**: deselect all items (single-toggle and multiple modes). @@ -211,19 +211,19 @@ For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host ### Methods -| Member | Description | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes and calls `refresh()`. | -| `refresh()` | Remove stale selections and optionally select the first item (`defaultToFirstSelectable`). | -| `getSelectedItems()` | Returns the current selection as an ordered array. | -| `isSelected(item)` | Returns `true` when `item` is currently selected. | -| `getMode()` | Returns the current `SelectionMode`. | -| `setSelectedItem(item \| null)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes). | -| `toggleItem(item)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | -| `selectAll()` | Select all eligible items. Multiple mode only; returns `false` otherwise. | -| `clearAll()` | Deselect all items. Single-toggle and multiple modes; returns `false` in `single` mode. | -| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) and calls `refresh()`. | -| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | +| Member | Description | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes and calls `refresh()`. | +| `refresh(options?)` | Remove stale selections and optionally select the first eligible item (`defaultToFirstSelectable`). Pass `{ silent: true }` to skip `confirmSelectionChange` and the change event. | +| `getSelectedItems()` | Returns the current selection as an ordered array. | +| `isSelected(item)` | Returns `true` when `item` is currently selected. | +| `getMode()` | Returns the current `SelectionMode`. | +| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes). Pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event — for resyncing from an external property change. | +| `toggleItem(item)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | +| `selectAll()` | Select all eligible items. Multiple mode only; returns `false` otherwise. | +| `clearAll()` | Deselect all items. Single-toggle and multiple modes; returns `false` in `single` mode. | +| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) and calls `refresh()`. | +| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | ### Events diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 6e4141ce3ac..1ecc40e5398 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -77,7 +77,9 @@ export type SelectionControllerOptions = { /** * Called **before** **`selectItem`** / **`deselectItem`** run for the proposed transition. * Return **`false`** to abort — mutators do not run, the selection stays unchanged, and no - * change event is dispatched. Omit for unconditional commits. + * change event is dispatched. Omit for unconditional commits. Not called for transitions + * committed with **`{ silent: true }`** (see {@link SelectionController.setSelectedItem} and + * {@link SelectionController.refresh}). */ confirmSelectionChange?: ( detail: SelectionControllerConfirmDetail @@ -176,13 +178,6 @@ export class SelectionController implements ReactiveController { private selectedItems: Set = new Set(); - /** - * Snapshot of the last non-empty raw item list. Used to support - * `defaultToFirstSelectable` and stale-item cleanup when `getItems()` - * returns `[]` (e.g. the host signals it is disabled). - */ - private lastKnownRawItems: HTMLElement[] = []; - private keydownListenerAttached = false; private readonly handleClickCapture = (event: MouseEvent): void => { @@ -303,8 +298,15 @@ export class SelectionController implements ReactiveController { * * Passing **`null`** clears the selection; only works when the mode allows an empty selection * (**`single-toggle`** or **`multiple`**). + * + * Pass **`{ silent: true }`** to commit this transition without invoking + * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to + * resync internal state from an external property change without raising a public event. */ - public setSelectedItem(item: HTMLElement | null): boolean { + public setSelectedItem( + item: HTMLElement | null, + options?: { silent?: boolean } + ): boolean { if (item === null) { if (this.options.mode === 'single') { return false; @@ -324,12 +326,12 @@ export class SelectionController implements ReactiveController { return true; } const next = [...Array.from(this.selectedItems), item]; - return this.applySelectionTransition(next, []); + return this.applySelectionTransition(next, [], options); } const prev = Array.from(this.selectedItems); const toRemove = prev.filter((el) => el !== item); - return this.applySelectionTransition([item], toRemove); + return this.applySelectionTransition([item], toRemove, options); } /** @@ -404,21 +406,20 @@ export class SelectionController implements ReactiveController { /** * Re-applies bookkeeping after structural changes: removes stale selections, and selects the * first eligible item when **`defaultToFirstSelectable`** is **`true`** and nothing is selected - * (single-item modes only). + * (single-item modes only). When nothing is currently eligible, no default selection is forced. + * + * Pass **`{ silent: true }`** to commit these transitions without invoking + * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to + * resync internal state from an external property change without raising a public event. */ - public refresh(): void { - // Call getScopedRawItems() once and derive eligible from it so we avoid a - // redundant getItems() call inside getEligibleItems(). - const rawItems = this.getScopedRawItems(); - if (rawItems.length > 0) { - this.lastKnownRawItems = rawItems; - } - const eligible = rawItems.filter((el) => this.isSelectableItem(el)); + public refresh(options?: { silent?: boolean }): void { + const silent = options?.silent ?? false; + const eligible = this.getEligibleItems(); // An item is stale when it is disconnected, or when the eligible list is // non-empty and does not include it. When eligible is empty (e.g. the host - // signals it is disabled via getItems()→[]), only disconnected items are - // removed — connected selections are preserved so aria-selected stays true. + // signals it is disabled), only disconnected items are removed — connected + // selections are preserved so aria-selected stays true. const stale = Array.from(this.selectedItems).filter( (el) => !el.isConnected || (eligible.length > 0 && !eligible.includes(el)) ); @@ -427,7 +428,7 @@ export class SelectionController implements ReactiveController { const next = Array.from(this.selectedItems).filter( (el) => !stale.includes(el) ); - this.applySelectionTransition(next, stale); + this.applySelectionTransition(next, stale, { silent }); } const isSingle = @@ -435,17 +436,10 @@ export class SelectionController implements ReactiveController { if ( isSingle && this.options.defaultToFirstSelectable && - this.selectedItems.size === 0 + this.selectedItems.size === 0 && + eligible.length > 0 ) { - // For radio types one item must be selected even when the host is disabled. - // Use the eligible list when available; fall back to the last known raw - // items when getItems() returns [] (host disabled) so the first item - // is still selected. - const candidates = - eligible.length > 0 ? eligible : this.lastKnownRawItems; - if (candidates.length > 0) { - this.applySelectionTransition([candidates[0]], []); - } + this.applySelectionTransition([eligible[0]], [], { silent }); } } @@ -502,8 +496,10 @@ export class SelectionController implements ReactiveController { */ private applySelectionTransition( next: HTMLElement[], - removedItems: HTMLElement[] + removedItems: HTMLElement[], + options?: { silent?: boolean } ): boolean { + const silent = options?.silent ?? false; const priorItems = Array.from(this.selectedItems); const addedItems = next.filter((el) => !this.selectedItems.has(el)); @@ -511,7 +507,7 @@ export class SelectionController implements ReactiveController { return true; } - if (this.options.confirmSelectionChange) { + if (!silent && this.options.confirmSelectionChange) { const ok = this.options.confirmSelectionChange({ candidateItems: next, priorItems, @@ -524,7 +520,10 @@ export class SelectionController implements ReactiveController { this.applyMutators(next); this.selectedItems = new Set(next); - this.dispatchChange({ selectedItems: next, addedItems, removedItems }); + this.dispatchChange( + { selectedItems: next, addedItems, removedItems }, + silent + ); return true; } @@ -543,8 +542,19 @@ export class SelectionController implements ReactiveController { } } - private dispatchChange(detail: SelectionControllerChangeDetail): void { + /** + * Invokes **`onSelectionChange`** (always) and dispatches {@link selectionControllerChange} + * on the host, unless **`silent`** is **`true`** — used by silent commits to keep internal + * mirroring callbacks in sync without raising the public event. + */ + private dispatchChange( + detail: SelectionControllerChangeDetail, + silent: boolean + ): void { this.options.onSelectionChange?.(detail); + if (silent) { + return; + } this.host.dispatchEvent( new CustomEvent( selectionControllerChange, From b652aa4b9031710dd33d1a0d34d595eefaeec406 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 12:08:57 -0400 Subject: [PATCH 22/32] fix(tabs): updated tabs based on PR feedback --- .changeset/tabs-change-event-ordering.md | 10 +++++ .../core/components/tabs/Tabs.base.ts | 37 +++++-------------- .../swc/components/tabs/migration-guide.mdx | 27 ++++++++++++++ .../03_components/tabs/migration-plan.md | 1 + 4 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 .changeset/tabs-change-event-ordering.md diff --git a/.changeset/tabs-change-event-ordering.md b/.changeset/tabs-change-event-ordering.md new file mode 100644 index 00000000000..908aa36c5cf --- /dev/null +++ b/.changeset/tabs-change-event-ordering.md @@ -0,0 +1,10 @@ +--- +'@adobe/spectrum-wc': minor +--- + +**fix(tabs):** Addressed review feedback on the `SelectionController` / `FocusgroupNavigationController` integration in ``. + +- **Breaking:** the `change` event now fires _before_ selection state (``'s `selected` property and each ``'s `selected` property) is updated, instead of after. `preventDefault()` still blocks the transition; listeners that read `selected` from the event target or a tab to determine the newly selected tab must read the interacted tab directly instead. See the [Tabs migration guide](../2nd-gen/packages/swc/components/tabs/migration-guide.mdx#behavior-changes). +- `SelectionController.setSelectedItem()` and `.refresh()` accept a new `{ silent: true }` option to commit a selection transition without invoking `confirmSelectionChange` or dispatching the controller's change event, replacing an internal ad hoc suppression flag in `Tabs.base.ts` that could get stuck if a call threw. +- `FocusgroupNavigationController.setActiveItem()` and `.focusFirstItemByTextPrefix()` no longer depend on `refresh()` having run first in the same cycle; they now always recompute eligible items instead of trusting a cache that could be stale. +- `SelectionController`'s `defaultToFirstSelectable` no longer infers "host disabled" from an empty `getItems()` result; it only selects from the current eligible set. diff --git a/2nd-gen/packages/core/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index d89a29a665c..6a96478182b 100644 --- a/2nd-gen/packages/core/components/tabs/Tabs.base.ts +++ b/2nd-gen/packages/core/components/tabs/Tabs.base.ts @@ -247,16 +247,6 @@ export abstract class TabsBase extends SpectrumElement { */ private _tabs: TabLike[] = []; - /** - * @internal - * - * When `true`, the next `confirmSelectionChange` call returns `true` - * without dispatching a `change` event. Used to sync the - * SelectionController's internal state from external property changes - * without raising a user-facing event. - */ - private _suppressSelectionChangeEvent = false; - // ────────────────────────────── // REACTIVE CONTROLLERS // ────────────────────────────── @@ -317,9 +307,6 @@ export abstract class TabsBase extends SpectrumElement { } }, confirmSelectionChange: () => { - if (this._suppressSelectionChangeEvent) { - return true; - } if (this.disabled) { return false; } @@ -353,17 +340,13 @@ export abstract class TabsBase extends SpectrumElement { .assignedElements() .filter(TabsBase.isTabSlotNode) as TabLike[]; - // Refresh navigation first so its eligible-items cache is populated - // before _syncSelectionController calls setActiveItem. this._navigation.refresh(); // Sync any externally-set selection before refresh so a pre-selected tab // wins over the defaultToFirstSelectable default. this._syncSelectionController(); - // Refresh without firing a change event: populates lastKnownRawItems and - // applies defaultToFirstSelectable when no tab was pre-selected. - this._suppressSelectionChangeEvent = true; - this._selection.refresh(); - this._suppressSelectionChangeEvent = false; + // Refresh without firing a change event: applies defaultToFirstSelectable + // when no tab was pre-selected. + this._selection.refresh({ silent: true }); this.updateSelectionIndicator(); } @@ -392,9 +375,9 @@ export abstract class TabsBase extends SpectrumElement { const selectedTab = this._tabs.find((t) => t.tabId === this.selected); if (selectedTab) { - this._suppressSelectionChangeEvent = true; - this._selection.setSelectedItem(selectedTab as HTMLElement); - this._suppressSelectionChangeEvent = false; + this._selection.setSelectedItem(selectedTab as HTMLElement, { + silent: true, + }); // Also sync the roving tab stop so focus() and Tab re-entry target // the selected tab rather than the first item in the list. this._navigation.setActiveItem(selectedTab as HTMLElement); @@ -565,11 +548,9 @@ export abstract class TabsBase extends SpectrumElement { if (changes.has('disabled') && this._tabs.length) { // Refreshing both controllers re-applies roving tabindex accordingly. this._navigation.refresh(); - // Suppress change events: toggling disabled must not look like a - // user-initiated selection change to consumers. - this._suppressSelectionChangeEvent = true; - this._selection.refresh(); - this._suppressSelectionChangeEvent = false; + // Silent: toggling disabled must not look like a user-initiated + // selection change to consumers. + this._selection.refresh({ silent: true }); // After re-enabling, restore the roving tab stop to the selected tab // (refresh() defaults to items[0] when no item has tabindex=0). if (!this.disabled) { diff --git a/2nd-gen/packages/swc/components/tabs/migration-guide.mdx b/2nd-gen/packages/swc/components/tabs/migration-guide.mdx index 3e2aff3c8bb..38979a1ba05 100644 --- a/2nd-gen/packages/swc/components/tabs/migration-guide.mdx +++ b/2nd-gen/packages/swc/components/tabs/migration-guide.mdx @@ -67,6 +67,32 @@ import '@adobe/spectrum-wc/components/tabs/swc-tab-panel.js'; | `rovingTabindexController` field | Removed; keyboard navigation is internal | | `focusElement` getter | Removed; use `tabsEl.focus()` | +### Behavior changes + +
+ ⚠️ Breaking change. The change event now fires{' '} + before selection state updates. In Spectrum 1,{' '} + {''} set selected to the new value and then + dispatched change (reverting selected if{' '} + preventDefault() was called). In Spectrum 2, change{' '} + dispatches first; {''}'s selected property + and each {''}'s selected property are still + the previous selection while your listener runs.{' '} + preventDefault() still blocks the change. If your listener reads{' '} + event.target.selected or a tab's selected property + to get the newly selected tab, read the interacted tab directly instead (for + example the tab that received the click or keypress), or defer the read to a + microtask/next render. +
+ ## Update your code ### 1. Rename the tags and attributes @@ -214,3 +240,4 @@ Spectrum 2 uses a different custom property prefix. Spectrum 1 overrides will no - [ ] Replace `direction="vertical-right"` with `direction="vertical"` - [ ] Replace `label` on icon-only `` with `aria-label` on `` - [ ] Replace `--mod-tabs-*` CSS overrides with `--swc-tabs-*` / `--swc-tab-*` equivalents +- [ ] Update `change` listeners that read `selected` from the event target or a tab: that state is now pre-transition inside the listener (see [Behavior changes](#behavior-changes)) diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md index 636b570ea7b..15996d42b74 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md @@ -396,6 +396,7 @@ This full modifier surface will not be carried forward to 2nd-gen. Consumers mus |---|---|---|---|---| | **B19** | `slot="tab-panel"` auto-assignment | `TabPanel.firstUpdated()` sets `this.slot = 'tab-panel'` programmatically. Consumers do **not** write `slot="tab-panel"` on ``. | TBD — if 2nd-gen changes slotting, consumers may need explicit `slot` attributes. | Verify panel slotting works without explicit `slot` attribute; if changed, add `slot="tab-panel"` to all `` elements. | | **B20** | TabPanel focus-in/out tabindex management | `TabPanel` removes its own `tabindex` on `focusin` (so it doesn't intercept Tab when content inside the panel has focus), then restores `tabindex` on `focusout`. | 2nd-gen must replicate this behavior to avoid Tab-key trapping inside panels. If omitted, keyboard navigation through panel content changes. | No consumer action if behavior is preserved. If changed, consumers with complex panel content should verify Tab key behavior. | +| **B27** | `change` event now fires before selection state updates | `Tabs.selectTarget()` sets `this.selected` to the new value **before** dispatching the cancelable `change` event; on `preventDefault()` it reverts `this.selected` back. | `SelectionController.applySelectionTransition` calls the consumer's `confirmSelectionChange` (which dispatches `change`) **before** running `selectItem`/`deselectItem` mutators or updating the public `selected` property. A `change` listener now sees the **pre-transition** state on `event.target.selected` and on each ``'s `selected` property; `preventDefault()` still blocks the transition, but there is no longer a revert step because nothing was applied yet. | Consumers reading `event.target.selected` or a tab's `selected` property inside a `change` listener to get the *new* value must instead read the tab that was clicked/activated (e.g. `event.target.querySelector('[aria-selected="true"]')` after the listener returns, or track the interacted element directly). | ### Additive — ships when ready, zero breakage for consumers already on 2nd-gen From ba5c257d241dcd2e1af06c9d7b649975c6ba0c5c Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 15:06:04 -0400 Subject: [PATCH 23/32] feat(accordion): refactored accordion to use selection controller --- .../components/accordion/Accordion.base.ts | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/2nd-gen/packages/core/components/accordion/Accordion.base.ts b/2nd-gen/packages/core/components/accordion/Accordion.base.ts index 34bd66070a4..b85a586f4ba 100644 --- a/2nd-gen/packages/core/components/accordion/Accordion.base.ts +++ b/2nd-gen/packages/core/components/accordion/Accordion.base.ts @@ -13,6 +13,7 @@ import { PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; +import { SelectionController } from '@spectrum-web-components/core/controllers/index.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; @@ -102,6 +103,34 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { .filter((el): el is AccordionItemBase => el instanceof AccordionItemBase); } + /** + * @internal + * + * Applies the exclusive-open constraint when `allowMultiple` is false, in + * place of manually iterating `assignedItems()`. Only ever driven imperatively + * from `closeSiblingsOnOpen` with `{ silent: true }` — items keep their own + * click handling and cancelable-toggle lifecycle in `AccordionItemBase.toggle()` + * unchanged. `getItems` is not used for interaction (this controller's own + * capture-phase click/keydown handling never applies: `confirmSelectionChange` + * unconditionally rejects it), only for `applyMutators`' live scan of every + * assigned item when a silent transition is asserted. + * + * Known limitation: an item opened via a direct `open` property/attribute set + * (bypassing `toggle()`) is not reconciled by this controller, the same way + * `closeSiblingsOnOpen` itself only reacts to toggle-driven changes today. + */ + private readonly _selection = new SelectionController(this, { + getItems: () => this.assignedItems() as HTMLElement[], + selectItem: (item) => { + (item as AccordionItemBase).open = true; + }, + deselectItem: (item) => { + (item as AccordionItemBase).open = false; + }, + mode: 'single-toggle', + confirmSelectionChange: () => false, + }); + private closeSiblingsOnOpen = (event: Event): void => { if (this.disabled) { event.preventDefault(); @@ -120,11 +149,10 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { if (!toggling.open) { return; } - for (const item of this.assignedItems()) { - if (item !== toggling) { - item.open = false; - } - } + // Asserts toggling as the sole selection; applyMutators diffs against a + // live scan of every assigned item, so every other item is closed + // regardless of what this controller previously believed was selected. + this._selection.setSelectedItem(toggling, { silent: true }); }); }; From c6927100e009ba7d4c090bb757de9cf6d17ab942 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 15:17:55 -0400 Subject: [PATCH 24/32] feat(core): refactored selection controller with silent, enable interaction, and disabled checking --- .../components/accordion/Accordion.base.ts | 9 +- .../selection-controller.mdx | 98 ++++++++++---- .../src/selection-controller.ts | 124 ++++++++++++++---- 3 files changed, 176 insertions(+), 55 deletions(-) diff --git a/2nd-gen/packages/core/components/accordion/Accordion.base.ts b/2nd-gen/packages/core/components/accordion/Accordion.base.ts index b85a586f4ba..f8362bc7446 100644 --- a/2nd-gen/packages/core/components/accordion/Accordion.base.ts +++ b/2nd-gen/packages/core/components/accordion/Accordion.base.ts @@ -110,10 +110,9 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { * place of manually iterating `assignedItems()`. Only ever driven imperatively * from `closeSiblingsOnOpen` with `{ silent: true }` — items keep their own * click handling and cancelable-toggle lifecycle in `AccordionItemBase.toggle()` - * unchanged. `getItems` is not used for interaction (this controller's own - * capture-phase click/keydown handling never applies: `confirmSelectionChange` - * unconditionally rejects it), only for `applyMutators`' live scan of every - * assigned item when a silent transition is asserted. + * unchanged. `enableInteraction: false` means this controller never attaches + * its own click/keydown listeners; `getItems` is only used for `applyMutators`' + * live scan of every assigned item when a silent transition is asserted. * * Known limitation: an item opened via a direct `open` property/attribute set * (bypassing `toggle()`) is not reconciled by this controller, the same way @@ -128,7 +127,7 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { (item as AccordionItemBase).open = false; }, mode: 'single-toggle', - confirmSelectionChange: () => false, + enableInteraction: false, }); private closeSiblingsOnOpen = (event: Event): void => { diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx index e20a1206904..25687a8b625 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -25,13 +25,15 @@ import * as SelectionControllerStories from './stories/selection-controller.stor ### Programmatic API -- **`setSelectedItem(item | null, options?)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only). Pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event — for resyncing internal state from an external property change. -- **`toggleItem(item)`**: toggle using the current mode rules. -- **`selectAll()`**: select all eligible items (multiple mode only). -- **`clearAll()`**: deselect all items (single-toggle and multiple modes). +- **`setSelectedItem(item | null, options?)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only). +- **`toggleItem(item, options?)`**: toggle using the current mode rules. +- **`selectAll(options?)`**: select all eligible items (multiple mode only). +- **`clearAll(options?)`**: deselect all items (single-toggle and multiple modes). - **`getSelectedItems()`**: returns the current selection as an ordered array. - **`isSelected(item)`**: check whether a specific item is selected. +All five mutating methods above (plus `refresh()`) accept `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event — for resyncing internal state from an external property change. Silent calls always re-run `selectItem` / `deselectItem` for every current item rather than trusting this controller's cached selection, so they self-correct even if that cache has drifted from reality (see [Bookkeeping-only usage](#bookkeeping-only-usage) below). + ## Basic usage 1. Construct the controller in your element's `constructor`, passing `getItems`, `selectItem`, `deselectItem`, and `mode`. @@ -184,7 +186,7 @@ The `SelectionController` implements several accessibility features: #### Pointer interaction -Capture-phase `click` listener resolves the deepest eligible item in the composed event path. Primary clicks only (button 0, no modifier keys). Disabled and `aria-disabled="true"` items are never toggled. +Capture-phase `click` listener resolves the deepest eligible item in the composed event path. Primary clicks only (button 0, no modifier keys). Disabled and `aria-disabled="true"` items are never toggled. Set `enableInteraction: false` to skip attaching this listener entirely — see [Bookkeeping-only usage](#bookkeeping-only-usage). #### Keyboard interaction @@ -211,19 +213,21 @@ For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host ### Methods -| Member | Description | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes and calls `refresh()`. | -| `refresh(options?)` | Remove stale selections and optionally select the first eligible item (`defaultToFirstSelectable`). Pass `{ silent: true }` to skip `confirmSelectionChange` and the change event. | -| `getSelectedItems()` | Returns the current selection as an ordered array. | -| `isSelected(item)` | Returns `true` when `item` is currently selected. | -| `getMode()` | Returns the current `SelectionMode`. | -| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes). Pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event — for resyncing from an external property change. | -| `toggleItem(item)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | -| `selectAll()` | Select all eligible items. Multiple mode only; returns `false` otherwise. | -| `clearAll()` | Deselect all items. Single-toggle and multiple modes; returns `false` in `single` mode. | -| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) and calls `refresh()`. | -| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | +| Member | Description | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes, re-syncs interaction listeners for `enableInteraction` / `keydownActivation`, and calls `refresh()`. | +| `refresh(options?)` | Remove stale selections and optionally select the first eligible item (`defaultToFirstSelectable`). | +| `getSelectedItems()` | Returns the current selection as an ordered array. | +| `isSelected(item)` | Returns `true` when `item` is currently selected. | +| `getMode()` | Returns the current `SelectionMode`. | +| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes). | +| `toggleItem(item, options?)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | +| `selectAll(options?)` | Select all eligible items. Multiple mode only; returns `false` otherwise. | +| `clearAll(options?)` | Deselect all items. Single-toggle and multiple modes; returns `false` in `single` mode. | +| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) unless `enableInteraction` is `false`, then calls `refresh()`. | +| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | + +`refresh`, `setSelectedItem`, `toggleItem`, `selectAll`, and `clearAll` all accept an `options?: { silent?: boolean }` parameter — pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event. ### Events @@ -240,16 +244,18 @@ host.addEventListener(selectionControllerChange, (event) => { ### Options -| Option | Type | Default | Description | -| -------------------------- | ------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | -| `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | -| `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | -| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | -| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | -| `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the eligible item on the event path. Pair with `FocusgroupNavigationController` for arrow keys. | -| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Callback with the same payload as the change event. | -| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Return `false` to abort the transition before mutators run. | +| Option | Type | Default | Description | +| -------------------------- | ------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | +| `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | +| `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | +| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | +| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | +| `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the eligible item on the event path. Pair with `FocusgroupNavigationController` for arrow keys. | +| `enableInteraction` | `boolean` | `true` | When `false`, this controller never attaches its own click/keydown listeners. Use for bookkeeping-only integrations — see [Bookkeeping-only usage](#bookkeeping-only-usage). | +| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Callback with the same payload as the change event. | +| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Return `false` to abort the transition before mutators run. | +| `isDisabled` | `(item: HTMLElement) => boolean` | — | Override for the eligibility disabled check. Use when disabled state cascades from an ancestor rather than being reflected on the participant itself. | ## Appendix @@ -289,6 +295,42 @@ private readonly tabSelection = new SelectionController(this, { +### Bookkeeping-only usage + +Some composites can't hand click/keydown handling to this controller at all — each item already owns its own interaction (its own click binding, its own cancelable-event lifecycle), and the "selected" state is a public property on the item itself rather than something only this controller ever mutates. An accordion is the canonical case: each `` has an independently, publicly settable `open` property, and clicking a header runs the item's own toggle-and-dispatch-cancelable-event flow before the container ever finds out. + +For that shape, set `enableInteraction: false` so this controller never attaches its own listeners, and drive it imperatively with `{ silent: true }` calls from your own event handler after the real state change has already happened: + +```typescript +private readonly panelSelection = new SelectionController(this, { + getItems: () => this.panels, + selectItem: (panel) => { + (panel as AccordionItem).open = true; + }, + deselectItem: (panel) => { + (panel as AccordionItem).open = false; + }, + mode: 'single-toggle', + enableInteraction: false, +}); + +private handleItemToggle = (event: Event): void => { + const toggling = event.target as AccordionItem; + if (!toggling.open) { + return; + } + // Asserts `toggling` as the sole selection. Mutators are computed from a + // live scan of every item (see "Mutators always reflect a live scan" in + // the class overview above), so every other panel is closed to match — + // regardless of what this controller previously believed was selected, + // including panels whose `open` was set directly rather than through this + // flow. + this.panelSelection.setSelectedItem(toggling, { silent: true }); +}; +``` + +This gets you the mode-aware transition math (`single-toggle` vs. `multiple`, exclusivity on assert) without the controller ever fighting your own interaction handling for control of the click. + ### See also - [Focusgroup navigation controller](../?path=/docs/controllers-focus-group-navigation-controller--docs) diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 1ecc40e5398..72ec0798f14 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -71,6 +71,16 @@ export type SelectionControllerOptions = { */ keydownActivation?: boolean; + /** + * When **`false`**, this controller never attaches its own capture-phase **`click`** / + * **`keydown`** listeners, so user interaction can never drive a transition. Use this when a + * consumer's items already own their own interaction handling (their own click binding, their + * own cancelable-event lifecycle) and the controller is only wanted for its mode-aware + * selection bookkeeping, driven imperatively via **`{ silent: true }`** calls. Defaults to + * **`true`**. + */ + enableInteraction?: boolean; + /** Optional callback mirroring {@link selectionControllerChange}. */ onSelectionChange?: (detail: SelectionControllerChangeDetail) => void; @@ -84,6 +94,15 @@ export type SelectionControllerOptions = { confirmSelectionChange?: ( detail: SelectionControllerConfirmDetail ) => boolean; + + /** + * Optional override for the eligibility disabled check. When provided, called instead of the + * built-in **`disabled`** property / **`aria-disabled="true"`** heuristic. Use this when a + * consumer's disabled state cascades from an ancestor or is computed rather than reflected + * directly on the participant itself (for example, a parent-disabled flag mirrored only onto + * internal shadow DOM, not onto the participant element `getItems` returns). + */ + isDisabled?: (item: HTMLElement) => boolean; }; /** @@ -143,14 +162,26 @@ export function deepestSelectionItemContaining( * - **`multiple`**: any number of items may be selected; clicks toggle individual items. * * This controller handles capture-phase **`click`** and optional capture-phase **`keydown`** - * (**Enter** / **Space**) when **`keydownActivation`** is **`true`**. Pair with - * **`FocusgroupNavigationController`** when the composite also needs roving **`tabindex`**, - * arrow keys, Home/End, or focus memory; this controller does not implement those behaviors. + * (**Enter** / **Space**) when **`keydownActivation`** is **`true`**, unless + * **`enableInteraction`** is **`false`**. Pair with **`FocusgroupNavigationController`** when + * the composite also needs roving **`tabindex`**, arrow keys, Home/End, or focus memory; this + * controller does not implement those behaviors. * * Call **`setOptions({ mode })`** at any time to switch modes without reconstructing the * controller. When switching from **`multiple`** to a single-item mode and more than one item * is currently selected, all but the first item are deselected. * + * **Mutators always reflect a live scan, not the cached selection.** {@link + * SelectionController.applyMutators}, called by every transition, walks a fresh + * `getItems()` result and calls `selectItem` / `deselectItem` for every item based on + * membership in the transition's `next` set — never based on what this controller previously + * believed was selected. This is what makes **`{ silent: true }`** calls (see + * {@link SelectionController.setSelectedItem} and {@link SelectionController.refresh}) safe to + * use for reconciling from participants whose selected-ish state can also change independently + * of this controller (for example, an item with its own public, freely settable `open` + * property): even if this controller's cached notion of the selection has drifted, asserting a + * transition still corrects every participant to match `next`. + * * @see https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ */ @@ -167,17 +198,20 @@ export class SelectionController implements ReactiveController { | 'mode' | 'defaultToFirstSelectable' | 'keydownActivation' + | 'enableInteraction' > >, never > & Pick< SelectionControllerOptions, - 'onSelectionChange' | 'confirmSelectionChange' + 'onSelectionChange' | 'confirmSelectionChange' | 'isDisabled' >; private selectedItems: Set = new Set(); + private clickListenerAttached = false; + private keydownListenerAttached = false; private readonly handleClickCapture = (event: MouseEvent): void => { @@ -226,8 +260,10 @@ export class SelectionController implements ReactiveController { mode: options.mode ?? 'single', defaultToFirstSelectable: options.defaultToFirstSelectable ?? false, keydownActivation: options.keydownActivation ?? false, + enableInteraction: options.enableInteraction ?? true, onSelectionChange: options.onSelectionChange, confirmSelectionChange: options.confirmSelectionChange, + isDisabled: options.isDisabled, }; host.addController(this); @@ -268,6 +304,10 @@ export class SelectionController implements ReactiveController { typeof partial.keydownActivation === 'boolean' ? partial.keydownActivation : this.options.keydownActivation, + enableInteraction: + typeof partial.enableInteraction === 'boolean' + ? partial.enableInteraction + : this.options.enableInteraction, getItems: partial.getItems ?? this.options.getItems, selectItem: partial.selectItem ?? this.options.selectItem, deselectItem: partial.deselectItem ?? this.options.deselectItem, @@ -275,6 +315,7 @@ export class SelectionController implements ReactiveController { partial.onSelectionChange ?? this.options.onSelectionChange, confirmSelectionChange: partial.confirmSelectionChange ?? this.options.confirmSelectionChange, + isDisabled: partial.isDisabled ?? this.options.isDisabled, }; const nextMode = this.options.mode; @@ -288,7 +329,7 @@ export class SelectionController implements ReactiveController { this.applySelectionTransition(candidate, toRemove); } - this.syncKeydownActivationListener(); + this.syncInteractionListeners(); this.refresh(); } @@ -311,7 +352,7 @@ export class SelectionController implements ReactiveController { if (this.options.mode === 'single') { return false; } - return this.clearAll(); + return this.clearAll(options); } if ( @@ -343,8 +384,14 @@ export class SelectionController implements ReactiveController { * - **`multiple`**: always toggles. * * @returns **`false`** when the item is ineligible or when the mode disallows the operation. + * + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or + * dispatching {@link selectionControllerChange}. */ - public toggleItem(item: HTMLElement): boolean { + public toggleItem( + item: HTMLElement, + options?: { silent?: boolean } + ): boolean { if ( !this.getEligibleItems().includes(item) || this.isDisabledParticipant(item) @@ -359,23 +406,26 @@ export class SelectionController implements ReactiveController { return false; } const next = Array.from(this.selectedItems).filter((el) => el !== item); - return this.applySelectionTransition(next, [item]); + return this.applySelectionTransition(next, [item], options); } else { if (this.options.mode === 'multiple') { const next = [...Array.from(this.selectedItems), item]; - return this.applySelectionTransition(next, []); + return this.applySelectionTransition(next, [], options); } const prev = Array.from(this.selectedItems); const toRemove = prev.filter((el) => el !== item); - return this.applySelectionTransition([item], toRemove); + return this.applySelectionTransition([item], toRemove, options); } } /** * Selects all eligible items. Only meaningful in **`multiple`** mode; returns **`false`** in * single-item modes (does not throw). + * + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or + * dispatching {@link selectionControllerChange}. */ - public selectAll(): boolean { + public selectAll(options?: { silent?: boolean }): boolean { if (this.options.mode !== 'multiple') { return false; } @@ -385,14 +435,17 @@ export class SelectionController implements ReactiveController { return true; } const next = [...Array.from(this.selectedItems), ...toAdd]; - return this.applySelectionTransition(next, []); + return this.applySelectionTransition(next, [], options); } /** * Deselects all items. Works in **`single-toggle`** and **`multiple`** modes. In **`single`** * mode, returns **`false`** and leaves selection unchanged. + * + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or + * dispatching {@link selectionControllerChange}. */ - public clearAll(): boolean { + public clearAll(options?: { silent?: boolean }): boolean { if (this.options.mode === 'single') { return false; } @@ -400,7 +453,7 @@ export class SelectionController implements ReactiveController { return true; } const toRemove = Array.from(this.selectedItems); - return this.applySelectionTransition([], toRemove); + return this.applySelectionTransition([], toRemove, options); } /** @@ -444,13 +497,15 @@ export class SelectionController implements ReactiveController { } public hostConnected(): void { - this.host.addEventListener('click', this.handleClickCapture, true); - this.syncKeydownActivationListener(); + this.syncInteractionListeners(); this.refresh(); } public hostDisconnected(): void { - this.host.removeEventListener('click', this.handleClickCapture, true); + if (this.clickListenerAttached) { + this.host.removeEventListener('click', this.handleClickCapture, true); + this.clickListenerAttached = false; + } if (this.keydownListenerAttached) { this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); this.keydownListenerAttached = false; @@ -493,6 +548,13 @@ export class SelectionController implements ReactiveController { /** * Runs **`confirmSelectionChange`** (if any), applies mutators, updates internal state, and * dispatches the change event. + * + * The added/removed short-circuit below is computed against this controller's own cached + * **`selectedItems`**, which is only trustworthy when this controller is the sole mutator of + * selected-ish state (true for interactive, non-silent transitions). Silent transitions exist + * specifically to reconcile from participants whose state can change independently of this + * controller, so that cache may be stale — silent calls always proceed to {@link applyMutators} + * rather than trusting a diff against it. */ private applySelectionTransition( next: HTMLElement[], @@ -503,7 +565,7 @@ export class SelectionController implements ReactiveController { const priorItems = Array.from(this.selectedItems); const addedItems = next.filter((el) => !this.selectedItems.has(el)); - if (addedItems.length === 0 && removedItems.length === 0) { + if (!silent && addedItems.length === 0 && removedItems.length === 0) { return true; } @@ -567,15 +629,30 @@ export class SelectionController implements ReactiveController { ); } - private syncKeydownActivationListener(): void { + /** + * Attaches or removes the capture-phase **`click`** and **`keydown`** listeners to match + * **`enableInteraction`** / **`keydownActivation`**. Neither listener is attached when + * **`enableInteraction`** is **`false`**, regardless of **`keydownActivation`**. + */ + private syncInteractionListeners(): void { if (!this.host.isConnected) { return; } - const want = this.options.keydownActivation; - if (want && !this.keydownListenerAttached) { + const wantClick = this.options.enableInteraction; + if (wantClick && !this.clickListenerAttached) { + this.host.addEventListener('click', this.handleClickCapture, true); + this.clickListenerAttached = true; + } else if (!wantClick && this.clickListenerAttached) { + this.host.removeEventListener('click', this.handleClickCapture, true); + this.clickListenerAttached = false; + } + + const wantKeydown = + this.options.enableInteraction && this.options.keydownActivation; + if (wantKeydown && !this.keydownListenerAttached) { this.host.addEventListener('keydown', this.handleKeyDownCapture, true); this.keydownListenerAttached = true; - } else if (!want && this.keydownListenerAttached) { + } else if (!wantKeydown && this.keydownListenerAttached) { this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); this.keydownListenerAttached = false; } @@ -610,6 +687,9 @@ export class SelectionController implements ReactiveController { } private isDisabledParticipant(participant: HTMLElement): boolean { + if (this.options.isDisabled) { + return this.options.isDisabled(participant); + } if ('disabled' in participant) { if ((participant as HTMLButtonElement).disabled) { return true; From 1d7e10bb0f14f872c6a8e983f0e629912b11b7df Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 15:28:09 -0400 Subject: [PATCH 25/32] fix(core): updated based on change handler feedback --- .changeset/tabs-change-event-ordering.md | 6 +- .../src/selection-controller.ts | 94 +++++++++++-------- .../swc/components/tabs/migration-guide.mdx | 27 ------ .../swc/components/tabs/test/tabs.test.ts | 43 +++++++++ .../03_components/tabs/migration-plan.md | 1 - 5 files changed, 102 insertions(+), 69 deletions(-) diff --git a/.changeset/tabs-change-event-ordering.md b/.changeset/tabs-change-event-ordering.md index 908aa36c5cf..91a0197e9d2 100644 --- a/.changeset/tabs-change-event-ordering.md +++ b/.changeset/tabs-change-event-ordering.md @@ -4,7 +4,9 @@ **fix(tabs):** Addressed review feedback on the `SelectionController` / `FocusgroupNavigationController` integration in ``. -- **Breaking:** the `change` event now fires _before_ selection state (``'s `selected` property and each ``'s `selected` property) is updated, instead of after. `preventDefault()` still blocks the transition; listeners that read `selected` from the event target or a tab to determine the newly selected tab must read the interacted tab directly instead. See the [Tabs migration guide](../2nd-gen/packages/swc/components/tabs/migration-guide.mdx#behavior-changes). -- `SelectionController.setSelectedItem()` and `.refresh()` accept a new `{ silent: true }` option to commit a selection transition without invoking `confirmSelectionChange` or dispatching the controller's change event, replacing an internal ad hoc suppression flag in `Tabs.base.ts` that could get stuck if a call threw. +- `SelectionController.setSelectedItem()`, `.refresh()`, `.toggleItem()`, `.selectAll()`, and `.clearAll()` accept a new `{ silent: true }` option to commit a selection transition without invoking `confirmSelectionChange` or dispatching the controller's change event, replacing an internal ad hoc suppression flag in `Tabs.base.ts` that could get stuck if a call threw. +- `confirmSelectionChange` now runs _after_ mutators and internal state have already been applied for the candidate transition (reverted if it returns `false`), matching 1st-gen Tabs' apply-then-revert-on-cancel pattern. A `change` listener on `` now sees the newly selected tab on both `event.target.selected` and the tab's own `selected` property, as it did in 1st-gen — this is a bug fix, not a behavior change consumers need to migrate for. +- New `enableInteraction` option lets a consumer use `SelectionController` purely for its mode-aware selection bookkeeping without attaching its own click/keydown handling — used by ``, whose items own their own interaction and cancelable-toggle lifecycle. +- New `isDisabled` option lets a consumer override the built-in disabled-participant check for cascading or computed disabled state. - `FocusgroupNavigationController.setActiveItem()` and `.focusFirstItemByTextPrefix()` no longer depend on `refresh()` having run first in the same cycle; they now always recompute eligible items instead of trusting a cache that could be stale. - `SelectionController`'s `defaultToFirstSelectable` no longer infers "host disabled" from an empty `getItems()` result; it only selects from the current eligible set. diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 72ec0798f14..3f7f1275332 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -85,10 +85,13 @@ export type SelectionControllerOptions = { onSelectionChange?: (detail: SelectionControllerChangeDetail) => void; /** - * Called **before** **`selectItem`** / **`deselectItem`** run for the proposed transition. - * Return **`false`** to abort — mutators do not run, the selection stays unchanged, and no - * change event is dispatched. Omit for unconditional commits. Not called for transitions - * committed with **`{ silent: true }`** (see {@link SelectionController.setSelectedItem} and + * Called **after** **`selectItem`** / **`deselectItem`** have already run for the proposed + * transition — so if you dispatch your own cancelable event from inside this callback (the + * common pattern), listeners reading selected-ish state synchronously see the *new* value, not + * the prior one. Return **`false`** to abort: the transition (mutators, internal state, and + * the **`onSelectionChange`** mirror) is rolled back to the prior selection and no change event + * is dispatched. Omit for unconditional commits. Not called for transitions committed with + * **`{ silent: true }`** (see {@link SelectionController.setSelectedItem} and * {@link SelectionController.refresh}). */ confirmSelectionChange?: ( @@ -126,9 +129,13 @@ export type SelectionControllerChangeDetail = { * Payload for {@link SelectionControllerOptions.confirmSelectionChange}. */ export type SelectionControllerConfirmDetail = { - /** Selection that would result if the transition is committed. */ + /** + * Selection for this transition — already applied (mutators have run, internal state + * reflects it) by the time this callback is called. Reverted if this callback returns + * **`false`**. + */ candidateItems: HTMLElement[]; - /** Selection before this transition. */ + /** Selection before this transition; what state reverts to if this callback returns **`false`**. */ priorItems: HTMLElement[]; }; @@ -546,8 +553,18 @@ export class SelectionController implements ReactiveController { } /** - * Runs **`confirmSelectionChange`** (if any), applies mutators, updates internal state, and - * dispatches the change event. + * Applies the transition optimistically, then runs **`confirmSelectionChange`** (if any) and + * reverts if it returns **`false`**. Finally dispatches the change event when committed. + * + * **Ordering matches native cancelable-event conventions (and 1st-gen Tabs), not a + * confirm-before-apply gate.** Mutators, internal state, and the **`onSelectionChange`** mirror + * are all applied *before* **`confirmSelectionChange`** runs, so a consumer's own cancelable + * event — typically dispatched from inside **`confirmSelectionChange`** — sees the *new* state + * when read synchronously inside its listener (e.g. a host's reflected selection property, or + * an item's own selected-ish property). If **`confirmSelectionChange`** returns **`false`**, + * every one of those effects is rolled back to **`priorItems`**, including a second + * **`onSelectionChange`** call so external mirrors roll back too. Lit batches property updates + * into a microtask, so an apply-then-synchronously-revert never paints the intermediate state. * * The added/removed short-circuit below is computed against this controller's own cached * **`selectedItems`**, which is only trustworthy when this controller is the sole mutator of @@ -569,24 +586,48 @@ export class SelectionController implements ReactiveController { return true; } + this.commit(next); + this.options.onSelectionChange?.({ + selectedItems: next, + addedItems, + removedItems, + }); + if (!silent && this.options.confirmSelectionChange) { const ok = this.options.confirmSelectionChange({ candidateItems: next, priorItems, }); if (!ok) { + this.commit(priorItems); + this.options.onSelectionChange?.({ + selectedItems: priorItems, + addedItems: removedItems, + removedItems: addedItems, + }); return false; } } + if (!silent) { + this.host.dispatchEvent( + new CustomEvent( + selectionControllerChange, + { + bubbles: true, + composed: true, + detail: { selectedItems: next, addedItems, removedItems }, + } + ) + ); + } + return true; + } + + /** Applies mutators for {@link next} and updates the cached selection to match. */ + private commit(next: HTMLElement[]): void { this.applyMutators(next); this.selectedItems = new Set(next); - - this.dispatchChange( - { selectedItems: next, addedItems, removedItems }, - silent - ); - return true; } /** @@ -604,31 +645,6 @@ export class SelectionController implements ReactiveController { } } - /** - * Invokes **`onSelectionChange`** (always) and dispatches {@link selectionControllerChange} - * on the host, unless **`silent`** is **`true`** — used by silent commits to keep internal - * mirroring callbacks in sync without raising the public event. - */ - private dispatchChange( - detail: SelectionControllerChangeDetail, - silent: boolean - ): void { - this.options.onSelectionChange?.(detail); - if (silent) { - return; - } - this.host.dispatchEvent( - new CustomEvent( - selectionControllerChange, - { - bubbles: true, - composed: true, - detail, - } - ) - ); - } - /** * Attaches or removes the capture-phase **`click`** and **`keydown`** listeners to match * **`enableInteraction`** / **`keydownActivation`**. Neither listener is attached when diff --git a/2nd-gen/packages/swc/components/tabs/migration-guide.mdx b/2nd-gen/packages/swc/components/tabs/migration-guide.mdx index 38979a1ba05..3e2aff3c8bb 100644 --- a/2nd-gen/packages/swc/components/tabs/migration-guide.mdx +++ b/2nd-gen/packages/swc/components/tabs/migration-guide.mdx @@ -67,32 +67,6 @@ import '@adobe/spectrum-wc/components/tabs/swc-tab-panel.js'; | `rovingTabindexController` field | Removed; keyboard navigation is internal | | `focusElement` getter | Removed; use `tabsEl.focus()` | -### Behavior changes - -
- ⚠️ Breaking change. The change event now fires{' '} - before selection state updates. In Spectrum 1,{' '} - {''} set selected to the new value and then - dispatched change (reverting selected if{' '} - preventDefault() was called). In Spectrum 2, change{' '} - dispatches first; {''}'s selected property - and each {''}'s selected property are still - the previous selection while your listener runs.{' '} - preventDefault() still blocks the change. If your listener reads{' '} - event.target.selected or a tab's selected property - to get the newly selected tab, read the interacted tab directly instead (for - example the tab that received the click or keypress), or defer the read to a - microtask/next render. -
- ## Update your code ### 1. Rename the tags and attributes @@ -240,4 +214,3 @@ Spectrum 2 uses a different custom property prefix. Spectrum 1 overrides will no - [ ] Replace `direction="vertical-right"` with `direction="vertical"` - [ ] Replace `label` on icon-only `` with `aria-label` on `` - [ ] Replace `--mod-tabs-*` CSS overrides with `--swc-tabs-*` / `--swc-tab-*` equivalents -- [ ] Update `change` listeners that read `selected` from the event target or a tab: that state is now pre-transition inside the listener (see [Behavior changes](#behavior-changes)) diff --git a/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts b/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts index f15df62b3ce..711c83cacbd 100644 --- a/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts +++ b/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts @@ -1083,6 +1083,49 @@ export const ChangeEventTest: Story = { }, }; +export const ChangeHandlerSelectedTest: Story = { + render: () => html` + + Tab 1 + Tab 2 +

Panel 1

+

Panel 2

+
+ `, + play: async ({ canvasElement, step }) => { + const tabs = await getComponent(canvasElement, 'swc-tabs'); + + await step( + 'selected is the new tab inside the change handler', + async () => { + const tab2 = canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab; + let selectedInHandler: string | undefined; + let tab2SelectedInHandler: boolean | undefined; + tabs.addEventListener( + 'change', + () => { + selectedInHandler = tabs.selected; + tab2SelectedInHandler = tab2.selected; + }, + { once: true } + ); + + tab2.click(); + await tabs.updateComplete; + + expect( + selectedInHandler, + 'tabs.selected is the newly selected tab inside the handler' + ).toBe('2'); + expect( + tab2SelectedInHandler, + 'the clicked tab is already selected inside the handler' + ).toBe(true); + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── // TEST: Slots // ────────────────────────────────────────────────────────────── diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md index 15996d42b74..636b570ea7b 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tabs/migration-plan.md @@ -396,7 +396,6 @@ This full modifier surface will not be carried forward to 2nd-gen. Consumers mus |---|---|---|---|---| | **B19** | `slot="tab-panel"` auto-assignment | `TabPanel.firstUpdated()` sets `this.slot = 'tab-panel'` programmatically. Consumers do **not** write `slot="tab-panel"` on ``. | TBD — if 2nd-gen changes slotting, consumers may need explicit `slot` attributes. | Verify panel slotting works without explicit `slot` attribute; if changed, add `slot="tab-panel"` to all `` elements. | | **B20** | TabPanel focus-in/out tabindex management | `TabPanel` removes its own `tabindex` on `focusin` (so it doesn't intercept Tab when content inside the panel has focus), then restores `tabindex` on `focusout`. | 2nd-gen must replicate this behavior to avoid Tab-key trapping inside panels. If omitted, keyboard navigation through panel content changes. | No consumer action if behavior is preserved. If changed, consumers with complex panel content should verify Tab key behavior. | -| **B27** | `change` event now fires before selection state updates | `Tabs.selectTarget()` sets `this.selected` to the new value **before** dispatching the cancelable `change` event; on `preventDefault()` it reverts `this.selected` back. | `SelectionController.applySelectionTransition` calls the consumer's `confirmSelectionChange` (which dispatches `change`) **before** running `selectItem`/`deselectItem` mutators or updating the public `selected` property. A `change` listener now sees the **pre-transition** state on `event.target.selected` and on each ``'s `selected` property; `preventDefault()` still blocks the transition, but there is no longer a revert step because nothing was applied yet. | Consumers reading `event.target.selected` or a tab's `selected` property inside a `change` listener to get the *new* value must instead read the tab that was clicked/activated (e.g. `event.target.querySelector('[aria-selected="true"]')` after the listener returns, or track the interacted element directly). | ### Additive — ships when ready, zero breakage for consumers already on 2nd-gen From 2dd846b7576f590db80f822d91f2b04e03c088b3 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 15:41:30 -0400 Subject: [PATCH 26/32] fix(core): updated to fix navigation change handler --- .changeset/tabs-change-event-ordering.md | 1 + .../core/components/tabs/Tabs.base.ts | 13 ++- .../focusgroup-navigation-controller.mdx | 62 +++++++++++--- .../src/focusgroup-navigation-controller.ts | 63 ++++++++++++--- .../swc/components/tabs/test/tabs.test.ts | 81 +++++++++++++++++++ 5 files changed, 198 insertions(+), 22 deletions(-) diff --git a/.changeset/tabs-change-event-ordering.md b/.changeset/tabs-change-event-ordering.md index 91a0197e9d2..d22e75cd649 100644 --- a/.changeset/tabs-change-event-ordering.md +++ b/.changeset/tabs-change-event-ordering.md @@ -10,3 +10,4 @@ - New `isDisabled` option lets a consumer override the built-in disabled-participant check for cascading or computed disabled state. - `FocusgroupNavigationController.setActiveItem()` and `.focusFirstItemByTextPrefix()` no longer depend on `refresh()` having run first in the same cycle; they now always recompute eligible items instead of trusting a cache that could be stale. - `SelectionController`'s `defaultToFirstSelectable` no longer infers "host disabled" from an empty `getItems()` result; it only selects from the current eligible set. +- **fix:** `focusgroupNavigationActiveChange`'s `detail` now includes a `reason` (`'keyboard' | 'focus' | 'programmatic' | 'refresh'`). `` with `keyboard-activation="automatic"` was treating every roving-tab-stop recompute as if the user had navigated there, so mounting with a pre-selected tab, or disabling then re-enabling the tablist, could silently move the selection to the first tab and fire a spurious `change` event. `` now only follows `'keyboard'` / `'focus'` reasons. diff --git a/2nd-gen/packages/core/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index 6a96478182b..f01a43246ca 100644 --- a/2nd-gen/packages/core/components/tabs/Tabs.base.ts +++ b/2nd-gen/packages/core/components/tabs/Tabs.base.ts @@ -391,14 +391,25 @@ export abstract class TabsBase extends SpectrumElement { * Handles `focusgroupNavigationActiveChange` events dispatched by * `_navigation`. In automatic activation mode, selects the newly * focused tab (unless it is disabled) and dispatches `change`. + * + * Only `reason: 'keyboard'` (arrow keys, Home/End) and `reason: 'focus'` + * (pointer click, Tab-key entry) represent a real focus move that + * "selection follows focus" should react to. `'refresh'` and + * `'programmatic'` fire when `_navigation` re-parks the roving tab stop + * without anyone actually moving focus there — for example on mount with + * a pre-selected tab, or when re-enabling after `disabled` — and must not + * be treated as if the user navigated there. */ private readonly _handleNavigationActiveChange = (event: Event): void => { if (this._keyboardActivation !== 'automatic') { return; } - const { activeElement } = ( + const { activeElement, reason } = ( event as CustomEvent ).detail; + if (reason !== 'keyboard' && reason !== 'focus') { + return; + } if (!activeElement) { return; } diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx index 583292593cb..5e13e3b2050 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/focusgroup-navigation-controller.mdx @@ -257,27 +257,39 @@ Collapses a composite widget to a single Tab stop by managing `tabindex="0"` on ### Events -The controller dispatches **`swc-focusgroup-navigation-active-change`** (`focusgroupNavigationActiveChange`) on the host with `detail: { activeElement }` when the active item changes. The event bubbles and is composed. +The controller dispatches **`swc-focusgroup-navigation-active-change`** (`focusgroupNavigationActiveChange`) on the host with `detail: { activeElement, reason }` when the active item changes. The event bubbles and is composed. + +`reason` tells you _why_ the active item changed: + +- **`keyboard`**: arrow keys, Home/End, Page Up/Down, or Ctrl+Home/Ctrl+End moved the roving tab stop. +- **`focus`**: a managed item received DOM focus directly — a pointer click, Tab-key entry into the group, or a consumer's own `.focus()` call landing on a managed item. +- **`programmatic`**: an explicit `setActiveItem()` or `focusFirstItemByTextPrefix()` call. +- **`refresh`**: `refresh()` recomputed the roving tab stop after items or eligibility changed (including the reset applied when focus leaves the group and `memory` is `false`) — not a real focus or selection intent. + +If you're implementing "selection follows focus" (for example APG automatic tab activation), only react to `'keyboard'` and `'focus'`. Reacting to `'refresh'` or `'programmatic'` too means mounting with a pre-selected item, disabling/re-enabling the group, or any other DOM change that makes `refresh()` re-park the tab stop gets misread as if the user navigated there — see [Selection follows focus, correctly](#selection-follows-focus-correctly) in the Appendix below. ```typescript import { focusgroupNavigationActiveChange } from '@spectrum-web-components/core/controllers/focusgroup-navigation-controller.js'; host.addEventListener(focusgroupNavigationActiveChange, (event) => { + if (event.detail.reason !== 'keyboard' && event.detail.reason !== 'focus') { + return; + } console.log('Active item:', event.detail.activeElement); }); ``` ### Options -| Option | Type | Default | Description | -| -------------------- | ------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | -| `direction` | `'horizontal'` \| `'vertical'` \| `'both'` \| `'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | -| `wrap` | `boolean` | `false` | Wrap at ends. | -| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | -| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | -| `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**). `0` / omitted / non-finite: disabled. | -| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | +| Option | Type | Default | Description | +| -------------------- | ------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | +| `direction` | `'horizontal'` \| `'vertical'` \| `'both'` \| `'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | +| `wrap` | `boolean` | `false` | Wrap at ends. | +| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | +| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | +| `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**). `0` / omitted / non-finite: disabled. | +| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. Does not receive `reason` — use the `focusgroupNavigationActiveChange` event if you need to filter by cause. | ## Appendix @@ -285,6 +297,36 @@ host.addEventListener(focusgroupNavigationActiveChange, (event) => { Native `focusgroup` would supply guaranteed tab stops, memory, and arrow behavior in the browser. This controller provides a **JavaScript** implementation for custom elements: you keep explicit ARIA roles and selection logic, and use the controller for tabindex and arrow-key focus movement. +### Selection follows focus, correctly + +A composite that pairs this controller with `SelectionController` for "selection follows focus" (APG automatic tab activation) must filter `focusgroupNavigationActiveChange` by `reason`, not react to every event: + +```typescript +private readonly handleActiveChange = (event: Event): void => { + if (this.activationMode !== 'automatic') { + return; + } + const { activeElement, reason } = ( + event as CustomEvent + ).detail; + // Only a real focus move (arrow keys, Home/End, pointer click, Tab-key + // entry) should drive selection. 'refresh' and 'programmatic' fire when + // the roving tab stop is re-parked without the user actually navigating + // there — for example on mount with a pre-selected item, or when + // re-enabling after `disabled` — and must be ignored, or mounting / + // toggling `disabled` will silently steal the selection and fire a + // spurious change event. + if (reason !== 'keyboard' && reason !== 'focus') { + return; + } + if (activeElement) { + this.selection.setSelectedItem(activeElement); + } +}; +``` + +`` implements exactly this pattern for `keyboard-activation="automatic"`; see `TabsBase._handleNavigationActiveChange`. + ### See also - [Keyboard navigation inside components (APG)](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents) diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts index eec97909150..7cf547a0db4 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts @@ -134,6 +134,23 @@ export type FocusgroupNavigationActiveChangeDetail = { * Element that now has `tabindex="0"` among managed items, or null when the group is empty. */ activeElement: HTMLElement | null; + + /** + * What caused the active item to change: + * + * - **`keyboard`**: arrow keys, Home/End, Page Up/Down, or Ctrl+Home/Ctrl+End moved the + * roving tab stop. + * - **`focus`**: a managed item received DOM focus directly — a pointer click, Tab-key entry + * into the group, or a consumer's own `.focus()` call landing on a managed item. + * - **`programmatic`**: an explicit `setActiveItem()` or `focusFirstItemByTextPrefix()` call. + * - **`refresh`**: `refresh()` recomputed the roving tab stop after items or eligibility + * changed, including the reset applied when focus leaves the group and `memory` is + * `false`. Not a real focus or selection intent — a consumer implementing + * "selection follows focus" (for example APG automatic tab activation) should ignore this + * reason so that DOM changes, disabling/re-enabling, or mounting with a pre-selected item + * don't get treated as if the user navigated there. + */ + reason: 'keyboard' | 'focus' | 'programmatic' | 'refresh'; }; /** @@ -352,7 +369,7 @@ export class FocusgroupNavigationController implements ReactiveController { this.lastFocused = null; if (this.previousActive !== null) { this.previousActive = null; - this.dispatchActiveChange(null); + this.dispatchActiveChange(null, 'refresh'); this.options.onActiveItemChange?.(null); } return; @@ -368,7 +385,7 @@ export class FocusgroupNavigationController implements ReactiveController { this.getActiveItem() ?? items[0]; - this.applyRovingTabindex(preferred); + this.applyRovingTabindex(preferred, 'refresh'); } /** @@ -376,10 +393,26 @@ export class FocusgroupNavigationController implements ReactiveController { * group are `-1`. Does **not** call `focus()`. When {@link FocusgroupNavigationOptions.memory} * is true, updates the stored last-focused item so Tab re-entry can target this item. * + * Dispatches {@link focusgroupNavigationActiveChange} with **`reason: 'programmatic'`** — call + * this from consumer code that re-syncs the roving tab stop (for example to match an + * externally-owned selection), not from code implementing arrow-key movement itself. + * * @param item - Item to mark active; must be returned by `getItems` and pass eligibility checks. * @returns False if `item` is not in the current eligible item list. */ public setActiveItem(item: HTMLElement): boolean { + return this.assignActiveItem(item, 'programmatic'); + } + + /** + * Shared implementation for {@link setActiveItem} and keyboard-driven navigation, threading + * through the {@link FocusgroupNavigationActiveChangeDetail.reason} so consumers can + * distinguish real arrow-key movement from a programmatic re-sync. + */ + private assignActiveItem( + item: HTMLElement, + reason: FocusgroupNavigationActiveChangeDetail['reason'] + ): boolean { // Clear the eligible-items cache so this reflects the current DOM rather // than a stale snapshot left behind by a previous refresh()/keydown/focusin // cycle. Public entry points must not depend on caller ordering. @@ -389,7 +422,7 @@ export class FocusgroupNavigationController implements ReactiveController { if (!items.includes(item)) { return false; } - this.applyRovingTabindex(item); + this.applyRovingTabindex(item, reason); if (this.options.memory) { this.lastFocused = item; } @@ -432,7 +465,7 @@ export class FocusgroupNavigationController implements ReactiveController { if (!match) { return false; } - this.applyRovingTabindex(match); + this.applyRovingTabindex(match, 'programmatic'); return true; } @@ -642,8 +675,12 @@ export class FocusgroupNavigationController implements ReactiveController { * Dispatches the active-change event and {@link FocusgroupNavigationOptions.onActiveItemChange}. * * @param active - Preferred item to mark as the single tab stop when eligible. + * @param reason - Cause of the change, forwarded to {@link dispatchActiveChange}. */ - private applyRovingTabindex(active: HTMLElement): void { + private applyRovingTabindex( + active: HTMLElement, + reason: FocusgroupNavigationActiveChangeDetail['reason'] + ): void { const items = this.getEligibleItems(); const eligibleSet = new Set(items); for (const el of this.getRawItems()) { @@ -674,7 +711,7 @@ export class FocusgroupNavigationController implements ReactiveController { } if (safeActive !== this.previousActive) { this.previousActive = safeActive; - this.dispatchActiveChange(safeActive); + this.dispatchActiveChange(safeActive, reason); this.options.onActiveItemChange?.(safeActive); } } @@ -683,15 +720,19 @@ export class FocusgroupNavigationController implements ReactiveController { * Dispatches {@link focusgroupNavigationActiveChange} on the reactive host with the given detail. * * @param activeElement - New active item, or null when clearing selection. + * @param reason - Cause of the change; see {@link FocusgroupNavigationActiveChangeDetail.reason}. */ - private dispatchActiveChange(activeElement: HTMLElement | null): void { + private dispatchActiveChange( + activeElement: HTMLElement | null, + reason: FocusgroupNavigationActiveChangeDetail['reason'] + ): void { this.host.dispatchEvent( new CustomEvent( focusgroupNavigationActiveChange, { bubbles: true, composed: true, - detail: { activeElement }, + detail: { activeElement, reason }, } ) ); @@ -753,7 +794,7 @@ export class FocusgroupNavigationController implements ReactiveController { if (!target) { return; } - this.applyRovingTabindex(target); + this.applyRovingTabindex(target, 'focus'); if (this.options.memory) { this.lastFocused = target; } @@ -785,7 +826,7 @@ export class FocusgroupNavigationController implements ReactiveController { this.cachedRows = null; const items = this.getEligibleItems(); if (items.length > 0) { - this.applyRovingTabindex(items[0]); + this.applyRovingTabindex(items[0], 'refresh'); } } } @@ -969,7 +1010,7 @@ export class FocusgroupNavigationController implements ReactiveController { private moveKeyNavigationFocusTo(item: HTMLElement): void { this.isNavigating = true; try { - if (this.setActiveItem(item)) { + if (this.assignActiveItem(item, 'keyboard')) { item.focus(); } } finally { diff --git a/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts b/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts index 711c83cacbd..d72e4c8ffca 100644 --- a/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts +++ b/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts @@ -968,6 +968,87 @@ export const AutoActivationTest: Story = { }, }; +// ────────────────────────────────────────────────────────────── +// TEST: Automatic activation — mount keeps the pre-selection +// ────────────────────────────────────────────────────────────── + +export const AutomaticActivationMountTest: Story = { + render: () => html` + + Tab 1 + Tab 2 + Tab 3 +

Panel 1

+

Panel 2

+

Panel 3

+
+ `, + play: async ({ canvasElement, step }) => { + const tabs = await getComponent(canvasElement, 'swc-tabs'); + + await step('mount preserves the pre-selected tab', async () => { + // The pre-selected tab must win over the default-to-first-item roving + // tab stop that _navigation.refresh() establishes before + // _syncSelectionController() has a chance to correct it. + expect(tabs.selected).toBe('2'); + const tab2 = canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab; + expect(tab2.selected).toBe(true); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Automatic activation — disable toggle keeps the selection +// ────────────────────────────────────────────────────────────── + +export const AutomaticActivationDisableToggleTest: Story = { + render: () => html` + + Tab 1 + Tab 2 + Tab 3 +

Panel 1

+

Panel 2

+

Panel 3

+
+ `, + play: async ({ canvasElement, step }) => { + const tabs = await getComponent(canvasElement, 'swc-tabs'); + + await step('disabling then re-enabling keeps the selection', async () => { + // Reset in case mount already moved the selection, so this step is + // independent and isolates the disable/enable behavior. + tabs.selected = '2'; + await tabs.updateComplete; + + let changeCount = 0; + const onChange = (): void => { + changeCount += 1; + }; + tabs.addEventListener('change', onChange); + + tabs.disabled = true; + await tabs.updateComplete; + tabs.disabled = false; + await tabs.updateComplete; + + tabs.removeEventListener('change', onChange); + + // Toggling `disabled` is not a user selection. + expect(tabs.selected).toBe('2'); + expect(changeCount).toBe(0); + }); + }, +}; + export const DisabledTabKeyboardTest: Story = { render: () => html` From 1b4a0719cdebddfd376779ea9a83bbed0613b835 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 15:51:43 -0400 Subject: [PATCH 27/32] fix(core): updated selection controller to clear selection when nothing is selected --- .changeset/tabs-change-event-ordering.md | 1 + .../core/components/tabs/Tabs.base.ts | 16 +++++++- .../selection-controller.mdx | 10 ++--- .../src/selection-controller.ts | 14 ++++--- .../swc/components/tabs/test/tabs.test.ts | 39 +++++++++++++++++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.changeset/tabs-change-event-ordering.md b/.changeset/tabs-change-event-ordering.md index d22e75cd649..7e9b579c5a8 100644 --- a/.changeset/tabs-change-event-ordering.md +++ b/.changeset/tabs-change-event-ordering.md @@ -11,3 +11,4 @@ - `FocusgroupNavigationController.setActiveItem()` and `.focusFirstItemByTextPrefix()` no longer depend on `refresh()` having run first in the same cycle; they now always recompute eligible items instead of trusting a cache that could be stale. - `SelectionController`'s `defaultToFirstSelectable` no longer infers "host disabled" from an empty `getItems()` result; it only selects from the current eligible set. - **fix:** `focusgroupNavigationActiveChange`'s `detail` now includes a `reason` (`'keyboard' | 'focus' | 'programmatic' | 'refresh'`). `` with `keyboard-activation="automatic"` was treating every roving-tab-stop recompute as if the user had navigated there, so mounting with a pre-selected tab, or disabling then re-enabling the tablist, could silently move the selection to the first tab and fire a spurious `change` event. `` now only follows `'keyboard'` / `'focus'` reasons. +- **fix:** setting ``'s `selected` to `''` or to an id that doesn't match any tab previously left the prior tab's own `selected` property `true` (highlighted, with its panel hidden) even though the host's `selected` property read empty. `SelectionController.clearAll()` and `setSelectedItem(null, ...)` now accept `{ silent: true }` to clear the selection even in `single` mode (normally rejected, since a mandatory single-select group can't be emptied by clicking); `` uses this to deselect every tab whenever `selected` doesn't match one. diff --git a/2nd-gen/packages/core/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index f01a43246ca..4bb0825f87b 100644 --- a/2nd-gen/packages/core/components/tabs/Tabs.base.ts +++ b/2nd-gen/packages/core/components/tabs/Tabs.base.ts @@ -381,8 +381,20 @@ export abstract class TabsBase extends SpectrumElement { // Also sync the roving tab stop so focus() and Tab re-entry target // the selected tab rather than the first item in the list. this._navigation.setActiveItem(selectedTab as HTMLElement); - } else if (this.selected) { - // No tab with this id — reset the public property. + return; + } + + // this.selected is empty, or names a tab that doesn't exist — no tab + // should remain visually selected. `single` mode normally rejects + // clearing, so this requires `{ silent: true }` to bypass that + // interactive-only restriction. Clearing here (rather than only when + // this.selected is unset below) fixes the previously-selected tab + // immediately, without depending on a second reactive update cycle. + this._selection.clearAll({ silent: true }); + + if (this.selected) { + // Named a tab that doesn't exist — reset the public property to + // reflect that nothing is actually selected. this.selected = ''; } } diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx index 25687a8b625..4cb805804f6 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -25,10 +25,10 @@ import * as SelectionControllerStories from './stories/selection-controller.stor ### Programmatic API -- **`setSelectedItem(item | null, options?)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only). +- **`setSelectedItem(item | null, options?)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only — or `single` too with `{ silent: true }`). - **`toggleItem(item, options?)`**: toggle using the current mode rules. - **`selectAll(options?)`**: select all eligible items (multiple mode only). -- **`clearAll(options?)`**: deselect all items (single-toggle and multiple modes). +- **`clearAll(options?)`**: deselect all items (single-toggle and multiple modes — or `single` too with `{ silent: true }`). - **`getSelectedItems()`**: returns the current selection as an ordered array. - **`isSelected(item)`**: check whether a specific item is selected. @@ -220,14 +220,14 @@ For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host | `getSelectedItems()` | Returns the current selection as an ordered array. | | `isSelected(item)` | Returns `true` when `item` is currently selected. | | `getMode()` | Returns the current `SelectionMode`. | -| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes). | +| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes, or `single` with `{ silent: true }`). | | `toggleItem(item, options?)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | | `selectAll(options?)` | Select all eligible items. Multiple mode only; returns `false` otherwise. | -| `clearAll(options?)` | Deselect all items. Single-toggle and multiple modes; returns `false` in `single` mode. | +| `clearAll(options?)` | Deselect all items. Single-toggle and multiple modes, or `single` with `{ silent: true }`; otherwise returns `false` in `single` mode. | | `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) unless `enableInteraction` is `false`, then calls `refresh()`. | | `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | -`refresh`, `setSelectedItem`, `toggleItem`, `selectAll`, and `clearAll` all accept an `options?: { silent?: boolean }` parameter — pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event. +`refresh`, `setSelectedItem`, `toggleItem`, `selectAll`, and `clearAll` all accept an `options?: { silent?: boolean }` parameter — pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event. For `setSelectedItem(null, ...)` and `clearAll`, `{ silent: true }` also bypasses the `single`-mode restriction that otherwise rejects clearing: a mandatory single-select group can't be emptied by _clicking_, but a consumer resyncing from an external property that explicitly represents "nothing selected" (for example ``) can still clear it. ### Events diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 3f7f1275332..77942f612fe 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -344,8 +344,11 @@ export class SelectionController implements ReactiveController { * Asserts {@link item} as the selection in single / single-toggle modes, or adds it to the * set in multiple mode. Disabled or ineligible items are rejected (returns **`false`**). * - * Passing **`null`** clears the selection; only works when the mode allows an empty selection - * (**`single-toggle`** or **`multiple`**). + * Passing **`null`** clears the selection; in **`single`** mode this normally returns + * **`false`** and leaves the selection unchanged (interactively, a mandatory single-select + * group can't be emptied by clicking). Pass **`{ silent: true }`** to clear it anyway — for + * example when an external property is explicitly reset to represent "nothing selected," + * which a consumer may legitimately want to allow even in `single` mode. * * Pass **`{ silent: true }`** to commit this transition without invoking * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to @@ -356,7 +359,7 @@ export class SelectionController implements ReactiveController { options?: { silent?: boolean } ): boolean { if (item === null) { - if (this.options.mode === 'single') { + if (this.options.mode === 'single' && !options?.silent) { return false; } return this.clearAll(options); @@ -447,13 +450,14 @@ export class SelectionController implements ReactiveController { /** * Deselects all items. Works in **`single-toggle`** and **`multiple`** modes. In **`single`** - * mode, returns **`false`** and leaves selection unchanged. + * mode, returns **`false`** and leaves the selection unchanged — unless **`{ silent: true }`** + * is passed, which clears it anyway (see {@link SelectionController.setSelectedItem}). * * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or * dispatching {@link selectionControllerChange}. */ public clearAll(options?: { silent?: boolean }): boolean { - if (this.options.mode === 'single') { + if (this.options.mode === 'single' && !options?.silent) { return false; } if (this.selectedItems.size === 0) { diff --git a/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts b/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts index d72e4c8ffca..7ed1734cf62 100644 --- a/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts +++ b/2nd-gen/packages/swc/components/tabs/test/tabs.test.ts @@ -178,7 +178,46 @@ export const SelectedPropertyTest: Story = { await step('selecting nonexistent value clears selection', async () => { tabs.selected = 'nonexistent'; await tabs.updateComplete; + + const tab2 = canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab; expect(tabs.selected, 'selected resets to empty').toBe(''); + expect( + tab2.selected, + 'previously selected tab is deselected, not left highlighted' + ).toBe(false); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Clearing selection — selected = '' deselects the tab +// ────────────────────────────────────────────────────────────── + +export const ClearSelectionTest: Story = { + render: () => html` + + Tab 1 + Tab 2 +

Panel 1

+

Panel 2

+
+ `, + play: async ({ canvasElement, step }) => { + const tabs = await getComponent(canvasElement, 'swc-tabs'); + + await step('setting selected to "" deselects the tab', async () => { + const tab2 = canvasElement.querySelector('swc-tab[tab-id="2"]') as Tab; + expect(tab2.selected, 'tab 2 starts selected').toBe(true); + + tabs.selected = ''; + await tabs.updateComplete; + + // Clearing the property should leave no tab looking selected. + expect(tabs.selected).toBe(''); + expect( + tab2.selected, + 'tab 2 should be deselected when the selection clears' + ).toBe(false); }); }, }; From 40d738b0dc6caf2e8841f8f2d273c10d843f4bba Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 16:06:10 -0400 Subject: [PATCH 28/32] test(core): added selection and focus group controller tests --- .../stories/demo-hosts.ts | 26 +++++- .../focusgroup-navigation-controller.test.ts | 71 +++++++++++++++ .../stories/demo-hosts.ts | 54 ++++++++++++ .../test/selection-controller.test.ts | 86 ++++++++++++++++++- 4 files changed, 235 insertions(+), 2 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/demo-hosts.ts index b9b6f993882..01e43ba4628 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/demo-hosts.ts @@ -1084,12 +1084,24 @@ export class DemoFocusgroupEventTracker extends LitElement { public activeChangeLog: (string | null)[] = []; public callbackLog: (string | null)[] = []; + public reasonLog: FocusgroupNavigationActiveChangeDetail['reason'][] = []; + + /** + * When `true`, `getItems` returns `[]` — mirrors a host reporting itself + * disabled, so `refresh()` re-parks (or clears) the roving tab stop the + * same way it does across a real disable/re-enable cycle. + * + * @internal + */ + public simulateEmpty = false; private readonly navigation = new FocusgroupNavigationController(this, { direction: 'horizontal', wrap: false, getItems: () => - Array.from(this.renderRoot.querySelectorAll('button')), + this.simulateEmpty + ? [] + : Array.from(this.renderRoot.querySelectorAll('button')), onActiveItemChange: (el: HTMLElement | null) => { this.callbackLog.push(el?.textContent?.trim() ?? null); }, @@ -1122,11 +1134,23 @@ export class DemoFocusgroupEventTracker extends LitElement { this.activeChangeLog.push( event.detail.activeElement?.textContent?.trim() ?? null ); + this.reasonLog.push(event.detail.reason); }; public clearLogs(): void { this.activeChangeLog = []; this.callbackLog = []; + this.reasonLog = []; + } + + /** Triggers a `reason: 'refresh'` active-change, if the active item recomputes to a new value. */ + public callRefresh(): void { + this.navigation.refresh(); + } + + /** Triggers a `reason: 'programmatic'` active-change. */ + public callSetActiveItem(item: HTMLElement): boolean { + return this.navigation.setActiveItem(item); } protected override render(): TemplateResult { diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/test/focusgroup-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/test/focusgroup-navigation-controller.test.ts index e2066ce417c..a6e88d80008 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/test/focusgroup-navigation-controller.test.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/test/focusgroup-navigation-controller.test.ts @@ -836,6 +836,77 @@ export const ActiveChangeEventAndCallback: Story = { handler ); }); + + // Entering here, previous steps left the active item on "Third" + // (button[2]), with real DOM focus on "Third" too. + + await step('reason is "keyboard" for arrow-key navigation', async () => { + host.clearLogs(); + const current = shadowActiveButton(host)!; + current.focus(); + keydown(current, 'ArrowLeft'); + + expect(host.activeChangeLog[host.activeChangeLog.length - 1]).toBe( + 'Second' + ); + expect(host.reasonLog[host.reasonLog.length - 1]).toBe('keyboard'); + }); + + await step( + 'reason is "focus" when a managed item receives DOM focus directly', + async () => { + host.clearLogs(); + buttons[0].focus(); // "First" — different from the active "Second" + + expect(host.activeChangeLog[host.activeChangeLog.length - 1]).toBe( + 'First' + ); + expect(host.reasonLog[host.reasonLog.length - 1]).toBe('focus'); + } + ); + + await step( + 'reason is "programmatic" for an explicit setActiveItem() call', + async () => { + host.clearLogs(); + // Does not call .focus() — only the roving tab stop moves. + const changed = host.callSetActiveItem(buttons[2]); // "Third" + + expect(changed).toBe(true); + expect(host.activeChangeLog[host.activeChangeLog.length - 1]).toBe( + 'Third' + ); + expect(host.reasonLog[host.reasonLog.length - 1]).toBe('programmatic'); + } + ); + + await step( + 'reason is "refresh" when refresh() re-parks the tab stop without any focus move', + async () => { + // Mirrors the bug this reason exists to prevent misreading: a host + // reporting itself disabled (getItems() -> []) then re-enabling, + // with nothing actually focused in between. + host.clearLogs(); + host.simulateEmpty = true; + host.callRefresh(); + + expect( + host.activeChangeLog[host.activeChangeLog.length - 1], + 'active item clears when the group becomes empty' + ).toBeNull(); + expect(host.reasonLog[host.reasonLog.length - 1]).toBe('refresh'); + + host.clearLogs(); + host.simulateEmpty = false; + host.callRefresh(); + + expect( + host.activeChangeLog[host.activeChangeLog.length - 1], + 're-populating re-parks the tab stop on the first item' + ).toBe('First'); + expect(host.reasonLog[host.reasonLog.length - 1]).toBe('refresh'); + } + ); }, }; diff --git a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts index 427b9434467..c0d035ba9cb 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts @@ -98,6 +98,23 @@ const starRatingStyles = css` block-size: 1.85rem; flex-shrink: 0; } + .action-btn { + align-self: flex-start; + font: inherit; + font-size: 0.85rem; + padding: 0.3rem 0.75rem; + border: 1px solid var(--spectrum-gray-400, #b9b9b9); + border-radius: 4px; + background: transparent; + cursor: pointer; + } + .action-btn:hover { + background: var(--spectrum-gray-100, #f1f1f1); + } + .action-btn:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } `; const starSvg = html` @@ -153,6 +170,18 @@ export class DemoSelectionStarSingle extends LitElement { this.selection.refresh(); } + /** + * Clears the selection despite `mode: 'single'` — normally rejected + * interactively, but a consumer resyncing from an external property that + * explicitly represents "nothing selected" (like ``) + * can still clear it with `{ silent: true }`. + * + * @internal + */ + public clearSelectionSilently(): void { + this.selection.setSelectedItem(null, { silent: true }); + } + protected override render(): TemplateResult { return html`
@@ -181,6 +210,13 @@ export class DemoSelectionStarSingle extends LitElement { `; })}
+ `; } @@ -724,6 +760,14 @@ export class DemoSelectionListbox extends LitElement { // Tabs — pairing with FocusgroupNavigationController // ───────────────────────────────────────────── +/** + * Cancelable event dispatched from `confirmSelectionChange`, mirroring + * ``'s `change` event. + * + * @internal + */ +export const DEMO_TAB_CHANGE_EVENT = 'demo-tab-change'; + const tabStyles = css` :host { display: block; @@ -818,6 +862,16 @@ export class DemoSelectionTabs extends LitElement { mode: 'single', keydownActivation: true, defaultToFirstSelectable: true, + // Mirrors : dispatches a cancelable event from + // confirmSelectionChange, which SelectionController calls *after* + // mutators and internal state are already applied for the candidate + // transition — so a listener reading tab/panel state synchronously + // sees the new selection, not the prior one. Returning false (via + // preventDefault) reverts everything to the prior selection. + confirmSelectionChange: () => + this.dispatchEvent( + new Event(DEMO_TAB_CHANGE_EVENT, { cancelable: true }) + ), }); protected override firstUpdated(): void { diff --git a/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts index 554fbc6d746..0eaffb6040e 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts @@ -21,6 +21,10 @@ import { import '../stories/demo-hosts.js'; import { getComponent } from '../../../../swc/utils/test-utils.js'; +import { + DEMO_TAB_CHANGE_EVENT, + type DemoSelectionStarSingle, +} from '../stories/demo-hosts.js'; import selectionMeta, { AccordionModeSwitch, MultipleListbox, @@ -86,7 +90,7 @@ export default { export const SingleModeRatingTest: Story = { ...SingleModeRating, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, 'demo-selection-star-single' ); @@ -142,6 +146,24 @@ export const SingleModeRatingTest: Story = { expect(stars[4].getAttribute('aria-checked')).toBe('true'); expect(stars[0].getAttribute('aria-checked')).toBe('false'); }); + + await step( + 'a silent { silent: true } call clears the selection despite single mode', + async () => { + // Clicking the active star has no effect in single mode (asserted + // above), but a silent programmatic clear — the pattern a consumer + // uses to resync from an external property reset to "nothing + // selected" — can still empty it. + expect(stars[4].getAttribute('aria-checked')).toBe('true'); + + host.clearSelectionSilently(); + + expect( + stars[4].getAttribute('aria-checked'), + 'previously active star is deselected' + ).toBe('false'); + } + ); }, }; @@ -515,5 +537,67 @@ export const TablistWithFocusgroupTest: Story = { expect(shadowFocused(host)?.getAttribute('data-tab')).toBe('export'); expect(tab('layers').getAttribute('aria-selected')).toBe('true'); }); + + await step( + 'confirmSelectionChange runs after mutators — the listener sees the new selection', + async () => { + // 'layers' is selected entering this step (from the prior step). + let ariaSelectedInHandler: string | null = null; + let panelHiddenInHandler: boolean | null = null; + host.addEventListener( + DEMO_TAB_CHANGE_EVENT, + () => { + ariaSelectedInHandler = + tab('adjustments').getAttribute('aria-selected'); + panelHiddenInHandler = + tabPanel('adjustments').hasAttribute('hidden'); + }, + { once: true } + ); + + tab('adjustments').click(); + + expect( + ariaSelectedInHandler, + 'tab is already aria-selected inside the confirm handler' + ).toBe('true'); + expect( + panelHiddenInHandler, + "tab's panel is already visible inside the confirm handler" + ).toBe(false); + expect(tab('adjustments').getAttribute('aria-selected')).toBe('true'); + } + ); + + await step( + 'canceling confirmSelectionChange reverts to the prior selection', + async () => { + // 'adjustments' is selected entering this step. + host.addEventListener( + DEMO_TAB_CHANGE_EVENT, + (event) => event.preventDefault(), + { once: true } + ); + + tab('export').click(); + + expect( + tab('export').getAttribute('aria-selected'), + 'clicked tab reverts to deselected' + ).toBe('false'); + expect( + tabPanel('export').hasAttribute('hidden'), + "clicked tab's panel reverts to hidden" + ).toBe(true); + expect( + tab('adjustments').getAttribute('aria-selected'), + 'prior selection is restored' + ).toBe('true'); + expect( + tabPanel('adjustments').hasAttribute('hidden'), + "prior selection's panel is restored to visible" + ).toBe(false); + } + ); }, }; From d7b3312408f3aa4487909844791f67fddd41212f Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Tue, 7 Jul 2026 16:19:27 -0400 Subject: [PATCH 29/32] fix(core): fixed host connected refresh, added force flag, and updated docs --- .../selection-controller.mdx | 54 +++++++++---------- .../src/selection-controller.ts | 43 ++++++++++++--- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx index 4cb805804f6..ef278a70aa0 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -21,7 +21,7 @@ import * as SelectionControllerStories from './stories/selection-controller.stor - **`swc-selection-controller-change`** (`selectionControllerChange`): dispatched on the host (bubbles, composed) with `{ selectedItems, addedItems, removedItems }` whenever the selection changes. - **`onSelectionChange`**: optional callback with the same payload. -- **`confirmSelectionChange`**: optional gate; return `false` to abort a pending transition before any DOM mutators run. +- **`confirmSelectionChange`**: optional gate, called _after_ mutators and internal state are already applied for the candidate transition — return `false` to revert everything back to the prior selection. Not called for `{ silent: true }` transitions, nor for transitions this controller makes to enforce its own invariants (removing a disconnected item, normalizing a `mode` switch, asserting `defaultToFirstSelectable`). ### Programmatic API @@ -154,7 +154,7 @@ this.selection.clearAll(); ### Accordion: runtime mode switch via `setOptions` -Call `setOptions({ mode })` to switch modes at runtime without reconstructing the controller. This accordion starts in `single` mode; the buttons switch it to `single-toggle` or `multiple` on the fly. When switching from `multiple` back to a single-item mode while more than one panel is open, all but the first are closed automatically and a change event is dispatched for the deselected items. +Call `setOptions({ mode })` to switch modes at runtime without reconstructing the controller. This accordion starts in `single` mode; the buttons switch it to `single-toggle` or `multiple` on the fly. When switching from `multiple` back to a single-item mode while more than one panel is open, all but the first are closed automatically and a change event is dispatched for the deselected items — this cleanup isn't gated by `confirmSelectionChange`, since it's this controller enforcing the new mode's own invariant, not a selection a consumer chose to veto. ```typescript // Initial setup: @@ -213,19 +213,19 @@ For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host ### Methods -| Member | Description | -| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes, re-syncs interaction listeners for `enableInteraction` / `keydownActivation`, and calls `refresh()`. | -| `refresh(options?)` | Remove stale selections and optionally select the first eligible item (`defaultToFirstSelectable`). | -| `getSelectedItems()` | Returns the current selection as an ordered array. | -| `isSelected(item)` | Returns `true` when `item` is currently selected. | -| `getMode()` | Returns the current `SelectionMode`. | -| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes, or `single` with `{ silent: true }`). | -| `toggleItem(item, options?)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | -| `selectAll(options?)` | Select all eligible items. Multiple mode only; returns `false` otherwise. | -| `clearAll(options?)` | Deselect all items. Single-toggle and multiple modes, or `single` with `{ silent: true }`; otherwise returns `false` in `single` mode. | -| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) unless `enableInteraction` is `false`, then calls `refresh()`. | -| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | +| Member | Description | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes, re-syncs interaction listeners for `enableInteraction` / `keydownActivation`, and calls `refresh()`. | +| `refresh(options?)` | Remove stale selections and optionally select the first eligible item (`defaultToFirstSelectable`). | +| `getSelectedItems()` | Returns the current selection as an ordered array. | +| `isSelected(item)` | Returns `true` when `item` is currently selected. | +| `getMode()` | Returns the current `SelectionMode`. | +| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes, or `single` with `{ silent: true }`). | +| `toggleItem(item, options?)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | +| `selectAll(options?)` | Select all eligible items. Multiple mode only; returns `false` otherwise. | +| `clearAll(options?)` | Deselect all items. Single-toggle and multiple modes, or `single` with `{ silent: true }`; otherwise returns `false` in `single` mode. | +| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) unless `enableInteraction` is `false`, then calls `refresh({ silent: true })` — silent because this runs before the host's first render. | +| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | `refresh`, `setSelectedItem`, `toggleItem`, `selectAll`, and `clearAll` all accept an `options?: { silent?: boolean }` parameter — pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event. For `setSelectedItem(null, ...)` and `clearAll`, `{ silent: true }` also bypasses the `single`-mode restriction that otherwise rejects clearing: a mandatory single-select group can't be emptied by _clicking_, but a consumer resyncing from an external property that explicitly represents "nothing selected" (for example ``) can still clear it. @@ -244,18 +244,18 @@ host.addEventListener(selectionControllerChange, (event) => { ### Options -| Option | Type | Default | Description | -| -------------------------- | ------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | -| `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | -| `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | -| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | -| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | -| `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the eligible item on the event path. Pair with `FocusgroupNavigationController` for arrow keys. | -| `enableInteraction` | `boolean` | `true` | When `false`, this controller never attaches its own click/keydown listeners. Use for bookkeeping-only integrations — see [Bookkeeping-only usage](#bookkeeping-only-usage). | -| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Callback with the same payload as the change event. | -| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Return `false` to abort the transition before mutators run. | -| `isDisabled` | `(item: HTMLElement) => boolean` | — | Override for the eligibility disabled check. Use when disabled state cascades from an ancestor rather than being reflected on the participant itself. | +| Option | Type | Default | Description | +| -------------------------- | ------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | +| `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | +| `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | +| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | +| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | +| `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the eligible item on the event path. Pair with `FocusgroupNavigationController` for arrow keys. | +| `enableInteraction` | `boolean` | `true` | When `false`, this controller never attaches its own click/keydown listeners. Use for bookkeeping-only integrations — see [Bookkeeping-only usage](#bookkeeping-only-usage). | +| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Callback with the same payload as the change event. | +| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Called after mutators run for the candidate transition; return `false` to revert it. Never called for invariant-enforcing transitions (see [Events and callbacks](#events-and-callbacks)) or `{ silent: true }` calls. | +| `isDisabled` | `(item: HTMLElement) => boolean` | — | Override for the eligibility disabled check. Use when disabled state cascades from an ancestor rather than being reflected on the participant itself. | ## Appendix diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 77942f612fe..b1a7e4cd668 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -92,7 +92,11 @@ export type SelectionControllerOptions = { * the **`onSelectionChange`** mirror) is rolled back to the prior selection and no change event * is dispatched. Omit for unconditional commits. Not called for transitions committed with * **`{ silent: true }`** (see {@link SelectionController.setSelectedItem} and - * {@link SelectionController.refresh}). + * {@link SelectionController.refresh}), nor for transitions this controller makes to enforce + * its own invariants — removing a disconnected item on **`refresh`**, normalizing a **`mode`** + * switch via **`setOptions`**, and asserting **`defaultToFirstSelectable`** are never vetoable, + * since reverting any of them would leave the controller violating the invariant it was + * enforcing. */ confirmSelectionChange?: ( detail: SelectionControllerConfirmDetail @@ -333,7 +337,9 @@ export class SelectionController implements ReactiveController { const [first] = this.selectedItems; const toRemove = Array.from(this.selectedItems).slice(1); const candidate = first ? [first] : []; - this.applySelectionTransition(candidate, toRemove); + // force: normalizing for the new mode is this controller enforcing its + // own invariant, not a selection a consumer should be able to veto. + this.applySelectionTransition(candidate, toRemove, { force: true }); } this.syncInteractionListeners(); @@ -489,10 +495,13 @@ export class SelectionController implements ReactiveController { ); if (stale.length > 0) { + // force: removing a disconnected/ineligible item is this controller + // enforcing its own invariant, not a selection a consumer should be + // able to veto. const next = Array.from(this.selectedItems).filter( (el) => !stale.includes(el) ); - this.applySelectionTransition(next, stale, { silent }); + this.applySelectionTransition(next, stale, { silent, force: true }); } const isSingle = @@ -503,13 +512,18 @@ export class SelectionController implements ReactiveController { this.selectedItems.size === 0 && eligible.length > 0 ) { - this.applySelectionTransition([eligible[0]], [], { silent }); + // force: same reasoning — defaultToFirstSelectable exists specifically + // to guarantee a selection exists; a veto would leave that unmet. + this.applySelectionTransition([eligible[0]], [], { silent, force: true }); } } public hostConnected(): void { this.syncInteractionListeners(); - this.refresh(); + // Silent: this runs during connectedCallback, before the host's first + // render — a consumer's confirmSelectionChange (and any event it + // dispatches) firing this early would be surprising and premature. + this.refresh({ silent: true }); } public hostDisconnected(): void { @@ -570,19 +584,34 @@ export class SelectionController implements ReactiveController { * **`onSelectionChange`** call so external mirrors roll back too. Lit batches property updates * into a microtask, so an apply-then-synchronously-revert never paints the intermediate state. * + * Because a revert restores **`priorItems`** exactly, **`selectItem`** / **`deselectItem`** (and + * **`onSelectionChange`**) may run for a transition that a moment later turns out not to have + * happened. Keep them idempotent and side-effect-light beyond reflecting state — safe for + * something like setting an attribute, risky for something like an analytics call or a counter. + * * The added/removed short-circuit below is computed against this controller's own cached * **`selectedItems`**, which is only trustworthy when this controller is the sole mutator of * selected-ish state (true for interactive, non-silent transitions). Silent transitions exist * specifically to reconcile from participants whose state can change independently of this * controller, so that cache may be stale — silent calls always proceed to {@link applyMutators} * rather than trusting a diff against it. + * + * **`force`** (internal only — never exposed on the public **`{ silent }`** options bag) skips + * **`confirmSelectionChange`** the same way **`silent`** does, but leaves the public event + * dispatch alone. It exists for transitions that enforce this controller's *own* invariants + * (removing a disconnected item, normalizing a mode switch, asserting + * **`defaultToFirstSelectable`**) rather than representing a selection a consumer chose to + * make. Those must never be vetoable: reverting one would leave the controller violating the + * very invariant it was enforcing (for example more than one item selected while + * **`mode: 'single'`**, or a disconnected element still in the selection set). */ private applySelectionTransition( next: HTMLElement[], removedItems: HTMLElement[], - options?: { silent?: boolean } + options?: { silent?: boolean; force?: boolean } ): boolean { const silent = options?.silent ?? false; + const force = options?.force ?? false; const priorItems = Array.from(this.selectedItems); const addedItems = next.filter((el) => !this.selectedItems.has(el)); @@ -597,7 +626,7 @@ export class SelectionController implements ReactiveController { removedItems, }); - if (!silent && this.options.confirmSelectionChange) { + if (!silent && !force && this.options.confirmSelectionChange) { const ok = this.options.confirmSelectionChange({ candidateItems: next, priorItems, From 2e1836dc104621f02586b4533eab9c26bc2eaf12 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Wed, 8 Jul 2026 13:42:17 -0400 Subject: [PATCH 30/32] feat(accordion): allowed accordion to switch to allow multiple via selection controller --- .../components/accordion/Accordion.base.ts | 36 +++++++-- .../accordion/test/accordion.test.ts | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/2nd-gen/packages/core/components/accordion/Accordion.base.ts b/2nd-gen/packages/core/components/accordion/Accordion.base.ts index f8362bc7446..d12f32a98bd 100644 --- a/2nd-gen/packages/core/components/accordion/Accordion.base.ts +++ b/2nd-gen/packages/core/components/accordion/Accordion.base.ts @@ -106,13 +106,28 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { /** * @internal * - * Applies the exclusive-open constraint when `allowMultiple` is false, in - * place of manually iterating `assignedItems()`. Only ever driven imperatively - * from `closeSiblingsOnOpen` with `{ silent: true }` — items keep their own - * click handling and cancelable-toggle lifecycle in `AccordionItemBase.toggle()` - * unchanged. `enableInteraction: false` means this controller never attaches - * its own click/keydown listeners; `getItems` is only used for `applyMutators`' - * live scan of every assigned item when a silent transition is asserted. + * Applies the exclusive-open constraint when in `single-toggle` mode, in + * place of manually iterating `assignedItems()`. `allowMultiple` drives this + * controller's `mode` via `setOptions` (see the `allowMultiple` branch in + * `update()`), so `closeSiblingsOnOpen` only needs to consult `getMode()` + * rather than reading the `allowMultiple` property directly. The controller + * is only ever driven imperatively from `closeSiblingsOnOpen` with + * `{ silent: true }` — items keep their own click handling and + * cancelable-toggle lifecycle in `AccordionItemBase.toggle()` unchanged. + * `enableInteraction: false` means this controller never attaches its own + * click/keydown listeners; `getItems` is only used for `applyMutators`' live + * scan of every assigned item when a silent transition is asserted. + * + * In `multiple` mode this controller's selection cache is intentionally left + * unpopulated: `closeSiblingsOnOpen` no-ops entirely, since items already + * manage their own `open` state independently and multiple mode does not + * need siblings closed. Routing every open/close through the controller's + * `multiple`-mode bookkeeping (`setSelectedItem`/`toggleItem`) was + * considered, but their `next` selection set is built by extending the + * cached `selectedItems`, so a cache that drifted from reality (an item + * closing without going through the controller) could cause a later open to + * re-select, and therefore re-open, an already-closed item. This controller + * is deliberately not the source of truth for `multiple` mode as a result. * * Known limitation: an item opened via a direct `open` property/attribute set * (bypassing `toggle()`) is not reconciled by this controller, the same way @@ -135,7 +150,7 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { event.preventDefault(); return; } - if (this.allowMultiple) { + if (this._selection.getMode() === 'multiple') { return; } const toggling = event.target; @@ -193,6 +208,11 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { } protected override update(changedProperties: PropertyValues): void { + if (changedProperties.has('allowMultiple')) { + this._selection.setOptions({ + mode: this.allowMultiple ? 'multiple' : 'single-toggle', + }); + } if (changedProperties.has('level')) { const clamped = Math.min( 6, diff --git a/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts b/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts index 347e7117e1a..0133ca87ac7 100644 --- a/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts +++ b/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts @@ -468,6 +468,84 @@ export const AllowMultipleTest: Story = { }, }; +export const AllowMultipleRuntimeSwitchTest: Story = { + render: () => html` + + + Item 1 +

Panel 1

+
+ + Item 2 +

Panel 2

+
+ + Item 3 +

Panel 3

+
+
+ `, + play: async ({ canvasElement, step }) => { + const accordion = await getComponent( + canvasElement, + 'swc-accordion' + ); + const [item1, item2, item3] = await getComponents( + canvasElement, + 'swc-accordion-item' + ); + + await step('open items 1 and 2 while allow-multiple is true', async () => { + getHeader(item1).click(); + await flushMicrotasks(); + getHeader(item2).click(); + await flushMicrotasks(); + await accordion.updateComplete; + await Promise.all([item1.updateComplete, item2.updateComplete]); + + expect(item1.open, 'item 1 is open').toBe(true); + expect(item2.open, 'item 2 is open').toBe(true); + }); + + await step( + 'switching allow-multiple to false re-enforces exclusive open on the next toggle', + async () => { + accordion.allowMultiple = false; + await accordion.updateComplete; + + getHeader(item3).click(); + await flushMicrotasks(); + await accordion.updateComplete; + await Promise.all([ + item1.updateComplete, + item2.updateComplete, + item3.updateComplete, + ]); + + expect(item1.open, 'item 1 is closed').toBe(false); + expect(item2.open, 'item 2 is closed').toBe(false); + expect(item3.open, 'item 3 is the sole open item').toBe(true); + } + ); + + await step( + 'switching allow-multiple back to true permits multiple open items again', + async () => { + accordion.allowMultiple = true; + await accordion.updateComplete; + + getHeader(item1).click(); + await flushMicrotasks(); + await accordion.updateComplete; + await Promise.all([item1.updateComplete, item3.updateComplete]); + + expect(item1.open, 'item 1 is open').toBe(true); + expect(item3.open, 'item 3 remains open').toBe(true); + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── // TEST: Controlled (cancelable toggle) // ────────────────────────────────────────────────────────────── From eb5a3029af52fba7eb347789d3ec435631c8a93b Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Wed, 8 Jul 2026 14:27:34 -0400 Subject: [PATCH 31/32] fix(core): made selection controller more useful for cases like accordion --- .../components/accordion/Accordion.base.ts | 75 ++--- .../src/selection-controller.ts | 311 ++++++++++++++---- .../accordion/test/accordion.test.ts | 29 +- 3 files changed, 291 insertions(+), 124 deletions(-) diff --git a/2nd-gen/packages/core/components/accordion/Accordion.base.ts b/2nd-gen/packages/core/components/accordion/Accordion.base.ts index d12f32a98bd..7a51f81b8ee 100644 --- a/2nd-gen/packages/core/components/accordion/Accordion.base.ts +++ b/2nd-gen/packages/core/components/accordion/Accordion.base.ts @@ -106,32 +106,17 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { /** * @internal * - * Applies the exclusive-open constraint when in `single-toggle` mode, in - * place of manually iterating `assignedItems()`. `allowMultiple` drives this - * controller's `mode` via `setOptions` (see the `allowMultiple` branch in - * `update()`), so `closeSiblingsOnOpen` only needs to consult `getMode()` - * rather than reading the `allowMultiple` property directly. The controller - * is only ever driven imperatively from `closeSiblingsOnOpen` with - * `{ silent: true }` — items keep their own click handling and - * cancelable-toggle lifecycle in `AccordionItemBase.toggle()` unchanged. - * `enableInteraction: false` means this controller never attaches its own - * click/keydown listeners; `getItems` is only used for `applyMutators`' live - * scan of every assigned item when a silent transition is asserted. - * - * In `multiple` mode this controller's selection cache is intentionally left - * unpopulated: `closeSiblingsOnOpen` no-ops entirely, since items already - * manage their own `open` state independently and multiple mode does not - * need siblings closed. Routing every open/close through the controller's - * `multiple`-mode bookkeeping (`setSelectedItem`/`toggleItem`) was - * considered, but their `next` selection set is built by extending the - * cached `selectedItems`, so a cache that drifted from reality (an item - * closing without going through the controller) could cause a later open to - * re-select, and therefore re-open, an already-closed item. This controller - * is deliberately not the source of truth for `multiple` mode as a result. - * - * Known limitation: an item opened via a direct `open` property/attribute set - * (bypassing `toggle()`) is not reconciled by this controller, the same way - * `closeSiblingsOnOpen` itself only reacts to toggle-driven changes today. + * Items are self-owning — `AccordionItemBase.toggle()` flips its own `open` + * property and dispatches its own cancelable toggle event independently of + * this controller. `readSelected` makes `open` the live source of truth + * (no cache to drift), and `observeEvent` reacts to the item's own toggle + * event: in `single`/`single-toggle` mode, when an item announces it just + * opened, this controller asserts it as the sole selection, closing every + * other live-open item via the normal mutator rescan; in `multiple` mode it + * is a no-op, since a self-toggling item needs no cross-item enforcement. + * `allowMultiple` drives `mode` via `setOptions` (see `update()`). + * `enableInteraction: false` — items own click/keydown, not this + * controller. */ private readonly _selection = new SelectionController(this, { getItems: () => this.assignedItems() as HTMLElement[], @@ -143,31 +128,21 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { }, mode: 'single-toggle', enableInteraction: false, + readSelected: (item) => (item as AccordionItemBase).open, + observeEvent: SWC_ACCORDION_ITEM_TOGGLE_EVENT, }); - private closeSiblingsOnOpen = (event: Event): void => { + /** + * Cancels a toggle dispatched while the host is disabled. `toggle()`'s own + * `mayExpand()` guard normally prevents this via `parentDisabled`, but that + * propagates through a reactive update — a click landing in the same tick + * `disabled` is set true, before that update runs, would otherwise still + * go through. + */ + private readonly guardDisabledToggle = (event: Event): void => { if (this.disabled) { event.preventDefault(); - return; } - if (this._selection.getMode() === 'multiple') { - return; - } - const toggling = event.target; - if (!(toggling instanceof AccordionItemBase)) { - return; - } - // Defer until after dispatch returns so that a canceled toggle (where the - // item reverts open back to false) does not incorrectly close siblings. - queueMicrotask(() => { - if (!toggling.open) { - return; - } - // Asserts toggling as the sole selection; applyMutators diffs against a - // live scan of every assigned item, so every other item is closed - // regardless of what this controller previously believed was selected. - this._selection.setSelectedItem(toggling, { silent: true }); - }); }; protected syncAccordionItems(): void { @@ -195,7 +170,7 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { super.connectedCallback(); this.addEventListener( SWC_ACCORDION_ITEM_TOGGLE_EVENT, - this.closeSiblingsOnOpen + this.guardDisabledToggle ); } @@ -203,12 +178,16 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { super.disconnectedCallback(); this.removeEventListener( SWC_ACCORDION_ITEM_TOGGLE_EVENT, - this.closeSiblingsOnOpen + this.guardDisabledToggle ); } protected override update(changedProperties: PropertyValues): void { if (changedProperties.has('allowMultiple')) { + // `readSelected` makes `open` the live source of truth (see the + // `_selection` doc comment), so `setOptions`'s own multi→single + // normalization sees every actually-open item and collapses to the + // first of them immediately — no separate live-DOM pass needed here. this._selection.setOptions({ mode: this.allowMultiple ? 'multiple' : 'single-toggle', }); diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index b1a7e4cd668..6ffae4ecbce 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -76,12 +76,55 @@ export type SelectionControllerOptions = { * **`keydown`** listeners, so user interaction can never drive a transition. Use this when a * consumer's items already own their own interaction handling (their own click binding, their * own cancelable-event lifecycle) and the controller is only wanted for its mode-aware - * selection bookkeeping, driven imperatively via **`{ silent: true }`** calls. Defaults to - * **`true`**. + * selection bookkeeping — pair with **`observeEvent`** and **`readSelected`** so the controller + * reacts to the item's own event instead. Defaults to **`true`**. */ enableInteraction?: boolean; - /** Optional callback mirroring {@link selectionControllerChange}. */ + /** + * When provided, the current selection is *read live from the items themselves* on every + * decision (`toggleItem`, `setSelectedItem`, `setOptions` mode-switch normalization, `refresh`, + * interactive clicks/keys) instead of from this controller's internal cache. Use this for + * self-owning items — an accordion item with its own `open` property, a menu item with its own + * `selected` state — where the item can change that state independently of this controller (its + * own click handler, a direct property/attribute set). Without `readSelected`, the internal + * cache can drift from what the items actually show, which is why `multiple` mode is unsafe to + * drive through a cache-based controller for this kind of item: extending a stale cached set can + * re-open an item a consumer already closed. With `readSelected`, there is no cache to drift — + * every decision reflects what is true on the DOM right now. Pair with `observeEvent` so the + * controller also reacts when an item changes itself. + */ + readSelected?: (item: HTMLElement) => boolean; + + /** + * Name of a bubbling custom event, dispatched by a self-owning item on its own initiative (for + * example an accordion item's own cancelable toggle event), that this controller listens for + * instead of capturing `click`/`keydown` itself. Pair with `readSelected` — required when this + * option is set. On the event, this controller waits a microtask (so a cancelable event that the + * item itself reverts has already settled), reads the item's live state via `readSelected`, and + * — in `single` / `single-toggle` mode, when the item is now selected — asserts it as the sole + * selection, closing every other live-selected item via the normal mutator rescan. In `multiple` + * mode this is a no-op: a self-owning item toggling itself needs no cross-item enforcement, so + * this controller does nothing. Ignored when `enableInteraction` interaction is also relied upon + * — the two are alternative ways of learning about a selection attempt, not additive. + */ + observeEvent?: string; + + /** + * Optional callback mirroring {@link selectionControllerChange}. + * + * **Fires on every commit, including `{ silent: true }` transitions** — unlike + * `confirmSelectionChange` and the `selectionControllerChange` DOM event, `silent` does not gate + * this callback. Silent transitions exist to reconcile controller state from participants whose + * selected-ish state changed independently (an external property write, a `defaultToFirstSelectable` + * assertion during a silent `refresh`), and this callback is frequently the *only* channel a host + * uses to mirror that reconciled selection into its own public property — see `swc-tabs`' + * `onSelectionChange`, which is how a silently-applied default-selected tab reaches the host's + * `selected` property. Gating this callback on `silent` would break that pattern. Keep it + * idempotent and side-effect-light beyond mirroring state (safe: setting a property; risky: an + * analytics call), since it may also run for a transition that a moment later gets rolled back by + * `confirmSelectionChange` returning `false`. + */ onSelectionChange?: (detail: SelectionControllerChangeDetail) => void; /** @@ -193,6 +236,17 @@ export function deepestSelectionItemContaining( * property): even if this controller's cached notion of the selection has drifted, asserting a * transition still corrects every participant to match `next`. * + * **Two ways to know what's selected.** By default this controller is the source of truth: it + * owns an internal cache, and `selectItem` / `deselectItem` are the only way selected-ish state + * changes. That works well for items with no state of their own (plain buttons, list rows). For + * *self-owning* items — an accordion item with its own `open` property, a menu item with its own + * `selected` state, anything that can change that state on its own initiative — pass + * **`readSelected`** so this controller reads truth from the items on every decision instead of + * trusting a cache that item can silently invalidate. Pair it with **`observeEvent`** so this + * controller also reacts when an item changes itself, instead of (or in addition to) + * **`enableInteraction`**-driven clicks. With `readSelected` in place, `multiple` mode becomes + * safe for self-owning items too: there's no cache to extend with a stale, already-closed entry. + * * @see https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ */ @@ -216,7 +270,11 @@ export class SelectionController implements ReactiveController { > & Pick< SelectionControllerOptions, - 'onSelectionChange' | 'confirmSelectionChange' | 'isDisabled' + | 'onSelectionChange' + | 'confirmSelectionChange' + | 'isDisabled' + | 'readSelected' + | 'observeEvent' >; private selectedItems: Set = new Set(); @@ -225,6 +283,8 @@ export class SelectionController implements ReactiveController { private keydownListenerAttached = false; + private observedEventName: string | undefined; + private readonly handleClickCapture = (event: MouseEvent): void => { if (event.button !== 0) { return; @@ -244,7 +304,7 @@ export class SelectionController implements ReactiveController { if (!this.options.keydownActivation) { return; } - if (event.code !== 'Enter' && event.code !== 'Space') { + if (event.key !== 'Enter' && event.key !== ' ') { return; } if (event.repeat) { @@ -262,6 +322,33 @@ export class SelectionController implements ReactiveController { this.applyClickOrKey(hit); }; + /** + * Reacts to a self-owning item's own `observeEvent`, rather than capturing the interaction + * directly. See {@link SelectionControllerOptions.observeEvent}. + */ + private readonly handleObservedEvent = (event: Event): void => { + const readSelected = this.options.readSelected; + if (!readSelected) { + return; + } + const target = deepestSelectionItemContaining( + event, + this.getScopedRawItems() + ); + if (!target || this.isDisabledParticipant(target)) { + return; + } + // Defer until the item's own cancelable-event lifecycle settles — a + // canceled change reverts the item's state synchronously, but only after + // every bubble listener (including this one) has already run. + queueMicrotask(() => { + if (this.options.mode === 'multiple' || !readSelected(target)) { + return; + } + this.setSelectedItem(target, { silent: true }); + }); + }; + constructor(host: ReactiveElement, options: SelectionControllerOptions) { this.host = host; this.options = { @@ -275,18 +362,36 @@ export class SelectionController implements ReactiveController { onSelectionChange: options.onSelectionChange, confirmSelectionChange: options.confirmSelectionChange, isDisabled: options.isDisabled, + readSelected: options.readSelected, + observeEvent: options.observeEvent, }; host.addController(this); } - /** Returns a snapshot of the current selection (ordered by insertion). */ - public getSelectedItems(): HTMLElement[] { + /** + * Returns the current selection. Read live from the items via `readSelected` when provided + * (see {@link SelectionControllerOptions.readSelected}); otherwise a snapshot of the internal + * cache, ordered by insertion. + */ + private currentSelection(): HTMLElement[] { + const { readSelected } = this.options; + if (readSelected) { + return this.getScopedRawItems().filter(readSelected); + } return Array.from(this.selectedItems); } + /** Returns the current selection. See {@link SelectionController.currentSelection}. */ + public getSelectedItems(): HTMLElement[] { + return this.currentSelection(); + } + /** Returns **`true`** when {@link item} is currently selected. */ public isSelected(item: HTMLElement): boolean { + if (this.options.readSelected) { + return this.options.readSelected(item); + } return this.selectedItems.has(item); } @@ -327,22 +432,32 @@ export class SelectionController implements ReactiveController { confirmSelectionChange: partial.confirmSelectionChange ?? this.options.confirmSelectionChange, isDisabled: partial.isDisabled ?? this.options.isDisabled, + readSelected: partial.readSelected ?? this.options.readSelected, + observeEvent: partial.observeEvent ?? this.options.observeEvent, }; const nextMode = this.options.mode; const wasSingle = prevMode === 'single' || prevMode === 'single-toggle'; const isNowSingle = nextMode === 'single' || nextMode === 'single-toggle'; - if (!wasSingle && isNowSingle && this.selectedItems.size > 1) { - const [first] = this.selectedItems; - const toRemove = Array.from(this.selectedItems).slice(1); - const candidate = first ? [first] : []; - // force: normalizing for the new mode is this controller enforcing its - // own invariant, not a selection a consumer should be able to veto. - this.applySelectionTransition(candidate, toRemove, { force: true }); + if (!wasSingle && isNowSingle) { + // Read live rather than trusting the pre-switch cache: with + // `readSelected`, `multiple` mode never populates the cache (see its + // docs), so this is the only way to see everything actually selected + // going into the switch. + const current = this.currentSelection(); + if (current.length > 1) { + const [first] = current; + const toRemove = current.slice(1); + const candidate = first ? [first] : []; + // force: normalizing for the new mode is this controller enforcing + // its own invariant, not a selection a consumer should be able to + // veto. + this.applySelectionTransition(candidate, toRemove, { force: true }); + } } - this.syncInteractionListeners(); + this.syncListeners(); this.refresh(); } @@ -359,6 +474,7 @@ export class SelectionController implements ReactiveController { * Pass **`{ silent: true }`** to commit this transition without invoking * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to * resync internal state from an external property change without raising a public event. + * **`onSelectionChange`** still runs (see its docs for why). */ public setSelectedItem( item: HTMLElement | null, @@ -378,16 +494,17 @@ export class SelectionController implements ReactiveController { return false; } + const current = this.currentSelection(); + if (this.options.mode === 'multiple') { - if (this.selectedItems.has(item)) { + if (current.includes(item)) { return true; } - const next = [...Array.from(this.selectedItems), item]; + const next = [...current, item]; return this.applySelectionTransition(next, [], options); } - const prev = Array.from(this.selectedItems); - const toRemove = prev.filter((el) => el !== item); + const toRemove = current.filter((el) => el !== item); return this.applySelectionTransition([item], toRemove, options); } @@ -402,7 +519,7 @@ export class SelectionController implements ReactiveController { * @returns **`false`** when the item is ineligible or when the mode disallows the operation. * * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or - * dispatching {@link selectionControllerChange}. + * dispatching {@link selectionControllerChange}. **`onSelectionChange`** still runs. */ public toggleItem( item: HTMLElement, @@ -415,21 +532,21 @@ export class SelectionController implements ReactiveController { return false; } - const isSelected = this.selectedItems.has(item); + const current = this.currentSelection(); + const isSelected = current.includes(item); if (isSelected) { if (this.options.mode === 'single') { return false; } - const next = Array.from(this.selectedItems).filter((el) => el !== item); + const next = current.filter((el) => el !== item); return this.applySelectionTransition(next, [item], options); } else { if (this.options.mode === 'multiple') { - const next = [...Array.from(this.selectedItems), item]; + const next = [...current, item]; return this.applySelectionTransition(next, [], options); } - const prev = Array.from(this.selectedItems); - const toRemove = prev.filter((el) => el !== item); + const toRemove = current.filter((el) => el !== item); return this.applySelectionTransition([item], toRemove, options); } } @@ -439,18 +556,19 @@ export class SelectionController implements ReactiveController { * single-item modes (does not throw). * * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or - * dispatching {@link selectionControllerChange}. + * dispatching {@link selectionControllerChange}. **`onSelectionChange`** still runs. */ public selectAll(options?: { silent?: boolean }): boolean { if (this.options.mode !== 'multiple') { return false; } + const current = this.currentSelection(); const eligible = this.getEligibleItems(); - const toAdd = eligible.filter((el) => !this.selectedItems.has(el)); + const toAdd = eligible.filter((el) => !current.includes(el)); if (toAdd.length === 0) { return true; } - const next = [...Array.from(this.selectedItems), ...toAdd]; + const next = [...current, ...toAdd]; return this.applySelectionTransition(next, [], options); } @@ -460,37 +578,44 @@ export class SelectionController implements ReactiveController { * is passed, which clears it anyway (see {@link SelectionController.setSelectedItem}). * * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or - * dispatching {@link selectionControllerChange}. + * dispatching {@link selectionControllerChange}. **`onSelectionChange`** still runs. */ public clearAll(options?: { silent?: boolean }): boolean { if (this.options.mode === 'single' && !options?.silent) { return false; } - if (this.selectedItems.size === 0) { + const current = this.currentSelection(); + if (current.length === 0) { return true; } - const toRemove = Array.from(this.selectedItems); - return this.applySelectionTransition([], toRemove, options); + return this.applySelectionTransition([], current, options); } /** - * Re-applies bookkeeping after structural changes: removes stale selections, and selects the - * first eligible item when **`defaultToFirstSelectable`** is **`true`** and nothing is selected - * (single-item modes only). When nothing is currently eligible, no default selection is forced. + * Re-applies bookkeeping after structural changes: removes stale selections, collapses an + * over-selected single-item mode down to one, and selects the first eligible item when + * **`defaultToFirstSelectable`** is **`true`** and nothing is selected (single-item modes only). + * When nothing is currently eligible, no default selection is forced. * * Pass **`{ silent: true }`** to commit these transitions without invoking * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to * resync internal state from an external property change without raising a public event. + * **`onSelectionChange`** still runs — this is how `swc-tabs` mirrors a silently-applied + * `defaultToFirstSelectable` selection into its own `selected` property. */ public refresh(options?: { silent?: boolean }): void { const silent = options?.silent ?? false; const eligible = this.getEligibleItems(); + const current = this.currentSelection(); // An item is stale when it is disconnected, or when the eligible list is // non-empty and does not include it. When eligible is empty (e.g. the host // signals it is disabled), only disconnected items are removed — connected - // selections are preserved so aria-selected stays true. - const stale = Array.from(this.selectedItems).filter( + // selections are preserved so aria-selected stays true. With + // `readSelected`, `current` is always a live scan of connected, in-scope + // items, so a "disconnected but still selected" item can't occur — this + // only does real work for the cache-based path. + const stale = current.filter( (el) => !el.isConnected || (eligible.length > 0 && !eligible.includes(el)) ); @@ -498,18 +623,41 @@ export class SelectionController implements ReactiveController { // force: removing a disconnected/ineligible item is this controller // enforcing its own invariant, not a selection a consumer should be // able to veto. - const next = Array.from(this.selectedItems).filter( - (el) => !stale.includes(el) - ); + const next = current.filter((el) => !stale.includes(el)); this.applySelectionTransition(next, stale, { silent, force: true }); } const isSingle = this.options.mode === 'single' || this.options.mode === 'single-toggle'; + + if (isSingle && this.options.readSelected) { + // Only meaningful with `readSelected`: without it, this controller is + // the sole mutator of the cache, so more than one item selected in a + // single-item mode can only happen mid-transition from `multiple` + // (already normalized by `setOptions`). With `readSelected`, an item + // can select itself independently of any transition this controller + // drove — e.g. two items both declared `open` in markup — so this + // catches that drift wherever `refresh` runs, including from + // `hostUpdated` (see below). + const overSelected = this.currentSelection(); + if (overSelected.length > 1) { + const [first] = overSelected; + const toRemove = overSelected.slice(1); + // force: same reasoning as the stale-removal above — enforcing this + // controller's own mode invariant, not a consumer's selection. + this.applySelectionTransition([first], toRemove, { + silent, + force: true, + }); + } + } + if ( isSingle && this.options.defaultToFirstSelectable && - this.selectedItems.size === 0 && + // Recomputed rather than reusing `current`: the steps above may have + // just changed what's actually selected. + this.currentSelection().length === 0 && eligible.length > 0 ) { // force: same reasoning — defaultToFirstSelectable exists specifically @@ -519,13 +667,34 @@ export class SelectionController implements ReactiveController { } public hostConnected(): void { - this.syncInteractionListeners(); + this.syncListeners(); // Silent: this runs during connectedCallback, before the host's first // render — a consumer's confirmSelectionChange (and any event it // dispatches) firing this early would be surprising and premature. this.refresh({ silent: true }); } + /** + * Best-effort backstop for `readSelected` consumers: a free, silent `refresh` after every + * update of *this controller's host* — cheap, since Lit already schedules the pass. + * + * **This does not fire when only a descendant item updates.** `hostUpdated` is scoped to the + * `ReactiveElement` this controller is attached to; an accordion item toggling its own `open` + * property re-renders *that item*, a separate `ReactiveElement` with its own independent update + * cycle — it does not, by itself, cause the accordion's `hostUpdated` to run. Real-time reaction + * to a self-owning item's own change is `observeEvent`'s job, not this one. What this catches is + * drift from a source that never announces itself at all — an `open` attribute set directly in + * markup on two items, or a property write outside `toggle()` — reconciled the next time the + * host happens to re-render for any reason. It is a supplement to `observeEvent`, not a + * replacement for it. + */ + public hostUpdated(): void { + if (!this.options.readSelected) { + return; + } + this.refresh({ silent: true }); + } + public hostDisconnected(): void { if (this.clickListenerAttached) { this.host.removeEventListener('click', this.handleClickCapture, true); @@ -535,6 +704,13 @@ export class SelectionController implements ReactiveController { this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); this.keydownListenerAttached = false; } + if (this.observedEventName) { + this.host.removeEventListener( + this.observedEventName, + this.handleObservedEvent + ); + this.observedEventName = undefined; + } } // ───────────────────────── @@ -544,27 +720,26 @@ export class SelectionController implements ReactiveController { /** Applies the mode-appropriate selection logic for a pointer click or Enter/Space key. */ private applyClickOrKey(hit: HTMLElement): void { const mode = this.options.mode; - const isSelected = this.selectedItems.has(hit); + const current = this.currentSelection(); + const isSelected = current.includes(hit); if (mode === 'single') { if (isSelected) { return; } - const prev = Array.from(this.selectedItems); - this.applySelectionTransition([hit], prev); + this.applySelectionTransition([hit], current); } else if (mode === 'single-toggle') { if (isSelected) { this.applySelectionTransition([], [hit]); } else { - const prev = Array.from(this.selectedItems); - this.applySelectionTransition([hit], prev); + this.applySelectionTransition([hit], current); } } else { if (isSelected) { - const next = Array.from(this.selectedItems).filter((el) => el !== hit); + const next = current.filter((el) => el !== hit); this.applySelectionTransition(next, [hit]); } else { - const next = [...Array.from(this.selectedItems), hit]; + const next = [...current, hit]; this.applySelectionTransition(next, []); } } @@ -589,12 +764,14 @@ export class SelectionController implements ReactiveController { * happened. Keep them idempotent and side-effect-light beyond reflecting state — safe for * something like setting an attribute, risky for something like an analytics call or a counter. * - * The added/removed short-circuit below is computed against this controller's own cached - * **`selectedItems`**, which is only trustworthy when this controller is the sole mutator of - * selected-ish state (true for interactive, non-silent transitions). Silent transitions exist + * The added/removed short-circuit below is computed against **`currentSelection()`** — the + * cache when no **`readSelected`** was given, or a live scan when it was. Without + * **`readSelected`**, that cache is only trustworthy when this controller is the sole mutator of + * selected-ish state (true for interactive, non-silent transitions); silent transitions exist * specifically to reconcile from participants whose state can change independently of this - * controller, so that cache may be stale — silent calls always proceed to {@link applyMutators} - * rather than trusting a diff against it. + * controller, so the cache may be stale there — silent calls always proceed to + * {@link applyMutators} rather than trusting a diff against it. With **`readSelected`**, there is + * no cache to go stale in the first place. * * **`force`** (internal only — never exposed on the public **`{ silent }`** options bag) skips * **`confirmSelectionChange`** the same way **`silent`** does, but leaves the public event @@ -612,8 +789,8 @@ export class SelectionController implements ReactiveController { ): boolean { const silent = options?.silent ?? false; const force = options?.force ?? false; - const priorItems = Array.from(this.selectedItems); - const addedItems = next.filter((el) => !this.selectedItems.has(el)); + const priorItems = this.currentSelection(); + const addedItems = next.filter((el) => !priorItems.includes(el)); if (!silent && addedItems.length === 0 && removedItems.length === 0) { return true; @@ -679,11 +856,11 @@ export class SelectionController implements ReactiveController { } /** - * Attaches or removes the capture-phase **`click`** and **`keydown`** listeners to match - * **`enableInteraction`** / **`keydownActivation`**. Neither listener is attached when - * **`enableInteraction`** is **`false`**, regardless of **`keydownActivation`**. + * Attaches or removes the capture-phase **`click`** / **`keydown`** listeners (per + * **`enableInteraction`** / **`keydownActivation`**) and the bubble-phase **`observeEvent`** + * listener (per {@link SelectionControllerOptions.observeEvent}). */ - private syncInteractionListeners(): void { + private syncListeners(): void { if (!this.host.isConnected) { return; } @@ -705,6 +882,20 @@ export class SelectionController implements ReactiveController { this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); this.keydownListenerAttached = false; } + + const wantObserved = this.options.observeEvent; + if (wantObserved !== this.observedEventName) { + if (this.observedEventName) { + this.host.removeEventListener( + this.observedEventName, + this.handleObservedEvent + ); + } + if (wantObserved) { + this.host.addEventListener(wantObserved, this.handleObservedEvent); + } + this.observedEventName = wantObserved; + } } private isNodeWithinHostScope(node: Node | null): boolean { diff --git a/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts b/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts index 0133ca87ac7..2ef013bed7b 100644 --- a/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts +++ b/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts @@ -508,23 +508,20 @@ export const AllowMultipleRuntimeSwitchTest: Story = { }); await step( - 'switching allow-multiple to false re-enforces exclusive open on the next toggle', + 'switching allow-multiple to false immediately collapses to one open item, before any further toggle', async () => { accordion.allowMultiple = false; await accordion.updateComplete; + await Promise.all([item1.updateComplete, item2.updateComplete]); - getHeader(item3).click(); - await flushMicrotasks(); - await accordion.updateComplete; - await Promise.all([ - item1.updateComplete, - item2.updateComplete, - item3.updateComplete, - ]); - - expect(item1.open, 'item 1 is closed').toBe(false); - expect(item2.open, 'item 2 is closed').toBe(false); - expect(item3.open, 'item 3 is the sole open item').toBe(true); + const openItems = [item1, item2, item3].filter((i) => i.open); + expect( + openItems.length, + 'exactly one item remains open immediately after allow-multiple is cleared, with no intervening toggle' + ).toBe(1); + expect(item1.open, 'item 1 (opened first) is the one kept open').toBe( + true + ); } ); @@ -534,13 +531,13 @@ export const AllowMultipleRuntimeSwitchTest: Story = { accordion.allowMultiple = true; await accordion.updateComplete; - getHeader(item1).click(); + getHeader(item3).click(); await flushMicrotasks(); await accordion.updateComplete; await Promise.all([item1.updateComplete, item3.updateComplete]); - expect(item1.open, 'item 1 is open').toBe(true); - expect(item3.open, 'item 3 remains open').toBe(true); + expect(item1.open, 'item 1 remains open').toBe(true); + expect(item3.open, 'item 3 is also open').toBe(true); } ); }, From 6210479ec7a7c735dcace4fc111ccd7d1f6709d4 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Wed, 8 Jul 2026 15:52:33 -0400 Subject: [PATCH 32/32] fix(core): performance fixes and updated docs --- .../components/accordion/Accordion.base.ts | 27 +-- 2nd-gen/packages/core/controllers/index.ts | 1 - .../controllers/selection-controller/index.ts | 1 - .../selection-controller.mdx | 202 +++++++++-------- .../src/selection-controller.ts | 205 +++++++++++------- .../test/selection-controller.test.ts | 47 +--- .../accordion/test/accordion.test.ts | 36 +++ 7 files changed, 282 insertions(+), 237 deletions(-) diff --git a/2nd-gen/packages/core/components/accordion/Accordion.base.ts b/2nd-gen/packages/core/components/accordion/Accordion.base.ts index 7a51f81b8ee..37d79b723d8 100644 --- a/2nd-gen/packages/core/components/accordion/Accordion.base.ts +++ b/2nd-gen/packages/core/components/accordion/Accordion.base.ts @@ -153,19 +153,6 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { } } - private enforceExclusiveOpen(): void { - let foundOpen = false; - for (const item of this.assignedItems()) { - if (item.open) { - if (foundOpen) { - item.open = false; - } else { - foundOpen = true; - } - } - } - } - public override connectedCallback(): void { super.connectedCallback(); this.addEventListener( @@ -208,15 +195,11 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { ) { this.syncAccordionItems(); } - // changedProperties.get() returns the previous value; this fires only when - // disabled transitions from true → false (re-enable). - if ( - changedProperties.has('disabled') && - changedProperties.get('disabled') === true && - !this.allowMultiple - ) { - this.enforceExclusiveOpen(); - } + // Re-enabling with more than one item open (set while disabled, when + // toggling was blocked) needs no explicit handling here: this update + // cycle's `hostUpdated` runs `_selection`'s `readSelected`-gated + // over-selection collapse regardless of what changed, closing every + // open item but the first. super.update(changedProperties); } } diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 7707994b744..6cacd9d0268 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -47,7 +47,6 @@ export { export { deepestSelectionItemContaining, SelectionController, - selectionControllerChange, type SelectionControllerChangeDetail, type SelectionControllerConfirmDetail, type SelectionControllerOptions, diff --git a/2nd-gen/packages/core/controllers/selection-controller/index.ts b/2nd-gen/packages/core/controllers/selection-controller/index.ts index bc1bc28cdc6..23bfb00e2c7 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/index.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/index.ts @@ -13,7 +13,6 @@ export { deepestSelectionItemContaining, SelectionController, - selectionControllerChange, type SelectionControllerChangeDetail, type SelectionControllerConfirmDetail, type SelectionControllerOptions, diff --git a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx index ef278a70aa0..6be73dae12d 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -11,36 +11,41 @@ import * as SelectionControllerStories from './stories/selection-controller.stor ### Modes -`SelectionController` supports three selection modes that can be set in the constructor and changed at runtime via `setOptions`: +`SelectionController` supports three selection modes. Set a mode in the constructor, or change it later with `setOptions`. -- **`single`**: at most one item is selected at a time. Clicking the active item has no effect. Use for tab lists, mandatory radio groups, and exclusive category filters. -- **`single-toggle`**: at most one item is selected at a time. Clicking the active item deselects it (clears the selection). Use for optional ratings, toggleable accordion headers, and filters where clearing is valid. -- **`multiple`**: any number of items may be selected; each click toggles one item independently. Use for multiselect listboxes, checkboxes, and tag filters. +- **`single`**: Only one item can be selected. Clicking the selected item does nothing. Use this for tab lists, required radio groups, and category filters that always need a value. +- **`single-toggle`**: Only one item can be selected. Clicking the selected item deselects it. Use this for optional ratings, accordion headers, and filters that can be cleared. +- **`multiple`**: Any number of items can be selected. Each click toggles one item on its own. Use this for multiselect listboxes, checkboxes, and tag filters. -### Events and callbacks +### Callbacks -- **`swc-selection-controller-change`** (`selectionControllerChange`): dispatched on the host (bubbles, composed) with `{ selectedItems, addedItems, removedItems }` whenever the selection changes. -- **`onSelectionChange`**: optional callback with the same payload. -- **`confirmSelectionChange`**: optional gate, called _after_ mutators and internal state are already applied for the candidate transition — return `false` to revert everything back to the prior selection. Not called for `{ silent: true }` transitions, nor for transitions this controller makes to enforce its own invariants (removing a disconnected item, normalizing a `mode` switch, asserting `defaultToFirstSelectable`). +This controller has no DOM event of its own. It only offers two callbacks. + +Why no event? A DOM event dispatched from inside the controller would bubble out of every host's shadow root, whether or not that host wants it. Instead, a host dispatches its own public event, on its own terms, usually from `confirmSelectionChange`. For an example, see `swc-tabs`' `change` event. + +- **`onSelectionChange`**: Runs on every change, with `{ selectedItems, addedItems, removedItems }`. Runs even for `{ silent: true }` changes. +- **`confirmSelectionChange`**: An optional check. It runs after the controller has already applied the change, so it can reject a change it doesn't like. Return `false` to undo the change. This callback does not run for `{ silent: true }` changes. It also skips changes the controller makes to enforce its own rules, like removing a disconnected item, switching modes, fixing a mode with too many items selected, or applying `defaultToFirstSelectable`. ### Programmatic API -- **`setSelectedItem(item | null, options?)`**: assert one item (single/single-toggle) or add to a multiple selection; pass `null` to clear (single-toggle and multiple only — or `single` too with `{ silent: true }`). -- **`toggleItem(item, options?)`**: toggle using the current mode rules. -- **`selectAll(options?)`**: select all eligible items (multiple mode only). -- **`clearAll(options?)`**: deselect all items (single-toggle and multiple modes — or `single` too with `{ silent: true }`). -- **`getSelectedItems()`**: returns the current selection as an ordered array. -- **`isSelected(item)`**: check whether a specific item is selected. +- **`setSelectedItem(item | null, options?)`**: Selects one item. In `single` or `single-toggle` mode, this replaces the selection. In `multiple` mode, this adds the item to the selection. Pass `null` to clear the selection (works in `single-toggle` and `multiple` mode, or in `single` mode with `{ silent: true }`). +- **`toggleItem(item, options?)`**: Toggles one item, based on the current mode's rules. +- **`selectAll(options?)`**: Selects every eligible item. Works only in `multiple` mode. +- **`clearAll(options?)`**: Deselects every item. Works in `single-toggle` and `multiple` mode, or in `single` mode with `{ silent: true }`. +- **`getSelectedItems()`**: Returns the current selection as an ordered array. +- **`isSelected(item)`**: Returns `true` if the item is selected. -All five mutating methods above (plus `refresh()`) accept `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event — for resyncing internal state from an external property change. Silent calls always re-run `selectItem` / `deselectItem` for every current item rather than trusting this controller's cached selection, so they self-correct even if that cache has drifted from reality (see [Bookkeeping-only usage](#bookkeeping-only-usage) below). +All five methods above, plus `refresh()`, accept `{ silent: true }`. A silent call skips `confirmSelectionChange`, so nothing can reject it (`onSelectionChange` still runs). Use this to sync the controller's state after an outside change, like a property update. + +A silent call also re-applies `selectItem` and `deselectItem` to every current item. This fixes any drift between the controller and the real DOM. See [Bookkeeping-only usage](#bookkeeping-only-usage) below for an example. ## Basic usage -1. Construct the controller in your element's `constructor`, passing `getItems`, `selectItem`, `deselectItem`, and `mode`. -2. Ensure `getItems` returns live `HTMLElement` references from `this.renderRoot` or slotted content. -3. After the first render, call **`refresh()`** from `firstUpdated` so the initial selection state is applied. -4. Provide appropriate **roles**, **ARIA attributes**, and **labels** on the host and items — the controller does not set them. -5. Pair with **`FocusgroupNavigationController`** when the composite also needs roving `tabindex`, arrow keys, Home/End, or focus memory. +1. Create the controller in your element's `constructor`. Pass `getItems`, `selectItem`, `deselectItem`, and `mode`. +2. Make sure `getItems` returns live `HTMLElement` references, from `this.renderRoot` or from slotted content. +3. Call `refresh()` in `firstUpdated`, after the first render. This applies the starting selection. +4. Add the right roles, ARIA attributes, and labels to the host and items yourself. The controller does not set these. +5. Need arrow keys, Home, End, or roving `tabindex`? Pair this controller with `FocusgroupNavigationController`. ```typescript import { LitElement, html } from 'lit'; @@ -92,7 +97,9 @@ export class MyListbox extends LitElement { ### `single` mode -In `single` mode, at most one item is selected at a time. Clicking the active item has no effect. Arrow keys (via `FocusgroupNavigationController`) move focus without changing selection; press **Enter** or **Space** to select the focused item when `keydownActivation` is `true`. +In `single` mode, only one item can be selected. Clicking the selected item does nothing. + +Arrow keys move focus without changing the selection, when paired with `FocusgroupNavigationController`. Press **Enter** or **Space** to select the focused item, if `keydownActivation` is `true`. ```typescript private readonly selection = new SelectionController(this, { @@ -109,7 +116,9 @@ private readonly selection = new SelectionController(this, { ### `single-toggle` mode -In `single-toggle` mode, clicking the active item deselects it (clears the selection). Clicking a different item replaces the selection. This is useful for optional ratings and accordion headers where all panels may legitimately be closed. +In `single-toggle` mode, clicking the selected item deselects it. Clicking a different item replaces the selection. + +Use this mode when it's OK for nothing to be selected, like an optional star rating or an accordion where every panel can close. ```typescript private readonly selection = new SelectionController(this, { @@ -126,7 +135,9 @@ private readonly selection = new SelectionController(this, { ### `multiple` mode: listbox with `selectAll` and `clearAll` -In `multiple` mode, each click independently toggles one item. Call `selectAll()` to select all eligible items and `clearAll()` to deselect all of them. Pair with `FocusgroupNavigationController` for keyboard navigation; set `keydownActivation: true` so **Enter** / **Space** toggle items. +In `multiple` mode, each click toggles one item on its own. Call `selectAll()` to select every eligible item. Call `clearAll()` to deselect them all. + +Pair this with `FocusgroupNavigationController` for keyboard navigation. Set `keydownActivation: true` so **Enter** and **Space** also toggle items. ```typescript private readonly navigation = new FocusgroupNavigationController(this, { @@ -154,7 +165,11 @@ this.selection.clearAll(); ### Accordion: runtime mode switch via `setOptions` -Call `setOptions({ mode })` to switch modes at runtime without reconstructing the controller. This accordion starts in `single` mode; the buttons switch it to `single-toggle` or `multiple` on the fly. When switching from `multiple` back to a single-item mode while more than one panel is open, all but the first are closed automatically and a change event is dispatched for the deselected items — this cleanup isn't gated by `confirmSelectionChange`, since it's this controller enforcing the new mode's own invariant, not a selection a consumer chose to veto. +Call `setOptions({ mode })` to switch modes at any time. You don't need to rebuild the controller. + +This accordion starts in `single` mode. Its buttons switch it to `single-toggle` or `multiple` mode. + +If you switch from `multiple` mode back to a single-item mode while more than one panel is open, the controller closes every panel but the first. It calls `onSelectionChange` for each panel it closes. This cleanup skips `confirmSelectionChange`, because the controller is enforcing the new mode's own rule. It isn't a change a person chose to make, so it isn't one they can veto. ```typescript // Initial setup: @@ -182,30 +197,34 @@ this.accordionSelection.setOptions({ mode: 'multiple' }); ### Features -The `SelectionController` implements several accessibility features: +`SelectionController` includes a few built-in accessibility features. #### Pointer interaction -Capture-phase `click` listener resolves the deepest eligible item in the composed event path. Primary clicks only (button 0, no modifier keys). Disabled and `aria-disabled="true"` items are never toggled. Set `enableInteraction: false` to skip attaching this listener entirely — see [Bookkeeping-only usage](#bookkeeping-only-usage). +This controller listens for clicks during the capture phase. It finds the deepest eligible item in the click's path. + +It only responds to a primary click (button 0) with no modifier key held down. It never toggles a disabled item, or one with `aria-disabled="true"`. + +Set `enableInteraction: false` to turn off this listener entirely. See [Bookkeeping-only usage](#bookkeeping-only-usage) for when to do that. #### Keyboard interaction -With `keydownActivation: true`, capture-phase `keydown` for **Enter** / **Space** applies the current mode rules to the deepest eligible item on the event path. Modifier keys (Ctrl, Meta, Shift, Alt) and `event.repeat` are ignored. +When `keydownActivation` is `true`, this controller listens for **Enter** and **Space** during the capture phase. It applies the current mode's rules to the deepest eligible item in the event's path. It ignores modifier keys (Ctrl, Meta, Shift, Alt) and repeated key presses. -The controller does **not** implement roving `tabindex`, arrow keys, Home/End, or programmatic `focus()`. Add `FocusgroupNavigationController` when the composite needs those behaviors. +This controller does not move focus. It does not support roving `tabindex`, arrow keys, Home, End, or `focus()`. Add `FocusgroupNavigationController` if your composite needs those. #### ARIA management -The controller does not read or set ARIA attributes itself — it calls your `selectItem` / `deselectItem` callbacks and leaves ARIA management to you. Keep `aria-selected`, `aria-checked`, `aria-expanded`, and related attributes in sync inside those two callbacks. +This controller never reads or sets ARIA attributes on its own. It calls your `selectItem` and `deselectItem` callbacks and leaves ARIA up to you. Set attributes like `aria-selected`, `aria-checked`, and `aria-expanded` inside those two callbacks. -For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host element so screen readers announce the correct interaction model. +For a multiselect listbox, also set `aria-multiselectable="true"` on the host. This tells screen readers how the list works. ### Best practices -- Always provide `role` and `aria-label` or `aria-labelledby` on the host and items; the controller does not set these. -- Use `aria-selected` for `option` and `tab` roles; `aria-checked` for `radio` and `menuitemradio` roles; `aria-expanded` for accordion headers. -- For multiselect listboxes, set `aria-multiselectable="true"` on the host. -- Use `aria-disabled="true"` instead of native `disabled` when items should remain focusable but not selectable. +- Always add `role`, and either `aria-label` or `aria-labelledby`, to the host and items. The controller does not add these for you. +- Use `aria-selected` for `option` and `tab` roles. Use `aria-checked` for `radio` and `menuitemradio` roles. Use `aria-expanded` for accordion headers. +- Set `aria-multiselectable="true"` on the host for multiselect listboxes. +- Use `aria-disabled="true"` instead of the native `disabled` attribute when an item should stay focusable but not selectable. - Call `refresh()` after any DOM change that adds, removes, or hides items. - Pair with `FocusgroupNavigationController` for composites that also need arrow-key focus movement. @@ -213,55 +232,52 @@ For a multiselect `listbox`, also set `aria-multiselectable="true"` on the host ### Methods -| Member | Description | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `setOptions(partial)` | Merge option updates; normalizes selection when mode changes, re-syncs interaction listeners for `enableInteraction` / `keydownActivation`, and calls `refresh()`. | -| `refresh(options?)` | Remove stale selections and optionally select the first eligible item (`defaultToFirstSelectable`). | -| `getSelectedItems()` | Returns the current selection as an ordered array. | -| `isSelected(item)` | Returns `true` when `item` is currently selected. | -| `getMode()` | Returns the current `SelectionMode`. | -| `setSelectedItem(item \| null, options?)` | Select one item or add to multiple selection; pass `null` to clear (single-toggle and multiple modes, or `single` with `{ silent: true }`). | -| `toggleItem(item, options?)` | Toggle an item using the current mode rules. Returns `false` when the item is ineligible or the operation is not allowed (e.g. re-clicking active item in `single` mode). | -| `selectAll(options?)` | Select all eligible items. Multiple mode only; returns `false` otherwise. | -| `clearAll(options?)` | Deselect all items. Single-toggle and multiple modes, or `single` with `{ silent: true }`; otherwise returns `false` in `single` mode. | -| `hostConnected()` | Lit `ReactiveController`: registers capture-phase `click` (and `keydown` when `keydownActivation` is `true`) unless `enableInteraction` is `false`, then calls `refresh({ silent: true })` — silent because this runs before the host's first render. | -| `hostDisconnected()` | Lit `ReactiveController`: removes event listeners. | - -`refresh`, `setSelectedItem`, `toggleItem`, `selectAll`, and `clearAll` all accept an `options?: { silent?: boolean }` parameter — pass `{ silent: true }` to commit without invoking `confirmSelectionChange` or dispatching the change event. For `setSelectedItem(null, ...)` and `clearAll`, `{ silent: true }` also bypasses the `single`-mode restriction that otherwise rejects clearing: a mandatory single-select group can't be emptied by _clicking_, but a consumer resyncing from an external property that explicitly represents "nothing selected" (for example ``) can still clear it. - -### Events - -The controller dispatches **`swc-selection-controller-change`** (`selectionControllerChange`) on the host. The event bubbles and is composed. - -```typescript -import { selectionControllerChange } from '@spectrum-web-components/core/controllers'; - -host.addEventListener(selectionControllerChange, (event) => { - const { selectedItems, addedItems, removedItems } = event.detail; - console.log('Selected:', selectedItems); -}); -``` +| Member | Description | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `setOptions(partial)` | Updates one or more options. Fixes the selection if the mode changed, updates the click and key listeners, and calls `refresh()`. | +| `refresh(options?)` | Removes stale (disconnected or out-of-scope) selections. Fixes a mode with too many items selected (`readSelected` only). Selects the first eligible item if `defaultToFirstSelectable` is set and nothing is selected. | +| `getSelectedItems()` | Returns the current selection, in order. | +| `isSelected(item)` | Returns `true` if `item` is selected. | +| `getMode()` | Returns the current `SelectionMode`. | +| `setSelectedItem(item \| null, options?)` | Selects one item, or adds it to a multiple selection. Pass `null` to clear the selection (works in `single-toggle` and `multiple` mode, or in `single` mode with `{ silent: true }`). | +| `toggleItem(item, options?)` | Toggles one item, based on the current mode. Returns `false` if the item isn't eligible, or the mode doesn't allow it, like clicking the selected item in `single` mode. | +| `selectAll(options?)` | Selects every eligible item. Works only in `multiple` mode; returns `false` otherwise. | +| `clearAll(options?)` | Deselects every item. Works in `single-toggle` and `multiple` mode, or in `single` mode with `{ silent: true }`; returns `false` otherwise. | +| `hostConnected()` | A Lit `ReactiveController` method. Adds the click listener, and the key listener if `keydownActivation` is `true`, unless `enableInteraction` is `false`. Then calls `refresh({ silent: true })`, since this runs before the first render. | +| `hostUpdated()` | A Lit `ReactiveController` method. Only does work when `readSelected` is set. Calls a silent `refresh()` after every update, to catch drift from a change the controller didn't make itself. | +| `hostDisconnected()` | A Lit `ReactiveController` method. Removes every event listener. | + +`refresh`, `setSelectedItem`, `toggleItem`, `selectAll`, and `clearAll` all take an `options?: { silent?: boolean }` parameter. Pass `{ silent: true }` to skip `confirmSelectionChange` (`onSelectionChange` still runs). + +For `setSelectedItem(null, ...)` and `clearAll`, `{ silent: true }` also lifts the `single`-mode rule against clearing. Normally, a click can't empty a required single-select group. But your own code can still clear it this way, for example when `` means nothing is selected. ### Options -| Option | Type | Default | Description | -| -------------------------- | ------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `getItems` | `() => HTMLElement[]` | (required) | Current selection participants. | -| `selectItem` | `(item: HTMLElement) => void` | (required) | Called on each item entering the selection. | -| `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on each item leaving the selection. | -| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Selection behavior. | -| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected (single-item modes only). | -| `keydownActivation` | `boolean` | `false` | When `true`, Enter/Space toggles the eligible item on the event path. Pair with `FocusgroupNavigationController` for arrow keys. | -| `enableInteraction` | `boolean` | `true` | When `false`, this controller never attaches its own click/keydown listeners. Use for bookkeeping-only integrations — see [Bookkeeping-only usage](#bookkeeping-only-usage). | -| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Callback with the same payload as the change event. | -| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Called after mutators run for the candidate transition; return `false` to revert it. Never called for invariant-enforcing transitions (see [Events and callbacks](#events-and-callbacks)) or `{ silent: true }` calls. | -| `isDisabled` | `(item: HTMLElement) => boolean` | — | Override for the eligibility disabled check. Use when disabled state cascades from an ancestor rather than being reflected on the participant itself. | +| Option | Type | Default | Description | +| -------------------------- | ------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Returns the items that can be selected. | +| `selectItem` | `(item: HTMLElement) => void` | (required) | Runs when an item becomes selected. | +| `deselectItem` | `(item: HTMLElement) => void` | (required) | Runs when an item becomes deselected. | +| `mode` | `'single' \| 'single-toggle' \| 'multiple'` | `'single'` | Sets how selection works. | +| `defaultToFirstSelectable` | `boolean` | `false` | When `true`, `refresh` selects the first eligible item if nothing is selected. Only matters in `single` and `single-toggle` mode. | +| `keydownActivation` | `boolean` | `false` | When `true`, **Enter** and **Space** toggle the eligible item under focus. Pair with `FocusgroupNavigationController` for arrow keys. | +| `enableInteraction` | `boolean` | `true` | When `false`, this controller never adds its own click or key listeners. Use this for items that already manage their own clicks. See [Bookkeeping-only usage](#bookkeeping-only-usage). | +| `readSelected` | `(item: HTMLElement) => boolean` | — | Reads the selection straight from the items, instead of from an internal cache. Needed to safely use `multiple` mode with items that manage their own state. See [Bookkeeping-only usage](#bookkeeping-only-usage). | +| `observeEvent` | `string` | — | The name of an event to listen for, instead of capturing clicks. Only works when `enableInteraction` is `false`. Pair with `readSelected`. | +| `onSelectionChange` | `(detail: SelectionControllerChangeDetail) => void` | — | Runs on every change, with `{ selectedItems, addedItems, removedItems }`. This is the only way this controller reports changes; it never dispatches a DOM event. Runs even for `{ silent: true }` changes. | +| `confirmSelectionChange` | `(detail: SelectionControllerConfirmDetail) => boolean` | — | Runs after the controller applies a change. Return `false` to undo it. Skipped for changes the controller makes to enforce its own rules, and for `{ silent: true }` changes. | +| `isDisabled` | `(item: HTMLElement) => boolean` | — | Replaces the built-in check for whether an item is disabled. Use this when the disabled state comes from a parent element, not the item itself. | +| `isSelectable` | `(item: HTMLElement) => boolean` | — | Replaces the whole check for whether an item can be selected, including the built-in visibility check. Use this for a large list where every item is already known to be visible. | ## Appendix ### Pairing with `FocusgroupNavigationController` -`SelectionController` handles what is selected; it does not manage keyboard focus movement. For composites that need roving `tabindex`, arrow keys, Home/End, or focus memory, pair it with `FocusgroupNavigationController`. A common pattern is a manual-activation tab list: `FocusgroupNavigationController` moves focus with arrow keys while `SelectionController` (`mode: 'single'`, `keydownActivation: true`) activates the focused tab on **Enter** or **Space**. +`SelectionController` handles what's selected. It does not move keyboard focus. + +If you need roving `tabindex`, arrow keys, Home, End, or focus memory, pair it with `FocusgroupNavigationController`. + +A common pattern is a manual-activation tab list. `FocusgroupNavigationController` moves focus with the arrow keys. `SelectionController`, set to `mode: 'single'` and `keydownActivation: true`, selects the focused tab when you press **Enter** or **Space**. ```typescript import { @@ -297,9 +313,15 @@ private readonly tabSelection = new SelectionController(this, { ### Bookkeeping-only usage -Some composites can't hand click/keydown handling to this controller at all — each item already owns its own interaction (its own click binding, its own cancelable-event lifecycle), and the "selected" state is a public property on the item itself rather than something only this controller ever mutates. An accordion is the canonical case: each `` has an independently, publicly settable `open` property, and clicking a header runs the item's own toggle-and-dispatch-cancelable-event flow before the container ever finds out. +Some items handle their own clicks and keep their own "selected" state as a public property. This controller can't own the interaction for items like that. + +An accordion is a good example. Each `` has its own `open` property that anyone can set. Clicking a header runs the item's own toggle logic, and dispatches its own cancelable event, before the accordion container ever finds out. -For that shape, set `enableInteraction: false` so this controller never attaches its own listeners, and drive it imperatively with `{ silent: true }` calls from your own event handler after the real state change has already happened: +For items like this, set three options: + +- `enableInteraction: false`, so this controller never adds its own click or key listeners. +- `readSelected`, so this controller reads the truth from the item's own property, instead of from an internal cache. +- `observeEvent`, so this controller reacts to the item's own toggle event. ```typescript private readonly panelSelection = new SelectionController(this, { @@ -312,24 +334,20 @@ private readonly panelSelection = new SelectionController(this, { }, mode: 'single-toggle', enableInteraction: false, + readSelected: (panel) => (panel as AccordionItem).open, + observeEvent: 'accordion-item-toggle', }); - -private handleItemToggle = (event: Event): void => { - const toggling = event.target as AccordionItem; - if (!toggling.open) { - return; - } - // Asserts `toggling` as the sole selection. Mutators are computed from a - // live scan of every item (see "Mutators always reflect a live scan" in - // the class overview above), so every other panel is closed to match — - // regardless of what this controller previously believed was selected, - // including panels whose `open` was set directly rather than through this - // flow. - this.panelSelection.setSelectedItem(toggling, { silent: true }); -}; ``` -This gets you the mode-aware transition math (`single-toggle` vs. `multiple`, exclusivity on assert) without the controller ever fighting your own interaction handling for control of the click. +Here's what happens on `accordion-item-toggle`: + +1. The controller waits one microtask. This gives a canceled toggle time to revert the item's `open` property back. +2. It checks whether the item is now open. +3. If the item is open, the controller makes it the only selected item. It closes every other open panel to match, by scanning the real items each time. This works even for panels whose `open` property changed outside this flow. + +`readSelected` is what makes that scan trustworthy. Instead of trusting a cache that can drift out of sync, the controller checks the real DOM every time. This is also what makes `multiple` mode safe for items that manage their own state: there's no cached list that could reopen an item someone already closed. + +With this setup, you get the same mode logic as any other use of this controller, like `single-toggle` versus `multiple` and one selection at a time, without a fight over who owns the click. ### See also diff --git a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts index 6ffae4ecbce..529328a6ed4 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -105,17 +105,21 @@ export type SelectionControllerOptions = { * — in `single` / `single-toggle` mode, when the item is now selected — asserts it as the sole * selection, closing every other live-selected item via the normal mutator rescan. In `multiple` * mode this is a no-op: a self-owning item toggling itself needs no cross-item enforcement, so - * this controller does nothing. Ignored when `enableInteraction` interaction is also relied upon - * — the two are alternative ways of learning about a selection attempt, not additive. + * this controller does nothing. **Only takes effect when `enableInteraction` is `false`** — + * enforced, not just documented: the two are alternative ways of learning about a selection + * attempt, not additive, so a consumer that sets both never gets both paths firing for the same + * click. */ observeEvent?: string; /** - * Optional callback mirroring {@link selectionControllerChange}. + * Optional callback with the `{ selectedItems, addedItems, removedItems }` shape of + * {@link SelectionControllerChangeDetail} — this controller's only notification channel; it + * never dispatches a DOM event (see {@link SelectionController.applySelectionTransition}). * * **Fires on every commit, including `{ silent: true }` transitions** — unlike - * `confirmSelectionChange` and the `selectionControllerChange` DOM event, `silent` does not gate - * this callback. Silent transitions exist to reconcile controller state from participants whose + * `confirmSelectionChange`, `silent` does not gate this callback. Silent transitions exist to + * reconcile controller state from participants whose * selected-ish state changed independently (an external property write, a `defaultToFirstSelectable` * assertion during a silent `refresh`), and this callback is frequently the *only* channel a host * uses to mirror that reconciled selection into its own public property — see `swc-tabs`' @@ -153,15 +157,22 @@ export type SelectionControllerOptions = { * internal shadow DOM, not onto the participant element `getItems` returns). */ isDisabled?: (item: HTMLElement) => boolean; -}; -/** - * Name of the bubbling composed `CustomEvent` dispatched when the selection changes. - */ -export const selectionControllerChange = 'swc-selection-controller-change'; + /** + * Optional override for the whole eligibility check — connected, not `inert`, not `hidden`, + * not disabled, and CSS-visible. When provided, called instead of the built-in check, which + * ends in a native `checkVisibility` call. Use this for a large or virtualized list that can + * guarantee its items are never CSS-hidden, to skip that call entirely; the built-in default + * stays correct for everyone else, since it only pays for `checkVisibility` after every cheaper + * signal (connected, `hidden`, disabled, `inert`) has already passed. + */ + isSelectable?: (item: HTMLElement) => boolean; +}; /** - * `detail` object for {@link selectionControllerChange}. + * Payload mirrored to {@link SelectionControllerOptions.onSelectionChange}. This controller has + * no DOM event of its own — see {@link SelectionController.applySelectionTransition} for why — + * so this shape only ever reaches consumers through that callback. */ export type SelectionControllerChangeDetail = { /** Current selection after the change. */ @@ -273,6 +284,7 @@ export class SelectionController implements ReactiveController { | 'onSelectionChange' | 'confirmSelectionChange' | 'isDisabled' + | 'isSelectable' | 'readSelected' | 'observeEvent' >; @@ -292,9 +304,14 @@ export class SelectionController implements ReactiveController { if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return; } - const items = this.getEligibleItems(); - const hit = deepestSelectionItemContaining(event, items); - if (!hit || this.isDisabledParticipant(hit)) { + // Find which item was hit from the cheap raw scan, then validate + // eligibility on only that one item — this is an O(1) question + // (`isSelectableItem`, ending in `checkVisibility`) about the hit, not an + // O(n) one (`getEligibleItems`) about the whole list. Matters for a large + // list: a picker with hundreds of items shouldn't force a visibility + // check on every item for every click. + const hit = deepestSelectionItemContaining(event, this.getScopedRawItems()); + if (!hit || !this.isSelectableItem(hit)) { return; } this.applyClickOrKey(hit); @@ -313,9 +330,9 @@ export class SelectionController implements ReactiveController { if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return; } - const items = this.getEligibleItems(); - const hit = deepestSelectionItemContaining(event, items); - if (!hit || this.isDisabledParticipant(hit)) { + // See `handleClickCapture` above for why this checks only the hit item. + const hit = deepestSelectionItemContaining(event, this.getScopedRawItems()); + if (!hit || !this.isSelectableItem(hit)) { return; } event.preventDefault(); @@ -335,7 +352,7 @@ export class SelectionController implements ReactiveController { event, this.getScopedRawItems() ); - if (!target || this.isDisabledParticipant(target)) { + if (!target || !this.isSelectableItem(target)) { return; } // Defer until the item's own cancelable-event lifecycle settles — a @@ -362,6 +379,7 @@ export class SelectionController implements ReactiveController { onSelectionChange: options.onSelectionChange, confirmSelectionChange: options.confirmSelectionChange, isDisabled: options.isDisabled, + isSelectable: options.isSelectable, readSelected: options.readSelected, observeEvent: options.observeEvent, }; @@ -432,6 +450,7 @@ export class SelectionController implements ReactiveController { confirmSelectionChange: partial.confirmSelectionChange ?? this.options.confirmSelectionChange, isDisabled: partial.isDisabled ?? this.options.isDisabled, + isSelectable: partial.isSelectable ?? this.options.isSelectable, readSelected: partial.readSelected ?? this.options.readSelected, observeEvent: partial.observeEvent ?? this.options.observeEvent, }; @@ -472,9 +491,9 @@ export class SelectionController implements ReactiveController { * which a consumer may legitimately want to allow even in `single` mode. * * Pass **`{ silent: true }`** to commit this transition without invoking - * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to - * resync internal state from an external property change without raising a public event. - * **`onSelectionChange`** still runs (see its docs for why). + * **`confirmSelectionChange`** — used to resync internal state from an external property change + * without raising a vetoable transition. **`onSelectionChange`** still runs (see its docs for + * why). */ public setSelectedItem( item: HTMLElement | null, @@ -488,8 +507,8 @@ export class SelectionController implements ReactiveController { } if ( - !this.getEligibleItems().includes(item) || - this.isDisabledParticipant(item) + !this.getScopedRawItems().includes(item) || + !this.isSelectableItem(item) ) { return false; } @@ -518,16 +537,16 @@ export class SelectionController implements ReactiveController { * * @returns **`false`** when the item is ineligible or when the mode disallows the operation. * - * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or - * dispatching {@link selectionControllerChange}. **`onSelectionChange`** still runs. + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`**. + * **`onSelectionChange`** still runs. */ public toggleItem( item: HTMLElement, options?: { silent?: boolean } ): boolean { if ( - !this.getEligibleItems().includes(item) || - this.isDisabledParticipant(item) + !this.getScopedRawItems().includes(item) || + !this.isSelectableItem(item) ) { return false; } @@ -555,8 +574,8 @@ export class SelectionController implements ReactiveController { * Selects all eligible items. Only meaningful in **`multiple`** mode; returns **`false`** in * single-item modes (does not throw). * - * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or - * dispatching {@link selectionControllerChange}. **`onSelectionChange`** still runs. + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`**. + * **`onSelectionChange`** still runs. */ public selectAll(options?: { silent?: boolean }): boolean { if (this.options.mode !== 'multiple') { @@ -577,8 +596,8 @@ export class SelectionController implements ReactiveController { * mode, returns **`false`** and leaves the selection unchanged — unless **`{ silent: true }`** * is passed, which clears it anyway (see {@link SelectionController.setSelectedItem}). * - * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`** or - * dispatching {@link selectionControllerChange}. **`onSelectionChange`** still runs. + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`**. + * **`onSelectionChange`** still runs. */ public clearAll(options?: { silent?: boolean }): boolean { if (this.options.mode === 'single' && !options?.silent) { @@ -598,29 +617,34 @@ export class SelectionController implements ReactiveController { * When nothing is currently eligible, no default selection is forced. * * Pass **`{ silent: true }`** to commit these transitions without invoking - * **`confirmSelectionChange`** or dispatching {@link selectionControllerChange} — used to - * resync internal state from an external property change without raising a public event. - * **`onSelectionChange`** still runs — this is how `swc-tabs` mirrors a silently-applied - * `defaultToFirstSelectable` selection into its own `selected` property. + * **`confirmSelectionChange`** — used to resync internal state from an external property change + * without raising a vetoable transition. **`onSelectionChange`** still runs — this is how + * `swc-tabs` mirrors a silently-applied `defaultToFirstSelectable` selection into its own + * `selected` property. */ public refresh(options?: { silent?: boolean }): void { const silent = options?.silent ?? false; - const eligible = this.getEligibleItems(); + const scoped = this.getScopedRawItems(); const current = this.currentSelection(); - // An item is stale when it is disconnected, or when the eligible list is - // non-empty and does not include it. When eligible is empty (e.g. the host - // signals it is disabled), only disconnected items are removed — connected - // selections are preserved so aria-selected stays true. With - // `readSelected`, `current` is always a live scan of connected, in-scope - // items, so a "disconnected but still selected" item can't occur — this - // only does real work for the cache-based path. + // An item is stale when it is disconnected, or when the scope is + // non-empty and no longer includes it — connectivity/membership, not + // CSS-driven eligibility. A selected item that merely went + // `display: none` (a collapsed panel, a media-query change) is still a + // legitimate participant; forcing it out of the selection over a + // transient visual state would be surprising. It also means this check + // never pays for `getEligibleItems()`'s `checkVisibility` — worth keeping + // cheap since this runs on every structural change, and, via + // `hostUpdated`, on every render for `readSelected` consumers. When scope + // is empty (e.g. the host signals it is disabled by returning `[]` from + // `getItems`), only disconnected items are removed — connected selections + // are preserved so `aria-selected` stays true. const stale = current.filter( - (el) => !el.isConnected || (eligible.length > 0 && !eligible.includes(el)) + (el) => !el.isConnected || (scoped.length > 0 && !scoped.includes(el)) ); if (stale.length > 0) { - // force: removing a disconnected/ineligible item is this controller + // force: removing a disconnected/out-of-scope item is this controller // enforcing its own invariant, not a selection a consumer should be // able to veto. const next = current.filter((el) => !stale.includes(el)); @@ -657,12 +681,21 @@ export class SelectionController implements ReactiveController { this.options.defaultToFirstSelectable && // Recomputed rather than reusing `current`: the steps above may have // just changed what's actually selected. - this.currentSelection().length === 0 && - eligible.length > 0 + this.currentSelection().length === 0 ) { - // force: same reasoning — defaultToFirstSelectable exists specifically - // to guarantee a selection exists; a veto would leave that unmet. - this.applySelectionTransition([eligible[0]], [], { silent, force: true }); + // `getEligibleItems()` — the only place in `refresh` that needs the + // full, `checkVisibility`-inclusive eligible set — is computed here, + // lazily, only when there's actually nothing selected to default from. + const eligible = this.getEligibleItems(); + if (eligible.length > 0) { + // force: same reasoning — defaultToFirstSelectable exists + // specifically to guarantee a selection exists; a veto would leave + // that unmet. + this.applySelectionTransition([eligible[0]], [], { + silent, + force: true, + }); + } } } @@ -747,7 +780,14 @@ export class SelectionController implements ReactiveController { /** * Applies the transition optimistically, then runs **`confirmSelectionChange`** (if any) and - * reverts if it returns **`false`**. Finally dispatches the change event when committed. + * reverts if it returns **`false`**. + * + * **This controller notifies only through callbacks — `onSelectionChange` and + * `confirmSelectionChange` — never a DOM event.** A DOM event dispatched here would bubble + * (composed) out of every host's shadow root whether or not the host's own consumers want it, + * which is exactly the kind of internal-controller detail a component's public event surface + * should not leak. A host that wants a public event dispatches its own from + * `confirmSelectionChange` (see `swc-tabs`' `change`) or `onSelectionChange`, on its own terms. * * **Ordering matches native cancelable-event conventions (and 1st-gen Tabs), not a * confirm-before-apply gate.** Mutators, internal state, and the **`onSelectionChange`** mirror @@ -773,14 +813,15 @@ export class SelectionController implements ReactiveController { * {@link applyMutators} rather than trusting a diff against it. With **`readSelected`**, there is * no cache to go stale in the first place. * - * **`force`** (internal only — never exposed on the public **`{ silent }`** options bag) skips - * **`confirmSelectionChange`** the same way **`silent`** does, but leaves the public event - * dispatch alone. It exists for transitions that enforce this controller's *own* invariants - * (removing a disconnected item, normalizing a mode switch, asserting - * **`defaultToFirstSelectable`**) rather than representing a selection a consumer chose to - * make. Those must never be vetoable: reverting one would leave the controller violating the - * very invariant it was enforcing (for example more than one item selected while - * **`mode: 'single'`**, or a disconnected element still in the selection set). + * **`silent`** and **`force`** both skip **`confirmSelectionChange`** — the only thing left for + * either to skip, now that there is no DOM event. **`force`** (internal only — never exposed on + * the public **`{ silent }`** options bag) exists for transitions that enforce this controller's + * *own* invariants (removing a disconnected item, normalizing a mode switch, asserting + * **`defaultToFirstSelectable`**, collapsing an over-selected single-item mode) rather than + * representing a selection a consumer chose to make. Those must never be vetoable: reverting one + * would leave the controller violating the very invariant it was enforcing (for example more + * than one item selected while **`mode: 'single'`**, or a disconnected element still in the + * selection set). */ private applySelectionTransition( next: HTMLElement[], @@ -819,18 +860,6 @@ export class SelectionController implements ReactiveController { } } - if (!silent) { - this.host.dispatchEvent( - new CustomEvent( - selectionControllerChange, - { - bubbles: true, - composed: true, - detail: { selectedItems: next, addedItems, removedItems }, - } - ) - ); - } return true; } @@ -859,6 +888,11 @@ export class SelectionController implements ReactiveController { * Attaches or removes the capture-phase **`click`** / **`keydown`** listeners (per * **`enableInteraction`** / **`keydownActivation`**) and the bubble-phase **`observeEvent`** * listener (per {@link SelectionControllerOptions.observeEvent}). + * + * **`observeEvent` only ever attaches when `enableInteraction` is `false`.** They are + * alternative ways of learning about a selection attempt — the click-owning path or the + * item-owns-itself path — not additive; enforced here rather than left as a documentation-only + * expectation, so a consumer that sets both never gets both paths firing for the same click. */ private syncListeners(): void { if (!this.host.isConnected) { @@ -883,7 +917,9 @@ export class SelectionController implements ReactiveController { this.keydownListenerAttached = false; } - const wantObserved = this.options.observeEvent; + const wantObserved = this.options.enableInteraction + ? undefined + : this.options.observeEvent; if (wantObserved !== this.observedEventName) { if (this.observedEventName) { this.host.removeEventListener( @@ -938,25 +974,28 @@ export class SelectionController implements ReactiveController { return participant.getAttribute('aria-disabled') === 'true'; } + /** + * Cheapest signals first, so the overwhelmingly common way SWC components exclude an item + * (disabled, `hidden`, `inert`) never reaches the last check. `checkVisibility` is a native, + * engine-optimized replacement for a hand-rolled `getComputedStyle` read, but it can still + * force layout — worth reaching only when every cheaper property/attribute read has already + * passed. `isSelectable` lets a consumer that can guarantee its items are never CSS-hidden (a + * large or virtualized list) skip `checkVisibility` entirely. + */ private isSelectableItem(participant: HTMLElement): boolean { - if (!participant.isConnected) { - return false; + if (this.options.isSelectable) { + return this.options.isSelectable(participant); } - if (participant.hasAttribute('inert') || participant.closest('[inert]')) { + if (!participant.isConnected) { return false; } - const style = getComputedStyle(participant); - if ( - style.visibility === 'hidden' || - style.display === 'none' || - participant.hidden - ) { + if (participant.hidden || this.isDisabledParticipant(participant)) { return false; } - if (this.isDisabledParticipant(participant)) { + if (participant.hasAttribute('inert') || participant.closest('[inert]')) { return false; } - return true; + return participant.checkVisibility({ checkVisibilityCSS: true }); } private getEligibleItems(): HTMLElement[] { diff --git a/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts index 0eaffb6040e..0a89caed1fd 100644 --- a/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts +++ b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts @@ -13,11 +13,6 @@ import { expect } from '@storybook/test'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; -import { - selectionControllerChange, - type SelectionControllerChangeDetail, -} from '@spectrum-web-components/core/controllers/index.js'; - import '../stories/demo-hosts.js'; import { getComponent } from '../../../../swc/utils/test-utils.js'; @@ -35,7 +30,8 @@ import selectionMeta, { /** * Dispatches a keydown with both `key` and `code` set. The SelectionController - * checks `event.code` for Enter/Space activation, so both must be present. + * checks `event.key` for Enter/Space activation; `code` is included since some + * consumers or assistive tech may still key off it. */ function activate( target: HTMLElement, @@ -286,42 +282,17 @@ export const MultipleListboxTest: Story = { }); await step( - 'selectionControllerChange event fires with correct detail', + 'onSelectionChange mirrors the selection into the host — this controller has no DOM event, only callbacks', async () => { - let receivedDetail: SelectionControllerChangeDetail | null = null; - const handler = (( - event: CustomEvent - ) => { - receivedDetail = event.detail; - }) as EventListener; - - host.addEventListener(selectionControllerChange, handler); options[1].click(); + expect(options[1].getAttribute('aria-selected')).toBe('true'); - expect(receivedDetail).toBeTruthy(); - expect(receivedDetail!.selectedItems).toContain(options[1]); - expect(receivedDetail!.addedItems).toContain(options[1]); - expect(receivedDetail!.removedItems.length).toBe(0); - expect(receivedDetail!.selectedItems.length).toBe(1); - - host.removeEventListener(selectionControllerChange, handler); - } - ); + const count = root.querySelector('.count'); + expect(count?.textContent?.trim()).toBe('1 app selected'); - await step( - 'selectionControllerChange event bubbles and is composed', - async () => { - let captured = false; - const handler = ((event: CustomEvent) => { - expect(event.bubbles).toBe(true); - expect(event.composed).toBe(true); - captured = true; - }) as EventListener; - - canvasElement.addEventListener(selectionControllerChange, handler); - options[3].click(); - expect(captured).toBe(true); - canvasElement.removeEventListener(selectionControllerChange, handler); + // Restore the cleared state left by the previous step. + options[1].click(); + expect(count?.textContent?.trim()).toBe('No apps selected'); } ); diff --git a/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts b/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts index 2ef013bed7b..f4664659927 100644 --- a/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts +++ b/2nd-gen/packages/swc/components/accordion/test/accordion.test.ts @@ -1168,6 +1168,42 @@ export const ToggleEventTest: Story = { true ); }); + + await step( + 'setting the open property directly does not dispatch swc-accordion-item-toggle', + async () => { + // AccordionBase's SelectionController drives sibling closes via its + // `selectItem`/`deselectItem` mutators, which set `item.open` directly + // (not through `toggle()`). This guards the invariant that mutator + // relies on: if the `open` setter ever started dispatching the toggle + // event itself, closing a sibling from inside the controller's own + // `observeEvent` handler would re-trigger that same handler for the + // sibling, cascading into an event storm across every item in the + // accordion. + let fired = false; + accordion.addEventListener( + SWC_ACCORDION_ITEM_TOGGLE_EVENT, + () => { + fired = true; + }, + { once: true } + ); + + const openBefore = item.open; + item.open = !openBefore; + await item.updateComplete; + + expect(item.open, 'open property changed').toBe(!openBefore); + expect( + fired, + 'swc-accordion-item-toggle was NOT dispatched by the open setter' + ).toBe(false); + + // Restore so later steps/tests are unaffected. + item.open = openBefore; + await item.updateComplete; + } + ); }, };