From dde23edacf6099abbada4f7dbfe798b4609052c9 Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Tue, 23 Jun 2026 09:48:54 -0700 Subject: [PATCH 01/23] chore(actiongroup): set up feature branch From c11399ea0ea0725ccb7c8d70d00b418f8d052eec Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Tue, 23 Jun 2026 09:48:54 -0700 Subject: [PATCH 02/23] chore(actiongroup): set up feature branch From d875ab7ad143346ed0413bd3d12454d5665d32b5 Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Tue, 23 Jun 2026 09:48:54 -0700 Subject: [PATCH 03/23] chore(actiongroup): set up feature branch From f4a38a8cb23b9a2c6ac6109f670647de846c9943 Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Tue, 23 Jun 2026 08:03:08 -0700 Subject: [PATCH 04/23] chore(actiongroup): setup file structure --- .../action-group/ActionGroup.base.ts | 137 ++++++++++++++++++ .../action-group/ActionGroup.types.ts | 38 +++++ .../core/components/action-group/index.ts | 13 ++ 2nd-gen/packages/core/package.json | 4 + .../components/action-group/ActionGroup.ts | 43 ++++++ .../components/action-group/action-group.css | 13 ++ .../swc/components/action-group/index.ts | 12 ++ .../stories/action-group.stories.ts | 137 ++++++++++++++++++ .../action-group/swc-action-group.ts | 22 +++ .../01_status.md | 2 +- .../action-group/migration-plan.md | 6 +- 11 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 2nd-gen/packages/core/components/action-group/ActionGroup.base.ts create mode 100644 2nd-gen/packages/core/components/action-group/ActionGroup.types.ts create mode 100644 2nd-gen/packages/core/components/action-group/index.ts create mode 100644 2nd-gen/packages/swc/components/action-group/ActionGroup.ts create mode 100644 2nd-gen/packages/swc/components/action-group/action-group.css create mode 100644 2nd-gen/packages/swc/components/action-group/index.ts create mode 100644 2nd-gen/packages/swc/components/action-group/stories/action-group.stories.ts create mode 100644 2nd-gen/packages/swc/components/action-group/swc-action-group.ts diff --git a/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts b/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts new file mode 100644 index 00000000000..2c6a341da63 --- /dev/null +++ b/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts @@ -0,0 +1,137 @@ +/** + * 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 { PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; +import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; + +import { + ACTION_GROUP_ORIENTATIONS, + ACTION_GROUP_VALID_SIZES, + type ActionGroupOrientation, + type ActionGroupSize, +} from './ActionGroup.types.js'; + +/** + * An action group clusters related action buttons together with composite + * keyboard navigation (one Tab stop; arrow keys move among items). + * + * This base class owns the accessibility semantics, `label` → `aria-label` + * management, `disabled` state contract, and child collection logic. + * Rendering and styling live in the concrete SWC subclass. + * + * @slot - One or more `swc-action-button` or `swc-action-menu` elements. + * + * @attribute {ActionGroupSize} size - The size of the group and its children. + */ +export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { + validSizes: ACTION_GROUP_VALID_SIZES, + noDefaultSize: true, +}) { + /** + * The size of the action group. Propagated to all slotted children. + */ + declare public size: ActionGroupSize; + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * @internal + */ + static readonly ORIENTATIONS: readonly string[] = ACTION_GROUP_ORIENTATIONS; + + /** + * The layout direction of the action group. + * + * `aria-orientation` is set to `"vertical"` when `orientation="vertical"`. + * It is omitted for `"horizontal"` since that is the implicit default for + * `role="group"`. + * + * Breaking change: replaces the 1st-gen `vertical` boolean attribute. + * + * @default horizontal + */ + @property({ type: String, reflect: true }) + public orientation: ActionGroupOrientation = 'horizontal'; + + /** + * Accessible label for the group. Reflected to `aria-label` on the host. + * + * Recommended whenever the strip has a distinct purpose. An empty value + * removes the `aria-label` attribute. + */ + @property({ type: String }) + public label = ''; + + /** + * Whether the group and all of its children are disabled. + * + * Sets `aria-disabled="true"` on the host and propagates the state to + * each managed child. Children remain keyboard-reachable so that screen + * reader users can still discover the group. + */ + @property({ type: Boolean, reflect: true }) + public disabled = false; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + this.setAttribute('role', 'group'); + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + + if (changed.has('label')) { + if (this.label) { + this.setAttribute('aria-label', this.label); + } else { + this.removeAttribute('aria-label'); + } + } + + if (changed.has('disabled')) { + if (this.disabled) { + this.setAttribute('aria-disabled', 'true'); + } else { + this.removeAttribute('aria-disabled'); + } + } + + if (changed.has('orientation')) { + if (this.orientation === 'vertical') { + this.setAttribute('aria-orientation', 'vertical'); + } else { + this.removeAttribute('aria-orientation'); + } + } + + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof ActionGroupBase; + if (!constructor.ORIENTATIONS.includes(this.orientation)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "orientation" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/action-group/', + { issues: [...constructor.ORIENTATIONS] } + ); + } + } + } +} diff --git a/2nd-gen/packages/core/components/action-group/ActionGroup.types.ts b/2nd-gen/packages/core/components/action-group/ActionGroup.types.ts new file mode 100644 index 00000000000..10d464d5b0c --- /dev/null +++ b/2nd-gen/packages/core/components/action-group/ActionGroup.types.ts @@ -0,0 +1,38 @@ +/** + * 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 { ElementSize } from '@spectrum-web-components/core/mixins/index.js'; + +// ────────────────── +// SHARED +// ────────────────── + +export const ACTION_GROUP_VALID_SIZES = [ + 'xs', + 's', + 'm', + 'l', + 'xl', +] as const satisfies readonly ElementSize[]; + +export const ACTION_GROUP_ORIENTATIONS = ['horizontal', 'vertical'] as const; + +export const ACTION_GROUP_STATIC_COLORS = ['white', 'black'] as const; + +// ────────────────── +// TYPES +// ────────────────── + +export type ActionGroupSize = (typeof ACTION_GROUP_VALID_SIZES)[number]; +export type ActionGroupOrientation = (typeof ACTION_GROUP_ORIENTATIONS)[number]; +export type ActionGroupStaticColor = + (typeof ACTION_GROUP_STATIC_COLORS)[number]; diff --git a/2nd-gen/packages/core/components/action-group/index.ts b/2nd-gen/packages/core/components/action-group/index.ts new file mode 100644 index 00000000000..ec084caee9f --- /dev/null +++ b/2nd-gen/packages/core/components/action-group/index.ts @@ -0,0 +1,13 @@ +/** + * 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 * from './ActionGroup.base.js'; +export * from './ActionGroup.types.js'; diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 0716f4f6b4f..00b93561374 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -19,6 +19,10 @@ "types": "./dist/components/action-button/index.d.ts", "import": "./dist/components/action-button/index.js" }, + "./components/action-group": { + "types": "./dist/components/action-group/index.d.ts", + "import": "./dist/components/action-group/index.js" + }, "./components/alert-banner": { "types": "./dist/components/alert-banner/index.d.ts", "import": "./dist/components/alert-banner/index.js" diff --git a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts new file mode 100644 index 00000000000..1bc4c01f327 --- /dev/null +++ b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts @@ -0,0 +1,43 @@ +/** + * 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 { CSSResultArray, html, TemplateResult } from 'lit'; + +import { ActionGroupBase } from '@spectrum-web-components/core/components/action-group'; + +import styles from './action-group.css'; + +/** + * An action group clusters related action buttons together with composite + * keyboard navigation: one Tab stop into the strip, arrow keys move among + * `swc-action-button` and `swc-action-menu` children. + * + * @element swc-action-group + * @since 2.0.0 + * + * @slot - One or more `swc-action-button` or `swc-action-menu` elements. + */ +export class ActionGroup extends ActionGroupBase { + // ────────────────────────────── + // RENDERING & STYLING + // ────────────────────────────── + + public static override get styles(): CSSResultArray { + return [styles]; + } + + protected override render(): TemplateResult { + return html` + + `; + } +} diff --git a/2nd-gen/packages/swc/components/action-group/action-group.css b/2nd-gen/packages/swc/components/action-group/action-group.css new file mode 100644 index 00000000000..1e9c95ebdbc --- /dev/null +++ b/2nd-gen/packages/swc/components/action-group/action-group.css @@ -0,0 +1,13 @@ +/** + * 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. + */ + +/* Styles migrated in Phase 5. */ diff --git a/2nd-gen/packages/swc/components/action-group/index.ts b/2nd-gen/packages/swc/components/action-group/index.ts new file mode 100644 index 00000000000..69e1103da9a --- /dev/null +++ b/2nd-gen/packages/swc/components/action-group/index.ts @@ -0,0 +1,12 @@ +/** + * 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 * from './ActionGroup.js'; diff --git a/2nd-gen/packages/swc/components/action-group/stories/action-group.stories.ts b/2nd-gen/packages/swc/components/action-group/stories/action-group.stories.ts new file mode 100644 index 00000000000..ae9c5cef65c --- /dev/null +++ b/2nd-gen/packages/swc/components/action-group/stories/action-group.stories.ts @@ -0,0 +1,137 @@ +/** + * 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 as Story } from '@storybook/web-components'; +import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; + +import { + ACTION_GROUP_ORIENTATIONS, + ACTION_GROUP_VALID_SIZES, +} from '@spectrum-web-components/core/components/action-group'; + +import '@adobe/spectrum-wc/components/action-group/swc-action-group.js'; +import '@adobe/spectrum-wc/components/action-button/swc-action-button.js'; + +// ──────────────── +// METADATA +// ──────────────── + +const { args, argTypes } = getStorybookHelpers('swc-action-group'); + +argTypes.size = { + ...argTypes.size, + control: { type: 'select' }, + options: [...ACTION_GROUP_VALID_SIZES], + table: { + category: 'attributes', + defaultValue: { summary: 'none' }, + }, +}; + +argTypes.orientation = { + ...argTypes.orientation, + control: { type: 'select' }, + options: [...ACTION_GROUP_ORIENTATIONS], + table: { + category: 'attributes', + defaultValue: { summary: 'horizontal' }, + }, +}; + +argTypes.disabled = { + ...argTypes.disabled, + table: { + category: 'attributes', + defaultValue: { summary: 'false' }, + }, +}; + +argTypes.compact = { + table: { + category: 'attributes', + defaultValue: { summary: 'false' }, + }, +}; + +argTypes.quiet = { + table: { + category: 'attributes', + defaultValue: { summary: 'false' }, + }, +}; + +argTypes.justified = { + table: { + category: 'attributes', + defaultValue: { summary: 'false' }, + }, +}; + +/** + * An action group clusters related action buttons together with composite + * keyboard navigation: one Tab stop into the strip, arrow keys move among + * `swc-action-button` and `swc-action-menu` children. + * + * Unlike [Button Group](../?path=/docs/button-group--overview), which lets + * Tab reach each button independently, action group owns composite navigation + * (one Tab stop; arrow keys move among items). + */ +const meta: Meta = { + title: 'Action Group', + component: 'swc-action-group', + args, + argTypes, + render: (renderArgs) => html` + + Bold + Italic + Underline + + `, + parameters: { + docs: { + subtitle: + 'Clusters related action buttons with composite keyboard navigation', + }, + flexLayout: 'row-wrap', + }, + tags: ['migrated'], +}; + +export default meta; + +// ──────────────────── +// PLAYGROUND STORY +// ──────────────────── + +export const Playground: Story = { + tags: ['autodocs', 'dev'], + args: { + orientation: 'horizontal', + disabled: false, + }, +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + tags: ['overview'], + args: { + orientation: 'horizontal', + }, +}; diff --git a/2nd-gen/packages/swc/components/action-group/swc-action-group.ts b/2nd-gen/packages/swc/components/action-group/swc-action-group.ts new file mode 100644 index 00000000000..b49fcb90a25 --- /dev/null +++ b/2nd-gen/packages/swc/components/action-group/swc-action-group.ts @@ -0,0 +1,22 @@ +/** + * 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 { defineElement } from '@spectrum-web-components/core/element/index.js'; + +import { ActionGroup } from './ActionGroup.js'; + +declare global { + interface HTMLElementTagNameMap { + 'swc-action-group': ActionGroup; + } +} + +defineElement('swc-action-group', ActionGroup); diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md index 183e68a4d36..4c4029ba06c 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md @@ -23,7 +23,7 @@ | Accordion | ✓ | | | | | | ✓ | | Action Bar | | | | | | | | | Action Button | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Action Group | ✓ | ✓ | | | | | | +| Action Group | ✓ | ✓ | ✓ | | | | | | Action Menu | | | | | | | | | Alert Banner | ✓ | ✓ | | | | | | | Alert Dialog | | | | | | | | diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md index 0332f6f59be..75db7ddd9cc 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md @@ -372,9 +372,9 @@ No `_lit-styles/` fragment needed — action-group renders only a slot; all layo ### Setup -- [ ] Create `2nd-gen/packages/core/components/action-group/` -- [ ] Create `2nd-gen/packages/swc/components/action-group/` -- [ ] Wire exports in both `package.json` files +- [x] Create `2nd-gen/packages/core/components/action-group/` +- [x] Create `2nd-gen/packages/swc/components/action-group/` +- [x] Wire exports in both `package.json` files - [ ] Check out `spectrum-css` at `spectrum-two` branch as sibling directory (required for Phase 5 styling) - [x] `FocusgroupNavigationController` confirmed available in this branch - [x] `swc-action-button` API confirmed: no `selected` property, no `aria-pressed`/`aria-checked` forwarding; compact uses CSS custom property cascade via `::slotted(:first-child)` / `::slotted(:last-child)` (action-button must expose `--swc-action-button-border-*-radius` fallbacks) From be92ef9370258247bdf17812c23776c304d0ca31 Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Tue, 23 Jun 2026 10:21:26 -0700 Subject: [PATCH 05/23] feat(actiongroup): set up api --- .../packages/action-group/src/ActionGroup.ts | 29 ++++ .../action-group/ActionGroup.base.ts | 92 ++++++++++-- .../components/action-group/ActionGroup.ts | 132 +++++++++++++++++- .../01_status.md | 2 +- .../action-group/migration-plan.md | 10 +- 5 files changed, 245 insertions(+), 20 deletions(-) diff --git a/1st-gen/packages/action-group/src/ActionGroup.ts b/1st-gen/packages/action-group/src/ActionGroup.ts index 8d2176de984..a1842738367 100644 --- a/1st-gen/packages/action-group/src/ActionGroup.ts +++ b/1st-gen/packages/action-group/src/ActionGroup.ts @@ -102,6 +102,10 @@ export class ActionGroup extends SizedMixin(SpectrumElement, { @property({ type: Boolean, reflect: true }) public compact = false; + /** + * @deprecated The `emphasized` property is deprecated and will be removed + * in a future release. + */ @property({ type: Boolean, reflect: true }) public emphasized = false; @@ -114,20 +118,45 @@ export class ActionGroup extends SizedMixin(SpectrumElement, { @property({ type: Boolean, reflect: true }) public quiet = false; + /** + * @deprecated The `selects` property is deprecated and will be removed in + * a future release. Selection UX is available via `swc-toggle-button-group` + * (toggle and multi-select) and `swc-segmented-control` (exclusive choice) + * in the 2nd-gen library. + */ @property({ type: String }) public selects: undefined | 'single' | 'multiple'; @property({ reflect: true, attribute: 'static-color' }) public staticColor?: 'white' | 'black'; + /** + * @deprecated The `vertical` attribute is deprecated and will be removed in + * a future release. Use `orientation="vertical"` on `swc-action-group` + * instead. + */ @property({ type: Boolean, reflect: true }) public vertical = false; private _selected: string[] = EMPTY_SELECTION; + /** + * @deprecated The `selected` property is deprecated and will be removed in + * a future release. Selection UX is available via `swc-toggle-button-group` + * (toggle and multi-select) and `swc-segmented-control` (exclusive choice) + * in the 2nd-gen library. + */ set selected(selected: string[]) { this.requestUpdate('selected', this._selected); this._selected = selected; + if (window.__swc?.DEBUG) { + window.__swc.warn( + this, + `The "selected" attribute on <${this.localName}> is deprecated and will be removed in a future release.`, + 'https://opensource.adobe.com/spectrum-web-components/components/action-group/', + { level: 'deprecation' } + ); + } this.updateComplete.then(() => { this.applySelects(); this.manageChildren(); diff --git a/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts b/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts index 2c6a341da63..8f2c186f4a2 100644 --- a/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts +++ b/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts @@ -11,13 +11,14 @@ */ import { PropertyValues } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, queryAssignedElements } from 'lit/decorators.js'; import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; import { ACTION_GROUP_ORIENTATIONS, + ACTION_GROUP_STATIC_COLORS, ACTION_GROUP_VALID_SIZES, type ActionGroupOrientation, type ActionGroupSize, @@ -25,7 +26,8 @@ import { /** * An action group clusters related action buttons together with composite - * keyboard navigation (one Tab stop; arrow keys move among items). + * keyboard navigation: one Tab stop into the strip, arrow keys move among + * `swc-action-button` and `swc-action-menu` children. * * This base class owns the accessibility semantics, `label` → `aria-label` * management, `disabled` state contract, and child collection logic. @@ -39,6 +41,11 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { validSizes: ACTION_GROUP_VALID_SIZES, noDefaultSize: true, }) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + /** * The size of the action group. Propagated to all slotted children. */ @@ -50,15 +57,25 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { /** * @internal + * + * Valid orientation values for validation. */ static readonly ORIENTATIONS: readonly string[] = ACTION_GROUP_ORIENTATIONS; + /** + * @internal + * + * Valid static-color values for validation. + */ + static readonly STATIC_COLORS: readonly string[] = ACTION_GROUP_STATIC_COLORS; + /** * The layout direction of the action group. * - * `aria-orientation` is set to `"vertical"` when `orientation="vertical"`. - * It is omitted for `"horizontal"` since that is the implicit default for - * `role="group"`. + * When set to `"vertical"`, the group stacks children vertically and + * `aria-orientation="vertical"` is applied to the host. When `"horizontal"` + * (the default), `aria-orientation` is omitted because horizontal is the + * implicit default for `role="group"`. * * Breaking change: replaces the 1st-gen `vertical` boolean attribute. * @@ -70,8 +87,9 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { /** * Accessible label for the group. Reflected to `aria-label` on the host. * - * Recommended whenever the strip has a distinct purpose. An empty value - * removes the `aria-label` attribute. + * Providing a label is recommended whenever the strip has a distinct + * purpose (e.g. "Text formatting" or "Alignment"). An empty value removes + * the `aria-label` attribute. */ @property({ type: String }) public label = ''; @@ -79,9 +97,12 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { /** * Whether the group and all of its children are disabled. * - * Sets `aria-disabled="true"` on the host and propagates the state to - * each managed child. Children remain keyboard-reachable so that screen - * reader users can still discover the group. + * Sets `aria-disabled="true"` on the host and propagates `aria-disabled` + * to each managed child. Children remain keyboard-reachable so that screen + * reader users can still discover the group — native `disabled` is not + * applied to children. + * + * New in 2nd-gen; not available in 1st-gen `sp-action-group`. */ @property({ type: Boolean, reflect: true }) public disabled = false; @@ -90,6 +111,44 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { // IMPLEMENTATION // ────────────────────── + /** + * @internal + * + * The managed children: all `swc-action-button` and `swc-action-menu` + * elements assigned to the default slot. Populated by the concrete + * subclass via `@queryAssignedElements`. + */ + @queryAssignedElements({ flatten: true }) + protected managedChildren!: HTMLElement[]; + + /** + * Focuses the first enabled child in the group. + * + * Phase 4 replaces this stub with `FocusgroupNavigationController.focus()` + * so that focus restores to the last active item. + */ + public override focus(options?: FocusOptions): void { + const firstEnabled = this.managedChildren?.find( + (el) => + !el.hasAttribute('disabled') && + el.getAttribute('aria-disabled') !== 'true' + ); + firstEnabled?.focus(options); + } + + /** + * @internal + * + * Handles slot-change events. Propagates `aria-disabled` to newly slotted + * children when the group is disabled. Concrete subclasses call + * `super.handleSlotchange()` and then propagate visual attributes. + */ + protected handleSlotchange(): void { + if (this.disabled) { + this.propagateDisabledToChildren(); + } + } + protected override firstUpdated(changed: PropertyValues): void { super.firstUpdated(changed); this.setAttribute('role', 'group'); @@ -112,6 +171,7 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { } else { this.removeAttribute('aria-disabled'); } + this.propagateDisabledToChildren(); } if (changed.has('orientation')) { @@ -127,11 +187,21 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { if (!constructor.ORIENTATIONS.includes(this.orientation)) { window.__swc.warn( this, - `<${this.localName}> element expects the "orientation" attribute to be one of the following:`, + `<${this.localName}> element expects the "orientation" attribute to be one of the following: ${constructor.ORIENTATIONS.join(', ')}.`, 'https://opensource.adobe.com/spectrum-web-components/components/action-group/', { issues: [...constructor.ORIENTATIONS] } ); } } } + + private propagateDisabledToChildren(): void { + for (const child of this.managedChildren ?? []) { + if (this.disabled) { + child.setAttribute('aria-disabled', 'true'); + } else { + child.removeAttribute('aria-disabled'); + } + } + } } diff --git a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts index 1bc4c01f327..6506e25824a 100644 --- a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts +++ b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts @@ -10,9 +10,13 @@ * governing permissions and limitations under the License. */ -import { CSSResultArray, html, TemplateResult } from 'lit'; +import { CSSResultArray, html, PropertyValues, TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; -import { ActionGroupBase } from '@spectrum-web-components/core/components/action-group'; +import { + ActionGroupBase, + type ActionGroupStaticColor, +} from '@spectrum-web-components/core/components/action-group'; import styles from './action-group.css'; @@ -21,12 +25,68 @@ import styles from './action-group.css'; * keyboard navigation: one Tab stop into the strip, arrow keys move among * `swc-action-button` and `swc-action-menu` children. * + * Unlike `swc-button-group`, which lets Tab reach each button independently, + * action group owns composite navigation (one Tab stop; arrow keys move + * among items). + * * @element swc-action-group * @since 2.0.0 * * @slot - One or more `swc-action-button` or `swc-action-menu` elements. + * + * @example + * + * Bold + * Italic + * + * + * @example + * + * Left + * Center + * */ export class ActionGroup extends ActionGroupBase { + // ─────────────────── + // API ADDITIONS + // ─────────────────── + + /** + * Whether the group uses compact density. Buttons visually join by + * collapsing shared borders and resetting interior border-radius values. + * + * Has no visual effect when `quiet` is also set. + */ + @property({ type: Boolean, reflect: true }) + public compact = false; + + /** + * Whether the group and its children use the quiet visual style. + * + * Propagated to all slotted `swc-action-button` and `swc-action-menu` + * children. Also disables the compact border-join styling when both + * `compact` and `quiet` are set. + */ + @property({ type: Boolean, reflect: true }) + public quiet = false; + + /** + * Whether slotted children should expand equally to fill the available + * inline width of the group. + */ + @property({ type: Boolean, reflect: true }) + public justified = false; + + /** + * The static color variant to apply when the group is placed over an image + * or colored background. Propagated to all slotted children. + * + * - `"white"`: use on dark or colored backgrounds. + * - `"black"`: use on light backgrounds. + */ + @property({ type: String, reflect: true, attribute: 'static-color' }) + public staticColor?: ActionGroupStaticColor; + // ────────────────────────────── // RENDERING & STYLING // ────────────────────────────── @@ -37,7 +97,73 @@ export class ActionGroup extends ActionGroupBase { protected override render(): TemplateResult { return html` - + `; } + + protected override handleSlotchange(): void { + super.handleSlotchange(); + this.propagateVisualStateToChildren(); + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + + if ( + changed.has('quiet') || + changed.has('size') || + changed.has('staticColor') || + // `compact` is pre-wired here so children re-render when the attribute + // changes, but the attribute is not propagated via JS — it affects layout + // via CSS cascade on ::slotted() selectors (wired in Phase 5). + changed.has('compact') + ) { + this.propagateVisualStateToChildren(); + } + + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof ActionGroup; + if ( + this.staticColor !== undefined && + !constructor.STATIC_COLORS.includes(this.staticColor) + ) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "static-color" attribute to be one of the following: ${constructor.STATIC_COLORS.join(', ')}.`, + 'https://opensource.adobe.com/spectrum-web-components/components/action-group/', + { issues: [...constructor.STATIC_COLORS] } + ); + } + } + } + + /** + * @internal + * + * Propagates visual attributes (`quiet`, `size`, `staticColor`) to slotted + * children. Called on slot change and when any of these properties update. + * + * Note: `compact` affects children via CSS custom property cascade + * (`::slotted(:first-child)` / `::slotted(:last-child)` in Phase 5) rather + * than JS attribute propagation. + */ + private propagateVisualStateToChildren(): void { + for (const child of this.managedChildren ?? []) { + if (this.quiet) { + child.setAttribute('quiet', ''); + } else { + child.removeAttribute('quiet'); + } + + if (this.size) { + child.setAttribute('size', this.size); + } + + if (this.staticColor !== undefined) { + child.setAttribute('static-color', this.staticColor); + } else { + child.removeAttribute('static-color'); + } + } + } } diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md index 4c4029ba06c..48d66a26515 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md @@ -23,7 +23,7 @@ | Accordion | ✓ | | | | | | ✓ | | Action Bar | | | | | | | | | Action Button | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Action Group | ✓ | ✓ | ✓ | | | | | +| Action Group | ✓ | ✓ | ✓ | ✓ | | | | | Action Menu | | | | | | | | | Alert Banner | ✓ | ✓ | | | | | | | Alert Dialog | | | | | | | | diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md index 75db7ddd9cc..714e1208969 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md @@ -383,15 +383,15 @@ No `_lit-styles/` fragment needed — action-group renders only a slot; all layo #### Naming and public surface -- [ ] `ActionGroup.types.ts`: define `ActionGroupOrientation` (`'horizontal' | 'vertical'`) -- [ ] `ActionGroupSize` type: `(typeof ACTION_GROUP_VALID_SIZES)[number]` — same pattern as `BadgeSize`, `ButtonSize`, `StatusLightSize` -- [ ] `ActionGroup.base.ts`: `label` → `aria-label`, `disabled` propagation contract, child collection logic -- [ ] `ActionGroup.ts`: `compact`, `quiet`, `orientation`, `justified`, `size`, `staticColor`, child propagation, `FocusgroupNavigationController` wiring, `delegatesFocus: true` +- [x] `ActionGroup.types.ts`: define `ActionGroupOrientation` (`'horizontal' | 'vertical'`) +- [x] `ActionGroupSize` type: `(typeof ACTION_GROUP_VALID_SIZES)[number]` — same pattern as `BadgeSize`, `ButtonSize`, `StatusLightSize` +- [x] `ActionGroup.base.ts`: `label` → `aria-label`, `disabled` propagation contract, child collection logic +- [x] `ActionGroup.ts`: `compact`, `quiet`, `orientation`, `justified`, `size`, `staticColor`, child propagation, `delegatesFocus: true`; `FocusgroupNavigationController` wiring deferred to Phase 4 - [ ] Drop `--mod-*` CSS custom properties; introduce `--swc-*` set after Phase 5 review #### 1st-gen deprecation notices -- [ ] `@deprecated` JSDoc on `vertical` property in `sp-action-group`; runtime warn in existing `vertical` setter via `window.__swc.warn()` +- [x] `@deprecated` JSDoc on `vertical`, `selects`, `selected`, `emphasized` in `sp-action-group`; `window.__swc.warn()` added to `selected` setter (existing setter); no runtime warn on `vertical`, `selects`, `emphasized` as no setter existed to add it without refactoring #### Alignment checks From 62f09413d8f8c7ab29b93774f67d8ce4785c53fd Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Thu, 25 Jun 2026 16:21:48 -0700 Subject: [PATCH 06/23] feat(actiongroup): a11y implementation --- .../action-group/ActionGroup.base.ts | 6 +- .../components/action-group/ActionGroup.ts | 83 +++++++++++++++++++ .../action-group/migration-plan.md | 33 ++++---- 3 files changed, 103 insertions(+), 19 deletions(-) diff --git a/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts b/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts index 8f2c186f4a2..505e4cd7a55 100644 --- a/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts +++ b/2nd-gen/packages/core/components/action-group/ActionGroup.base.ts @@ -43,7 +43,6 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { }) { static override shadowRootOptions = { ...SpectrumElement.shadowRootOptions, - delegatesFocus: true, }; /** @@ -124,8 +123,9 @@ export abstract class ActionGroupBase extends SizedMixin(SpectrumElement, { /** * Focuses the first enabled child in the group. * - * Phase 4 replaces this stub with `FocusgroupNavigationController.focus()` - * so that focus restores to the last active item. + * The SWC concrete class overrides this with `FocusgroupNavigationController`, + * restoring focus to the last active item when memory is enabled. This + * implementation serves as a fallback for non-SWC subclasses. */ public override focus(options?: FocusOptions): void { const firstEnabled = this.managedChildren?.find( diff --git a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts index 6506e25824a..a4137d74abb 100644 --- a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts +++ b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts @@ -17,6 +17,7 @@ import { ActionGroupBase, type ActionGroupStaticColor, } from '@spectrum-web-components/core/components/action-group'; +import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers'; import styles from './action-group.css'; @@ -87,14 +88,82 @@ export class ActionGroup extends ActionGroupBase { @property({ type: String, reflect: true, attribute: 'static-color' }) public staticColor?: ActionGroupStaticColor; + // ──────────────────── + // API OVERRIDES + // ──────────────────── + + /** + * Focuses the roving tab stop within the group. When the + * `FocusgroupNavigationController` has memory, restores focus to the last + * active item; otherwise falls back to the first managed child. + */ + public override focus(options?: FocusOptions): void { + const target = + this.navigation.getActiveItem() ?? + this.managedChildren?.find( + (el) => + !el.hasAttribute('disabled') && + el.getAttribute('aria-disabled') !== 'true' + ); + target?.focus(options); + } + // ────────────────────────────── // RENDERING & STYLING // ────────────────────────────── + /** + * @internal + * + * Composite keyboard navigation controller. Manages roving `tabindex` among + * slotted `swc-action-button` and `swc-action-menu` children. Direction is + * initialized to `horizontal` and updated via `setOptions` whenever + * `orientation` changes. + * + * `skipDisabled: true` excludes both natively `disabled` and + * `aria-disabled="true"` children from arrow navigation and the initial tab + * stop. Arrow keys step over disabled buttons intuitively. Group-level + * `disabled` propagates `aria-disabled` to all children uniformly, so the + * per-item skip does not conflict with the group disable pattern. + * + * `wrap: true` matches the APG Toolbar example: arrow keys wrap from the + * last item to the first and vice versa. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: this.orientation, + wrap: true, + skipDisabled: true, + getItems: () => this.managedChildren ?? [], + }); + + /** + * @internal + * + * Watches for `disabled` and `aria-disabled` attribute changes on individual + * managed children. `slotchange` does not fire when an existing child's + * disabled state changes, so without this observer the navigation controller + * would hold a stale tab-stop assignment (e.g. btn1 retaining tabindex=0 + * after becoming disabled) and Tab could no longer enter the group. + */ + private childObserver?: MutationObserver; + public static override get styles(): CSSResultArray { return [styles]; } + public override connectedCallback(): void { + super.connectedCallback(); + this.childObserver = new MutationObserver(() => { + this.navigation.refresh(); + }); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.childObserver?.disconnect(); + this.childObserver = undefined; + } + protected override render(): TemplateResult { return html` @@ -104,11 +173,25 @@ export class ActionGroup extends ActionGroupBase { protected override handleSlotchange(): void { super.handleSlotchange(); this.propagateVisualStateToChildren(); + + this.childObserver?.disconnect(); + for (const child of this.managedChildren ?? []) { + this.childObserver?.observe(child, { + attributes: true, + attributeFilter: ['disabled', 'aria-disabled'], + }); + } + + this.navigation.refresh(); } protected override updated(changed: PropertyValues): void { super.updated(changed); + if (changed.has('orientation')) { + this.navigation.setOptions({ direction: this.orientation }); + } + if ( changed.has('quiet') || changed.has('size') || diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md index 714e1208969..9cac6ae2487 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/action-group/migration-plan.md @@ -213,7 +213,7 @@ This full modifier surface will not be carried forward to 2nd-gen. | --- | ------------ | ---------------- | ---------------- | ----------------------- | | **B6** | Host role fixed to `role="group"` | Default `role="toolbar"`; switches to `role="radiogroup"` when `selects="single"` | Always `role="group"`; not author-overridable | Remove any `role="toolbar"` or `role="radiogroup"` on ``. Move `role="toolbar"` to a parent wrapper element. Source: [accessibility migration analysis](./accessibility-migration-analysis.md). | | **B7** | Child roles fixed to `role="button"` | `selects="single"` assigns `role="radio"` + `aria-checked`; `selects="multiple"` assigns `role="checkbox"` + `aria-checked` | Children always `role="button"`; `selects` and `selected` are dropped; selection UX moves to `swc-toggle-button-group` / `swc-segmented-control` | Remove any consumer code that relied on child `role="radio"`/`role="checkbox"` or on action-group's `selects`/`selected` API. Source: [accessibility migration analysis](./accessibility-migration-analysis.md), [action button accessibility migration analysis](../action-button/accessibility-migration-analysis.md). | -| **B8** | `RovingTabindexController` → `FocusgroupNavigationController` | `RovingTabindexController` with `hostDelegatesFocus: true` | `FocusgroupNavigationController`; direction tied to `orientation`; `delegatesFocus` preserved | No consumer-visible change; keyboard behavior is equivalent | +| **B8** | `RovingTabindexController` → `FocusgroupNavigationController` | `RovingTabindexController` with `hostDelegatesFocus: true` | `FocusgroupNavigationController`; direction tied to `orientation`; `delegatesFocus: true` removed (shadow host with only a `` claims Tab focus when delegation finds no shadow focusable target, blocking Tab entry) | No consumer-visible change; keyboard behavior is equivalent | | **B9** | Focus stacking z-index fixed | Focused button's z-index may hide focus indicator (SWC-1342) | Focus indicator always visible | No consumer action required | ### Additive — ships when ready, zero breakage for consumers already on 2nd-gen @@ -300,7 +300,7 @@ Initial focus target: first enabled child. When a child `swc-action-menu` is open, focus enters the menu; when the menu closes, focus returns to the menu trigger (the `swc-action-menu` host). -`delegatesFocus: true` on the shadow root is preserved: calling `focus()` on the host delegates to the current roving item. +`delegatesFocus: true` was removed in Phase 4. Per the WHATWG spec, a shadow host with `delegatesFocus: true` is itself placed in the sequential Tab order even without a `tabindex` attribute. When the action group has only a `` in its shadow DOM and no focusable shadow children, focus delegation fails silently and the host claims focus — causing Tab to skip all slotted children. Programmatic `.focus()` on the host is handled by the explicit override in `ActionGroup.ts` (delegates to `FocusgroupNavigationController.getActiveItem()`), so `delegatesFocus` is not needed. Mouse click updates the roving `tabindex="0"` to the clicked item (SWC-250 fix required; the 1st-gen mouse test is `it.skip`). @@ -386,7 +386,7 @@ No `_lit-styles/` fragment needed — action-group renders only a slot; all layo - [x] `ActionGroup.types.ts`: define `ActionGroupOrientation` (`'horizontal' | 'vertical'`) - [x] `ActionGroupSize` type: `(typeof ACTION_GROUP_VALID_SIZES)[number]` — same pattern as `BadgeSize`, `ButtonSize`, `StatusLightSize` - [x] `ActionGroup.base.ts`: `label` → `aria-label`, `disabled` propagation contract, child collection logic -- [x] `ActionGroup.ts`: `compact`, `quiet`, `orientation`, `justified`, `size`, `staticColor`, child propagation, `delegatesFocus: true`; `FocusgroupNavigationController` wiring deferred to Phase 4 +- [x] `ActionGroup.ts`: `compact`, `quiet`, `orientation`, `justified`, `size`, `staticColor`, child propagation; `FocusgroupNavigationController` wiring deferred to Phase 4; `delegatesFocus: true` removed in Phase 4 (see B8) - [ ] Drop `--mod-*` CSS custom properties; introduce `--swc-*` set after Phase 5 review #### 1st-gen deprecation notices @@ -426,22 +426,23 @@ No `_lit-styles/` fragment needed — action-group renders only a slot; all layo #### Naming and semantics -- [ ] Host always `role="group"`; never author-overridable; no `toolbar`, `radiogroup`, or other role -- [ ] `aria-orientation` reflects `orientation` value; wired to `FocusgroupNavigationController` direction -- [ ] `label` → `aria-label` on host; empty `label` removes `aria-label` -- [ ] `swc-action-button` children stay `role="button"` only; no `role="radio"` or `role="checkbox"` assigned by action-group -- [ ] Group `disabled`: host `aria-disabled="true"`, children `aria-disabled="true"`; children remain keyboard-reachable (SWC-621) +- [x] Host always `role="group"`; never author-overridable; no `toolbar`, `radiogroup`, or other role +- [x] `aria-orientation` reflects `orientation` value; wired to `FocusgroupNavigationController` direction +- [x] `label` → `aria-label` on host; empty `label` removes `aria-label` +- [x] `swc-action-button` children stay `role="button"` only; no `role="radio"` or `role="checkbox"` assigned by action-group +- [x] Group `disabled`: host `aria-disabled="true"`, children `aria-disabled="true"`; children remain keyboard-reachable (SWC-621) #### State verification -- [ ] Roving tabindex: exactly one child has `tabindex="0"`, others `tabindex="-1"` -- [ ] Initial focus target is first selected enabled child, or first enabled child -- [ ] Mouse click updates `tabindex="0"` to clicked item (SWC-250 fix; port `it.skip` test) -- [ ] Focus ring visible on focused child; no z-index stacking hides indicator (SWC-1342 fix) -- [ ] `change` fires after `selected` state is committed (SWC-889 fix) -- [ ] `selected` removal does not throw console error (SWC-282 fix) -- [ ] `swc-action-menu` in group: open menu; arrow inside; Escape returns focus to menu trigger; roving continues from trigger -- [ ] `FormFieldMixin` not applied (SWC-1612 not applied) +- [x] Roving tabindex: exactly one child has `tabindex="0"`, others `tabindex="-1"` +- [x] Initial focus target is first enabled child (no selection concept in 2nd-gen) +- [x] Mouse click updates `tabindex="0"` to clicked item (SWC-250 fix; port `it.skip` test) +- [ ] Focus ring visible on focused child; no z-index stacking hides indicator (SWC-1342 fix) — Phase 5 +- [skip] `change` fires after `selected` state is committed (SWC-889 fix) — `selects`/`selected` dropped +- [skip] `selected` removal does not throw console error (SWC-282 fix) — `selected` dropped +- [ ] `swc-action-menu` in group: open menu; arrow inside; Escape returns focus to menu trigger; roving continues from trigger — Phase 6 +- [x] `skipDisabled: true` — individually disabled children are skipped in arrow navigation; group-level `disabled` propagates `aria-disabled` uniformly so per-item skip does not conflict +- [x] `FormFieldMixin` not applied (SWC-1612 not applied) ### Testing From cd204d4abcc96939c3ae3848cf50bf13ea90f271 Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Mon, 29 Jun 2026 15:17:12 -0700 Subject: [PATCH 07/23] fix: keep aria-disabled discoverable --- .../components/action-group/ActionGroup.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts index a4137d74abb..2ced404a57b 100644 --- a/2nd-gen/packages/swc/components/action-group/ActionGroup.ts +++ b/2nd-gen/packages/swc/components/action-group/ActionGroup.ts @@ -100,11 +100,7 @@ export class ActionGroup extends ActionGroupBase { public override focus(options?: FocusOptions): void { const target = this.navigation.getActiveItem() ?? - this.managedChildren?.find( - (el) => - !el.hasAttribute('disabled') && - el.getAttribute('aria-disabled') !== 'true' - ); + this.managedChildren?.find((el) => !el.hasAttribute('disabled')); target?.focus(options); } @@ -120,11 +116,12 @@ export class ActionGroup extends ActionGroupBase { * initialized to `horizontal` and updated via `setOptions` whenever * `orientation` changes. * - * `skipDisabled: true` excludes both natively `disabled` and - * `aria-disabled="true"` children from arrow navigation and the initial tab - * stop. Arrow keys step over disabled buttons intuitively. Group-level - * `disabled` propagates `aria-disabled` to all children uniformly, so the - * per-item skip does not conflict with the group disable pattern. + * `skipDisabled: false` keeps all children — including those with + * `aria-disabled="true"` — in the roving tabindex sequence. This is + * intentional: per APG toolbar guidance, disabled items should remain + * keyboard-discoverable. Group-level `disabled` propagates `aria-disabled` + * to all children; with skip enabled those children would all be excluded + * and Tab could no longer enter the group. * * `wrap: true` matches the APG Toolbar example: arrow keys wrap from the * last item to the first and vice versa. @@ -132,7 +129,7 @@ export class ActionGroup extends ActionGroupBase { private readonly navigation = new FocusgroupNavigationController(this, { direction: this.orientation, wrap: true, - skipDisabled: true, + skipDisabled: false, getItems: () => this.managedChildren ?? [], }); From 23fa7f28cf3bfe78d0aede16602d913a287d6843 Mon Sep 17 00:00:00 2001 From: Rise Erpelding Date: Mon, 29 Jun 2026 15:54:39 -0700 Subject: [PATCH 08/23] fix(actionbutton): apply aria-disabled to children of disabled action-group --- .../components/action-button/ActionButton.ts | 18 ++++++++++++++-- .../action-button/action-button.css | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/2nd-gen/packages/swc/components/action-button/ActionButton.ts b/2nd-gen/packages/swc/components/action-button/ActionButton.ts index 4d0935b7237..7b8500e1f5f 100644 --- a/2nd-gen/packages/swc/components/action-button/ActionButton.ts +++ b/2nd-gen/packages/swc/components/action-button/ActionButton.ts @@ -85,7 +85,12 @@ export class ActionButton extends ButtonBase { // conflicting with ARIAMixin types on HTMLElement and appearing in the CEM. /** @internal */ static override get observedAttributes(): string[] { - return [...super.observedAttributes, 'aria-haspopup', 'aria-expanded']; + return [ + ...super.observedAttributes, + 'aria-haspopup', + 'aria-expanded', + 'aria-disabled', + ]; } /** @@ -121,6 +126,12 @@ export class ActionButton extends ButtonBase { } return; } + if (name === 'aria-disabled') { + // Kept on the host (not stripped) — the host attribute is the CSS hook + // for disabled appearance via :host([aria-disabled="true"]). The value + // is also stored as reactive state so the inner