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 6a3abd3901c..5f824181b9b 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 @@ -318,6 +336,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 @@ -338,7 +414,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 @@ -680,10 +758,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 @@ -715,9 +793,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`) 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. diff --git a/.changeset/tabs-change-event-ordering.md b/.changeset/tabs-change-event-ordering.md new file mode 100644 index 00000000000..7e9b579c5a8 --- /dev/null +++ b/.changeset/tabs-change-event-ordering.md @@ -0,0 +1,14 @@ +--- +'@adobe/spectrum-wc': minor +--- + +**fix(tabs):** Addressed review feedback on the `SelectionController` / `FocusgroupNavigationController` integration in ``. + +- `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. +- **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/accordion/Accordion.base.ts b/2nd-gen/packages/core/components/accordion/Accordion.base.ts index 34bd66070a4..37d79b723d8 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,30 +103,46 @@ export abstract class AccordionBase extends SizedMixin(SpectrumElement, { .filter((el): el is AccordionItemBase => el instanceof AccordionItemBase); } - private closeSiblingsOnOpen = (event: Event): void => { + /** + * @internal + * + * 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[], + selectItem: (item) => { + (item as AccordionItemBase).open = true; + }, + deselectItem: (item) => { + (item as AccordionItemBase).open = false; + }, + mode: 'single-toggle', + enableInteraction: false, + readSelected: (item) => (item as AccordionItemBase).open, + observeEvent: SWC_ACCORDION_ITEM_TOGGLE_EVENT, + }); + + /** + * 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.allowMultiple) { - 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; - } - for (const item of this.assignedItems()) { - if (item !== toggling) { - item.open = false; - } - } - }); }; protected syncAccordionItems(): void { @@ -136,24 +153,11 @@ 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( SWC_ACCORDION_ITEM_TOGGLE_EVENT, - this.closeSiblingsOnOpen + this.guardDisabledToggle ); } @@ -161,11 +165,20 @@ 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', + }); + } if (changedProperties.has('level')) { const clamped = Math.min( 6, @@ -182,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/components/tabs/Tabs.base.ts b/2nd-gen/packages/core/components/tabs/Tabs.base.ts index 91dd03c1778..4bb0825f87b 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,73 @@ export abstract class TabsBase extends SpectrumElement { */ private _tabs: TabLike[] = []; + // ────────────────────────────── + // 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` 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._tabs as HTMLElement[], + selectItem: (item) => { + (item as TabLike).selected = true; + }, + deselectItem: (item) => { + (item as TabLike).selected = false; + }, + mode: 'single', + defaultToFirstSelectable: true, + 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.disabled) { + return false; + } + 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 +331,22 @@ 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(); + + 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: applies defaultToFirstSelectable + // when no tab was pre-selected. + this._selection.refresh({ silent: true }); this.updateSelectionIndicator(); } @@ -282,227 +362,75 @@ export abstract class TabsBase extends SpectrumElement { } /** - * Click handler bound to the tablist wrapper in the concrete - * template. Activates the clicked tab. + * 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. */ - 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); - } + private _syncSelectionController(): void { + if (!this._tabs.length) { return; } - const delta = this.getNavigationDelta(code); - if (delta !== null) { - event.preventDefault(); - this.focusByDelta(delta); - return; - } + const selectedTab = this._tabs.find((t) => t.tabId === this.selected); - if (code === 'Home') { - event.preventDefault(); - this.focusTabAtIndex(0); - return; - } - - if (code === 'End') { - event.preventDefault(); - this.focusTabAtIndex(this._tabs.length - 1); + if (selectedTab) { + 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); 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'; + // 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._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; + if (this.selected) { + // Named a tab that doesn't exist — reset the public property to + // reflect that nothing is actually selected. + this.selected = ''; } - 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. + * 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 focusByDelta(delta: number): void { - if (!this._tabs.length) { + private readonly _handleNavigationActiveChange = (event: Event): void => { + if (this._keyboardActivation !== 'automatic') { 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); - - this.focusTabAtIndex(nextIndex); - } - - /** - * 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. - */ - private focusTabAtIndex(index: number): void { - if (!this._tabs.length) { + const { activeElement, reason } = ( + event as CustomEvent + ).detail; + if (reason !== 'keyboard' && reason !== 'focus') { return; } - - const clamped = this.wrapIndex(index); - const tab = this._tabs[clamped]; - if (!tab) { + 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 @@ -607,7 +535,7 @@ export abstract class TabsBase extends SpectrumElement { ) as TabLike | null; if (selectedChild) { - this.selectTarget(selectedChild); + this.selected = selectedChild.tabId; } } @@ -615,7 +543,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; @@ -641,16 +569,30 @@ export abstract class TabsBase extends SpectrumElement { } if (changes.has('disabled') && this._tabs.length) { - this.updateCheckedState(); + // Refreshing both controllers re-applies roving tabindex accordingly. + this._navigation.refresh(); + // 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) { + 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( @@ -679,6 +621,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( @@ -694,23 +640,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/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 57c601a6546..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'; }; /** @@ -280,6 +297,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,18 +358,24 @@ 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; - this.dispatchActiveChange(null); + this.dispatchActiveChange(null, 'refresh'); this.options.onActiveItemChange?.(null); } return; } + this.lastKnownItems = this.getRawItems(); const preferred = (this.options.memory && this.lastFocused && @@ -355,7 +385,7 @@ export class FocusgroupNavigationController implements ReactiveController { this.getActiveItem() ?? items[0]; - this.applyRovingTabindex(preferred); + this.applyRovingTabindex(preferred, 'refresh'); } /** @@ -363,15 +393,36 @@ 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. + this.cachedEligibleItems = null; + this.cachedRows = null; const items = this.getEligibleItems(); if (!items.includes(item)) { return false; } - this.applyRovingTabindex(item); + this.applyRovingTabindex(item, reason); if (this.options.memory) { this.lastFocused = item; } @@ -401,6 +452,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) => { @@ -410,7 +465,7 @@ export class FocusgroupNavigationController implements ReactiveController { if (!match) { return false; } - this.applyRovingTabindex(match); + this.applyRovingTabindex(match, 'programmatic'); return true; } @@ -620,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()) { @@ -652,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); } } @@ -661,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 }, } ) ); @@ -731,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; } @@ -763,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'); } } } @@ -839,18 +902,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 +933,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 +952,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 +973,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 +986,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 +995,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); @@ -949,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/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/index.ts b/2nd-gen/packages/core/controllers/index.ts index eeedf96ba39..6cacd9d0268 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -44,3 +44,11 @@ export { type PlacementOptions, type VirtualTrigger, } from './placement-controller/index.js'; +export { + deepestSelectionItemContaining, + SelectionController, + 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..23bfb00e2c7 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/index.ts @@ -0,0 +1,20 @@ +/** + * 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, + 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..6be73dae12d --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/selection-controller.mdx @@ -0,0 +1,359 @@ +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. Set a mode in the constructor, or change it later with `setOptions`. + +- **`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. + +### Callbacks + +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?)`**: 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 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. 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'; +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, 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, { + 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 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, { + 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 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, { + 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 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: +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' }); +``` + + + +## Accessibility + +### Features + +`SelectionController` includes a few built-in accessibility features. + +#### Pointer interaction + +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 + +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. + +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 + +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. This tells screen readers how the list works. + +### Best practices + +- 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. + +## API + +### Methods + +| 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) | 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'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 { + 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, +}); +``` + + + +### Bookkeeping-only usage + +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 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, { + getItems: () => this.panels, + selectItem: (panel) => { + (panel as AccordionItem).open = true; + }, + deselectItem: (panel) => { + (panel as AccordionItem).open = false; + }, + mode: 'single-toggle', + enableInteraction: false, + readSelected: (panel) => (panel as AccordionItem).open, + observeEvent: 'accordion-item-toggle', +}); +``` + +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 + +- [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..529328a6ed4 --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/src/selection-controller.ts @@ -0,0 +1,1004 @@ +/** + * 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; + + /** + * 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 — pair with **`observeEvent`** and **`readSelected`** so the controller + * reacts to the item's own event instead. Defaults to **`true`**. + */ + enableInteraction?: boolean; + + /** + * 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. **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 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`, `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; + + /** + * 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}), 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 + ) => 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; + + /** + * 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; +}; + +/** + * 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. */ + 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 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; what state reverts to if this callback returns **`false`**. */ + 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`**, 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`. + * + * **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/ + */ +export class SelectionController implements ReactiveController { + private readonly host: ReactiveElement; + + private options: Omit< + Required< + Pick< + SelectionControllerOptions, + | 'getItems' + | 'selectItem' + | 'deselectItem' + | 'mode' + | 'defaultToFirstSelectable' + | 'keydownActivation' + | 'enableInteraction' + > + >, + never + > & + Pick< + SelectionControllerOptions, + | 'onSelectionChange' + | 'confirmSelectionChange' + | 'isDisabled' + | 'isSelectable' + | 'readSelected' + | 'observeEvent' + >; + + private selectedItems: Set = new Set(); + + private clickListenerAttached = false; + + private keydownListenerAttached = false; + + private observedEventName: string | undefined; + + private readonly handleClickCapture = (event: MouseEvent): void => { + if (event.button !== 0) { + return; + } + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + // 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); + }; + + private readonly handleKeyDownCapture = (event: KeyboardEvent): void => { + if (!this.options.keydownActivation) { + return; + } + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + if (event.repeat) { + return; + } + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + // 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(); + 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.isSelectableItem(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 = { + getItems: options.getItems, + selectItem: options.selectItem, + deselectItem: options.deselectItem, + 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, + isSelectable: options.isSelectable, + readSelected: options.readSelected, + observeEvent: options.observeEvent, + }; + + host.addController(this); + } + + /** + * 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); + } + + /** 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, + 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, + onSelectionChange: + partial.onSelectionChange ?? this.options.onSelectionChange, + 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, + }; + + const nextMode = this.options.mode; + const wasSingle = prevMode === 'single' || prevMode === 'single-toggle'; + const isNowSingle = nextMode === 'single' || nextMode === 'single-toggle'; + + 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.syncListeners(); + 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; 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`** — 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, + options?: { silent?: boolean } + ): boolean { + if (item === null) { + if (this.options.mode === 'single' && !options?.silent) { + return false; + } + return this.clearAll(options); + } + + if ( + !this.getScopedRawItems().includes(item) || + !this.isSelectableItem(item) + ) { + return false; + } + + const current = this.currentSelection(); + + if (this.options.mode === 'multiple') { + if (current.includes(item)) { + return true; + } + const next = [...current, item]; + return this.applySelectionTransition(next, [], options); + } + + const toRemove = current.filter((el) => el !== item); + return this.applySelectionTransition([item], toRemove, options); + } + + /** + * 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. + * + * Pass **`{ silent: true }`** to commit without invoking **`confirmSelectionChange`**. + * **`onSelectionChange`** still runs. + */ + public toggleItem( + item: HTMLElement, + options?: { silent?: boolean } + ): boolean { + if ( + !this.getScopedRawItems().includes(item) || + !this.isSelectableItem(item) + ) { + return false; + } + + const current = this.currentSelection(); + const isSelected = current.includes(item); + + if (isSelected) { + if (this.options.mode === 'single') { + return false; + } + const next = current.filter((el) => el !== item); + return this.applySelectionTransition(next, [item], options); + } else { + if (this.options.mode === 'multiple') { + const next = [...current, item]; + return this.applySelectionTransition(next, [], options); + } + const toRemove = current.filter((el) => el !== item); + 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`**. + * **`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) => !current.includes(el)); + if (toAdd.length === 0) { + return true; + } + const next = [...current, ...toAdd]; + return this.applySelectionTransition(next, [], options); + } + + /** + * Deselects all items. Works in **`single-toggle`** and **`multiple`** modes. In **`single`** + * 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`**. + * **`onSelectionChange`** still runs. + */ + public clearAll(options?: { silent?: boolean }): boolean { + if (this.options.mode === 'single' && !options?.silent) { + return false; + } + const current = this.currentSelection(); + if (current.length === 0) { + return true; + } + return this.applySelectionTransition([], current, options); + } + + /** + * 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`** — 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 scoped = this.getScopedRawItems(); + const current = this.currentSelection(); + + // 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 || (scoped.length > 0 && !scoped.includes(el)) + ); + + if (stale.length > 0) { + // 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)); + 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 && + // Recomputed rather than reusing `current`: the steps above may have + // just changed what's actually selected. + this.currentSelection().length === 0 + ) { + // `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, + }); + } + } + } + + public hostConnected(): void { + 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); + this.clickListenerAttached = false; + } + if (this.keydownListenerAttached) { + this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = false; + } + if (this.observedEventName) { + this.host.removeEventListener( + this.observedEventName, + this.handleObservedEvent + ); + this.observedEventName = undefined; + } + } + + // ───────────────────────── + // 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 current = this.currentSelection(); + const isSelected = current.includes(hit); + + if (mode === 'single') { + if (isSelected) { + return; + } + this.applySelectionTransition([hit], current); + } else if (mode === 'single-toggle') { + if (isSelected) { + this.applySelectionTransition([], [hit]); + } else { + this.applySelectionTransition([hit], current); + } + } else { + if (isSelected) { + const next = current.filter((el) => el !== hit); + this.applySelectionTransition(next, [hit]); + } else { + const next = [...current, hit]; + this.applySelectionTransition(next, []); + } + } + } + + /** + * Applies the transition optimistically, then runs **`confirmSelectionChange`** (if any) and + * 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 + * 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. + * + * 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 **`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 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. + * + * **`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[], + removedItems: HTMLElement[], + options?: { silent?: boolean; force?: boolean } + ): boolean { + const silent = options?.silent ?? false; + const force = options?.force ?? false; + const priorItems = this.currentSelection(); + const addedItems = next.filter((el) => !priorItems.includes(el)); + + if (!silent && addedItems.length === 0 && removedItems.length === 0) { + return true; + } + + this.commit(next); + this.options.onSelectionChange?.({ + selectedItems: next, + addedItems, + removedItems, + }); + + if (!silent && !force && 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; + } + } + + 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); + } + + /** + * 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); + } + } + } + + /** + * 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) { + return; + } + 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 (!wantKeydown && this.keydownListenerAttached) { + this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = false; + } + + const wantObserved = this.options.enableInteraction + ? undefined + : 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 { + 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 (this.options.isDisabled) { + return this.options.isDisabled(participant); + } + if ('disabled' in participant) { + if ((participant as HTMLButtonElement).disabled) { + return true; + } + } + 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 (this.options.isSelectable) { + return this.options.isSelectable(participant); + } + if (!participant.isConnected) { + return false; + } + if (participant.hidden || this.isDisabledParticipant(participant)) { + return false; + } + if (participant.hasAttribute('inert') || participant.closest('[inert]')) { + return false; + } + return participant.checkVisibility({ checkVisibilityCSS: 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..c0d035ba9cb --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/demo-hosts.ts @@ -0,0 +1,1037 @@ +/** + * 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; + } + .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` + +`; + +// ───────────────────────────────────────────── +// 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(); + } + + /** + * 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` +
+
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 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 { + 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 }) => html` +
+

+ +

+ +
+ ` + )} +
+ `; + } +} + +// ───────────────────────────────────────────── +// 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; + visibility: hidden; + } + [aria-selected='true'] .checkbox svg { + visibility: visible; + } + .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 +// ───────────────────────────────────────────── + +/** + * 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; + 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, + // 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 { + 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..4ef337e99ad --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/stories/selection-controller.stories.ts @@ -0,0 +1,355 @@ +/** + * 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: ['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'], +}; + +export const SingleToggleModeRating: Story = { + name: 'single-toggle mode: click active item to deselect', + render: () => html` + + `, + tags: ['behaviors'], +}; + +export const MultipleListbox: Story = { + name: 'multiple mode: listbox with selectAll and clearAll', + render: () => html` + + `, + tags: ['behaviors'], +}; + +export const AccordionModeSwitch: Story = { + name: 'Accordion: runtime mode switch via setOptions', + render: () => html` + + `, + tags: ['behaviors'], +}; + +export const TablistWithFocusgroup: Story = { + name: 'Tablist: FocusgroupNavigationController + SelectionController', + render: () => html` + + `, + tags: ['appendix'], +}; 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..0a89caed1fd --- /dev/null +++ b/2nd-gen/packages/core/controllers/selection-controller/test/selection-controller.test.ts @@ -0,0 +1,574 @@ +/** + * 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 '../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, + SingleModeRating, + SingleToggleModeRating, + TablistWithFocusgroup, +} from '../stories/selection-controller.stories.js'; + +/** + * Dispatches a keydown with both `key` and `code` set. The SelectionController + * 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, + 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'); + }); + + 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'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// 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( + 'onSelectionChange mirrors the selection into the host — this controller has no DOM event, only callbacks', + async () => { + options[1].click(); + expect(options[1].getAttribute('aria-selected')).toBe('true'); + + const count = root.querySelector('.count'); + expect(count?.textContent?.trim()).toBe('1 app selected'); + + // Restore the cleared state left by the previous step. + options[1].click(); + expect(count?.textContent?.trim()).toBe('No apps selected'); + } + ); + + 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'); + }); + + 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); + } + ); + }, +}; 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..f4664659927 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,81 @@ 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 immediately collapses to one open item, before any further toggle', + async () => { + accordion.allowMultiple = false; + await accordion.updateComplete; + await Promise.all([item1.updateComplete, item2.updateComplete]); + + 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 + ); + } + ); + + await step( + 'switching allow-multiple back to true permits multiple open items again', + async () => { + accordion.allowMultiple = true; + await accordion.updateComplete; + + getHeader(item3).click(); + await flushMicrotasks(); + await accordion.updateComplete; + await Promise.all([item1.updateComplete, item3.updateComplete]); + + expect(item1.open, 'item 1 remains open').toBe(true); + expect(item3.open, 'item 3 is also open').toBe(true); + } + ); + }, +}; + // ────────────────────────────────────────────────────────────── // TEST: Controlled (cancelable toggle) // ────────────────────────────────────────────────────────────── @@ -1093,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; + } + ); }, }; 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} >
{ 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); }); }, }; @@ -968,6 +1007,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` @@ -1083,6 +1203,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/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" },