diff --git a/.ai/README.md b/.ai/README.md index ce21cff4b4d..79fb991f22f 100644 --- a/.ai/README.md +++ b/.ai/README.md @@ -231,6 +231,13 @@ Skills are used on-demand. When a task matches a skill’s purpose, the agent re - Use when: Writing one Storybook-renderable MDX file per component at `2nd-gen/packages/swc/components/[component-name]/consumer-migration-guide.mdx` with code updates, styling guidance, accessibility notes, testing changes, and rollout advice - Provides: Workflow summary (verified source inputs, required section order, before/after examples, migration checklist, rollout guidance). Full instructions in `.ai/skills/consumer-migration-guide/references/consumer-migration-guide-prompt.md` +#### Controller development (`controller-development`) + +- **purpose**: Scaffold or revise 2nd-gen Lit controllers under `2nd-gen/packages/core/controllers/` with source, demo hosts, Storybook stories (including `controllerApi` tables), and tests aligned to the focus group navigation controller layout +- **How to invoke**: Say “add a new core controller”, “scaffold a controller like focus group navigation”, “align radio controller stories with focus group”, or “revise controller docs/tests for [name]” +- Use when: Creating a new controller package, restructuring controller Storybook/docs, or matching stories and tests to project conventions +- Provides: Directory layout, meta and story tag conventions (`usage`, `behaviors`, `api`, `a11y`, `appendix`), `demo-hosts` guidance, test file pattern, checklist, and anti-patterns to avoid. Full instructions in `.ai/skills/controller-development/SKILL.md` + #### Washing machine migration workflow #### Migration — phase 1: prep (`migration-prep`) diff --git a/.ai/skills/controller-development/SKILL.md b/.ai/skills/controller-development/SKILL.md new file mode 100644 index 00000000000..fc42ef0f9f8 --- /dev/null +++ b/.ai/skills/controller-development/SKILL.md @@ -0,0 +1,131 @@ +--- +name: controller-development +description: >- + Scaffolds or revises 2nd-gen Lit controllers under `2nd-gen/packages/core/controllers/` with + source, `demo-hosts`, Storybook stories, API tables, and tests aligned to the focus group + navigation controller pattern. Use when adding a new controller, overhauling controller docs, + or matching an existing controller to project conventions. +--- + +# Controller development (2nd-gen core) + +Use this skill whenever you **add a new Lit reactive controller** under `2nd-gen/packages/core/controllers/` or **bring an existing controller** in line with current project conventions. + +## Canonical reference + +Treat **`focusgroup-navigation-controller`** as the structural template (not necessarily every API detail): + +| Area | Reference path | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| Controller source | `2nd-gen/packages/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts` | +| Package barrel | `2nd-gen/packages/core/controllers/focusgroup-navigation-controller/index.ts` | +| Storybook + API metadata | `2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts` | +| Live demos | `2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/demo-hosts.ts` | +| Tests | `2nd-gen/packages/core/controllers/focusgroup-navigation-controller/test/focusgroup-navigation-controller.test.ts` | +| Core re-export | `2nd-gen/packages/core/controllers/index.ts` | + +A second aligned example (post-refactor, no duplicate snippet file): **`radio-controller/`** in the same tree. + +## Directory layout (new controller) + +Use **kebab-case** folder names matching the public import path segment (e.g. `my-feature-controller/`). + +```text +2nd-gen/packages/core/controllers// +├── index.ts # Re-export public API from src/ +├── src/ +│ └── .ts # Implementation + JSDoc +├── stories/ +│ ├── .stories.ts # Storybook CSF + controllerApi +│ └── demo-hosts.ts # Lit @customElement demo hosts +└── test/ + └── .test.ts # Vitest / Storybook test stories +``` + +Wire **`2nd-gen/packages/core/controllers/index.ts`** with explicit named exports (same style as existing controllers). + +## Source file (`src/*.ts`) + +- Implement as a **Lit `ReactiveController`** (or equivalent) with clear public types exported from **`index.ts`**. +- Document **host contract**, **options**, **events**, and **limitations** in JSDoc on the class and public members. +- Export **event name constants** and **detail types** next to the class when the controller dispatches custom events. +- Keep behavior and eligibility rules **explicit** (what is ignored, what happens on disconnect, etc.). + +## `demo-hosts.ts` + +- One or more **`@customElement('demo-…')`** `LitElement` hosts that exercise real DOM patterns (toolbar, menu, grid, etc.). +- Declare **`declare global { interface HTMLElementTagNameMap { … } }`** for each demo tag. +- Demos should be **self-contained** and readable; story JSDoc can show short TypeScript excerpts—**avoid** a second parallel “snippets only” source file unless the team explicitly wants that maintenance cost. + +## Stories (`*.stories.ts`) + +Match the **heading / section structure** used by `focusgroup-navigation-controller.stories.ts` and `DocumentTemplate.mdx`: + +1. **API constant** at the top — `const _CONTROLLER_API = { … } as const` with: + - `title` — short label for the API block + - `options` — constructor options (name, type, defaultValue, description) + - `methods` — name, signature, returns, description + - `events` — name, detail, description (if any) + - `exports` — named exports consumers use (constants, types, helpers) + +2. **`meta`**: + - `title`: `'Controllers/'` (Storybook sidebar; matches `GettingStarted` import path derivation). + - `tags`: `['migrated', 'controller']` — do **not** add `docs-getting-started-inline` unless you have a documented reason to suppress the shared Getting started block. + - `component`: primary demo custom element tag (string), e.g. `'demo-focusgroup-playground'`. + - `parameters.docs.subtitle` — one-line summary. + - `parameters.docs.canvas.sourceState`: prefer **`'none'`** for controller READMEs (demos speak for themselves); align with the reference controller unless product asks otherwise. + - `parameters.controllerApi`: assign the API constant above — **`ApiTable.tsx`** renders controller API tables when this is set (see `2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx`). + - **`render` on `meta`**: default canvas for **Playground** and **Overview** (same pattern as focus group). + +3. **Story exports** (order and tags matter for docs): + + | Export | Tags | Notes | + | ------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | + | `Playground` | `['autodocs', 'dev']` | Often empty body — inherits `meta.render`. Optional `args` / `argTypes` when the controller exposes tunable demo options. | + | `Overview` | `['overview']` | Inherits `meta.render`; hero canvas comes from `OverviewStory` in the docs template. | + | `Usage` | `['usage', 'description-only']` | Long-form **how to adopt** prose + fenced TypeScript in JSDoc only (no canvas). | + | Each interactive example | `['behaviors']` + `parameters['section-order']` | `render` returns the right ``; JSDoc holds short **pattern-specific** code samples. | + | `API` | `['api', 'description-only']` | Supplementary tables or narrative; the machine-readable contract remains in `controllerApi`. | + | `Accessibility` | `['a11y', 'description-only']` | AT / keyboard / ARIA notes. | + | `Appendix` | `['description-only', 'appendix']` | Links, background, non-normative notes. | + +Docs sections **`Usage`**, **`Behaviors`**, **`Accessibility`**, **`Appendix`** come from **`ConditionalSection` + `SpectrumStories`** in `2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx`. **Do not** rely on per-story `dev` tags to force the bottom `` list unless you have a one-off reason; the section tags are the supported layout. + +## Tests (`test/*.test.ts`) + +Follow **`focusgroup-navigation-controller.test.ts`**: + +- **`import from '../stories/.stories.js'`** and spread into `export default { … }` with `title: '…/Tests'`, **`docs: { disable: true, page: null }`**, **`tags: ['!autodocs', '!dev']`** (keep **`!dev`** so Vitest test modules do not appear in the Storybook sidebar), and any test-only parameters. +- **`import '../stories/demo-hosts.js'`** so custom elements are defined. +- Import **named behavior stories** when using **`@storybook/test`** `play` functions against real story ids. +- Prefer **fixture elements** in the same test file (or colocated) for unit-level assertions; use **demo hosts** for integration-style behavior aligned with Storybook. + +## Revising an existing controller + +1. Diff its **stories file** against `focusgroup-navigation-controller.stories.ts` for **meta shape**, **story tags**, and **section comments**. +2. Ensure **`controllerApi`** matches the real `src` export surface; update rows when options/methods/events change. +3. Normalize **demo-host** naming and **story `render`** targets. +4. Run **`yarn build`** from `2nd-gen/packages/core` and the relevant **Storybook** / **test** commands your change touches. + +## Anti-patterns (avoid) + +- Custom per-story **`parameters.docs.description`** blobs that duplicate **Usage** / **Behaviors** unless migrating incrementally—prefer **JSDoc on stories** like the reference. +- A separate **`implementation-snippets.ts`** that mirrors **`demo-hosts.ts`** (two sources of truth). +- Meta tags **`docs-getting-started-inline`** or **`docs-skip-overview-canvas`** unless there is a team-approved exception (they change global docs behavior). +- Scatter **`tags: ['dev']`** across every story to fix docs layout—fix **structure** (usage / behaviors / api) instead. + +## Cross-links + +- Storybook template: `2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx` +- Getting started for controllers: `2nd-gen/packages/swc/.storybook/blocks/GettingStarted.tsx` (branch on `tags.includes('controller')`) +- API rendering: `2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx` (`controllerApi` branch) + +## Checklist (new controller) + +- [ ] `src/.ts` + types + event constants as needed +- [ ] `index.ts` barrel exports +- [ ] `controllers/index.ts` re-exports +- [ ] `stories/demo-hosts.ts` with `HTMLElementTagNameMap` +- [ ] `stories/.stories.ts` with `controllerApi`, meta `render`/`component`, Playground, Overview, Usage, behaviors, API, Accessibility, Appendix +- [ ] `test/.test.ts` spreading story meta, docs disabled +- [ ] Build passes; Storybook README sections render as expected diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts index 2b02ab0760b..de31ca38c4c 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts @@ -17,6 +17,147 @@ import './demo-hosts.js'; import type { FocusgroupDirection } from '../index.js'; +// ──────────────── +// API (Storybook ApiTable — see `meta.parameters.controllerApi`) +// ──────────────── + +const FOCUSGROUP_CONTROLLER_API = { + title: 'FocusgroupNavigationController', + options: [ + { + name: 'getItems', + type: '() => HTMLElement[]', + defaultValue: '(required)', + description: + 'Returns the current items that participate in roving tabindex and directional navigation.', + }, + { + name: 'direction', + type: "'horizontal' | 'vertical' | 'both' | 'grid'", + defaultValue: '(required)', + description: + 'Which arrow axes apply; both maps horizontal and vertical arrows to the same getItems() order; grid uses bounding-rect rows.', + }, + { + name: 'wrap', + type: 'boolean | undefined', + defaultValue: 'false', + description: + 'When true, arrow keys wrap from last item to first and reverse.', + }, + { + name: 'memory', + type: 'boolean | undefined', + defaultValue: 'true', + description: + 'When true, Tab re-entry prefers the last focused item if it is still in the group.', + }, + { + name: 'skipDisabled', + type: 'boolean | undefined', + defaultValue: 'false', + description: + 'When true, skips disabled and aria-disabled items for arrows and roving tabindex.', + }, + { + name: 'onActiveItemChange', + type: '(active: HTMLElement | null) => void', + defaultValue: 'undefined', + description: + 'Optional callback after the active item (tabindex 0) changes.', + }, + { + name: 'pageStep', + type: 'number | undefined', + defaultValue: '0', + description: + 'Non-zero magnitude enables Page Up/Page Down stepping by that many items (linear modes) or rows (grid).', + }, + ], + methods: [ + { + name: 'constructor', + signature: + 'new FocusgroupNavigationController(host: ReactiveElement, options: FocusgroupNavigationOptions)', + returns: 'FocusgroupNavigationController', + description: 'Registers the controller on the Lit host.', + }, + { + name: 'setOptions', + signature: + 'setOptions(partial: Partial): void', + returns: 'void', + description: 'Merges partial options and runs refresh().', + }, + { + name: 'getActiveItem', + signature: 'getActiveItem()', + returns: 'HTMLElement | null', + description: 'Returns the item with tabindex 0, or null.', + }, + { + name: 'refresh', + signature: 'refresh(): void', + returns: 'void', + description: + 'Re-queries getItems(), recomputes eligibility, and syncs roving tabindex (and optional memory).', + }, + { + name: 'setActiveItem', + signature: 'setActiveItem(item: HTMLElement): boolean', + returns: 'boolean', + description: + 'Sets item to tabindex 0 without calling focus(); returns false if item is not eligible.', + }, + { + name: 'focusFirstItemByTextPrefix', + signature: 'focusFirstItemByTextPrefix(prefix: string): boolean', + returns: 'boolean', + description: + 'Typeahead-style roving tabindex for the first eligible label starting with prefix (case-insensitive); does not focus().', + }, + { + name: 'hostConnected', + signature: 'hostConnected(): void', + returns: 'void', + description: + 'Lit ReactiveController: registers keydown/focusin/focusout listeners and runs refresh().', + }, + { + name: 'hostDisconnected', + signature: 'hostDisconnected(): void', + returns: 'void', + description: + 'Lit ReactiveController: removes listeners registered in hostConnected.', + }, + ], + events: [ + { + name: 'swc-focusgroup-navigation-active-change', + detail: '{ activeElement: HTMLElement | null }', + description: + 'Bubbles and composed; dispatched when the roving tabindex active item changes.', + }, + ], + exports: [ + { + name: 'focusgroupNavigationActiveChange', + kind: 'constant', + description: 'Event name string for the active-change event.', + }, + { + name: 'FocusgroupNavigationActiveChangeDetail', + kind: 'type', + description: 'TypeScript detail shape for the active-change event.', + }, + { + name: 'FocusgroupDirection', + kind: 'type', + description: 'Union of supported spatial modes for the direction option.', + }, + ], +} as const; + // ──────────────── // METADATA // ──────────────── @@ -89,6 +230,7 @@ const meta: Meta = { 'Roving tabindex and directional keys for composite widgets (APG-aligned, focusgroup-like).', canvas: { sourceState: 'none' }, }, + controllerApi: FOCUSGROUP_CONTROLLER_API, }, tags: ['migrated', 'controller'], }; 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 e4886943f1b..d4e0fe1e24b 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 @@ -79,7 +79,7 @@ export default { ...focusMeta.parameters, docs: { disable: true, page: null }, }, - tags: ['!autodocs', 'dev'], + tags: ['!autodocs', '!dev'], } as Meta; // ────────────────────────────────────────────────────────────── diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 71efb81b073..f563c37ae98 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -25,3 +25,10 @@ export { LanguageResolutionController, languageResolverUpdatedSymbol, } from './language-resolution.js'; +export { + deepestRadioItemContaining, + RadioController, + radioControllerSelectionChange, + type RadioControllerOptions, + type RadioControllerSelectionChangeDetail, +} from './radio-controller/index.js'; diff --git a/2nd-gen/packages/core/controllers/radio-controller/index.ts b/2nd-gen/packages/core/controllers/radio-controller/index.ts new file mode 100644 index 00000000000..a21283415b2 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/index.ts @@ -0,0 +1,19 @@ +/** + * 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 { + deepestRadioItemContaining, + RadioController, + radioControllerSelectionChange, + type RadioControllerOptions, + type RadioControllerSelectionChangeDetail, +} from './src/radio-controller.js'; diff --git a/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts new file mode 100644 index 00000000000..930421de0d1 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts @@ -0,0 +1,463 @@ +/** + * 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 +// ───────────────────────── + +/** + * Options for {@link RadioController}. + */ +export type RadioControllerOptions = { + /** + * Returns mutually exclusive participants. Items outside the host subtree (shadow-inclusive) + * are ignored. + */ + getItems: () => HTMLElement[]; + + /** + * Invoked when {@link item} should read as asserted (for example `aria-checked="true"`). + * + * @param item - Becoming the exclusive winner. + */ + selectItem: (item: HTMLElement) => void; + + /** + * Invoked whenever {@link item} must read as inactive (unchecked or collapsed sibling). + */ + deselectItem: (item: HTMLElement) => void; + + /** + * When **`true`**, the roster may hold no asserted item: **`setSelectedItem(null)`** succeeds, a + * primary **click** on the already-selected item clears selection, and **`toggleItem`** on that + * item clears as well. When **`false`**, at least one item stays asserted whenever any eligible + * item exists (unless **`defaultToFirstSelectable`** applies after structural changes). + */ + toggles?: boolean; + + /** + * When nothing is asserted after structural updates, asserts the earliest eligible sibling. + */ + defaultToFirstSelectable?: boolean; + + /** + * When **`true`**, **Enter** or **Space** on an eligible focused item (resolved like pointer + * hits via {@link deepestRadioItemContaining}) asserts that item, mirroring manual keyboard + * activation in the tabs pattern. When **`false`** (default), only capture-phase **click** + * changes selection (plus **`setSelectedItem`** / **`toggleItem`**). + */ + keydownActivation?: boolean; + + /** Optional listener mirroring {@link radioControllerSelectionChange}. */ + onSelectionChange?: (detail: RadioControllerSelectionChangeDetail) => void; +}; + +/** + * Name of the bubbling composed `CustomEvent` describing exclusive selection changes. + */ +export const radioControllerSelectionChange = + 'swc-radio-controller-selection-change'; + +/** + * `detail` object for {@link radioControllerSelectionChange}. + */ +export type RadioControllerSelectionChangeDetail = { + /** Active exclusive entry, or null after an intentional clear. */ + selectedItem: HTMLElement | null; +}; + +/** + * 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 deepestRadioItemContaining( + 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; +} + +/** + * Maintains mutually exclusive asserted state across sibling elements using supplied mutators. + * Scoping follows the reactive host subtree (light DOM and shadow descendants). Items that are + * disconnected, inert, not visible, native **`disabled`**, or **`aria-disabled="true"`** are never + * eligible and primary clicks on them do not change selection. This controller handles + * capture-phase **`click`**, optional capture-phase **`keydown`** for **Enter** / **Space** when + * **`keydownActivation`** is **`true`**, **`setSelectedItem`**, and **`toggleItem`** — it does not + * implement arrow keys, roving **`tabindex`**, programmatic **`focus()`**, or an active-item/focus + * sync callback. + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ + * @see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ + */ +export class RadioController implements ReactiveController { + private readonly host: ReactiveElement; + + private options: Omit< + Required< + Pick< + RadioControllerOptions, + | 'getItems' + | 'selectItem' + | 'deselectItem' + | 'toggles' + | 'defaultToFirstSelectable' + | 'keydownActivation' + > + >, + never + > & + Pick; + + private selectedItem: HTMLElement | null = null; + + private keydownListenerAttached = false; + + private readonly handleClickCapture = (event: MouseEvent): void => { + if (event.button !== 0) { + return; + } + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + const items = this.getEligibleItems(); + const hit = deepestRadioItemContaining(event, items); + if (!hit || this.isDisabledParticipant(hit)) { + return; + } + if (this.options.toggles && hit === this.selectedItem) { + this.applyExclusiveSelection(null); + return; + } + this.applyExclusiveSelection(hit); + }; + + private readonly handleKeyDownCapture = (event: KeyboardEvent): void => { + if (!this.options.keydownActivation) { + return; + } + if (event.code !== 'Enter' && event.code !== 'Space') { + return; + } + if (event.repeat) { + return; + } + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { + return; + } + const items = this.getEligibleItems(); + const hit = deepestRadioItemContaining(event, items); + if (!hit || this.isDisabledParticipant(hit)) { + return; + } + event.preventDefault(); + if (this.options.toggles && hit === this.selectedItem) { + this.applyExclusiveSelection(null); + return; + } + this.applyExclusiveSelection(hit); + }; + + constructor(host: ReactiveElement, options: RadioControllerOptions) { + this.host = host; + this.options = { + getItems: options.getItems, + selectItem: options.selectItem, + deselectItem: options.deselectItem, + toggles: options.toggles ?? false, + defaultToFirstSelectable: options.defaultToFirstSelectable ?? false, + keydownActivation: options.keydownActivation ?? false, + onSelectionChange: options.onSelectionChange, + }; + + host.addController(this); + } + + /** Returns the last asserted exclusive sibling tracked by this controller instance. */ + public getSelectedItem(): HTMLElement | null { + return this.selectedItem; + } + + /** Merges option deltas and reapplies {@link refresh}. */ + public setOptions(partial: Partial): void { + const defaultFirstProvided = + 'defaultToFirstSelectable' in partial && + typeof partial.defaultToFirstSelectable === 'boolean'; + + const togglesProvided = + 'toggles' in partial && typeof partial.toggles === 'boolean'; + + const keydownActivationProvided = + 'keydownActivation' in partial && + typeof partial.keydownActivation === 'boolean'; + + this.options = { + ...this.options, + ...partial, + toggles: togglesProvided ? partial.toggles! : this.options.toggles, + defaultToFirstSelectable: defaultFirstProvided + ? partial.defaultToFirstSelectable! + : this.options.defaultToFirstSelectable, + keydownActivation: keydownActivationProvided + ? partial.keydownActivation! + : this.options.keydownActivation, + getItems: partial.getItems ?? this.options.getItems, + selectItem: partial.selectItem ?? this.options.selectItem, + deselectItem: partial.deselectItem ?? this.options.deselectItem, + onSelectionChange: + partial.onSelectionChange ?? this.options.onSelectionChange, + }; + + this.syncKeydownActivationListener(); + this.refresh(); + } + + /** + * Asserts exclusive state without synthetic pointer events — returns {@link false} when + * {@link candidate} cannot join the exclusive roster. Clearing to **`null`** requires + * **`toggles: true`**. + */ + public setSelectedItem(candidate: HTMLElement | null): boolean { + if (candidate !== null) { + if ( + !this.getEligibleItems().includes(candidate) || + this.isDisabledParticipant(candidate) + ) { + return false; + } + } else if (!this.canClearSelection()) { + return false; + } + + this.applyExclusiveSelection(candidate); + + return true; + } + + /** + * Selects {@link item} when it is not the current exclusive choice. When **`toggles`** is + * **`true`** and {@link item} is already selected, clears to **`null`**. + * + * @returns {@link false} when {@link item} is ineligible, or when it is already selected and + * **`toggles`** is **`false`**. + */ + public toggleItem(item: HTMLElement): boolean { + if ( + !this.getEligibleItems().includes(item) || + this.isDisabledParticipant(item) + ) { + return false; + } + if (this.selectedItem !== item) { + this.applyExclusiveSelection(item); + return true; + } + if (!this.options.toggles) { + return false; + } + this.applyExclusiveSelection(null); + return true; + } + + /** Re-applies bookkeeping after structural changes (stale selection or defaulting). */ + public refresh(): void { + const items = this.getEligibleItems(); + + if ( + this.selectedItem !== null && + (!this.selectedItem.isConnected || !items.includes(this.selectedItem)) + ) { + const replacement = + items.length === 0 + ? null + : this.canClearSelection() && !this.options.defaultToFirstSelectable + ? null + : (items[0] ?? null); + this.applyExclusiveSelection(replacement); + } else if ( + this.selectedItem === null && + this.options.defaultToFirstSelectable && + items.length > 0 + ) { + this.applyExclusiveSelection(items[0]); + } + } + + public hostConnected(): void { + this.host.addEventListener('click', this.handleClickCapture, true); + this.syncKeydownActivationListener(); + this.refresh(); + } + + public hostDisconnected(): void { + this.host.removeEventListener('click', this.handleClickCapture, true); + if (this.keydownListenerAttached) { + this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = false; + } + } + + /** Adds or removes the capture-phase keydown listener when `keydownActivation` changes. */ + private syncKeydownActivationListener(): void { + if (!this.host.isConnected) { + return; + } + const want = this.options.keydownActivation; + if (want && !this.keydownListenerAttached) { + this.host.addEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = true; + } else if (!want && this.keydownListenerAttached) { + this.host.removeEventListener('keydown', this.handleKeyDownCapture, true); + this.keydownListenerAttached = false; + } + } + + private applyExclusiveSelection(next: HTMLElement | null): void { + const roster = this.getEligibleItems(); + let asserted = next; + if (asserted === null && !this.canClearSelection() && roster.length > 0) { + asserted = roster[0]; + } + + if (this.selectedItem === asserted) { + return; + } + + const prior = this.selectedItem; + const raw = this.getScopedRawItems(); + + for (const candidate of raw) { + if (asserted !== null && candidate === asserted) { + this.options.selectItem(candidate); + } else { + this.options.deselectItem(candidate); + } + } + + this.selectedItem = asserted; + + if (prior !== asserted) { + this.dispatchSelectionChange(); + } + } + + /** Mirrors `radioControllerSelectionChange` plus optional `onSelectionChange` hook. */ + private dispatchSelectionChange(): void { + const detail: RadioControllerSelectionChangeDetail = { + selectedItem: this.selectedItem, + }; + this.options.onSelectionChange?.(detail); + this.host.dispatchEvent( + new CustomEvent( + radioControllerSelectionChange, + { + bubbles: true, + composed: true, + detail, + } + ) + ); + } + + /** + * Whether `node` is the host itself or reachable by walking ancestors and shadow hosts. + * + * @param node - Node under test (may be null). + */ + 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; + } + + /** Raw query filtered to elements owned by the reactive host subtree. */ + private getScopedRawItems(): HTMLElement[] { + return this.options + .getItems() + .filter((element) => this.isNodeWithinHostScope(element)); + } + + /** + * Native **`disabled`** or **`aria-disabled="true"`** — never eligible and never activated by + * pointer or **`setSelectedItem`**. + */ + private isDisabledParticipant(participant: HTMLElement): boolean { + if ('disabled' in participant) { + if ((participant as HTMLButtonElement).disabled) { + return true; + } + } + return participant.getAttribute('aria-disabled') === 'true'; + } + + /** + * Eligibility filter: connected, visible, not inert, not disabled. + */ + private isRadioNavigableItem(participant: HTMLElement): boolean { + if (!participant.isConnected) { + return false; + } + if (participant.hasAttribute('inert') || participant.closest('[inert]')) { + return false; + } + const style = getComputedStyle(participant); + if ( + style.visibility === 'hidden' || + style.display === 'none' || + participant.hidden + ) { + return false; + } + if (this.isDisabledParticipant(participant)) { + return false; + } + return true; + } + + /** Eligible selectable siblings respecting visibility and disabled state. */ + private getEligibleItems(): HTMLElement[] { + return this.getScopedRawItems().filter((participant) => + this.isRadioNavigableItem(participant) + ); + } + + /** Whether the roster may hold no asserted item (`null`). */ + private canClearSelection(): boolean { + return this.options.toggles; + } +} diff --git a/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts new file mode 100644 index 00000000000..58954533a80 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts @@ -0,0 +1,968 @@ +/** + * 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 } from 'lit/decorators.js'; + +import { + RadioController, + type RadioControllerSelectionChangeDetail, +} from '../index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'demo-radio-group-rating': DemoRadioGroupRating; + 'demo-radio-rating-default-first': DemoRadioRatingDefaultFirst; + 'demo-radio-rating-on-selection-change-alert': DemoRadioRatingOnSelectionChangeAlert; + 'demo-radio-menu-item-radio': DemoRadioMenuItemRadio; + 'demo-radio-accordion-exclusive': DemoRadioAccordionExclusive; + 'demo-radio-accordion-multiple': DemoRadioAccordionMultiple; + 'demo-radio-tabs-keydown': DemoRadioTabsKeydown; + } +} + +/** @internal */ +@customElement('demo-radio-group-rating') +export class DemoRadioGroupRating extends LitElement { + static override styles = css` + :host { + display: block; + } + [role='radiogroup'] { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 20rem; + 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; + } + .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; + } + `; + + private readonly radios = new RadioController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-rating-star]' + ) + ), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: true, + toggles: true, + }); + + protected override firstUpdated(): void { + this.radios.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` + + `; + })} +
+
+ `; + } +} + +/** Shared chrome for the rating demo hosts below. */ +const radioRatingDemoChrome = css` + :host { + display: block; + } + [role='radiogroup'] { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 20rem; + 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; + } +`; + +/** + * Five-star layout like {@link DemoRadioGroupRating}, with **`defaultToFirstSelectable`** only + * (first star asserted after **`refresh`**; no **`onSelectionChange`**). + * + * @internal + */ +@customElement('demo-radio-rating-default-first') +export class DemoRadioRatingDefaultFirst extends LitElement { + static override styles = radioRatingDemoChrome; + + private readonly radios = new RadioController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-rating-star-default-first]' + ) + ), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: true, + toggles: false, + }); + + protected override firstUpdated(): void { + this.radios.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+
Rating
+

+ After + refresh + , the first star is selected because + defaultToFirstSelectable + is + true + . +

+
+ ${[1, 2, 3, 4, 5].map((value) => { + const label = value === 1 ? `${value} star` : `${value} stars`; + return html` + + `; + })} +
+
+ `; + } +} + +/** + * Same chrome as {@link DemoRadioRatingDefaultFirst}, with **`onSelectionChange`** only: each + * new asserted star triggers **`window.alert`** with its **`aria-label`** (Storybook demos only). + * + * @internal + */ +@customElement('demo-radio-rating-on-selection-change-alert') +export class DemoRadioRatingOnSelectionChangeAlert extends LitElement { + static override styles = radioRatingDemoChrome; + + private readonly radios = new RadioController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-rating-star-on-change]' + ) + ), + selectItem: (star) => star.setAttribute('aria-checked', 'true'), + deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: false, + toggles: true, + onSelectionChange: (detail: RadioControllerSelectionChangeDetail) => { + const star = detail.selectedItem; + const label = + star?.getAttribute('aria-label') ?? + (star ? 'Unknown star' : 'No star selected'); + window.alert(`Rating selection: ${label}`); + }, + }); + + protected override firstUpdated(): void { + this.radios.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+
Rating
+

+ Click stars to change selection; + onSelectionChange + shows a + window.alert + with the chosen label. + toggles + is + true + so you can clear by clicking the active star again. +

+
+ ${[1, 2, 3, 4, 5].map((value) => { + const label = value === 1 ? `${value} star` : `${value} stars`; + return html` + + `; + })} +
+
+ `; + } +} + +/** @internal */ +@customElement('demo-radio-menu-item-radio') +export class DemoRadioMenuItemRadio extends LitElement { + static override styles = css` + :host { + display: block; + } + .menubar { + display: inline-flex; + flex-direction: column; + align-items: stretch; + gap: 0.25rem; + min-inline-size: 12rem; + padding: 0.5rem; + border-radius: 6px; + border: 1px solid var(--spectrum-gray-300, #ccc); + background: var(--spectrum-gray-75, #f8f8f8); + font: + 0.9rem system-ui, + sans-serif; + } + button { + font: inherit; + text-align: start; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 1px; + } + button[aria-checked='true'] { + background: var(--spectrum-gray-200, #e8e8e8); + font-weight: 600; + } + `; + + private readonly radios = new RadioController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll('[data-alignment]') + ), + selectItem: (item) => item.setAttribute('aria-checked', 'true'), + deselectItem: (item) => item.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: true, + }); + + protected override firstUpdated(): void { + this.radios.refresh(); + } + + protected override render(): TemplateResult { + return html` + + `; + } +} + +/** @internal */ +@customElement('demo-radio-accordion-exclusive') +export class DemoRadioAccordionExclusive extends LitElement { + static override styles = css` + :host { + display: block; + font: + 0.95rem system-ui, + sans-serif; + } + .accordion { + max-width: 26rem; + border: 1px solid var(--spectrum-gray-300, #cbcbcb); + border-radius: 6px; + overflow: clip; + } + article { + inline-size: 300px; + 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; + } + `; + + private readonly panels = (): HTMLElement[] => + Array.from(this.renderRoot.querySelectorAll('.region')); + + private readonly accordionRadio = new RadioController(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); + }, + toggles: true, + }); + + private togglePanel(key: string, open: boolean): void { + this.panels().forEach((surface) => { + if (surface.dataset.panel === key) { + surface.hidden = !open; + } + }); + } + + protected override firstUpdated(): void { + const headers = Array.from( + this.renderRoot.querySelectorAll('[data-accordion]') + ); + headers.forEach((button) => + this.togglePanel( + button.dataset.accordion!, + button.getAttribute('aria-expanded') === 'true' + ) + ); + this.accordionRadio.refresh(); + } + + protected override render(): TemplateResult { + return html` +
+ ${[ + { + key: 'a', + heading: 'Brushes', + copy: 'Paint brushes behave like accordion headers with exclusive disclosure.', + }, + { + key: 'b', + heading: 'Filters', + copy: 'Accordion headers often use RadioController alone without a separate focus group.', + }, + { + key: 'c', + heading: 'Adjustments', + copy: '`aria-expanded` toggles mimic Spectrum accordion sizing demos.', + }, + ].map( + ({ key, heading, copy }, ordinal) => html` +
+ +
+ ${copy} +
+
+ ` + )} +
+ `; + } +} + +/** + * Accordion with four headers and panels — same `RadioController` pattern as + * {@link DemoRadioAccordionExclusive}, used by the “multiple sections” Storybook story. + * + * @internal + */ +@customElement('demo-radio-accordion-multiple') +export class DemoRadioAccordionMultiple extends LitElement { + static override styles = css` + :host { + display: block; + font: + 0.95rem system-ui, + sans-serif; + } + .accordion { + max-width: 28rem; + border: 1px solid var(--spectrum-gray-300, #cbcbcb); + border-radius: 6px; + overflow: clip; + } + article { + inline-size: 300px; + 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; + } + `; + + private readonly panels = (): HTMLElement[] => + Array.from(this.renderRoot.querySelectorAll('.region')); + + private readonly accordionRadio = new RadioController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-accordion-heading]' + ) + ), + 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); + }, + toggles: true, + }); + + private togglePanel(key: string, open: boolean): void { + this.panels().forEach((surface) => { + if (surface.dataset.panel === key) { + surface.hidden = !open; + } + }); + } + + protected override firstUpdated(): void { + const headers = Array.from( + this.renderRoot.querySelectorAll( + '[data-accordion-heading]' + ) + ); + headers.forEach((button) => + this.togglePanel( + button.dataset.accordion!, + button.getAttribute('aria-expanded') === 'true' + ) + ); + this.accordionRadio.refresh(); + } + + protected override render(): TemplateResult { + return html` + + `; + } +} + +const demoTabsKeydownStyles = 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); + } +`; + +/** + * Minimal `role="tablist"` demo: **`RadioController`** with **`keydownActivation: true`** so + * **Enter** / **Space** assert the focused tab; left/right arrows move focus (roving tabindex). + * Pointer clicks still select via the same controller. + * + * @internal + */ +@customElement('demo-radio-tabs-keydown') +export class DemoRadioTabsKeydown extends LitElement { + static override styles = demoTabsKeydownStyles; + + private tabButtons: HTMLButtonElement[] = []; + + private readonly tabRadio = new RadioController(this, { + getItems: () => this.tabButtons, + selectItem: (tab) => { + tab.setAttribute('aria-selected', 'true'); + tab.tabIndex = 0; + const key = tab.dataset.tab!; + const panel = this.renderRoot.querySelector( + `[data-tab-panel="${key}"]` + ); + panel?.removeAttribute('hidden'); + }, + deselectItem: (tab) => { + tab.setAttribute('aria-selected', 'false'); + tab.tabIndex = -1; + const key = tab.dataset.tab!; + const panel = this.renderRoot.querySelector( + `[data-tab-panel="${key}"]` + ); + panel?.setAttribute('hidden', ''); + }, + keydownActivation: true, + defaultToFirstSelectable: true, + }); + + protected override firstUpdated(): void { + this.tabButtons = Array.from( + this.renderRoot.querySelectorAll('[data-tab]') + ); + this.tabRadio.refresh(); + } + + private handleTabListKeydown(event: KeyboardEvent): void { + if (event.code !== 'ArrowLeft' && event.code !== 'ArrowRight') { + return; + } + const tabs = this.tabButtons; + const root = this.renderRoot as ShadowRoot | HTMLElement; + const active = root instanceof ShadowRoot ? root.activeElement : null; + const index = tabs.indexOf(active as HTMLButtonElement); + if (index === -1) { + return; + } + event.preventDefault(); + const delta = event.code === 'ArrowRight' ? 1 : -1; + const next = (index + delta + tabs.length) % tabs.length; + tabs[next]?.focus(); + } + + protected override render(): TemplateResult { + return html` +

+ Use arrow keys to move focus, then + Enter + or + Space + to select (via + keydownActivation: true + ). Pointer still works. +

+
+ + + +
+
+ + + +
+ `; + } +} diff --git a/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.stories.ts b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.stories.ts new file mode 100644 index 00000000000..26977a173ba --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.stories.ts @@ -0,0 +1,618 @@ +/** + * 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'; + +// ──────────────── +// API (Storybook ApiTable — see `meta.parameters.controllerApi`) +// ──────────────── + +const RADIO_CONTROLLER_API = { + title: 'RadioController', + options: [ + { + name: 'getItems', + type: '() => HTMLElement[]', + defaultValue: '(required)', + description: + 'Returns the current mutually exclusive participants. Items outside the reactive host subtree are ignored.', + }, + { + name: 'selectItem', + type: '(item: HTMLElement) => void', + defaultValue: '(required)', + description: + 'Called when an item becomes the exclusive winner (for example set aria-checked or aria-expanded).', + }, + { + name: 'deselectItem', + type: '(item: HTMLElement) => void', + defaultValue: '(required)', + description: + 'Called for every other scoped item whenever the asserted item changes so losers update DOM or ARIA.', + }, + { + name: 'toggles', + type: 'boolean | undefined', + defaultValue: 'false', + description: + 'When true, setSelectedItem(null) may clear every asserted item (subject to defaultToFirstSelectable), and a primary click or toggleItem on the already-selected item can clear selection.', + }, + { + name: 'defaultToFirstSelectable', + type: 'boolean | undefined', + defaultValue: 'false', + description: + 'When true, refresh selects the first eligible item if nothing is currently asserted.', + }, + { + name: 'keydownActivation', + type: 'boolean | undefined', + defaultValue: 'false', + description: + 'When true, capture-phase Enter or Space on a focused eligible item asserts it (tabs-style manual activation). Clicks still apply when false or true.', + }, + { + name: 'onSelectionChange', + type: '(detail: RadioControllerSelectionChangeDetail) => void', + defaultValue: 'undefined', + description: + 'Optional callback with the same payload as the swc-radio-controller-selection-change event.', + }, + ], + methods: [ + { + name: 'constructor', + signature: + 'new RadioController(host: ReactiveElement, options: RadioControllerOptions)', + returns: 'RadioController', + description: + 'Registers the controller on the Lit host via host.addController(this).', + }, + { + name: 'getSelectedItem', + signature: 'getSelectedItem()', + returns: 'HTMLElement | null', + description: + 'Returns the last asserted exclusive item, or null when none.', + }, + { + name: 'setOptions', + signature: 'setOptions(partial: Partial): void', + returns: 'void', + description: 'Merges option updates and runs refresh().', + }, + { + name: 'setSelectedItem', + signature: 'setSelectedItem(candidate: HTMLElement | null): boolean', + returns: 'boolean', + description: + 'Asserts candidate or clears to null when toggles is true; returns false when the item is ineligible, disabled, or clearing is not allowed.', + }, + { + name: 'toggleItem', + signature: 'toggleItem(item: HTMLElement): boolean', + returns: 'boolean', + description: + 'Selects item when it is not active. When toggles is true and item is already selected, clears to null if allowed; otherwise returns false.', + }, + { + name: 'refresh', + signature: 'refresh(): void', + returns: 'void', + description: + 'Re-applies selection after DOM or eligibility changes (stale selection, defaultToFirstSelectable, etc.).', + }, + { + name: 'hostConnected', + signature: 'hostConnected(): void', + returns: 'void', + description: + 'Lit ReactiveController: registers capture-phase click on the host, optionally capture-phase keydown when keydownActivation is true, and calls refresh().', + }, + { + name: 'hostDisconnected', + signature: 'hostDisconnected(): void', + returns: 'void', + description: + 'Lit ReactiveController: removes capture-phase click and any keydown listener registered for keydownActivation.', + }, + ], + events: [ + { + name: 'swc-radio-controller-selection-change', + detail: '{ selectedItem: HTMLElement | null }', + description: + 'Bubbles and composed; dispatched whenever the tracked exclusive item changes.', + }, + ], + exports: [ + { + name: 'radioControllerSelectionChange', + kind: 'constant', + description: + 'String event name for swc-radio-controller-selection-change (use with addEventListener).', + }, + { + name: 'deepestRadioItemContaining', + kind: 'function', + description: + 'Returns the deepest node on event.composedPath() that appears in the supplied items array.', + }, + { + name: 'RadioControllerSelectionChangeDetail', + kind: 'type', + description: 'TypeScript detail shape for the selection change event.', + }, + ], +} as const; + +// ──────────────── +// METADATA +// ──────────────── + +/** + * `RadioController` enforces **one asserted sibling at a time** inside your reactive host. You + * supply **`getItems`** (who participates) and **`selectItem` / `deselectItem`** (how DOM or ARIA + * reflects the winner and the losers). It does **not** wire native ``; use + * it for custom roles (`radio`, `menuitemradio`, accordion headers, and similar). + * + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ | APG rating radio group} + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ | APG menu / menubar} + * @see {@link https://opensource.adobe.com/spectrum-web-components/components/accordion/#sizes | Spectrum accordion} + */ +const meta: Meta = { + title: 'Controllers/Radio controller', + component: 'demo-radio-group-rating', + parameters: { + docs: { + subtitle: + 'Exclusive selection with configurable DOM updates; pointer clicks, optional Enter/Space (keydownActivation), and toggleItem.', + canvas: { sourceState: 'none' }, + }, + controllerApi: RADIO_CONTROLLER_API, + }, + tags: ['migrated', 'controller'], + render: () => html` + + `, +}; + +export default meta; + +type Story = StoryObj; + +// ────────────────────────── +// AUTODOCS STORY +// ────────────────────────── + +export const Playground: Story = { + tags: ['autodocs', 'dev'], +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + tags: ['overview'], +}; + +// ────────────────────────── +// BASIC USAGE STORY +// ────────────────────────── + +/** + * ## Anatomy of a `RadioController` + * + * The`RadioController` is a contructor with the following parameters: + * - `host: ReactiveElement` - the host element + * - `options: RadioControllerOptions` - the options for the controller + * - `getItems: () => HTMLElement[]` - the function that returns the current list of `HTMLElement` nodes + * - `selectItem: (item: HTMLElement) => void` - the function that is called when the item is selected + * - `deselectItem: (item: HTMLElement) => void` - the function that is called when the item is deselected + * - `toggles: boolean` - optional: whether the controller allows toggling the item + * - `defaultToFirstSelectable: boolean` - optional: whether the controller should select the first item if no item is selected + * - `keydownActivation: boolean` - optional: when true, **Enter** / **Space** on a focused eligible item asserts it (tabs-style manual activation); default is click-only + * - `onSelectionChange: (detail: RadioControllerSelectionChangeDetail) => void` - optional: the function that is called when the selection changes + * + * ```typescript + * import { RadioController } from '@spectrum-web-components/core/controllers'; + * + * private readonly radios = new RadioController(this, { + * getItems: () => [...], + * selectItem: (el) => { ... }, + * deselectItem: (el) => { ... }, + * keydownActivation: true, + * }); + * ``` + * + * ### `getItems` + * + * Pass a **function** (not a static array) that returns the current list of `HTMLElement` nodes + * that should behave as one mutually exclusive set — for example every `button[role="radio"]` + * inside `this.renderRoot`. + * + * **Rules the controller applies:** + * + * - Only nodes **inside the reactive host’s subtree** count (light DOM children and shadow + * descendants; nodes outside the host are ignored). + * - Disconnected, `[inert]`, `hidden`, or `display: none` / `visibility: hidden` items are + * skipped for interaction. + * - Native **`disabled`** and **`aria-disabled="true"`** are never eligible; primary clicks on + * them do not change selection, and **`setSelectedItem`** returns **`false`** for them. + * + * **When to refresh:** after the shadow tree exists and whenever the list of controls changes, + * call **`radios.refresh()`** — typically from `firstUpdated` and from `updated` when your + * template or slot content changes which elements exist. + * + * ```typescript + * protected override firstUpdated(): void { + * this.radios.refresh(); + * } + * + * // Also call `this.radios.refresh()` from `updated()` when slots, repeats, or props change + * // which items exist or their order — any time `getItems()` would return a different array. + * ``` + * + * ### `selectItem` and `deselectItem` + * + * Whenever the asserted item changes, the controller walks **every** currently scoped raw item + * from `getItems` (after host filtering) and: + * + * - calls **`selectItem`** once on the **new** exclusive item, and + * - calls **`deselectItem`** on **each** other item in that same raw list. + * + * 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) based on + * `element.dataset` or `element.id`. + * + * **Typical `aria-checked` radios / menu radios:** + * + * ```typescript + * selectItem: (item) => item.setAttribute('aria-checked', 'true'), + * deselectItem: (item) => item.setAttribute('aria-checked', 'false'), + * ``` + * + * The controller does **not** interpret `aria-checked` or `aria-expanded` to infer selection; it + * only drives your callbacks. Keep visual state and ARIA in sync inside those two functions. + */ +export const Usage: Story = { + tags: ['usage', 'description-only'], + parameters: { 'section-order': 0 }, +}; + +/** + * ## Examples + * ### Selection with `aria-expanded` and opening a panel + * + * **Accordion-style headers** (one expanded section): treat “selected” as expanded and the rest + * as collapsed — often `selectItem` sets `aria-expanded="true"` and shows a panel, while + * `deselectItem` sets `aria-expanded="false"` and hides the matching region. Accordion headers + * typically use **`RadioController` alone** (pointer + `setSelectedItem` only). + * + * ```typescript + * this.accordionRadio = new RadioController(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); + * }, + * }); + * ``` + */ +export const UsageExampleSelectionWithAriaExpanded: Story = { + name: 'Example: selection with `aria-expanded` and opening a panel', + tags: ['usage'], + parameters: { 'section-order': 1 }, + render: () => html` + + `, +}; + +/** + * ### Selection with `aria-checked` + * + * `role="menuitemradio"` siblings with mirrored `aria-checked`, aligned with the APG + * **[Menu and menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)** pattern. + * + * ```typescript + * this.radios = new RadioController(this, { + * getItems: () => + * Array.from( + * this.renderRoot.querySelectorAll('[data-alignment]') + * ), + * selectItem: (item) => item.setAttribute('aria-checked', 'true'), + * deselectItem: (item) => item.setAttribute('aria-checked', 'false'), + * defaultToFirstSelectable: true, + * }); + * ``` + */ +export const UsageExampleSelectionWithAriaChecked: Story = { + name: 'Example: selection with `aria-checked`', + tags: ['usage'], + parameters: { 'section-order': 2 }, + render: () => html` + + `, +}; + +/** + * ### Toggling to deselect + * + * Set **`toggles: true`** so **`setSelectedItem(null)`** or a primary click on the already-expanded + * heading can clear the asserted heading and every panel can close. With **`toggles: false`**, the + * controller keeps an asserted item whenever the roster is non-empty. + * + * ```typescript + * this.accordionRadio = new RadioController(this, { + * getItems: () => + * Array.from( + * this.renderRoot.querySelectorAll('[data-accordion-heading]') + * ), + * 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); + * }, + * toggles: true, + * }); + * ``` + */ +export const UsageExampleTogglingToDeselect: Story = { + name: 'Example: toggling to deselect', + tags: ['usage'], + parameters: { 'section-order': 3 }, + render: () => html` + + `, +}; + +/** + * ### `keydownActivation` (tabs-style Enter / Space) + * + * Set **`keydownActivation: true`** so **Enter** or **Space** on a focused participant asserts + * it, in addition to primary **click**. Combine with your own arrow-key / roving tabindex logic + * for a full tablist, or use the demo below. + * + * ```typescript + * this.tabRadio = new RadioController(this, { + * getItems: () => this.tabButtons, + * selectItem: (tab) => { + * tab.setAttribute('aria-selected', 'true'); + * tab.tabIndex = 0; + * this.showPanelForTab(tab); + * }, + * deselectItem: (tab) => { + * tab.setAttribute('aria-selected', 'false'); + * tab.tabIndex = -1; + * this.hidePanelForTab(tab); + * }, + * keydownActivation: true, + * defaultToFirstSelectable: true, + * }); + * ``` + */ +export const UsageExampleKeydownActivationTabs: Story = { + name: 'Example: keydownActivation (tablist)', + tags: ['usage'], + parameters: { 'section-order': 4 }, + render: () => html` + + `, +}; + +// ────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────── +export const AccordionExpandedExclusive: Story = { + name: 'Setting `aria-expanded` and opening a panel on an accordion', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { + 'section-order': 1, + docs: { + disable: true, + }, + }, +}; +export const AccordionMultipleSectionsAriaExpandedHidden: Story = { + render: () => html` + + `, + tags: ['behaviors'], + parameters: { + 'section-order': 2, + docs: { + disable: true, + }, + }, +}; + +/** + * Tablist-style demo: arrow keys move focus; **Enter** / **Space** select via + * **`keydownActivation: true`**; pointer still works. + */ +export const KeydownActivationTabsDemo: Story = { + name: 'keydownActivation: tablist demo', + render: () => html` + + `, + tags: ['behaviors'], + parameters: { + 'section-order': 3, + docs: { + disable: true, + }, + }, +}; + +/** + * Five-star rating illustrating **`defaultToFirstSelectable`** only (no **`onSelectionChange`**). + * Rendered under **Setting default selection** on the docs page (no **`dev`** tag, so it stays out + * of the Storybook sidebar). + * + * ```typescript + * this.radios = new RadioController(this, { + * getItems: () => + * Array.from( + * this.renderRoot.querySelectorAll( + * '[data-rating-star-default-first]' + * ) + * ), + * selectItem: (star) => star.setAttribute('aria-checked', 'true'), + * deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + * defaultToFirstSelectable: true, + * toggles: false, + * }); + * ``` + */ +export const RatingDefaultFirstSelectable: Story = { + name: 'Rating: defaultToFirstSelectable', + render: () => html` + + `, + tags: ['setting-default-selection'], + parameters: { 'section-order': 0 }, +}; + +/** + * Same layout, illustrating **`onSelectionChange`** with **`window.alert`** (and **`toggles`** so + * the active star can be cleared). Rendered under **Behaviors → Responding to selection change** on + * the docs page (no **`dev`** tag, so it stays out of the Storybook sidebar). + * + * ```typescript + * this.radios = new RadioController(this, { + * getItems: () => + * Array.from( + * this.renderRoot.querySelectorAll( + * '[data-rating-star-on-change]' + * ) + * ), + * selectItem: (star) => star.setAttribute('aria-checked', 'true'), + * deselectItem: (star) => star.setAttribute('aria-checked', 'false'), + * defaultToFirstSelectable: false, + * toggles: true, + * onSelectionChange: ({ selectedItem }) => { + * const label = + * selectedItem?.getAttribute('aria-label') ?? 'No star selected'; + * window.alert(`Rating selection: ${label}`); + * }, + * }); + * ``` + */ +export const RatingOnSelectionChangeAlert: Story = { + name: 'Rating: onSelectionChange (alert)', + render: () => html` + + `, + tags: ['responding-to-selection-change'], + parameters: { 'section-order': 0 }, +}; + +/** + * ### Methods + * + * | Member | Description | + * |---|---| + * | `setOptions(partial)` | Merge new options and reapply selection. | + * | `refresh()` | Re-query items and reassert selection (call after DOM changes). | + * | `getSelectedItem()` | Returns the asserted item, if any. | + * | `setSelectedItem(element \| null)` | Assert exclusive item or clear when allowed; returns `false` if ineligible. | + * | `toggleItem(element)` | Select or clear when `toggles` allows clearing the active item. | + * + * ### Events + * + * The controller dispatches **`swc-radio-controller-selection-change`** + * (`radioControllerSelectionChange`) on the host with `detail: { selectedItem }` when the + * asserted item changes. The event bubbles and is composed. + * + * ```typescript + * import { radioControllerSelectionChange } from + * '@spectrum-web-components/core/controllers/radio-controller.js'; + * + * host.addEventListener(radioControllerSelectionChange, (event) => { + * console.log('Selected:', event.detail.selectedItem); + * }); + * ``` + * + * ### Options + * + * | Option | Type | Default | Description | + * |---|---|---|---| + * | `getItems` | `() => HTMLElement[]` | (required) | Current exclusive participants. | + * | `selectItem` | `(item: HTMLElement) => void` | (required) | Called on the new asserted item. | + * | `deselectItem` | `(item: HTMLElement) => void` | (required) | Called on every other scoped item. | + * | `toggles` | `boolean` | `false` | When true, allow clearing to no asserted item (`setSelectedItem(null)`, click or `toggleItem` on the active item). | + * | `defaultToFirstSelectable` | `boolean` | `false` | When true, `refresh` may select the first eligible item if none asserted. | + * | `keydownActivation` | `boolean` | `false` | When true, Enter/Space on a focused eligible item asserts it (manual tabs-style activation). | + * | `onSelectionChange` | `(detail) => void` | — | Callback when selection changes. | + * + * See the **API** table above for the full machine-readable contract. + */ +export const API: Story = { + tags: ['api', 'description-only'], +}; + +// ──────────────────────────────── +// ACCESSIBILITY STORIES +// ──────────────────────────────── + +/** + * ### Pointer and eligibility + * + * **`RadioController`** resolves primary clicks with **`deepestRadioItemContaining`**. Disabled + * and **`aria-disabled="true"`** items are never asserted and do not receive selection changes from + * pointer interaction. + * + * ### Keyboard and focus + * + * With **`keydownActivation: true`**, the controller listens for **Enter** and **Space** on the + * host (capture phase) and asserts the deepest eligible item on the event path, similar to manual + * keyboard activation in **`swc-tabs`**. It still does **not** implement roving **`tabindex`** or + * arrow-key navigation; pair it with **`FocusgroupNavigationController`** or your own tablist + * handlers when you need those behaviors. + * + * ### ARIA + * + * Keep `aria-checked`, `aria-expanded`, and related attributes in sync inside your + * **`selectItem`** / **`deselectItem`** callbacks; the controller does not infer state from the DOM. + */ +export const Accessibility: Story = { + tags: ['a11y', 'description-only'], +}; + +/** + * ### See also + * + * - [APG rating radio group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/) + * - [APG menu / menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) + * - [Spectrum accordion](https://opensource.adobe.com/spectrum-web-components/components/accordion/#sizes) + */ +export const Appendix: Story = { + tags: ['description-only', 'appendix'], +}; diff --git a/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts new file mode 100644 index 00000000000..b1496517641 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts @@ -0,0 +1,1006 @@ +/** + * 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 } from 'lit/decorators.js'; +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { getComponent } from '../../../../swc/utils/test-utils.js'; +import { + RadioController, + radioControllerSelectionChange, + type RadioControllerSelectionChangeDetail, +} from '../index.js'; +import radioMeta from './radio-controller.stories.js'; + +const FIXTURE_TAG = 'test-radio-controller-fixture'; + +const FIXTURE_DISABLED_TAG = 'test-radio-controller-disabled-fixture'; + +const FIXTURE_TOGGLE_TAG = 'test-radio-controller-toggle-fixture'; + +const FIXTURE_DEFAULT_FIRST_ONCHANGE_TAG = + 'test-radio-default-first-onchange-fixture'; + +const FIXTURE_KEYDOWN_ACTIVATION_TAG = 'test-radio-keydown-activation-fixture'; + +const FIXTURE_KEYDOWN_OFF_DIV_TAG = 'test-radio-keydown-off-div-fixture'; + +/** + * Three shadow `role="radio"` buttons; tests assert callback wiring and pointer selection only. + */ +@customElement(FIXTURE_TAG) +export class TestRadioControllerFixture extends LitElement { + static override styles = css` + :host { + display: block; + } + button { + font: inherit; + margin-inline-end: 0.25rem; + } + `; + + /** Increments whenever the controller re-queries participants via `getItems`. */ + getItemsCallCount = 0; + + readonly selectCalls: HTMLElement[] = []; + + readonly deselectCalls: HTMLElement[] = []; + + private readonly radio = new RadioController(this, { + getItems: () => { + this.getItemsCallCount += 1; + return Array.from( + this.renderRoot.querySelectorAll('[data-item]') + ); + }, + selectItem: (item) => { + this.selectCalls.push(item); + item.setAttribute('aria-checked', 'true'); + }, + deselectItem: (item) => { + this.deselectCalls.push(item); + item.setAttribute('aria-checked', 'false'); + }, + defaultToFirstSelectable: false, + }); + + getRadioController(): RadioController { + return this.radio; + } + + clearCallLogs(): void { + this.selectCalls.length = 0; + this.deselectCalls.length = 0; + } + + protected override firstUpdated(): void { + this.radio.refresh(); + } + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +/** + * A native **disabled** middle control and an **`aria-disabled="true"`** third control. + */ +@customElement(FIXTURE_DISABLED_TAG) +export class TestRadioControllerDisabledFixture extends LitElement { + static override styles = css` + :host { + display: block; + } + button { + font: inherit; + margin-inline-end: 0.25rem; + } + `; + + private readonly radio = new RadioController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-item]')), + selectItem: (item) => item.setAttribute('aria-checked', 'true'), + deselectItem: (item) => item.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: false, + }); + + getRadioController(): RadioController { + return this.radio; + } + + protected override firstUpdated(): void { + this.radio.refresh(); + } + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +/** + * Same three radios as {@link TestRadioControllerFixture}, with **`toggles: true`** so + * **`setSelectedItem(null)`**, **`toggleItem`** on the active control, and a click on the active + * control can clear selection. + */ +@customElement(FIXTURE_TOGGLE_TAG) +export class TestRadioControllerToggleFixture extends LitElement { + static override styles = css` + :host { + display: block; + } + button { + font: inherit; + margin-inline-end: 0.25rem; + } + `; + + private readonly radio = new RadioController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-item]')), + selectItem: (item) => item.setAttribute('aria-checked', 'true'), + deselectItem: (item) => item.setAttribute('aria-checked', 'false'), + toggles: true, + defaultToFirstSelectable: false, + }); + + getRadioController(): RadioController { + return this.radio; + } + + protected override firstUpdated(): void { + this.radio.refresh(); + } + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +/** + * Three radios with **`defaultToFirstSelectable: true`**, **`toggles: true`**, and + * **`onSelectionChange`** that records each **`selectedItem`** for assertions. + */ +@customElement(FIXTURE_DEFAULT_FIRST_ONCHANGE_TAG) +export class TestRadioDefaultFirstOnChangeFixture extends LitElement { + static override styles = css` + :host { + display: block; + } + button { + font: inherit; + margin-inline-end: 0.25rem; + } + `; + + /** Each **`selectedItem`** passed to **`onSelectionChange`**, in order. */ + readonly selectionLog: Array = []; + + private readonly radio = new RadioController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-item]')), + selectItem: (item) => item.setAttribute('aria-checked', 'true'), + deselectItem: (item) => item.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: true, + toggles: true, + onSelectionChange: (detail: RadioControllerSelectionChangeDetail) => { + this.selectionLog.push(detail.selectedItem); + }, + }); + + getRadioController(): RadioController { + return this.radio; + } + + clearSelectionLog(): void { + this.selectionLog.length = 0; + } + + protected override firstUpdated(): void { + this.radio.refresh(); + } + + protected override render(): TemplateResult { + return html` + + + + `; + } +} + +/** + * Three **`div[role="radio"]`** controls (no native **Enter** activation) with + * **`keydownActivation: true`** for **Enter** / **Space** selection tests. + */ +@customElement(FIXTURE_KEYDOWN_ACTIVATION_TAG) +export class TestRadioKeydownActivationFixture extends LitElement { + static override styles = css` + :host { + display: flex; + gap: 0.35rem; + } + [data-item] { + padding: 0.35rem 0.5rem; + border-radius: 4px; + border: 1px solid #ccc; + cursor: default; + font: inherit; + } + `; + + private readonly radio = new RadioController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-item]')), + selectItem: (item) => { + item.setAttribute('aria-checked', 'true'); + item.tabIndex = 0; + }, + deselectItem: (item) => { + item.setAttribute('aria-checked', 'false'); + item.tabIndex = -1; + }, + keydownActivation: true, + defaultToFirstSelectable: true, + }); + + getRadioController(): RadioController { + return this.radio; + } + + protected override firstUpdated(): void { + this.radio.refresh(); + } + + protected override render(): TemplateResult { + return html` +
A
+
B
+
C
+ `; + } +} + +/** + * Same **`div[role="radio"]`** roster with **`keydownActivation: false`** so **Enter** does not + * assert via the controller (native divs do not synthesize click on **Enter**). + */ +@customElement(FIXTURE_KEYDOWN_OFF_DIV_TAG) +export class TestRadioKeydownOffDivFixture extends LitElement { + static override styles = css` + :host { + display: flex; + gap: 0.35rem; + } + [data-item] { + padding: 0.35rem 0.5rem; + border-radius: 4px; + border: 1px solid #ccc; + cursor: default; + font: inherit; + } + `; + + private readonly radio = new RadioController(this, { + getItems: () => + Array.from(this.renderRoot.querySelectorAll('[data-item]')), + selectItem: (item) => { + item.setAttribute('aria-checked', 'true'); + item.tabIndex = 0; + }, + deselectItem: (item) => { + item.setAttribute('aria-checked', 'false'); + item.tabIndex = -1; + }, + keydownActivation: false, + defaultToFirstSelectable: true, + }); + + getRadioController(): RadioController { + return this.radio; + } + + protected override firstUpdated(): void { + this.radio.refresh(); + } + + protected override render(): TemplateResult { + return html` +
A
+
B
+
C
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'test-radio-controller-fixture': TestRadioControllerFixture; + 'test-radio-controller-disabled-fixture': TestRadioControllerDisabledFixture; + 'test-radio-controller-toggle-fixture': TestRadioControllerToggleFixture; + 'test-radio-default-first-onchange-fixture': TestRadioDefaultFirstOnChangeFixture; + 'test-radio-keydown-activation-fixture': TestRadioKeydownActivationFixture; + 'test-radio-keydown-off-div-fixture': TestRadioKeydownOffDivFixture; + } +} + +export default { + ...radioMeta, + title: 'Radio controller/Tests', + parameters: { + ...radioMeta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', '!dev'], +} as Meta; + +function fixtureRender() { + return html` + + `; +} + +function defaultFirstOnChangeFixtureRender() { + return html` + + `; +} + +/** `getItems` runs during `refresh` on connect and returns the three `[data-item]` controls. */ + +export const GetItemsSuppliesParticipants: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + + expect(host.getItemsCallCount).toBeGreaterThan(0); + const listed = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + expect(listed).toHaveLength(3); + }, +}; + +/** `setSelectedItem` calls `selectItem` on the target and `deselectItem` on every other item. */ + +export const SetSelectedItemInvokesSelectAndDeselectCallbacks: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + const middle = buttons[1]!; + + host.clearCallLogs(); + host.getRadioController().setSelectedItem(middle); + + expect(host.selectCalls).toEqual([middle]); + expect(host.deselectCalls).toEqual([buttons[0]!, buttons[2]!]); + }, +}; + +/** `selectItem` / `deselectItem` leave exactly one `aria-checked="true"` after `setSelectedItem`. */ + +export const SetSelectedItemLeavesSingleAriaChecked: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[2]!); + + expect(buttons[2]?.getAttribute('aria-checked')).toBe('true'); + expect(buttons[0]?.getAttribute('aria-checked')).toBe('false'); + expect(buttons[1]?.getAttribute('aria-checked')).toBe('false'); + expect( + buttons.filter((b) => b.getAttribute('aria-checked') === 'true').length + ).toBe(1); + }, +}; + +/** Primary click selects that control and clears the others. */ + +export const ClickSelectsExclusive: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[0]!); + await host.updateComplete; + + buttons[2]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(buttons[2]?.getAttribute('aria-checked')).toBe('true'); + expect(buttons[0]?.getAttribute('aria-checked')).toBe('false'); + expect(buttons[1]?.getAttribute('aria-checked')).toBe('false'); + expect( + buttons.filter((b) => b.getAttribute('aria-checked') === 'true').length + ).toBe(1); + }, +}; + +function disabledFixtureRender() { + return html` + + `; +} + +/** `setSelectedItem` returns false for `disabled` and `aria-disabled` controls. */ + +export const DisabledParticipantsRejectSetSelected: Story = { + render: disabledFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_DISABLED_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + const [first, nativeDisabled, ariaDisabled] = buttons; + + expect(host.getRadioController().setSelectedItem(first!)).toBe(true); + expect(host.getRadioController().setSelectedItem(nativeDisabled!)).toBe( + false + ); + expect(host.getRadioController().setSelectedItem(ariaDisabled!)).toBe( + false + ); + expect(first?.getAttribute('aria-checked')).toBe('true'); + expect(nativeDisabled?.getAttribute('aria-checked')).toBe('false'); + expect(ariaDisabled?.getAttribute('aria-checked')).toBe('false'); + }, +}; + +/** Primary click on an `aria-disabled` control does not move selection. */ + +export const AriaDisabledClickDoesNotChangeSelection: Story = { + render: disabledFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_DISABLED_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[0]!); + await host.updateComplete; + + buttons[2]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(buttons[0]?.getAttribute('aria-checked')).toBe('true'); + expect(buttons[2]?.getAttribute('aria-checked')).toBe('false'); + }, +}; + +function toggleFixtureRender() { + return html` + + `; +} + +/** `toggleItem` on the active control is a no-op when **`toggles`** is **`false`**. */ + +export const ToggleItemNoOpWhenAlreadySelectedWithoutToggles: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[0]!); + await host.updateComplete; + + expect(host.getRadioController().toggleItem(buttons[0]!)).toBe(false); + expect(buttons[0]?.getAttribute('aria-checked')).toBe('true'); + }, +}; + +/** `setSelectedItem(null)` returns false when **`toggles`** is **`false`**. */ + +export const SetNullRejectedWhenTogglesFalse: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[0]!); + await host.updateComplete; + + expect(host.getRadioController().setSelectedItem(null)).toBe(false); + expect(host.getRadioController().getSelectedItem()).toBe(buttons[0]!); + }, +}; + +/** `toggleItem` selects a different control when it is not yet active. */ + +export const ToggleItemSelectsWhenInactive: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + expect(host.getRadioController().toggleItem(buttons[1]!)).toBe(true); + expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); + expect(buttons[0]?.getAttribute('aria-checked')).toBe('false'); + }, +}; + +/** With **`toggles: true`**, `toggleItem` on the active control clears every `aria-checked`. */ + +export const ToggleItemClearsWhenTogglesTrue: Story = { + render: toggleFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TOGGLE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[0]!); + await host.updateComplete; + + expect(host.getRadioController().toggleItem(buttons[0]!)).toBe(true); + await host.updateComplete; + + expect( + buttons.every((b) => b.getAttribute('aria-checked') === 'false') + ).toBe(true); + expect(host.getRadioController().getSelectedItem()).toBeNull(); + }, +}; + +/** `setSelectedItem(null)` succeeds with only **`toggles: true`**. */ + +export const SetNullAllowedWhenTogglesTrue: Story = { + render: toggleFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TOGGLE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[2]!); + await host.updateComplete; + + expect(host.getRadioController().setSelectedItem(null)).toBe(true); + expect(host.getRadioController().getSelectedItem()).toBeNull(); + }, +}; + +/** Primary click on the active control clears selection when **`toggles`** is **`true`**. */ + +export const ClickActiveClearsWhenTogglesTrue: Story = { + render: toggleFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TOGGLE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[1]!); + await host.updateComplete; + + buttons[1]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect( + buttons.every((b) => b.getAttribute('aria-checked') === 'false') + ).toBe(true); + expect(host.getRadioController().getSelectedItem()).toBeNull(); + }, +}; + +/** Primary click on the active control does not clear when **`toggles`** is **`false`**. */ + +export const ClickActiveDoesNotClearWhenTogglesFalse: Story = { + render: fixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + host.getRadioController().setSelectedItem(buttons[1]!); + await host.updateComplete; + + buttons[1]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); + expect(host.getRadioController().getSelectedItem()).toBe(buttons[1]!); + }, +}; + +/** `setOptions({ toggles })` toggles whether **`setSelectedItem(null)`** can clear without reconstructing the controller. */ + +export const SetOptionsTogglesEnablesAndDisablesClear: Story = { + render: toggleFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TOGGLE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + const radio = host.getRadioController(); + + radio.setSelectedItem(buttons[0]!); + await host.updateComplete; + + radio.setOptions({ toggles: false }); + expect(radio.setSelectedItem(null)).toBe(false); + expect(radio.getSelectedItem()).toBe(buttons[0]!); + + radio.setOptions({ toggles: true }); + expect(radio.setSelectedItem(null)).toBe(true); + expect(radio.getSelectedItem()).toBeNull(); + }, +}; + +/** Clearing via **`toggleItem`** dispatches **`radioControllerSelectionChange`** with **`null`**. */ + +export const SelectionChangeEventWhenClearedViaToggleItem: Story = { + render: toggleFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TOGGLE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + const radio = host.getRadioController(); + + let last: RadioControllerSelectionChangeDetail | undefined; + host.addEventListener(radioControllerSelectionChange, ((event: Event) => { + last = (event as CustomEvent) + .detail; + }) as EventListener); + + radio.setSelectedItem(buttons[0]!); + await host.updateComplete; + + expect(radio.toggleItem(buttons[0]!)).toBe(true); + await host.updateComplete; + + expect(last?.selectedItem).toBeNull(); + }, +}; + +/** After clearing with **`toggles`**, a primary click selects again. */ + +export const ClearThenPrimaryClickReselectsWhenTogglesTrue: Story = { + render: toggleFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_TOGGLE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + const radio = host.getRadioController(); + + radio.setSelectedItem(buttons[0]!); + await host.updateComplete; + expect(radio.toggleItem(buttons[0]!)).toBe(true); + await host.updateComplete; + expect(radio.getSelectedItem()).toBeNull(); + + buttons[2]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(buttons[2]?.getAttribute('aria-checked')).toBe('true'); + expect(radio.getSelectedItem()).toBe(buttons[2]!); + }, +}; + +/** With **`defaultToFirstSelectable`**, **`refresh`** asserts the first eligible control. */ + +export const DefaultFirstSelectableSelectsFirstOnConnect: Story = { + render: defaultFirstOnChangeFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_DEFAULT_FIRST_ONCHANGE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + expect(buttons[0]?.getAttribute('aria-checked')).toBe('true'); + expect(buttons[1]?.getAttribute('aria-checked')).toBe('false'); + expect(buttons[2]?.getAttribute('aria-checked')).toBe('false'); + expect(host.getRadioController().getSelectedItem()).toBe(buttons[0]!); + }, +}; + +/** **`onSelectionChange`** runs when the default-first selection is applied and on each later change. */ + +export const OnSelectionChangeReceivesDetailAfterDefaultAndOnClick: Story = { + render: defaultFirstOnChangeFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_DEFAULT_FIRST_ONCHANGE_TAG + ); + const buttons = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + expect(host.selectionLog).toEqual([buttons[0]!]); + + buttons[1]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(host.selectionLog).toEqual([buttons[0]!, buttons[1]!]); + expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); + }, +}; + +function keydownActivationFixtureRender() { + return html` + + `; +} + +function keydownOffDivFixtureRender() { + return html` + + `; +} + +/** With **`keydownActivation: true`**, **Enter** on a focused eligible **`div[role="radio"]`** asserts it. */ + +export const KeydownActivationEnterSelects: Story = { + render: keydownActivationFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_KEYDOWN_ACTIVATION_TAG + ); + const items = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + expect(items[0]?.getAttribute('aria-checked')).toBe('true'); + + items[1]!.focus(); + items[1]!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + composed: true, + }) + ); + await host.updateComplete; + + expect(items[1]?.getAttribute('aria-checked')).toBe('true'); + expect(items[0]?.getAttribute('aria-checked')).toBe('false'); + expect(host.getRadioController().getSelectedItem()).toBe(items[1]!); + }, +}; + +/** With **`keydownActivation: true`**, **Space** on a focused eligible control asserts it. */ + +export const KeydownActivationSpaceSelects: Story = { + render: keydownActivationFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_KEYDOWN_ACTIVATION_TAG + ); + const items = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + items[2]!.focus(); + items[2]!.dispatchEvent( + new KeyboardEvent('keydown', { + key: ' ', + code: 'Space', + bubbles: true, + composed: true, + }) + ); + await host.updateComplete; + + expect(items[2]?.getAttribute('aria-checked')).toBe('true'); + expect(items[0]?.getAttribute('aria-checked')).toBe('false'); + }, +}; + +/** With **`keydownActivation: false`**, **Enter** on a focused **`div[role="radio"]`** does not change selection (no native activation). */ + +export const KeydownActivationFalseIgnoresEnter: Story = { + render: keydownOffDivFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_KEYDOWN_OFF_DIV_TAG + ); + const items = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + + expect(items[0]?.getAttribute('aria-checked')).toBe('true'); + + items[1]!.focus(); + items[1]!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + composed: true, + }) + ); + await host.updateComplete; + + expect(items[0]?.getAttribute('aria-checked')).toBe('true'); + expect(items[1]?.getAttribute('aria-checked')).toBe('false'); + expect(host.getRadioController().getSelectedItem()).toBe(items[0]!); + }, +}; + +/** **`setOptions({ keydownActivation: true })`** enables **Enter** after starting disabled for keyboard. */ + +export const SetOptionsKeydownActivationEnablesEnter: Story = { + render: keydownOffDivFixtureRender, + play: async ({ canvasElement }) => { + const host = await getComponent( + canvasElement, + FIXTURE_KEYDOWN_OFF_DIV_TAG + ); + const items = Array.from( + host.shadowRoot!.querySelectorAll('[data-item]') + ); + const radio = host.getRadioController(); + + items[1]!.focus(); + items[1]!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + composed: true, + }) + ); + await host.updateComplete; + expect(items[0]?.getAttribute('aria-checked')).toBe('true'); + + radio.setOptions({ keydownActivation: true }); + items[1]!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + composed: true, + }) + ); + await host.updateComplete; + + expect(items[1]?.getAttribute('aria-checked')).toBe('true'); + expect(items[0]?.getAttribute('aria-checked')).toBe('false'); + }, +}; diff --git a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx index a79b87e276e..a20332e1a0a 100644 --- a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx +++ b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx @@ -11,6 +11,7 @@ import { } from '@storybook/addon-docs/blocks'; import { ApiTable, + ConditionalBehaviorsSection, GettingStarted, OverviewStory, SpectrumStories, @@ -40,7 +41,12 @@ export const AdvancedExamplesStories = () => { } -export const ConditionalSection = ({ tag, title, hideTitle = false }) => { +export const ConditionalSection = ({ + tag, + title, + hideTitle = false, + titleLevel = 2, +}) => { const resolvedOf = useOf('meta', ['meta']); const hasStories = Object.values(resolvedOf.csfFile.stories).some( (story) => story.tags?.includes(tag) @@ -50,9 +56,12 @@ export const ConditionalSection = ({ tag, title, hideTitle = false }) => { return null; } + const level = Number(titleLevel) || 2; + const TitleHeading = level === 3 ? 'h3' : 'h2'; + return (
- {title &&

{title}

} + {title && {title}}
); @@ -113,7 +122,13 @@ export const ConditionalGettingStarted = () => { - + + diff --git a/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx b/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx index 450b3587bc6..18bff8c5d9a 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx @@ -71,6 +71,176 @@ const tableStyle: React.CSSProperties = { width: '100%', }; +// ──────────────────────────── +// Controller API (non-CEM) — `meta.parameters.controllerApi` +// ──────────────────────────── + +/** One row in the **Constructor options** table (mirrors component **Properties** layout). */ +export type ControllerApiOptionRow = { + name: string; + type: string; + /** Shown in the **Default** column; use `(required)` for mandatory callbacks. */ + defaultValue?: string; + description: string; +}; + +/** One public instance method on a Lit reactive controller. */ +export type ControllerApiMethodRow = { + name: string; + signature: string; + returns?: string; + description: string; +}; + +/** Custom event emitted by the controller (if any). */ +export type ControllerApiEventRow = { + name: string; + detail?: string; + description: string; +}; + +/** Module-level export next to the controller class. */ +export type ControllerApiExportRow = { + name: string; + kind: 'constant' | 'function' | 'type'; + description: string; +}; + +/** Structured API reference for core controllers (Storybook **API** section). */ +export type ControllerApiDocumentation = { + /** Optional heading above the first table (for example the class name). */ + title?: string; + options: ControllerApiOptionRow[]; + methods: ControllerApiMethodRow[]; + events?: ControllerApiEventRow[]; + exports?: ControllerApiExportRow[]; +}; + +function isControllerApiDocumentation( + value: unknown +): value is ControllerApiDocumentation { + if (!value || typeof value !== 'object') { + return false; + } + const doc = value as ControllerApiDocumentation; + return Array.isArray(doc.options) && Array.isArray(doc.methods); +} + +function ControllerApiTables({ doc }: { doc: ControllerApiDocumentation }) { + return ( + <> + {doc.title ?

{doc.title}

: null} +

Constructor options

+ + + + + + + + + + + {doc.options.map((row) => ( + + + + + + + ))} + +
NameTypeDefaultDescription
+ {row.name} + {row.type ? {row.type} : '—'} + {row.defaultValue != null && row.defaultValue !== '' ? ( + {row.defaultValue} + ) : ( + '—' + )} + {row.description}
+ +

Methods

+ + + + + + + + + + + {doc.methods.map((row) => ( + + + + + + + ))} + +
NameSignatureReturnsDescription
+ {row.name} + + {row.signature} + {row.returns ? {row.returns} : '—'}{row.description}
+ + {doc.events && doc.events.length > 0 ? ( + <> +

Events

+ + + + + + + + + + {doc.events.map((row) => ( + + + + + + ))} + +
NameDetailDescription
+ {row.name} + {row.detail ? {row.detail} : '—'}{row.description}
+ + ) : null} + + {doc.exports && doc.exports.length > 0 ? ( + <> +

Module exports

+ + + + + + + + + + {doc.exports.map((row) => ( + + + + + + ))} + +
NameKindDescription
+ {row.name} + {row.kind}{row.description}
+ + ) : null} + + ); +} + // ──────────────────────────── // Sub-tables // ──────────────────────────── @@ -112,57 +282,55 @@ function PropertiesTable({ return ( <>

Properties

-
- - - - - - - - - - - - {props.map((prop) => { - const attr = attrByField.get(prop.name); - const argType = argTypes[prop.name] ?? argTypes[attr?.name ?? '']; +
PropertyAttributeTypeDefaultDescription
+ + + + + + + + + + + {props.map((prop) => { + const attr = attrByField.get(prop.name); + const argType = argTypes[prop.name] ?? argTypes[attr?.name ?? '']; - // Prefer expanded options from argTypes, fall back to CEM type text. - const typeName = argType?.options - ? argType.options.map((o) => `'${o}'`).join(' | ') - : (prop.type?.text ?? ''); + // Prefer expanded options from argTypes, fall back to CEM type text. + const typeName = argType?.options + ? argType.options.map((o) => `'${o}'`).join(' | ') + : (prop.type?.text ?? ''); - return ( - - - - - - - - ); - })} - -
PropertyAttributeTypeDefaultDescription
- {prop.name} - - {attr ? ( - <> - {attr.name} - {prop.reflects && ( - - (reflects) - - )} - - ) : ( - '-' - )} - {typeName && {typeName}} - {prop.default != null ? {prop.default} : '-'} - {prop.description ?? ''}
-
+ return ( + + + {prop.name} + + + {attr ? ( + <> + {attr.name} + {prop.reflects && ( + + (reflects) + + )} + + ) : ( + '-' + )} + + {typeName && {typeName}} + + {prop.default != null ? {prop.default} : '-'} + + {prop.description ?? ''} + + ); + })} + + ); } @@ -202,26 +370,24 @@ function EventsTable({ events }: { events: CemEvent[] }) { return ( <>

Events

-
- - - - - +
NameDescription
+ + + + + + + + {events.map((event) => ( + + + - - - {events.map((event) => ( - - - - - ))} - -
NameDescription
+ {event.name} + {event.description ?? ''}
- {event.name} - {event.description ?? ''}
-
+ ))} + + ); } @@ -231,30 +397,28 @@ function CssPropsTable({ cssProps }: { cssProps: CssCustomProperty[] }) { return ( <>

CSS Custom Properties

-
- - - - - - +
NameDefaultDescription
+ + + + + + + + + {cssProps.map((prop) => ( + + + + - - - {cssProps.map((prop) => ( - - - - - - ))} - -
NameDefaultDescription
+ {prop.name} + + {prop.default != null ? {prop.default} : '-'} + {prop.description ?? ''}
- {prop.name} - - {prop.default != null ? {prop.default} : '-'} - {prop.description ?? ''}
-
+ ))} + + ); } @@ -264,26 +428,24 @@ function CssPartsTable({ cssParts }: { cssParts: CssPart[] }) { return ( <>

CSS Parts

-
- - - - - +
NameDescription
+ + + + + + + + {cssParts.map((part) => ( + + + - - - {cssParts.map((part) => ( - - - - - ))} - -
NameDescription
+ {part.name} + {part.description ?? ''}
- {part.name} - {part.description ?? ''}
-
+ ))} + + ); } @@ -293,18 +455,37 @@ function CssPartsTable({ cssParts }: { cssParts: CssPart[] }) { // ──────────────────────────── /** - * Custom API reference tables sourced directly from the Custom Elements - * Manifest. Renders categorized, read-only tables for Properties, Slots, - * Events, CSS Custom Properties, and CSS Parts. + * Renders API documentation for the active docs page. + * + * - When **`meta.parameters.controllerApi`** is set (core Lit controllers), renders **Constructor + * options**, **Methods**, **Events**, and **Module exports** tables in the same shape as + * component **Properties** / **Events** tables. + * - Otherwise reads the Custom Elements Manifest for **`meta.component`** and renders Properties, + * Slots, Events, CSS custom properties, and CSS parts. */ export function ApiTable() { const resolvedOf = useOf('meta', ['meta']); const meta = resolvedOf.csfFile?.meta as { component?: string; argTypes?: Record; + parameters?: { controllerApi?: unknown }; }; + const preparedMeta = resolvedOf.preparedMeta as + | { + parameters?: { controllerApi?: unknown }; + argTypes?: Record; + } + | undefined; + + const controllerApiRaw = + preparedMeta?.parameters?.controllerApi ?? meta?.parameters?.controllerApi; + + if (isControllerApiDocumentation(controllerApiRaw)) { + return ; + } + const tagName = meta?.component; - const argTypes = resolvedOf.preparedMeta?.argTypes ?? meta?.argTypes ?? {}; + const argTypes = preparedMeta?.argTypes ?? meta?.argTypes ?? {}; const cem = window.__STORYBOOK_CUSTOM_ELEMENTS_MANIFEST__; if (!cem || !tagName) { diff --git a/2nd-gen/packages/swc/.storybook/blocks/ConditionalBehaviorsSection.tsx b/2nd-gen/packages/swc/.storybook/blocks/ConditionalBehaviorsSection.tsx new file mode 100644 index 00000000000..98b3daf9d02 --- /dev/null +++ b/2nd-gen/packages/swc/.storybook/blocks/ConditionalBehaviorsSection.tsx @@ -0,0 +1,53 @@ +/** + * 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 { useOf } from '@storybook/addon-docs/blocks'; +import { Fragment } from 'react'; + +import { SpectrumStories } from './SpectrumStories'; + +/** + * Docs template block: **Behaviors** stories plus optional **Responding to selection change** + * (`responding-to-selection-change` tag) as an h3 subsection. + */ +export const ConditionalBehaviorsSection = (): JSX.Element | null => { + const resolvedOf = useOf('meta', ['meta']); + const stories = Object.values(resolvedOf.csfFile.stories); + const hasBehaviors = stories.some((story) => + story.tags?.includes('behaviors') + ); + const hasResponding = stories.some((story) => + story.tags?.includes('responding-to-selection-change') + ); + + if (!hasBehaviors && !hasResponding) { + return null; + } + + return ( +
+

Behaviors

+ {hasBehaviors ? ( + + ) : null} + {hasResponding ? ( + +

Responding to selection change

+ +
+ ) : null} +
+ ); +}; diff --git a/2nd-gen/packages/swc/.storybook/blocks/GettingStarted.tsx b/2nd-gen/packages/swc/.storybook/blocks/GettingStarted.tsx index 844364af7a5..c9d6521393f 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/GettingStarted.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/GettingStarted.tsx @@ -16,6 +16,9 @@ export const GettingStarted = ({ of, tags }: { of?: any; tags?: string }) => { if (tags?.includes('utility')) return null; + /** Story file renders import + narrative under its own single **Getting started** heading. */ + if (tags?.includes('docs-getting-started-inline')) return null; + if (tags?.includes('controller')) { // Extract component name in kebab-case from the title (e.g., "Components/Progress Circle" -> "progress-circle") const packageName = formatTitle(resolvedOf.preparedMeta?.title); diff --git a/2nd-gen/packages/swc/.storybook/blocks/OverviewStory.tsx b/2nd-gen/packages/swc/.storybook/blocks/OverviewStory.tsx index dd8b17da2a1..7f9b1e0fd77 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/OverviewStory.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/OverviewStory.tsx @@ -5,6 +5,12 @@ import { formatTitle } from '../helpers/index.js'; export const OverviewStory = () => { const resolvedOf = useOf('meta', ['meta']); + const tags = resolvedOf?.csfFile?.meta?.tags ?? []; + + /** Meta renders implementation + live demos inside the overview story canvas instead. */ + if (tags.includes('docs-skip-overview-canvas')) { + return null; + } const primaryStory = Object.values(resolvedOf.csfFile.stories).find((story) => story.tags?.includes('overview') diff --git a/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx b/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx index bccff395122..5fc754514f7 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx @@ -11,8 +11,12 @@ import React, { Fragment } from 'react'; * Stories are rendered in definition order (using story id which includes definition index). * * @param of - The Storybook meta or story to resolve the component from - * @param tag - The story tag to filter by (e.g., "usage", "a11y", "examples") + * @param tag - The story tag to filter by (e.g., "usage", "setting-default-selection", + * "responding-to-selection-change" (often rendered under Behaviors in docs), "a11y") * @param hideTitle - Whether to hide the story title heading + * + * Stories with `parameters.docs.disable: true` are omitted (for example when the same demo + * appears under Usage and should not repeat in Behaviors). */ export const SpectrumStories = ({ of, @@ -30,6 +34,10 @@ export const SpectrumStories = ({ (story: any) => story.tags?.includes(tag) ); + taggedStories = taggedStories.filter( + (story: any) => story.parameters?.docs?.disable !== true + ); + const descriptionOnlyStories = taggedStories.filter((story: any) => story.tags?.includes('description-only') ); diff --git a/2nd-gen/packages/swc/.storybook/blocks/index.ts b/2nd-gen/packages/swc/.storybook/blocks/index.ts index 17346333f67..cb558f5b222 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/index.ts +++ b/2nd-gen/packages/swc/.storybook/blocks/index.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ export * from './ApiTable'; +export * from './ConditionalBehaviorsSection'; export * from './GettingStarted'; export * from './OverviewStory'; export * from './SpectrumDocs'; diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 2783da64c3f..ebd4f04f161 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -458,7 +458,10 @@ const preview = { 'Thumbnail', ['Rendering and styling migration analysis'], 'Tooltip', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], ], 'Milestones', 'Strategies', @@ -478,7 +481,7 @@ const preview = { CORE_VERSION: { table: { disable: true } }, hasVisibleFocusInTree: { table: { disable: true } }, }, - tags: ['!autodocs', '!dev'], // We only want the playground stories to be visible in the docs and sidenav. Since a majority of our stories are tagged with '!autodocs' and '!dev', we set those tags globally. We can opt in to visibility by adding the 'autodocs' or 'dev' tags to individual stories. + tags: ['!autodocs', '!dev'], // We only want the playground stories to be visible in the docs and sidenav. Since a majority of our stories are tagged with '!autodocs' and '!dev', we set those tags globally. We can opt in to visibility by adding the 'autodocs' or 'dev' tags to individual stories. Vitest `*.test.ts` CSF modules should keep `!dev` (do not add `dev`) so they stay out of the Storybook sidebar while remaining runnable by the Vitest addon. loaders: [FontLoader], }; diff --git a/AGENTS.md b/AGENTS.md index e9314c18934..3ca0dbb2838 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Coding agents working in this repository should treat **`.ai/`** as the canonica 1. **Read** [`.ai/README.md`](./.ai/README.md) for the full list of rules, skills, when they apply, and how to invoke skills. 2. **Read** all files in [`.ai/memory/`](./.ai/memory/) for accumulated project-specific lessons — non-obvious constraints, tool behaviors, and corrections from previous sessions. Skip this step if the directory does not exist yet. 3. **Apply** the rules that match the files and tasks you touch (see globs and activation notes in that README). -4. **Load** a skill when the task matches its purpose: each skill lives under `.ai/skills//SKILL.md`. +4. **Load** a skill when the task matches its purpose: each skill lives under `.ai/skills//SKILL.md`. For **2nd-gen core Lit controllers** (new packages, stories, tests, or docs refactors), use `.ai/skills/controller-development/SKILL.md` as the playbook. ## Where things live