From b3b4b42177891d2042c975eb9c430640ec0f9489 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 8 May 2026 11:18:24 -0400 Subject: [PATCH 1/3] feat(core): radio controller proof of concept --- 2nd-gen/packages/core/controllers/index.ts | 7 + .../controllers/radio-controller/index.ts | 19 + .../radio-controller/src/radio-controller.ts | 599 ++++++++++++++++++ .../radio-controller/stories/demo-hosts.ts | 499 +++++++++++++++ .../stories/radio-controller.stories.ts | 124 ++++ .../stories/radio-controller.test.ts | 310 +++++++++ 6 files changed, 1558 insertions(+) create mode 100644 2nd-gen/packages/core/controllers/radio-controller/index.ts create mode 100644 2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts create mode 100644 2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts create mode 100644 2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.stories.ts create mode 100644 2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts 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..43a363ad796 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts @@ -0,0 +1,599 @@ +/** + * 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'; + +import { + FocusgroupNavigationController, + type FocusgroupNavigationOptions, +} from '../../focusgroup-navigation-controller/index.js'; + +// ───────────────────────── +// TYPES +// ───────────────────────── + +/** + * Options for {@link RadioController}. + */ +export type RadioControllerOptions = { + /** + * Returns mutually exclusive participants. Items outside the host subtree (shadow-inclusive) + * are ignored, matching {@link FocusgroupNavigationController} scoping. + */ + 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, drops native `disabled` or `aria-disabled="true"` from eligibility checks. */ + skipDisabled?: boolean; + + /** When navigation is bundled, aligns exclusive state with whichever item gained arrow focus. */ + selectionFollowsFocus?: boolean; + + /** + * When true (default mirrors navigation embedding), assigns {@link KeyboardEvent.key | Space} + * on the focused item without moving focus — APG radios and menu radios. + */ + handleSpaceActivatesSelection?: boolean; + + /** Allows clearing every asserted item through {@link RadioController.setSelectedItem}. */ + allowEmptySelection?: boolean; + + /** + * When nothing is asserted after structural updates, asserts the earliest eligible sibling. + */ + defaultToFirstSelectable?: boolean; + + /** Passed through to bundled {@link FocusgroupNavigationController}, or {@link false}. */ + navigation?: + | false + | Partial< + Omit + >; + + /** 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; +}; + +const DEFAULT_NAVIGATION = { + direction: 'horizontal' as const, + wrap: true, + memory: true, +} satisfies Partial< + Omit +>; + +/** + * 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 while + * sharing item discovery idioms from {@link FocusgroupNavigationController}. Optionally nests + * roving tabindex through {@link FocusgroupNavigationController} for arrow-key composites. + * + * @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' + | 'skipDisabled' + | 'selectionFollowsFocus' + | 'handleSpaceActivatesSelection' + | 'allowEmptySelection' + | 'defaultToFirstSelectable' + > + >, + never + > & + Pick; + + private embeddedNavigation: FocusgroupNavigationController | null = null; + + private selectedItem: HTMLElement | null = null; + + /** Prevents recursion when syncing focusgroup bookkeeping. */ + private suppressEmbeddedActiveCallback = false; + + /** + * Prevents reacting to stray focus pulses while rewriting selection from pointer / Space. + */ + private syncingExclusiveState = 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.isActivationDisabled(hit)) { + return; + } + this.syncingExclusiveState = true; + try { + this.applyExclusiveSelection(hit, { + propagateToEmbeddedNavigation: true, + }); + } finally { + this.syncingExclusiveState = false; + } + }; + + private readonly handleKeyCapture = (event: KeyboardEvent): void => { + if (!this.options.handleSpaceActivatesSelection) { + return; + } + if (event.key !== ' ' || event.repeat || event.defaultPrevented) { + return; + } + const items = new Set(this.getEligibleItems()); + + /** + * Matches composed-path participants first (shadow targets), otherwise the shadow root focus. + * + * @returns Focused selectable element participating in exclusivity. + */ + const resolveParticipant = (): HTMLElement | null => { + for (const node of event.composedPath()) { + if (!(node instanceof HTMLElement)) { + continue; + } + if (items.has(node)) { + return node; + } + } + const active = this.host.shadowRoot?.activeElement; + return active instanceof HTMLElement && items.has(active) ? active : null; + }; + + const participant = resolveParticipant(); + if (!participant || this.isActivationDisabled(participant)) { + return; + } + event.preventDefault(); + this.syncingExclusiveState = true; + try { + this.applyExclusiveSelection(participant, { + propagateToEmbeddedNavigation: true, + }); + } finally { + this.syncingExclusiveState = false; + } + }; + + constructor(host: ReactiveElement, options: RadioControllerOptions) { + this.host = host; + const navigationEnabled = options.navigation !== false; + type NavigationPatchInput = Omit< + FocusgroupNavigationOptions, + 'getItems' | 'onActiveItemChange' + >; + + const navigationPatch: false | NavigationPatchInput = + options.navigation === false + ? false + : { + skipDisabled: options.skipDisabled ?? false, + ...DEFAULT_NAVIGATION, + ...(typeof options.navigation === 'object' + ? options.navigation + : {}), + }; + + this.options = { + getItems: options.getItems, + selectItem: options.selectItem, + deselectItem: options.deselectItem, + skipDisabled: options.skipDisabled ?? false, + selectionFollowsFocus: options.selectionFollowsFocus ?? true, + handleSpaceActivatesSelection: + options.handleSpaceActivatesSelection ?? navigationEnabled, + allowEmptySelection: options.allowEmptySelection ?? false, + defaultToFirstSelectable: options.defaultToFirstSelectable ?? false, + navigation: navigationEnabled === false ? false : navigationPatch, + onSelectionChange: options.onSelectionChange, + }; + + if (navigationPatch) { + this.embeddedNavigation = new FocusgroupNavigationController(host, { + ...navigationPatch, + getItems: () => this.getEligibleItems(), + onActiveItemChange: (active) => { + if ( + this.suppressEmbeddedActiveCallback || + this.syncingExclusiveState || + !active + ) { + return; + } + if ( + !this.options.selectionFollowsFocus || + this.isActivationDisabled(active) + ) { + return; + } + this.applyExclusiveSelection(active, { + propagateToEmbeddedNavigation: false, + }); + }, + }); + } + + host.addController(this); + } + + /** Returns the last asserted exclusive sibling tracked by this controller instance. */ + public getSelectedItem(): HTMLElement | null { + return this.selectedItem; + } + + /** Merges deltas; rejects `navigation` changes because wiring is immutable post-construction. */ + public setOptions(partial: Partial): void { + if (partial.navigation !== undefined) { + throw new Error( + 'RadioController does not permit mutating navigation options after creation.' + ); + } + + const nextSkip = partial.skipDisabled ?? this.options.skipDisabled; + + const selectionFollowsFocusProvided = + 'selectionFollowsFocus' in partial && + typeof partial.selectionFollowsFocus === 'boolean'; + + const spaceProvided = + 'handleSpaceActivatesSelection' in partial && + typeof partial.handleSpaceActivatesSelection === 'boolean'; + + const allowEmptyProvided = + 'allowEmptySelection' in partial && + typeof partial.allowEmptySelection === 'boolean'; + + const defaultFirstProvided = + 'defaultToFirstSelectable' in partial && + typeof partial.defaultToFirstSelectable === 'boolean'; + + this.options = { + ...this.options, + ...partial, + skipDisabled: nextSkip, + selectionFollowsFocus: selectionFollowsFocusProvided + ? partial.selectionFollowsFocus! + : this.options.selectionFollowsFocus, + handleSpaceActivatesSelection: spaceProvided + ? partial.handleSpaceActivatesSelection! + : this.options.handleSpaceActivatesSelection, + allowEmptySelection: allowEmptyProvided + ? partial.allowEmptySelection! + : this.options.allowEmptySelection, + defaultToFirstSelectable: defaultFirstProvided + ? partial.defaultToFirstSelectable! + : this.options.defaultToFirstSelectable, + getItems: partial.getItems ?? this.options.getItems, + selectItem: partial.selectItem ?? this.options.selectItem, + deselectItem: partial.deselectItem ?? this.options.deselectItem, + onSelectionChange: + partial.onSelectionChange ?? this.options.onSelectionChange, + }; + + if (partial.skipDisabled !== undefined && this.embeddedNavigation) { + this.embeddedNavigation.setOptions({ skipDisabled: nextSkip }); + } + + this.refresh(); + } + + /** + * Programmatic assertion without synthetic clicks — returns {@link false} when {@link candidate} + * cannot join the exclusive roster. + */ + public setSelectedItem( + candidate: HTMLElement | null, + behavior?: { focus?: boolean } + ): boolean { + if (candidate !== null) { + if ( + !this.getEligibleItems().includes(candidate) || + this.isActivationDisabled(candidate) + ) { + return false; + } + } else if (!this.options.allowEmptySelection) { + return false; + } + + this.applyExclusiveSelection(candidate, { + propagateToEmbeddedNavigation: true, + }); + + if (candidate && behavior?.focus) { + queueMicrotask(() => { + candidate.focus(); + }); + } + + return true; + } + + /** Re-applies bookkeeping after structural changes and refreshes optional navigation sibling. */ + 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.options.allowEmptySelection && + !this.options.defaultToFirstSelectable + ? null + : (items[0] ?? null); + this.applyExclusiveSelection(replacement, { + propagateToEmbeddedNavigation: true, + }); + } else if ( + this.selectedItem === null && + this.options.defaultToFirstSelectable && + items.length > 0 + ) { + this.applyExclusiveSelection(items[0], { + propagateToEmbeddedNavigation: true, + }); + } else if (this.embeddedNavigation) { + this.embeddedNavigation.refresh(); + } + } + + public hostConnected(): void { + this.host.addEventListener('click', this.handleClickCapture, true); + this.host.addEventListener('keydown', this.handleKeyCapture, true); + this.refresh(); + } + + public hostDisconnected(): void { + this.host.removeEventListener('click', this.handleClickCapture, true); + this.host.removeEventListener('keydown', this.handleKeyCapture, true); + } + + private applyExclusiveSelection( + next: HTMLElement | null, + flags: { propagateToEmbeddedNavigation: boolean } + ): void { + const roster = this.getEligibleItems(); + let asserted = next; + if ( + asserted === null && + !this.options.allowEmptySelection && + roster.length > 0 + ) { + asserted = roster[0]; + } + + if (this.selectedItem === asserted) { + if ( + flags.propagateToEmbeddedNavigation && + asserted && + this.embeddedNavigation + ) { + this.propagateEmbeddedTabStop(asserted); + } else { + this.embeddedNavigation?.refresh(); + } + 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 ( + flags.propagateToEmbeddedNavigation && + asserted && + this.embeddedNavigation + ) { + this.propagateEmbeddedTabStop(asserted); + } else if (this.embeddedNavigation) { + this.embeddedNavigation.refresh(); + } + + if (prior !== asserted) { + this.dispatchSelectionChange(); + } + } + + /** Ensures tabindex mirrors the asserted participant without reacting to programmatic sync. */ + private propagateEmbeddedTabStop(participant: HTMLElement): void { + if (!this.embeddedNavigation) { + return; + } + this.suppressEmbeddedActiveCallback = true; + try { + this.embeddedNavigation.setActiveItem(participant); + } finally { + this.suppressEmbeddedActiveCallback = false; + } + } + + /** 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. + * + * Mirrors {@link FocusgroupNavigationController}'s containment guard so callers can query arbitrary + * slices without leaking into foreign shadow trees. + * + * @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 {@link RadioController}'s reactive host subtree. */ + private getScopedRawItems(): HTMLElement[] { + return this.options + .getItems() + .filter((element) => this.isNodeWithinHostScope(element)); + } + + /** + * Whether `participant` skips navigation when `{@link RadioControllerOptions.skipDisabled}` resolves true. + * + * @param participant - Potential radio sibling. + */ + private skipsDisabledSemantics(participant: HTMLElement): boolean { + if ('disabled' in participant) { + if ((participant as HTMLButtonElement).disabled) { + return true; + } + } + return participant.getAttribute('aria-disabled') === 'true'; + } + + /** + * Mirrors {@link FocusgroupNavigationController}'s eligibility filter for interactive composites. + */ + 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.options.skipDisabled && this.skipsDisabledSemantics(participant)) { + return false; + } + return true; + } + + /** + * Prevents asserting disabled controls even when callers forget to prune `getItems`. + * + * @param participant - Candidate asserting role states. + */ + private isActivationDisabled(participant: HTMLElement): boolean { + return ( + ('disabled' in participant && + (participant as HTMLButtonElement).disabled) || + participant.getAttribute('aria-disabled') === 'true' + ); + } + + /** Eligible selectable siblings respecting skip rules and viewport visibility. */ + private getEligibleItems(): HTMLElement[] { + return this.getScopedRawItems().filter((participant) => + this.isRadioNavigableItem(participant) + ); + } +} 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..b7bbdd64fc0 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts @@ -0,0 +1,499 @@ +/** + * 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 } from '../index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'demo-radio-group-rating': DemoRadioGroupRating; + 'demo-radio-menu-item-radio': DemoRadioMenuItemRadio; + 'demo-radio-accordion-exclusive': DemoRadioAccordionExclusive; + 'demo-radio-programmatic-selection': DemoRadioProgrammaticSelection; + } +} + +/** @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; + } + .stars button { + font: inherit; + min-inline-size: 2.5rem; + min-block-size: 2.5rem; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #b1b1b1); + background: var(--spectrum-gray-75, #fdfdfd); + cursor: pointer; + } + .stars button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .stars button[aria-checked='true'] { + background: var(--spectrum-orange-300, #ffb02e); + border-color: var(--spectrum-orange-800, #cb6f10); + } + `; + + 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, + navigation: { direction: 'horizontal', wrap: true, memory: 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` + + `; + })} +
+
+ `; + } +} + +/** @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, + navigation: { direction: 'vertical', wrap: true, memory: 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 { + 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); + }, + navigation: false, + selectionFollowsFocus: false, + handleSpaceActivatesSelection: false, + allowEmptySelection: 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: 'Use `navigation: false` when arrow keys should not imitate a radiogroup.', + }, + { + key: 'c', + heading: 'Adjustments', + copy: '`aria-expanded` toggles mimic Spectrum accordion sizing demos.', + }, + ].map( + ({ key, heading, copy }, ordinal) => html` +
+ +
+ ${copy} +
+
+ ` + )} +
+ `; + } +} + +/** @internal */ +@customElement('demo-radio-programmatic-selection') +export class DemoRadioProgrammaticSelection extends LitElement { + static override styles = css` + :host { + display: block; + font: + 0.95rem system-ui, + sans-serif; + } + .group { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; + } + .group button[data-program-item] { + font: inherit; + padding: 0.65rem 0.95rem; + border-radius: 6px; + border: 1px solid var(--spectrum-gray-400, #cfcfcf); + background: white; + cursor: pointer; + } + .group button[data-program-item]:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .group button[data-program-item][aria-checked='true'] { + border-color: var(--spectrum-blue-800, #0265dc); + background: var(--spectrum-blue-100, #e5f6ff); + } + footer { + margin-block-start: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + footer button { + font: inherit; + padding: 0.45rem 0.85rem; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-500, #b5b5b5); + background: var(--spectrum-gray-50, #f5f5f5); + cursor: pointer; + } + footer button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + private radios = new RadioController(this, { + getItems: () => + Array.from( + this.renderRoot.querySelectorAll( + '[data-program-item]' + ) + ), + selectItem: (pill) => pill.setAttribute('aria-checked', 'true'), + deselectItem: (pill) => pill.setAttribute('aria-checked', 'false'), + defaultToFirstSelectable: true, + navigation: { direction: 'horizontal', wrap: true }, + }); + + protected override firstUpdated(): void { + this.radios.refresh(); + this.programmaticShortcuts(); + } + + private programmaticShortcuts(): void { + const shortcuts = Array.from( + this.renderRoot.querySelectorAll( + '[data-program-select]' + ) + ); + + shortcuts.forEach((shortcut) => + shortcut.addEventListener( + 'click', + /** Selects programmatically alongside pointer-driven usage. */ + () => { + const indexRaw = shortcut.dataset.programSelect ?? '0'; + const indexValue = Number.parseInt(indexRaw, 10); + + const roster = Array.from( + this.renderRoot.querySelectorAll('[data-program-item]') + ); + + if (!Number.isFinite(indexValue) || indexValue >= roster.length) { + return; + } + + void this.radios.setSelectedItem(roster[indexValue], { + focus: true, + }); + } + ) + ); + } + + protected override render(): TemplateResult { + return html` +
+ + + +
+
+ + + +
+ `; + } +} 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..c0e39d27bd5 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.stories.ts @@ -0,0 +1,124 @@ +/** + * 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'; + +/** + * `RadioController` keeps mutually exclusive asserted state (`aria-checked`, + * [`menuitemradio`](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/), or `aria-expanded` + * accordion patterns). Item discovery aligns with `{@link FocusgroupNavigationController}` filtering + * (shadow-inclusive containment, visibility, optional disabled skipping). + * + * - **Selecting / deselecting**: supply callbacks that mutate ARIA bookkeeping on sibling elements — + * the controller never assumes native `` wiring. + * - **Pointers**: captures host `click`, resolves the deepest hit via + * `{@link deepestRadioItemContaining}` with the same eligibility list Focusgroup consumes. + * - **Embedded navigation**: by default nests `{@link FocusgroupNavigationController}` so arrow keys + * move roving `tabindex` *and*, when `{@link RadioControllerOptions.selectionFollowsFocus}` + * remains true (default), co-selects whichever item earns focus (`Space` activates the focused + * entry like the APG rating walkthrough). + * + * Dispatches bubbling composed **`swc-radio-controller-selection-change`** + * (`{@link radioControllerSelectionChange}`) with `{ selectedItem }`, ideal for bridging analytics + * or higher-level accordion state hosts. + * + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ | APG Rating radio group walkthrough} + * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ | APG menu / menuitemradio semantics} + * @see {@link https://opensource.adobe.com/spectrum-web-components/components/accordion/#sizes | Spectrum accordion sizing reference} + */ + +const meta: Meta = { + title: 'Controllers/Radio controller', + tags: ['migrated', 'controller'], + parameters: { + docs: { + subtitle: + 'Exclusive selection primitives built atop the same sibling discovery rules as Focusgroup navigation.', + canvas: { sourceState: 'none' }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** Autodocs entry plus Playground scaffold for future knobs. */ + +export const Playground: Story = { + tags: ['autodocs', 'dev'], + render: () => html` + + `, +}; + +/** + * ## Radiogroup + `radio` bookkeeping + * + * Mirrors the structural expectations from the APG **[Rating radio group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/)** + * demonstration: mutually exclusive `{@link HTMLElement.setAttribute}` calls toggle `aria-checked` + * (`"true"` / `"false"`) whenever the asserted star shifts. + */ + +export const RadioGroupAriaCheckedRating: Story = { + tags: ['overview'], + render: () => html` + + `, +}; + +/** + * ## `menubar` + `menuitemradio` + * + * The APG **[Menu and menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)** pattern calls for + * `role="menuitemradio"` siblings with mirrored `aria-checked` strings. Embedding **vertical** + * `{@link FocusgroupNavigationController}` matches column menus where **ArrowUp / ArrowDown** walk + * the linearized option list. + */ + +export const MenuMenubarAriaCheckedVertical: Story = { + render: () => html` + + `, +}; + +/** + * ## Accordion-style `aria-expanded` + * + * Passing `{@link RadioControllerOptions.navigation}: false` keeps arrow semantics off the accordion + * headers themselves (panels still rely on authored buttons). Selecting callbacks instead flip `aria-expanded` + * and synchronize panel `[hidden]` flags, analogous to Spectrum Web Components accordion sizing demos + * ([reference](https://opensource.adobe.com/spectrum-web-components/components/accordion/#sizes)). + */ + +export const AccordionExpandedExclusive: Story = { + render: () => html` + + `, +}; + +/** + * ## Programmatic assertions + * + * Host applications can steer exclusive state without synthesized clicks via + * `{@link RadioController.setSelectedItem}`. Passing `{ focus: true }` mirrors authoring guidance + * for restoring focus predictably inside composite widgets immediately after scripted selection. + */ + +export const ProgrammaticSetSelectedFocus: Story = { + render: () => html` + + `, +}; 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..4b24b782276 --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts @@ -0,0 +1,310 @@ +/** + * 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 { expect } from '@storybook/test'; +import type { Meta, StoryObj } from '@storybook/web-components'; + +import './demo-hosts.js'; + +import { getComponent } from '../../../../swc/utils/test-utils.js'; +import { + deepestRadioItemContaining, + radioControllerSelectionChange, + type RadioControllerSelectionChangeDetail, +} from '../index.js'; +import type { DemoRadioAccordionExclusive } from './demo-hosts.js'; +import meta, { + AccordionExpandedExclusive, + MenuMenubarAriaCheckedVertical, + ProgrammaticSetSelectedFocus, + RadioGroupAriaCheckedRating, +} from './radio-controller.stories.js'; + +function keydown(target: HTMLElement, key: string): void { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + }) + ); +} + +export default { + ...meta, + title: 'Radio controller/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +type Story = StoryObj; + +/** Radiogroup pointer interaction keeps exactly one asserted `aria-checked`. */ + +export const RadioGroupPointerExclusive: Story = { + ...RadioGroupAriaCheckedRating, + play: async ({ canvasElement, step }) => { + const host = await getComponent(canvasElement, 'demo-radio-group-rating'); + + await step('First selectable star initializes selected', async () => { + const buttons = Array.from( + host.shadowRoot!.querySelectorAll( + '[data-rating-star]' + ) + ); + expect( + buttons.some((star) => star.getAttribute('aria-checked') === 'true') + ).toBe(true); + }); + + await step('Click chooses a new asserted star exclusively', async () => { + const buttons = Array.from( + host.shadowRoot!.querySelectorAll( + '[data-rating-star]' + ) + ); + + buttons[4]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + + await host.updateComplete; + expect(buttons[4]?.getAttribute('aria-checked')).toBe('true'); + expect( + buttons.filter((star) => star.getAttribute('aria-checked') === 'true') + .length + ).toBe(1); + }); + }, +}; + +/** Arrow traversal co-selects the focused entry when navigation embeds Focusgroup semantics. */ + +export const RadioGroupArrowCoSelects: Story = { + ...RadioGroupAriaCheckedRating, + play: async ({ canvasElement, step }) => { + const host = await getComponent(canvasElement, 'demo-radio-group-rating'); + + await step( + 'Arrow keys move asserted state with roving tabindex', + async () => { + const buttons = Array.from( + host.shadowRoot!.querySelectorAll( + '[data-rating-star]' + ) + ); + + buttons[0]!.focus(); + expect(document.activeElement).toBe(buttons[0]); + + keydown(buttons[0]!, 'ArrowRight'); + await host.updateComplete; + expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); + expect( + buttons.filter((star) => star.getAttribute('aria-checked') === 'true') + .length + ).toBe(1); + expect(buttons[1]?.tabIndex).toBe(0); + + /** Space reinforces focus without collapsing selection (APG radios). */ + + keydown(buttons[1]!, ' '); + await host.updateComplete; + expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); + } + ); + }, +}; + +/** Vertical menu radios honor arrow traversal along Focusgroup orientation. */ + +export const MenuRadiosVerticalArrows: Story = { + ...MenuMenubarAriaCheckedVertical, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-radio-menu-item-radio' + ); + + await step('ArrowDown moves checked menu radios', async () => { + const items = Array.from( + host.shadowRoot!.querySelectorAll('[data-alignment]') + ); + + items[0]?.focus(); + + keydown(items[0]!, 'ArrowDown'); + await host.updateComplete; + expect(items[1]?.getAttribute('aria-checked')).toBe('true'); + expect( + items.filter((item) => item.getAttribute('aria-checked') === 'true') + .length + ).toBe(1); + }); + }, +}; + +/** Programmatic setter bypasses synthesized clicks yet keeps aria bookkeeping aligned. */ + +export const ProgrammaticSelectionAPI: Story = { + ...ProgrammaticSetSelectedFocus, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-radio-programmatic-selection' + ); + + await step( + 'Footer buttons call `RadioController#setSelectedItem`', + async () => { + host.shadowRoot + ?.querySelector('[data-program-select="1"]') + ?.click(); + + await host.updateComplete; + const items = Array.from( + host.shadowRoot!.querySelectorAll('[data-program-item]') + ); + expect(items[1]?.getAttribute('aria-checked')).toBe('true'); + expect( + items.filter((item) => item.getAttribute('aria-checked') === 'true') + .length + ).toBe(1); + } + ); + }, +}; + +/** Exclusive accordion headers flip `aria-expanded` + panel `[hidden]` through callbacks. */ + +export const AccordionExpandedCallbacks: Story = { + ...AccordionExpandedExclusive, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-radio-accordion-exclusive' + ); + + await step( + 'Opening a section collapses the previously expanded region', + async () => { + const headers = Array.from( + host.shadowRoot!.querySelectorAll( + '[data-accordion]' + ) + ); + + const filters = headers.find( + (header) => header.dataset.accordion === 'b' + ); + filters?.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + + await host.updateComplete; + + expect(headers[0]?.getAttribute('aria-expanded')).toBe('false'); + + /** Region visibility tracks header bookkeeping. */ + + const filtersPanel = + host.shadowRoot!.querySelector(`[data-panel="b"]`); + expect(filtersPanel?.hidden).toBe(false); + + const brushesPanel = + host.shadowRoot!.querySelector(`[data-panel="a"]`); + expect(brushesPanel?.hidden).toBe(true); + } + ); + }, +}; + +/** Dispatches bubbling composed selection change payloads on the reactive host. */ + +export const SelectionChangeEventDetail: Story = { + ...RadioGroupAriaCheckedRating, + play: async ({ canvasElement, step }) => { + const host = await getComponent(canvasElement, 'demo-radio-group-rating'); + + await step( + 'Pointer selection emits selection change payloads', + async () => { + const buttons = Array.from( + host.shadowRoot!.querySelectorAll( + '[data-rating-star]' + ) + ); + + let detail: RadioControllerSelectionChangeDetail | undefined; + + host.addEventListener(radioControllerSelectionChange, (( + event: Event + ) => { + detail = (event as CustomEvent) + .detail; + }) as EventListener); + + buttons[4]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(detail?.selectedItem).toBe(buttons[4]); + + detail = undefined; + buttons[4]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }) + ); + await host.updateComplete; + + expect(detail).toBeUndefined(); + } + ); + }, +}; + +/** `deepestRadioItemContaining` resolves composed paths deepest-first. */ + +export const DeepestRadioItemContainingStory: Story = { + render: () => html` + + `, + play: async ({ step }) => { + await step( + 'Prefers deepest eligible element on composed path sequence', + async () => { + const ancestor = document.createElement('article'); + const middle = document.createElement('button'); + const leaf = document.createElement('button'); + middle.type = 'button'; + leaf.type = 'button'; + middle.append(leaf); + ancestor.append(middle); + + const event = new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + }); + Object.defineProperty(event, 'composedPath', { + value: () => [leaf, middle, ancestor], + }); + + expect(deepestRadioItemContaining(event, [ancestor, leaf])).toBe(leaf); + expect(deepestRadioItemContaining(event, [ancestor])).toBe(ancestor); + } + ); + }, +}; From 772288afeaf1c903ab317052f5450d19f7b94ef3 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Fri, 8 May 2026 16:14:56 -0400 Subject: [PATCH 2/3] feat(core): updatied radioController docs and stories --- .ai/README.md | 7 + .ai/skills/controller-development/SKILL.md | 131 +++ ...ocusgroup-navigation-controller.stories.ts | 142 ++++ .../radio-controller/src/radio-controller.ts | 350 ++------ .../radio-controller/stories/demo-hosts.ts | 496 +++++++++--- .../stories/radio-controller.stories.ts | 536 +++++++++++-- .../stories/radio-controller.test.ts | 310 -------- .../test/radio-controller.test.ts | 749 ++++++++++++++++++ .../swc/.storybook/DocumentTemplate.mdx | 24 +- .../swc/.storybook/blocks/ApiTable.tsx | 409 +++++++--- .../swc/.storybook/blocks/GettingStarted.tsx | 3 + .../swc/.storybook/blocks/OverviewStory.tsx | 6 + .../swc/.storybook/blocks/SpectrumStories.tsx | 10 +- 2nd-gen/packages/swc/.storybook/preview.ts | 23 +- AGENTS.md | 2 +- 15 files changed, 2324 insertions(+), 874 deletions(-) create mode 100644 .ai/skills/controller-development/SKILL.md delete mode 100644 2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts create mode 100644 2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts 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..9178c4c67b8 --- /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 }`**, 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/radio-controller/src/radio-controller.ts b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts index 43a363ad796..4b83ec6280c 100644 --- a/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts +++ b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts @@ -12,11 +12,6 @@ import type { ReactiveController, ReactiveElement } from 'lit'; -import { - FocusgroupNavigationController, - type FocusgroupNavigationOptions, -} from '../../focusgroup-navigation-controller/index.js'; - // ───────────────────────── // TYPES // ───────────────────────── @@ -27,7 +22,7 @@ import { export type RadioControllerOptions = { /** * Returns mutually exclusive participants. Items outside the host subtree (shadow-inclusive) - * are ignored, matching {@link FocusgroupNavigationController} scoping. + * are ignored. */ getItems: () => HTMLElement[]; @@ -43,33 +38,19 @@ export type RadioControllerOptions = { */ deselectItem: (item: HTMLElement) => void; - /** When true, drops native `disabled` or `aria-disabled="true"` from eligibility checks. */ - skipDisabled?: boolean; - - /** When navigation is bundled, aligns exclusive state with whichever item gained arrow focus. */ - selectionFollowsFocus?: boolean; - /** - * When true (default mirrors navigation embedding), assigns {@link KeyboardEvent.key | Space} - * on the focused item without moving focus — APG radios and menu radios. + * 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). */ - handleSpaceActivatesSelection?: boolean; - - /** Allows clearing every asserted item through {@link RadioController.setSelectedItem}. */ - allowEmptySelection?: boolean; + toggles?: boolean; /** * When nothing is asserted after structural updates, asserts the earliest eligible sibling. */ defaultToFirstSelectable?: boolean; - /** Passed through to bundled {@link FocusgroupNavigationController}, or {@link false}. */ - navigation?: - | false - | Partial< - Omit - >; - /** Optional listener mirroring {@link radioControllerSelectionChange}. */ onSelectionChange?: (detail: RadioControllerSelectionChangeDetail) => void; }; @@ -88,14 +69,6 @@ export type RadioControllerSelectionChangeDetail = { selectedItem: HTMLElement | null; }; -const DEFAULT_NAVIGATION = { - direction: 'horizontal' as const, - wrap: true, - memory: true, -} satisfies Partial< - Omit ->; - /** * Returns the deepest entry from {@link Event.composedPath} that participates in {@link items}. * @@ -116,9 +89,12 @@ export function deepestRadioItemContaining( } /** - * Maintains mutually exclusive asserted state across sibling elements using supplied mutators while - * sharing item discovery idioms from {@link FocusgroupNavigationController}. Optionally nests - * roving tabindex through {@link FocusgroupNavigationController} for arrow-key composites. + * 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 only handles + * capture-phase **`click`**, **`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/ @@ -133,29 +109,16 @@ export class RadioController implements ReactiveController { | 'getItems' | 'selectItem' | 'deselectItem' - | 'skipDisabled' - | 'selectionFollowsFocus' - | 'handleSpaceActivatesSelection' - | 'allowEmptySelection' + | 'toggles' | 'defaultToFirstSelectable' > >, never > & - Pick; - - private embeddedNavigation: FocusgroupNavigationController | null = null; + Pick; private selectedItem: HTMLElement | null = null; - /** Prevents recursion when syncing focusgroup bookkeeping. */ - private suppressEmbeddedActiveCallback = false; - - /** - * Prevents reacting to stray focus pulses while rewriting selection from pointer / Space. - */ - private syncingExclusiveState = false; - private readonly handleClickCapture = (event: MouseEvent): void => { if (event.button !== 0) { return; @@ -165,119 +128,27 @@ export class RadioController implements ReactiveController { } const items = this.getEligibleItems(); const hit = deepestRadioItemContaining(event, items); - if (!hit || this.isActivationDisabled(hit)) { + if (!hit || this.isDisabledParticipant(hit)) { return; } - this.syncingExclusiveState = true; - try { - this.applyExclusiveSelection(hit, { - propagateToEmbeddedNavigation: true, - }); - } finally { - this.syncingExclusiveState = false; - } - }; - - private readonly handleKeyCapture = (event: KeyboardEvent): void => { - if (!this.options.handleSpaceActivatesSelection) { + if (this.options.toggles && hit === this.selectedItem) { + this.applyExclusiveSelection(null); return; } - if (event.key !== ' ' || event.repeat || event.defaultPrevented) { - return; - } - const items = new Set(this.getEligibleItems()); - - /** - * Matches composed-path participants first (shadow targets), otherwise the shadow root focus. - * - * @returns Focused selectable element participating in exclusivity. - */ - const resolveParticipant = (): HTMLElement | null => { - for (const node of event.composedPath()) { - if (!(node instanceof HTMLElement)) { - continue; - } - if (items.has(node)) { - return node; - } - } - const active = this.host.shadowRoot?.activeElement; - return active instanceof HTMLElement && items.has(active) ? active : null; - }; - - const participant = resolveParticipant(); - if (!participant || this.isActivationDisabled(participant)) { - return; - } - event.preventDefault(); - this.syncingExclusiveState = true; - try { - this.applyExclusiveSelection(participant, { - propagateToEmbeddedNavigation: true, - }); - } finally { - this.syncingExclusiveState = false; - } + this.applyExclusiveSelection(hit); }; constructor(host: ReactiveElement, options: RadioControllerOptions) { this.host = host; - const navigationEnabled = options.navigation !== false; - type NavigationPatchInput = Omit< - FocusgroupNavigationOptions, - 'getItems' | 'onActiveItemChange' - >; - - const navigationPatch: false | NavigationPatchInput = - options.navigation === false - ? false - : { - skipDisabled: options.skipDisabled ?? false, - ...DEFAULT_NAVIGATION, - ...(typeof options.navigation === 'object' - ? options.navigation - : {}), - }; - this.options = { getItems: options.getItems, selectItem: options.selectItem, deselectItem: options.deselectItem, - skipDisabled: options.skipDisabled ?? false, - selectionFollowsFocus: options.selectionFollowsFocus ?? true, - handleSpaceActivatesSelection: - options.handleSpaceActivatesSelection ?? navigationEnabled, - allowEmptySelection: options.allowEmptySelection ?? false, + toggles: options.toggles ?? false, defaultToFirstSelectable: options.defaultToFirstSelectable ?? false, - navigation: navigationEnabled === false ? false : navigationPatch, onSelectionChange: options.onSelectionChange, }; - if (navigationPatch) { - this.embeddedNavigation = new FocusgroupNavigationController(host, { - ...navigationPatch, - getItems: () => this.getEligibleItems(), - onActiveItemChange: (active) => { - if ( - this.suppressEmbeddedActiveCallback || - this.syncingExclusiveState || - !active - ) { - return; - } - if ( - !this.options.selectionFollowsFocus || - this.isActivationDisabled(active) - ) { - return; - } - this.applyExclusiveSelection(active, { - propagateToEmbeddedNavigation: false, - }); - }, - }); - } - host.addController(this); } @@ -286,45 +157,19 @@ export class RadioController implements ReactiveController { return this.selectedItem; } - /** Merges deltas; rejects `navigation` changes because wiring is immutable post-construction. */ + /** Merges option deltas and reapplies {@link refresh}. */ public setOptions(partial: Partial): void { - if (partial.navigation !== undefined) { - throw new Error( - 'RadioController does not permit mutating navigation options after creation.' - ); - } - - const nextSkip = partial.skipDisabled ?? this.options.skipDisabled; - - const selectionFollowsFocusProvided = - 'selectionFollowsFocus' in partial && - typeof partial.selectionFollowsFocus === 'boolean'; - - const spaceProvided = - 'handleSpaceActivatesSelection' in partial && - typeof partial.handleSpaceActivatesSelection === 'boolean'; - - const allowEmptyProvided = - 'allowEmptySelection' in partial && - typeof partial.allowEmptySelection === 'boolean'; - const defaultFirstProvided = 'defaultToFirstSelectable' in partial && typeof partial.defaultToFirstSelectable === 'boolean'; + const togglesProvided = + 'toggles' in partial && typeof partial.toggles === 'boolean'; + this.options = { ...this.options, ...partial, - skipDisabled: nextSkip, - selectionFollowsFocus: selectionFollowsFocusProvided - ? partial.selectionFollowsFocus! - : this.options.selectionFollowsFocus, - handleSpaceActivatesSelection: spaceProvided - ? partial.handleSpaceActivatesSelection! - : this.options.handleSpaceActivatesSelection, - allowEmptySelection: allowEmptyProvided - ? partial.allowEmptySelection! - : this.options.allowEmptySelection, + toggles: togglesProvided ? partial.toggles! : this.options.toggles, defaultToFirstSelectable: defaultFirstProvided ? partial.defaultToFirstSelectable! : this.options.defaultToFirstSelectable, @@ -335,46 +180,57 @@ export class RadioController implements ReactiveController { partial.onSelectionChange ?? this.options.onSelectionChange, }; - if (partial.skipDisabled !== undefined && this.embeddedNavigation) { - this.embeddedNavigation.setOptions({ skipDisabled: nextSkip }); - } - this.refresh(); } /** - * Programmatic assertion without synthetic clicks — returns {@link false} when {@link candidate} - * cannot join the exclusive roster. + * 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, - behavior?: { focus?: boolean } - ): boolean { + public setSelectedItem(candidate: HTMLElement | null): boolean { if (candidate !== null) { if ( !this.getEligibleItems().includes(candidate) || - this.isActivationDisabled(candidate) + this.isDisabledParticipant(candidate) ) { return false; } - } else if (!this.options.allowEmptySelection) { + } else if (!this.canClearSelection()) { return false; } - this.applyExclusiveSelection(candidate, { - propagateToEmbeddedNavigation: true, - }); + this.applyExclusiveSelection(candidate); - if (candidate && behavior?.focus) { - queueMicrotask(() => { - candidate.focus(); - }); - } + 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 and refreshes optional navigation sibling. */ + /** Re-applies bookkeeping after structural changes (stale selection or defaulting). */ public refresh(): void { const items = this.getEligibleItems(); @@ -385,61 +241,36 @@ export class RadioController implements ReactiveController { const replacement = items.length === 0 ? null - : this.options.allowEmptySelection && - !this.options.defaultToFirstSelectable + : this.canClearSelection() && !this.options.defaultToFirstSelectable ? null : (items[0] ?? null); - this.applyExclusiveSelection(replacement, { - propagateToEmbeddedNavigation: true, - }); + this.applyExclusiveSelection(replacement); } else if ( this.selectedItem === null && this.options.defaultToFirstSelectable && items.length > 0 ) { - this.applyExclusiveSelection(items[0], { - propagateToEmbeddedNavigation: true, - }); - } else if (this.embeddedNavigation) { - this.embeddedNavigation.refresh(); + this.applyExclusiveSelection(items[0]); } } public hostConnected(): void { this.host.addEventListener('click', this.handleClickCapture, true); - this.host.addEventListener('keydown', this.handleKeyCapture, true); this.refresh(); } public hostDisconnected(): void { this.host.removeEventListener('click', this.handleClickCapture, true); - this.host.removeEventListener('keydown', this.handleKeyCapture, true); } - private applyExclusiveSelection( - next: HTMLElement | null, - flags: { propagateToEmbeddedNavigation: boolean } - ): void { + private applyExclusiveSelection(next: HTMLElement | null): void { const roster = this.getEligibleItems(); let asserted = next; - if ( - asserted === null && - !this.options.allowEmptySelection && - roster.length > 0 - ) { + if (asserted === null && !this.canClearSelection() && roster.length > 0) { asserted = roster[0]; } if (this.selectedItem === asserted) { - if ( - flags.propagateToEmbeddedNavigation && - asserted && - this.embeddedNavigation - ) { - this.propagateEmbeddedTabStop(asserted); - } else { - this.embeddedNavigation?.refresh(); - } return; } @@ -456,34 +287,11 @@ export class RadioController implements ReactiveController { this.selectedItem = asserted; - if ( - flags.propagateToEmbeddedNavigation && - asserted && - this.embeddedNavigation - ) { - this.propagateEmbeddedTabStop(asserted); - } else if (this.embeddedNavigation) { - this.embeddedNavigation.refresh(); - } - if (prior !== asserted) { this.dispatchSelectionChange(); } } - /** Ensures tabindex mirrors the asserted participant without reacting to programmatic sync. */ - private propagateEmbeddedTabStop(participant: HTMLElement): void { - if (!this.embeddedNavigation) { - return; - } - this.suppressEmbeddedActiveCallback = true; - try { - this.embeddedNavigation.setActiveItem(participant); - } finally { - this.suppressEmbeddedActiveCallback = false; - } - } - /** Mirrors `radioControllerSelectionChange` plus optional `onSelectionChange` hook. */ private dispatchSelectionChange(): void { const detail: RadioControllerSelectionChangeDetail = { @@ -505,9 +313,6 @@ export class RadioController implements ReactiveController { /** * Whether `node` is the host itself or reachable by walking ancestors and shadow hosts. * - * Mirrors {@link FocusgroupNavigationController}'s containment guard so callers can query arbitrary - * slices without leaking into foreign shadow trees. - * * @param node - Node under test (may be null). */ private isNodeWithinHostScope(node: Node | null): boolean { @@ -532,7 +337,7 @@ export class RadioController implements ReactiveController { return false; } - /** Raw query filtered to elements owned by {@link RadioController}'s reactive host subtree. */ + /** Raw query filtered to elements owned by the reactive host subtree. */ private getScopedRawItems(): HTMLElement[] { return this.options .getItems() @@ -540,11 +345,10 @@ export class RadioController implements ReactiveController { } /** - * Whether `participant` skips navigation when `{@link RadioControllerOptions.skipDisabled}` resolves true. - * - * @param participant - Potential radio sibling. + * Native **`disabled`** or **`aria-disabled="true"`** — never eligible and never activated by + * pointer or **`setSelectedItem`**. */ - private skipsDisabledSemantics(participant: HTMLElement): boolean { + private isDisabledParticipant(participant: HTMLElement): boolean { if ('disabled' in participant) { if ((participant as HTMLButtonElement).disabled) { return true; @@ -554,7 +358,7 @@ export class RadioController implements ReactiveController { } /** - * Mirrors {@link FocusgroupNavigationController}'s eligibility filter for interactive composites. + * Eligibility filter: connected, visible, not inert, not disabled. */ private isRadioNavigableItem(participant: HTMLElement): boolean { if (!participant.isConnected) { @@ -571,29 +375,21 @@ export class RadioController implements ReactiveController { ) { return false; } - if (this.options.skipDisabled && this.skipsDisabledSemantics(participant)) { + if (this.isDisabledParticipant(participant)) { return false; } return true; } - /** - * Prevents asserting disabled controls even when callers forget to prune `getItems`. - * - * @param participant - Candidate asserting role states. - */ - private isActivationDisabled(participant: HTMLElement): boolean { - return ( - ('disabled' in participant && - (participant as HTMLButtonElement).disabled) || - participant.getAttribute('aria-disabled') === 'true' - ); - } - - /** Eligible selectable siblings respecting skip rules and viewport visibility. */ + /** 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 index b7bbdd64fc0..f95508df3d5 100644 --- a/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts @@ -13,14 +13,19 @@ import { css, html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { RadioController } from '../index.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-programmatic-selection': DemoRadioProgrammaticSelection; + 'demo-radio-accordion-multiple': DemoRadioAccordionMultiple; } } @@ -50,23 +55,41 @@ export class DemoRadioGroupRating extends LitElement { display: flex; gap: 0.35rem; flex-wrap: wrap; + align-items: center; } .stars button { - font: inherit; - min-inline-size: 2.5rem; - min-block-size: 2.5rem; - border-radius: 4px; - border: 1px solid var(--spectrum-gray-400, #b1b1b1); - background: var(--spectrum-gray-75, #fdfdfd); + 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); - border-color: var(--spectrum-orange-800, #cb6f10); + } + .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; } `; @@ -80,7 +103,7 @@ export class DemoRadioGroupRating extends LitElement { selectItem: (star) => star.setAttribute('aria-checked', 'true'), deselectItem: (star) => star.setAttribute('aria-checked', 'false'), defaultToFirstSelectable: true, - navigation: { direction: 'horizontal', wrap: true, memory: true }, + toggles: true, }); protected override firstUpdated(): void { @@ -102,7 +125,220 @@ export class DemoRadioGroupRating extends LitElement { aria-checked="false" aria-label=${label} > - ${value} + + + `; + })} + + + `; + } +} + +/** 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` + `; })} @@ -160,7 +396,6 @@ export class DemoRadioMenuItemRadio extends LitElement { selectItem: (item) => item.setAttribute('aria-checked', 'true'), deselectItem: (item) => item.setAttribute('aria-checked', 'false'), defaultToFirstSelectable: true, - navigation: { direction: 'vertical', wrap: true, memory: true }, }); protected override firstUpdated(): void { @@ -221,6 +456,7 @@ export class DemoRadioAccordionExclusive extends LitElement { overflow: clip; } article { + inline-size: 300px; border-block-end: 1px solid var(--spectrum-gray-200, #e6e6e6); } article:last-of-type { @@ -283,10 +519,7 @@ export class DemoRadioAccordionExclusive extends LitElement { header.setAttribute('aria-expanded', 'false'); this.togglePanel(header.dataset.accordion!, false); }, - navigation: false, - selectionFollowsFocus: false, - handleSpaceActivatesSelection: false, - allowEmptySelection: true, + toggles: true, }); private togglePanel(key: string, open: boolean): void { @@ -322,7 +555,7 @@ export class DemoRadioAccordionExclusive extends LitElement { { key: 'b', heading: 'Filters', - copy: 'Use `navigation: false` when arrow keys should not imitate a radiogroup.', + copy: 'Accordion headers often use RadioController alone without a separate focus group.', }, { key: 'c', @@ -358,9 +591,14 @@ export class DemoRadioAccordionExclusive extends LitElement { } } -/** @internal */ -@customElement('demo-radio-programmatic-selection') -export class DemoRadioProgrammaticSelection extends LitElement { +/** + * 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; @@ -368,132 +606,164 @@ export class DemoRadioProgrammaticSelection extends LitElement { 0.95rem system-ui, sans-serif; } - .group { + .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; - gap: 0.75rem; - flex-wrap: wrap; + width: 100%; + gap: 0.5rem; align-items: center; - } - .group button[data-program-item] { + justify-content: space-between; font: inherit; - padding: 0.65rem 0.95rem; - border-radius: 6px; - border: 1px solid var(--spectrum-gray-400, #cfcfcf); + padding: 0.75rem 1rem; + border: none; background: white; cursor: pointer; + text-align: start; } - .group button[data-program-item]:focus-visible { + button.trigger:focus-visible { outline: 2px solid var(--spectrum-blue-800, #0265dc); - outline-offset: 2px; + outline-offset: -2px; } - .group button[data-program-item][aria-checked='true'] { - border-color: var(--spectrum-blue-800, #0265dc); - background: var(--spectrum-blue-100, #e5f6ff); + 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; } - footer { - margin-block-start: 1rem; - display: flex; - gap: 0.5rem; - flex-wrap: wrap; + button[aria-expanded='true'] span.chevron::before { + rotate: 135deg; + translate: 0 -0.1rem; } - footer button { - font: inherit; - padding: 0.45rem 0.85rem; - border-radius: 4px; - border: 1px solid var(--spectrum-gray-500, #b5b5b5); - background: var(--spectrum-gray-50, #f5f5f5); - cursor: pointer; + .region { + padding: 0.75rem 1rem 1rem; + background: var(--spectrum-gray-75, #fafafa); + border-block-start: 1px solid var(--spectrum-gray-200, #e6e6e6); } - footer button:focus-visible { - outline: 2px solid var(--spectrum-blue-800, #0265dc); - outline-offset: 2px; + .region[hidden] { + display: none; + } + .accordion-heading { + margin: 0; + font: inherit; + font-weight: 600; } `; - private radios = new RadioController(this, { + private readonly panels = (): HTMLElement[] => + Array.from(this.renderRoot.querySelectorAll('.region')); + + private readonly accordionRadio = new RadioController(this, { getItems: () => Array.from( this.renderRoot.querySelectorAll( - '[data-program-item]' + '[data-accordion-heading]' ) ), - selectItem: (pill) => pill.setAttribute('aria-checked', 'true'), - deselectItem: (pill) => pill.setAttribute('aria-checked', 'false'), - defaultToFirstSelectable: true, - navigation: { direction: 'horizontal', wrap: true }, + 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, }); - protected override firstUpdated(): void { - this.radios.refresh(); - this.programmaticShortcuts(); + private togglePanel(key: string, open: boolean): void { + this.panels().forEach((surface) => { + if (surface.dataset.panel === key) { + surface.hidden = !open; + } + }); } - private programmaticShortcuts(): void { - const shortcuts = Array.from( + protected override firstUpdated(): void { + const headers = Array.from( this.renderRoot.querySelectorAll( - '[data-program-select]' + '[data-accordion-heading]' ) ); - - shortcuts.forEach((shortcut) => - shortcut.addEventListener( - 'click', - /** Selects programmatically alongside pointer-driven usage. */ - () => { - const indexRaw = shortcut.dataset.programSelect ?? '0'; - const indexValue = Number.parseInt(indexRaw, 10); - - const roster = Array.from( - this.renderRoot.querySelectorAll('[data-program-item]') - ); - - if (!Number.isFinite(indexValue) || indexValue >= roster.length) { - return; - } - - void this.radios.setSelectedItem(roster[indexValue], { - focus: true, - }); - } + headers.forEach((button) => + this.togglePanel( + button.dataset.accordion!, + button.getAttribute('aria-expanded') === 'true' ) ); + this.accordionRadio.refresh(); } protected override render(): TemplateResult { return html` -
- - - + -
- - - -
`; } } 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 index c0e39d27bd5..4dd95f4b136 100644 --- 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 @@ -15,110 +15,540 @@ 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: '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 and calls refresh().', + }, + { + name: 'hostDisconnected', + signature: 'hostDisconnected(): void', + returns: 'void', + description: + 'Lit ReactiveController: removes the capture-phase click listener.', + }, + ], + 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` keeps mutually exclusive asserted state (`aria-checked`, - * [`menuitemradio`](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/), or `aria-expanded` - * accordion patterns). Item discovery aligns with `{@link FocusgroupNavigationController}` filtering - * (shadow-inclusive containment, visibility, optional disabled skipping). - * - * - **Selecting / deselecting**: supply callbacks that mutate ARIA bookkeeping on sibling elements — - * the controller never assumes native `` wiring. - * - **Pointers**: captures host `click`, resolves the deepest hit via - * `{@link deepestRadioItemContaining}` with the same eligibility list Focusgroup consumes. - * - **Embedded navigation**: by default nests `{@link FocusgroupNavigationController}` so arrow keys - * move roving `tabindex` *and*, when `{@link RadioControllerOptions.selectionFollowsFocus}` - * remains true (default), co-selects whichever item earns focus (`Space` activates the focused - * entry like the APG rating walkthrough). - * - * Dispatches bubbling composed **`swc-radio-controller-selection-change`** - * (`{@link radioControllerSelectionChange}`) with `{ selectedItem }`, ideal for bridging analytics - * or higher-level accordion state hosts. - * - * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ | APG Rating radio group walkthrough} - * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ | APG menu / menuitemradio semantics} - * @see {@link https://opensource.adobe.com/spectrum-web-components/components/accordion/#sizes | Spectrum accordion sizing reference} + * `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', - tags: ['migrated', 'controller'], + component: 'demo-radio-group-rating', parameters: { docs: { subtitle: - 'Exclusive selection primitives built atop the same sibling discovery rules as Focusgroup navigation.', + 'Exclusive selection with configurable DOM updates; pointer clicks and toggleItem.', canvas: { sourceState: 'none' }, }, + controllerApi: RADIO_CONTROLLER_API, }, + tags: ['migrated', 'controller'], + render: () => html` + + `, }; export default meta; type Story = StoryObj; -/** Autodocs entry plus Playground scaffold for future knobs. */ +// ────────────────────────── +// AUTODOCS STORY +// ────────────────────────── export const Playground: Story = { tags: ['autodocs', 'dev'], - render: () => html` - - `, }; +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + tags: ['overview'], +}; + +// ────────────────────────── +// BASIC USAGE STORY +// ────────────────────────── + /** - * ## Radiogroup + `radio` bookkeeping + * ## 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 + * - `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) => { ... }, + * }); + * ``` + * + * ### `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:** * - * Mirrors the structural expectations from the APG **[Rating radio group](https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/)** - * demonstration: mutually exclusive `{@link HTMLElement.setAttribute}` calls toggle `aria-checked` - * (`"true"` / `"false"`) whenever the asserted star shifts. + * - 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 }, +}; -export const RadioGroupAriaCheckedRating: Story = { - tags: ['overview'], +/** + * ## 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` - + `, }; /** - * ## `menubar` + `menuitemradio` + * ### 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. * - * The APG **[Menu and menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)** pattern calls for - * `role="menuitemradio"` siblings with mirrored `aria-checked` strings. Embedding **vertical** - * `{@link FocusgroupNavigationController}` matches column menus where **ArrowUp / ArrowDown** walk - * the linearized option list. + * ```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 MenuMenubarAriaCheckedVertical: Story = { +export const UsageExampleSelectionWithAriaChecked: Story = { + name: 'Example: selection with `aria-checked`', + tags: ['usage'], + parameters: { 'section-order': 2 }, render: () => html` `, }; /** - * ## Accordion-style `aria-expanded` + * ### Toggling to deselect * - * Passing `{@link RadioControllerOptions.navigation}: false` keeps arrow semantics off the accordion - * headers themselves (panels still rely on authored buttons). Selecting callbacks instead flip `aria-expanded` - * and synchronize panel `[hidden]` flags, analogous to Spectrum Web Components accordion sizing demos - * ([reference](https://opensource.adobe.com/spectrum-web-components/components/accordion/#sizes)). + * 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` + + `, +}; +// ────────────────────────── +// 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, + }, + }, }; /** - * ## Programmatic assertions + * 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). * - * Host applications can steer exclusive state without synthesized clicks via - * `{@link RadioController.setSelectedItem}`. Passing `{ focus: true }` mirrors authoring guidance - * for restoring focus predictably inside composite widgets immediately after scripted selection. + * ```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 }, +}; -export const ProgrammaticSetSelectedFocus: Story = { +/** + * Same layout, illustrating **`onSelectionChange`** with **`window.alert`** (and **`toggles`** so + * the active star can be cleared). Rendered under **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. | + * | `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 + * + * The controller does **not** implement roving **`tabindex`** or arrow-key navigation. Pair it + * with **`FocusgroupNavigationController`** when your pattern needs APG-style keyboard movement + * inside the same host. + * + * ### 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 deleted file mode 100644 index 4b24b782276..00000000000 --- a/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * 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 { expect } from '@storybook/test'; -import type { Meta, StoryObj } from '@storybook/web-components'; - -import './demo-hosts.js'; - -import { getComponent } from '../../../../swc/utils/test-utils.js'; -import { - deepestRadioItemContaining, - radioControllerSelectionChange, - type RadioControllerSelectionChangeDetail, -} from '../index.js'; -import type { DemoRadioAccordionExclusive } from './demo-hosts.js'; -import meta, { - AccordionExpandedExclusive, - MenuMenubarAriaCheckedVertical, - ProgrammaticSetSelectedFocus, - RadioGroupAriaCheckedRating, -} from './radio-controller.stories.js'; - -function keydown(target: HTMLElement, key: string): void { - target.dispatchEvent( - new KeyboardEvent('keydown', { - key, - bubbles: true, - composed: true, - cancelable: true, - }) - ); -} - -export default { - ...meta, - title: 'Radio controller/Tests', - parameters: { - ...meta.parameters, - docs: { disable: true, page: null }, - }, - tags: ['!autodocs', 'dev'], -} as Meta; - -type Story = StoryObj; - -/** Radiogroup pointer interaction keeps exactly one asserted `aria-checked`. */ - -export const RadioGroupPointerExclusive: Story = { - ...RadioGroupAriaCheckedRating, - play: async ({ canvasElement, step }) => { - const host = await getComponent(canvasElement, 'demo-radio-group-rating'); - - await step('First selectable star initializes selected', async () => { - const buttons = Array.from( - host.shadowRoot!.querySelectorAll( - '[data-rating-star]' - ) - ); - expect( - buttons.some((star) => star.getAttribute('aria-checked') === 'true') - ).toBe(true); - }); - - await step('Click chooses a new asserted star exclusively', async () => { - const buttons = Array.from( - host.shadowRoot!.querySelectorAll( - '[data-rating-star]' - ) - ); - - buttons[4]!.dispatchEvent( - new MouseEvent('click', { bubbles: true, composed: true }) - ); - - await host.updateComplete; - expect(buttons[4]?.getAttribute('aria-checked')).toBe('true'); - expect( - buttons.filter((star) => star.getAttribute('aria-checked') === 'true') - .length - ).toBe(1); - }); - }, -}; - -/** Arrow traversal co-selects the focused entry when navigation embeds Focusgroup semantics. */ - -export const RadioGroupArrowCoSelects: Story = { - ...RadioGroupAriaCheckedRating, - play: async ({ canvasElement, step }) => { - const host = await getComponent(canvasElement, 'demo-radio-group-rating'); - - await step( - 'Arrow keys move asserted state with roving tabindex', - async () => { - const buttons = Array.from( - host.shadowRoot!.querySelectorAll( - '[data-rating-star]' - ) - ); - - buttons[0]!.focus(); - expect(document.activeElement).toBe(buttons[0]); - - keydown(buttons[0]!, 'ArrowRight'); - await host.updateComplete; - expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); - expect( - buttons.filter((star) => star.getAttribute('aria-checked') === 'true') - .length - ).toBe(1); - expect(buttons[1]?.tabIndex).toBe(0); - - /** Space reinforces focus without collapsing selection (APG radios). */ - - keydown(buttons[1]!, ' '); - await host.updateComplete; - expect(buttons[1]?.getAttribute('aria-checked')).toBe('true'); - } - ); - }, -}; - -/** Vertical menu radios honor arrow traversal along Focusgroup orientation. */ - -export const MenuRadiosVerticalArrows: Story = { - ...MenuMenubarAriaCheckedVertical, - play: async ({ canvasElement, step }) => { - const host = await getComponent( - canvasElement, - 'demo-radio-menu-item-radio' - ); - - await step('ArrowDown moves checked menu radios', async () => { - const items = Array.from( - host.shadowRoot!.querySelectorAll('[data-alignment]') - ); - - items[0]?.focus(); - - keydown(items[0]!, 'ArrowDown'); - await host.updateComplete; - expect(items[1]?.getAttribute('aria-checked')).toBe('true'); - expect( - items.filter((item) => item.getAttribute('aria-checked') === 'true') - .length - ).toBe(1); - }); - }, -}; - -/** Programmatic setter bypasses synthesized clicks yet keeps aria bookkeeping aligned. */ - -export const ProgrammaticSelectionAPI: Story = { - ...ProgrammaticSetSelectedFocus, - play: async ({ canvasElement, step }) => { - const host = await getComponent( - canvasElement, - 'demo-radio-programmatic-selection' - ); - - await step( - 'Footer buttons call `RadioController#setSelectedItem`', - async () => { - host.shadowRoot - ?.querySelector('[data-program-select="1"]') - ?.click(); - - await host.updateComplete; - const items = Array.from( - host.shadowRoot!.querySelectorAll('[data-program-item]') - ); - expect(items[1]?.getAttribute('aria-checked')).toBe('true'); - expect( - items.filter((item) => item.getAttribute('aria-checked') === 'true') - .length - ).toBe(1); - } - ); - }, -}; - -/** Exclusive accordion headers flip `aria-expanded` + panel `[hidden]` through callbacks. */ - -export const AccordionExpandedCallbacks: Story = { - ...AccordionExpandedExclusive, - play: async ({ canvasElement, step }) => { - const host = await getComponent( - canvasElement, - 'demo-radio-accordion-exclusive' - ); - - await step( - 'Opening a section collapses the previously expanded region', - async () => { - const headers = Array.from( - host.shadowRoot!.querySelectorAll( - '[data-accordion]' - ) - ); - - const filters = headers.find( - (header) => header.dataset.accordion === 'b' - ); - filters?.dispatchEvent( - new MouseEvent('click', { bubbles: true, composed: true }) - ); - - await host.updateComplete; - - expect(headers[0]?.getAttribute('aria-expanded')).toBe('false'); - - /** Region visibility tracks header bookkeeping. */ - - const filtersPanel = - host.shadowRoot!.querySelector(`[data-panel="b"]`); - expect(filtersPanel?.hidden).toBe(false); - - const brushesPanel = - host.shadowRoot!.querySelector(`[data-panel="a"]`); - expect(brushesPanel?.hidden).toBe(true); - } - ); - }, -}; - -/** Dispatches bubbling composed selection change payloads on the reactive host. */ - -export const SelectionChangeEventDetail: Story = { - ...RadioGroupAriaCheckedRating, - play: async ({ canvasElement, step }) => { - const host = await getComponent(canvasElement, 'demo-radio-group-rating'); - - await step( - 'Pointer selection emits selection change payloads', - async () => { - const buttons = Array.from( - host.shadowRoot!.querySelectorAll( - '[data-rating-star]' - ) - ); - - let detail: RadioControllerSelectionChangeDetail | undefined; - - host.addEventListener(radioControllerSelectionChange, (( - event: Event - ) => { - detail = (event as CustomEvent) - .detail; - }) as EventListener); - - buttons[4]!.dispatchEvent( - new MouseEvent('click', { bubbles: true, composed: true }) - ); - await host.updateComplete; - - expect(detail?.selectedItem).toBe(buttons[4]); - - detail = undefined; - buttons[4]!.dispatchEvent( - new MouseEvent('click', { bubbles: true, composed: true }) - ); - await host.updateComplete; - - expect(detail).toBeUndefined(); - } - ); - }, -}; - -/** `deepestRadioItemContaining` resolves composed paths deepest-first. */ - -export const DeepestRadioItemContainingStory: Story = { - render: () => html` - - `, - play: async ({ step }) => { - await step( - 'Prefers deepest eligible element on composed path sequence', - async () => { - const ancestor = document.createElement('article'); - const middle = document.createElement('button'); - const leaf = document.createElement('button'); - middle.type = 'button'; - leaf.type = 'button'; - middle.append(leaf); - ancestor.append(middle); - - const event = new PointerEvent('pointerdown', { - bubbles: true, - composed: true, - }); - Object.defineProperty(event, 'composedPath', { - value: () => [leaf, middle, ancestor], - }); - - expect(deepestRadioItemContaining(event, [ancestor, leaf])).toBe(leaf); - expect(deepestRadioItemContaining(event, [ancestor])).toBe(ancestor); - } - ); - }, -}; diff --git a/2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts b/2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts new file mode 100644 index 00000000000..17508f4af4e --- /dev/null +++ b/2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts @@ -0,0 +1,749 @@ +/** + * 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 '../stories/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'; + +/** + * 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` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [FIXTURE_TAG]: TestRadioControllerFixture; + [FIXTURE_DISABLED_TAG]: TestRadioControllerDisabledFixture; + [FIXTURE_TOGGLE_TAG]: TestRadioControllerToggleFixture; + [FIXTURE_DEFAULT_FIRST_ONCHANGE_TAG]: TestRadioDefaultFirstOnChangeFixture; + } +} + +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'); + }, +}; diff --git a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx index 38563aa1d30..ddeef9f059c 100644 --- a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx +++ b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx @@ -40,7 +40,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 +55,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}} ); @@ -112,6 +120,18 @@ 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/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..c7396629873 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", "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/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 0af2c8519e6..59d89980517 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -371,13 +371,19 @@ const preview = { 'Rendering and styling migration analysis', ], 'Button', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Migration plan', + 'Rendering and styling migration analysis', + ], 'Button group', ['Rendering and styling migration analysis'], 'Checkbox', ['Rendering and styling migration analysis'], 'Color field', ['Rendering and styling migration analysis'], + 'Color loupe', + ['Accessibility migration analysis'], 'Divider', [ 'Accessibility migration analysis', @@ -392,7 +398,10 @@ const preview = { 'Help text', ['Rendering and styling migration analysis'], 'Illustrated message', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Infield button', ['Rendering and styling migration analysis'], 'Infield progress circle', @@ -408,7 +417,10 @@ const preview = { 'Picker button', ['Rendering and styling migration analysis'], 'Progress bar', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Progress circle', [ 'Accessibility migration analysis', @@ -431,6 +443,11 @@ const preview = { ['Rendering and styling migration analysis'], 'Switch', ['Rendering and styling migration analysis'], + 'Tabs', + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Tag', ['Rendering and styling migration analysis'], 'Tags', 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 From 865ecada60ca71ab72debaedaf98ab8a7bf5d889 Mon Sep 17 00:00:00 2001 From: Nikki Massaro Date: Mon, 11 May 2026 13:17:54 -0400 Subject: [PATCH 3/3] feat(core): added keydown to radioController --- .ai/skills/controller-development/SKILL.md | 2 +- .../focusgroup-navigation-controller.test.ts | 2 +- .../radio-controller/src/radio-controller.ts | 74 ++++- .../radio-controller/stories/demo-hosts.ts | 199 +++++++++++++ .../stories/radio-controller.stories.ts | 80 +++++- .../radio-controller.test.ts | 269 +++++++++++++++++- .../swc/.storybook/DocumentTemplate.mdx | 11 +- .../blocks/ConditionalBehaviorsSection.tsx | 53 ++++ .../swc/.storybook/blocks/SpectrumStories.tsx | 2 +- .../packages/swc/.storybook/blocks/index.ts | 1 + 2nd-gen/packages/swc/.storybook/preview.ts | 7 +- 11 files changed, 670 insertions(+), 30 deletions(-) rename 2nd-gen/packages/core/controllers/radio-controller/{test => stories}/radio-controller.test.ts (74%) create mode 100644 2nd-gen/packages/swc/.storybook/blocks/ConditionalBehaviorsSection.tsx diff --git a/.ai/skills/controller-development/SKILL.md b/.ai/skills/controller-development/SKILL.md index 9178c4c67b8..fc42ef0f9f8 100644 --- a/.ai/skills/controller-development/SKILL.md +++ b/.ai/skills/controller-development/SKILL.md @@ -95,7 +95,7 @@ Docs sections **`Usage`**, **`Behaviors`**, **`Accessibility`**, **`Appendix`** Follow **`focusgroup-navigation-controller.test.ts`**: -- **`import from '../stories/.stories.js'`** and spread into `export default { … }` with `title: '…/Tests'`, **`docs: { disable: true, page: null }`**, and any test-only parameters. +- **`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. 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/radio-controller/src/radio-controller.ts b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts index 4b83ec6280c..930421de0d1 100644 --- a/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts +++ b/2nd-gen/packages/core/controllers/radio-controller/src/radio-controller.ts @@ -51,6 +51,14 @@ export type RadioControllerOptions = { */ 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; }; @@ -92,9 +100,11 @@ export function deepestRadioItemContaining( * 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 only handles - * capture-phase **`click`**, **`setSelectedItem`**, and **`toggleItem`** — it does not implement arrow keys, roving - * **`tabindex`**, programmatic **`focus()`**, or an active-item/focus sync callback. + * 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/ @@ -111,6 +121,7 @@ export class RadioController implements ReactiveController { | 'deselectItem' | 'toggles' | 'defaultToFirstSelectable' + | 'keydownActivation' > >, never @@ -119,6 +130,8 @@ export class RadioController implements ReactiveController { private selectedItem: HTMLElement | null = null; + private keydownListenerAttached = false; + private readonly handleClickCapture = (event: MouseEvent): void => { if (event.button !== 0) { return; @@ -138,6 +151,32 @@ export class RadioController implements ReactiveController { 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 = { @@ -146,6 +185,7 @@ export class RadioController implements ReactiveController { deselectItem: options.deselectItem, toggles: options.toggles ?? false, defaultToFirstSelectable: options.defaultToFirstSelectable ?? false, + keydownActivation: options.keydownActivation ?? false, onSelectionChange: options.onSelectionChange, }; @@ -166,6 +206,10 @@ export class RadioController implements ReactiveController { const togglesProvided = 'toggles' in partial && typeof partial.toggles === 'boolean'; + const keydownActivationProvided = + 'keydownActivation' in partial && + typeof partial.keydownActivation === 'boolean'; + this.options = { ...this.options, ...partial, @@ -173,6 +217,9 @@ export class RadioController implements ReactiveController { 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, @@ -180,6 +227,7 @@ export class RadioController implements ReactiveController { partial.onSelectionChange ?? this.options.onSelectionChange, }; + this.syncKeydownActivationListener(); this.refresh(); } @@ -256,11 +304,31 @@ export class RadioController implements ReactiveController { 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 { 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 index f95508df3d5..58954533a80 100644 --- a/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/demo-hosts.ts @@ -26,6 +26,7 @@ declare global { 'demo-radio-menu-item-radio': DemoRadioMenuItemRadio; 'demo-radio-accordion-exclusive': DemoRadioAccordionExclusive; 'demo-radio-accordion-multiple': DemoRadioAccordionMultiple; + 'demo-radio-tabs-keydown': DemoRadioTabsKeydown; } } @@ -767,3 +768,201 @@ export class DemoRadioAccordionMultiple extends LitElement { `; } } + +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 index 4dd95f4b136..26977a173ba 100644 --- 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 @@ -57,6 +57,13 @@ const RADIO_CONTROLLER_API = { 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', @@ -113,14 +120,14 @@ const RADIO_CONTROLLER_API = { signature: 'hostConnected(): void', returns: 'void', description: - 'Lit ReactiveController: registers capture-phase click on the host and calls refresh().', + '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 the capture-phase click listener.', + 'Lit ReactiveController: removes capture-phase click and any keydown listener registered for keydownActivation.', }, ], events: [ @@ -172,7 +179,7 @@ const meta: Meta = { parameters: { docs: { subtitle: - 'Exclusive selection with configurable DOM updates; pointer clicks and toggleItem.', + 'Exclusive selection with configurable DOM updates; pointer clicks, optional Enter/Space (keydownActivation), and toggleItem.', canvas: { sourceState: 'none' }, }, controllerApi: RADIO_CONTROLLER_API, @@ -218,6 +225,7 @@ export const Overview: Story = { * - `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 @@ -227,6 +235,7 @@ export const Overview: Story = { * getItems: () => [...], * selectItem: (el) => { ... }, * deselectItem: (el) => { ... }, + * keydownActivation: true, * }); * ``` * @@ -381,6 +390,40 @@ export const UsageExampleTogglingToDeselect: Story = { `, }; +/** + * ### `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 // ────────────────────────── @@ -410,6 +453,24 @@ export const AccordionMultipleSectionsAriaExpandedHidden: Story = { }, }; +/** + * 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 @@ -441,8 +502,8 @@ export const RatingDefaultFirstSelectable: Story = { /** * Same layout, illustrating **`onSelectionChange`** with **`window.alert`** (and **`toggles`** so - * the active star can be cleared). Rendered under **Responding to selection change** on the docs - * page (no **`dev`** tag, so it stays out of the Storybook sidebar). + * 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, { @@ -508,6 +569,7 @@ export const RatingOnSelectionChangeAlert: Story = { * | `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. @@ -529,9 +591,11 @@ export const API: Story = { * * ### Keyboard and focus * - * The controller does **not** implement roving **`tabindex`** or arrow-key navigation. Pair it - * with **`FocusgroupNavigationController`** when your pattern needs APG-style keyboard movement - * inside the same host. + * 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 * diff --git a/2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts similarity index 74% rename from 2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts rename to 2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts index 17508f4af4e..b1496517641 100644 --- a/2nd-gen/packages/core/controllers/radio-controller/test/radio-controller.test.ts +++ b/2nd-gen/packages/core/controllers/radio-controller/stories/radio-controller.test.ts @@ -20,7 +20,7 @@ import { radioControllerSelectionChange, type RadioControllerSelectionChangeDetail, } from '../index.js'; -import radioMeta from '../stories/radio-controller.stories.js'; +import radioMeta from './radio-controller.stories.js'; const FIXTURE_TAG = 'test-radio-controller-fixture'; @@ -31,6 +31,10 @@ 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. */ @@ -264,12 +268,118 @@ export class TestRadioDefaultFirstOnChangeFixture extends LitElement { } } +/** + * 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 { - [FIXTURE_TAG]: TestRadioControllerFixture; - [FIXTURE_DISABLED_TAG]: TestRadioControllerDisabledFixture; - [FIXTURE_TOGGLE_TAG]: TestRadioControllerToggleFixture; - [FIXTURE_DEFAULT_FIRST_ONCHANGE_TAG]: TestRadioDefaultFirstOnChangeFixture; + '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; } } @@ -280,7 +390,7 @@ export default { ...radioMeta.parameters, docs: { disable: true, page: null }, }, - tags: ['!autodocs', 'dev'], + tags: ['!autodocs', '!dev'], } as Meta; function fixtureRender() { @@ -747,3 +857,150 @@ export const OnSelectionChangeReceivesDetailAfterDefaultAndOnClick: Story = { 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 fd6751598ad..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, @@ -60,7 +61,7 @@ export const ConditionalSection = ({ return (
- {title &&

{title}

} + {title && {title}}
); @@ -127,13 +128,7 @@ export const ConditionalGettingStarted = () => { hideTitle={true} titleLevel={3} /> - - + 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/SpectrumStories.tsx b/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx index c7396629873..5fc754514f7 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/SpectrumStories.tsx @@ -12,7 +12,7 @@ import React, { Fragment } from 'react'; * * @param of - The Storybook meta or story to resolve the component from * @param tag - The story tag to filter by (e.g., "usage", "setting-default-selection", - * "responding-to-selection-change", "a11y") + * "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 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], };