From 7109c6d31ab9661b3aef854a1ad4e2d8e8ddb13a Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Mon, 25 May 2026 15:57:25 +0530 Subject: [PATCH] feat(button-group): 2nd-gen component with API, a11y, storybook, and tests Implements the full 2nd-gen button-group component: - Core: ButtonGroupBase class with orientation, align, disabled, size propagation, role="group", and dev-mode validation warnings - SWC: Concrete ButtonGroup with CSS (S2 tokens), classMap render - Stories: Full Storybook documentation with all sections, Figma link, args table, upcoming features, and accessibility guidance - Tests: Play-function unit tests (5 sections) and Playwright a11y tests (ARIA snapshots, keyboard interactions, aXe validation) - Marks SizedMixin._size as @internal Co-authored-by: Cursor --- .../button-group/ButtonGroup.base.ts | 149 ++++++ .../button-group/ButtonGroup.types.ts | 43 ++ .../core/components/button-group/index.ts | 13 + 2nd-gen/packages/core/mixins/sized-mixin.ts | 1 + 2nd-gen/packages/core/package.json | 7 + .../components/button-group/ButtonGroup.ts | 58 +++ .../components/button-group/button-group.css | 50 ++ .../swc/components/button-group/index.ts | 12 + .../stories/button-group.stories.ts | 435 ++++++++++++++++ .../button-group/swc-button-group.ts | 22 + .../test/button-group.a11y.spec.ts | 193 +++++++ .../button-group/test/button-group.test.ts | 489 ++++++++++++++++++ 12 files changed, 1472 insertions(+) create mode 100644 2nd-gen/packages/core/components/button-group/ButtonGroup.base.ts create mode 100644 2nd-gen/packages/core/components/button-group/ButtonGroup.types.ts create mode 100644 2nd-gen/packages/core/components/button-group/index.ts create mode 100644 2nd-gen/packages/swc/components/button-group/ButtonGroup.ts create mode 100644 2nd-gen/packages/swc/components/button-group/button-group.css create mode 100644 2nd-gen/packages/swc/components/button-group/index.ts create mode 100644 2nd-gen/packages/swc/components/button-group/stories/button-group.stories.ts create mode 100644 2nd-gen/packages/swc/components/button-group/swc-button-group.ts create mode 100644 2nd-gen/packages/swc/components/button-group/test/button-group.a11y.spec.ts create mode 100644 2nd-gen/packages/swc/components/button-group/test/button-group.test.ts diff --git a/2nd-gen/packages/core/components/button-group/ButtonGroup.base.ts b/2nd-gen/packages/core/components/button-group/ButtonGroup.base.ts new file mode 100644 index 00000000000..cfdc1353a4f --- /dev/null +++ b/2nd-gen/packages/core/components/button-group/ButtonGroup.base.ts @@ -0,0 +1,149 @@ +/** + * 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 { + BUTTON_GROUP_ALIGNMENTS, + BUTTON_GROUP_ORIENTATIONS, + BUTTON_GROUP_SIZES, + type ButtonGroupAlignment, + type ButtonGroupOrientation, + type ButtonGroupSize, +} from './ButtonGroup.types.js'; + +/** + * A button group clusters related actions together, providing consistent + * spacing, sizing, and orientation. The host exposes `role="group"` and + * propagates `size` and `disabled` to slotted button children. + * + * This base class owns shared logic and accessibility semantics. Rendering + * and styling live in the concrete SWC subclass. + * + * @slot - One or more `swc-button` elements. + * + * @attribute {ElementSize} size - The size of the button group and its children. + */ +export abstract class ButtonGroupBase extends SizedMixin(SpectrumElement, { + validSizes: BUTTON_GROUP_SIZES, +}) { + /** + * The size of the button group. Propagated to all slotted button children. + * + * @default m + */ + declare public size: ButtonGroupSize; + + // ────────────────── + // SHARED API + // ────────────────── + + /** + * @internal + * + * Valid orientation values for validation. + */ + static readonly ORIENTATIONS: readonly string[] = BUTTON_GROUP_ORIENTATIONS; + + /** + * @internal + * + * Valid alignment values for validation. + */ + static readonly ALIGNMENTS: readonly string[] = BUTTON_GROUP_ALIGNMENTS; + + /** + * The layout direction of the button group. + */ + @property({ type: String, reflect: true }) + public orientation: ButtonGroupOrientation = 'horizontal'; + + /** + * Whether all buttons in the group are disabled. When set, propagates + * the disabled state to each slotted button child. + */ + @property({ type: Boolean, reflect: true }) + public disabled = false; + + /** + * The alignment of buttons within the group along the main axis. + */ + @property({ type: String, reflect: true }) + public align: ButtonGroupAlignment = 'start'; + + // ────────────────────── + // IMPLEMENTATION + // ────────────────────── + + protected override firstUpdated(changed: PropertyValues): void { + super.firstUpdated(changed); + this.setAttribute('role', 'group'); + } + + protected override update(changedProperties: PropertyValues): void { + if (window.__swc?.DEBUG) { + const constructor = this.constructor as typeof ButtonGroupBase; + 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/button-group/', + { issues: [...constructor.ORIENTATIONS] } + ); + } + if (!constructor.ALIGNMENTS.includes(this.align)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "align" attribute to be one of the following:`, + 'https://opensource.adobe.com/spectrum-web-components/components/button-group/', + { issues: [...constructor.ALIGNMENTS] } + ); + } + } + super.update(changedProperties); + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + + if (changed.has('size') || changed.has('disabled')) { + this.propagateToChildren(); + } + } + + /** + * Handles slotchange events from the default slot. Ensures newly slotted + * children receive the current size and disabled state. + */ + protected handleSlotchange(): void { + this.propagateToChildren(); + } + + private propagateToChildren(): void { + const slot = this.renderRoot?.querySelector('slot'); + if (!slot) {return;} + + const buttons = slot.assignedElements() as HTMLElement[]; + for (const button of buttons) { + if ('size' in button) { + (button as HTMLElement & { size: string }).size = this.size; + } + if ('disabled' in button) { + (button as HTMLElement & { disabled: boolean }).disabled = this.disabled; + } + } + } +} diff --git a/2nd-gen/packages/core/components/button-group/ButtonGroup.types.ts b/2nd-gen/packages/core/components/button-group/ButtonGroup.types.ts new file mode 100644 index 00000000000..a74de282670 --- /dev/null +++ b/2nd-gen/packages/core/components/button-group/ButtonGroup.types.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 type { ElementSize } from '@spectrum-web-components/core/mixins/index.js'; + +// ────────────────── +// SHARED +// ────────────────── + +export const BUTTON_GROUP_SIZES = [ + 's', + 'm', + 'l', + 'xl', +] as const satisfies readonly ElementSize[]; + +export const BUTTON_GROUP_ORIENTATIONS = [ + 'horizontal', + 'vertical', +] as const; + +export const BUTTON_GROUP_ALIGNMENTS = [ + 'start', + 'center', + 'end', +] as const; + +// ────────────────── +// TYPES +// ────────────────── + +export type ButtonGroupSize = (typeof BUTTON_GROUP_SIZES)[number]; +export type ButtonGroupOrientation = (typeof BUTTON_GROUP_ORIENTATIONS)[number]; +export type ButtonGroupAlignment = (typeof BUTTON_GROUP_ALIGNMENTS)[number]; diff --git a/2nd-gen/packages/core/components/button-group/index.ts b/2nd-gen/packages/core/components/button-group/index.ts new file mode 100644 index 00000000000..e9c24fbc94d --- /dev/null +++ b/2nd-gen/packages/core/components/button-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 './ButtonGroup.base.js'; +export * from './ButtonGroup.types.js'; diff --git a/2nd-gen/packages/core/mixins/sized-mixin.ts b/2nd-gen/packages/core/mixins/sized-mixin.ts index 269e4f10473..10f5f9a6135 100644 --- a/2nd-gen/packages/core/mixins/sized-mixin.ts +++ b/2nd-gen/packages/core/mixins/sized-mixin.ts @@ -77,6 +77,7 @@ export function SizedMixin>( this.requestUpdate('size', oldSize); } + /** @internal */ private _size: ElementSize | null = defaultSize; protected override update(changes: PropertyValues): void { diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 86b4009e996..b688772c1d8 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -35,6 +35,10 @@ "types": "./dist/components/button/index.d.ts", "import": "./dist/components/button/index.js" }, + "./components/button-group": { + "types": "./dist/components/button-group/index.d.ts", + "import": "./dist/components/button-group/index.js" + }, "./components/divider": { "types": "./dist/components/divider/index.d.ts", "import": "./dist/components/divider/index.js" @@ -160,6 +164,9 @@ "components/button": [ "dist/components/button/index.d.ts" ], + "components/button-group": [ + "dist/components/button-group/index.d.ts" + ], "components/divider": [ "dist/components/divider/index.d.ts" ], diff --git a/2nd-gen/packages/swc/components/button-group/ButtonGroup.ts b/2nd-gen/packages/swc/components/button-group/ButtonGroup.ts new file mode 100644 index 00000000000..f5f8b12f681 --- /dev/null +++ b/2nd-gen/packages/swc/components/button-group/ButtonGroup.ts @@ -0,0 +1,58 @@ +/** + * 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 { classMap } from 'lit/directives/class-map.js'; + +import { ButtonGroupBase } from '@spectrum-web-components/core/components/button-group'; +import { capitalize } from '@spectrum-web-components/core/utils/index.js'; + +import styles from './button-group.css'; + +/** + * A button group clusters related actions together, providing consistent + * spacing, sizing, and orientation. + * + * @element swc-button-group + * @since 2.0.0 + * + * @slot - One or more `swc-button` elements. + * + * @cssprop --swc-button-group-gap - Space between buttons in the group. + * @cssprop --swc-button-group-justify-content - Alignment of buttons within the group along the main axis. + */ +export class ButtonGroup extends ButtonGroupBase { + // ────────────────────────────── + // RENDERING & STYLING + // ────────────────────────────── + + public static override get styles(): CSSResultArray { + return [styles]; + } + + protected override render(): TemplateResult { + return html` +
+ +
+ `; + } +} diff --git a/2nd-gen/packages/swc/components/button-group/button-group.css b/2nd-gen/packages/swc/components/button-group/button-group.css new file mode 100644 index 00000000000..fb8d3aff913 --- /dev/null +++ b/2nd-gen/packages/swc/components/button-group/button-group.css @@ -0,0 +1,50 @@ +/** + * 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. + */ + +:host { + display: flex; +} + +* { + box-sizing: border-box; +} + +.swc-ButtonGroup { + --_swc-button-group-gap: var(--swc-button-group-gap, token("spacing-300")); + + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--_swc-button-group-gap); + justify-content: var(--swc-button-group-justify-content, normal); +} + +:host([size="s"]) { + --swc-button-group-gap: token("spacing-200"); +} + +.swc-ButtonGroup--vertical { + display: inline-flex; + flex-direction: column; +} + +.swc-ButtonGroup--alignCenter { + justify-content: center; +} + +.swc-ButtonGroup--alignEnd { + justify-content: flex-end; +} + +::slotted(*) { + flex-shrink: 0; +} diff --git a/2nd-gen/packages/swc/components/button-group/index.ts b/2nd-gen/packages/swc/components/button-group/index.ts new file mode 100644 index 00000000000..3054bdae615 --- /dev/null +++ b/2nd-gen/packages/swc/components/button-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 './ButtonGroup.js'; diff --git a/2nd-gen/packages/swc/components/button-group/stories/button-group.stories.ts b/2nd-gen/packages/swc/components/button-group/stories/button-group.stories.ts new file mode 100644 index 00000000000..97503b17993 --- /dev/null +++ b/2nd-gen/packages/swc/components/button-group/stories/button-group.stories.ts @@ -0,0 +1,435 @@ +/** + * 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 '@adobe/spectrum-wc/components/button-group/swc-button-group.js'; +import '@adobe/spectrum-wc/components/button/swc-button.js'; + +import { + BUTTON_GROUP_ALIGNMENTS, + BUTTON_GROUP_ORIENTATIONS, + BUTTON_GROUP_SIZES, +} from '../../../../core/components/button-group/ButtonGroup.types.js'; + +// ──────────────── +// METADATA +// ──────────────── + +const { args, argTypes } = getStorybookHelpers('swc-button-group'); + +argTypes.size = { + ...argTypes.size, + control: { type: 'select' }, + options: [...BUTTON_GROUP_SIZES], + table: { + category: 'attributes', + defaultValue: { summary: 'm' }, + }, +}; + +argTypes.orientation = { + ...argTypes.orientation, + control: { type: 'select' }, + options: [...BUTTON_GROUP_ORIENTATIONS], + table: { + category: 'attributes', + defaultValue: { summary: 'horizontal' }, + }, +}; + +argTypes.align = { + ...argTypes.align, + control: { type: 'select' }, + options: [...BUTTON_GROUP_ALIGNMENTS], + table: { + category: 'attributes', + defaultValue: { summary: 'start' }, + }, +}; + +argTypes.disabled = { + ...argTypes.disabled, + table: { + category: 'attributes', + defaultValue: { summary: 'false' }, + }, +}; + +/** + * A button group clusters related actions together, providing consistent spacing, + * sizing, and orientation. It propagates `size` and `disabled` state to its slotted + * [Button](../?path=/docs/button--overview) children and exposes `role="group"` for + * accessibility. + * + * Use button group when you have two or more related button actions that belong + * together visually and semantically (for example dialog or form footers, toolbars, + * or contextual action sets). + * + * ### Usage note — breaking changes from 1st-gen + * + * This component replaces `` from 1st-gen. Key differences: + * + * - **Tag name**: `sp-button-group` → `swc-button-group` + * - **Orientation API**: The `vertical` boolean attribute is replaced by + * `orientation="horizontal|vertical"` (default: `horizontal`) + * - **ARIA role**: 2nd-gen adds `role="group"` automatically (was missing in 1st-gen) + * - **New `align` property**: Controls main-axis alignment (`start`, `center`, `end`) + * - **New `disabled` property**: Group-level disable that propagates to all children + * - **CSS custom properties**: `--mod-buttongroup-*` → `--swc-button-group-*` + * + * See the [migration guide](../?path=/docs/button-group-migration-guide--docs) + * for full details. + */ +const meta: Meta = { + title: 'Button Group', + component: 'swc-button-group', + args, + argTypes, + render: (renderArgs) => html` + + Save + Cancel + Reset + + `, + parameters: { + docs: { + subtitle: + 'Clusters related button actions with consistent spacing and sizing', + }, + design: { + type: 'figma', + url: 'https://www.figma.com/design/Mngz9H7WZLbrCvGQf3GnsY/S2---Web--Desktop-scale-?node-id=13663-6530', + }, + stackblitz: { + url: 'https://stackblitz.com/edit/swc-button-group?file=src%2Fmy-element.ts', + }, + flexLayout: 'row-wrap', + }, + tags: ['migrated'], +}; + +export default meta; + +// ──────────────────── +// HELPERS +// ──────────────────── + +const sizeLabels = { + s: 'Small', + m: 'Medium', + l: 'Large', + xl: 'Extra-large', +} as const satisfies Record<(typeof BUTTON_GROUP_SIZES)[number], string>; + +// ──────────────────── +// AUTODOCS STORY +// ──────────────────── + +export const Playground: Story = { + tags: ['autodocs', 'dev'], + args: { + size: 'm', + orientation: 'horizontal', + disabled: false, + align: 'start', + }, +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + args: { + size: 'm', + orientation: 'horizontal', + disabled: false, + align: 'start', + }, + tags: ['overview'], +}; + +// ────────────────────────── +// ANATOMY STORIES +// ────────────────────────── + +/** + * A button group consists of: + * + * - **Default slot**: One or more `` elements that form the action set + * + * The group propagates its `size` and `disabled` state to all slotted button children + * automatically, ensuring visual and behavioral consistency without requiring per-button + * configuration. + */ +export const Anatomy: Story = { + render: () => html` + + Primary action + Secondary action + Tertiary action + + `, + tags: ['anatomy'], +}; + +// ────────────────────────── +// UPCOMING FEATURES +// ────────────────────────── + +/** + * The following features are planned for future releases but are not yet available + * in this MVP. They are documented in the migration analysis as additive changes. + * + * | Feature | Description | + * |---------|-------------| + * | Overflow behavior | Buttons that exceed available space collapse into a menu or wrap to a second row | + * | Toolbar composition | Documentation and patterns for using button-group within `role="toolbar"` with `FocusgroupNavigationController` for arrow-key navigation | + * | Form-associated actions | Group-level `submit` / `reset` coordination with form elements | + */ +export const UpcomingFeatures: Story = { + render: () => html` + + Overflow (planned) + Toolbar (planned) + + `, + tags: ['upcoming'], +}; +UpcomingFeatures.storyName = 'Upcoming features'; + +// ────────────────────────── +// OPTIONS STORIES +// ────────────────────────── + +/** + * Button groups come in four sizes that propagate to all child buttons: + * + * - **Small (`s`)**: For compact interfaces with limited space + * - **Medium (`m`)**: Default size for most contexts + * - **Large (`l`)**: For prominent actions needing more visual weight + * - **Extra-large (`xl`)**: For hero sections or primary call-to-action areas + */ +export const Sizes: Story = { + render: () => html` + ${BUTTON_GROUP_SIZES.map( + (size) => html` + + ${sizeLabels[size]} Save + ${sizeLabels[size]} Cancel + + ` + )} + `, + tags: ['options'], + parameters: { 'section-order': 1 }, +}; + +/** + * Button groups support two orientations: + * + * - **Horizontal** (default): Buttons flow left-to-right (or inline-start to inline-end) + * - **Vertical**: Buttons stack top-to-bottom, useful when horizontal space is limited + * + * The `orientation` attribute reflects on the host for CSS styling hooks. + */ +export const Orientations: Story = { + render: () => html` + ${BUTTON_GROUP_ORIENTATIONS.map( + (orientation) => html` + + ${orientation} 1 + ${orientation} 2 + + ` + )} + `, + tags: ['options'], + parameters: { 'section-order': 2 }, +}; + +/** + * The `align` property controls the alignment of buttons within the group along + * the main axis: + * + * - **start** (default): Buttons align to the inline start + * - **center**: Buttons center within the available space + * - **end**: Buttons align to the inline end (useful for dialog footers) + */ +export const Alignment: Story = { + render: () => html` + ${BUTTON_GROUP_ALIGNMENTS.map( + (align) => html` + + ${align} + Action + + ` + )} + `, + tags: ['options'], + parameters: { 'section-order': 3 }, +}; + +// ────────────────────────── +// STATES STORIES +// ────────────────────────── + +/** + * Setting `disabled` on the group propagates the disabled state to all child + * buttons, preventing interaction. This is a convenience API — individual buttons + * can also be disabled independently when the group is not disabled. + */ +export const Disabled: Story = { + render: () => html` + + Save + Cancel + Reset + + `, + tags: ['states'], +}; + +// ────────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────────── + +/** + * When `size` is changed on the group, all slotted buttons update to the new size + * automatically. Similarly, dynamically added buttons receive the current group size + * and disabled state via the `slotchange` handler. + */ +export const SizePropagation: Story = { + render: () => html` + ${(['s', 'm', 'l', 'xl'] as const).map( + (size) => html` + + Propagated ${size.toUpperCase()} + Propagated ${size.toUpperCase()} + + ` + )} + `, + tags: ['behaviors'], +}; +SizePropagation.storyName = 'Size propagation'; + +// ──────────────────────────────── +// ACCESSIBILITY STORIES +// ──────────────────────────────── + +/** + * ### Features + * + * The `` element implements several accessibility features: + * + * #### ARIA implementation + * + * 1. **ARIA role**: Automatically sets `role="group"` on the host element + * 2. **Orientation**: Reflects the `orientation` attribute on the host for CSS styling + * 3. **Group naming**: Supports `aria-label` or `aria-labelledby` for screen readers. + * Provide a name when the group's purpose is not obvious from context. + * + * #### Keyboard navigation + * + * - Tab / Shift+Tab: Moves focus between buttons in DOM order + * - Enter / Space: Activates the focused button + * - Each button is a **separate Tab stop** — the group does NOT use roving tabindex + * - The group host is **NOT focusable** + * + * #### What button-group is NOT + * + * - **Not a radio group**: Do not use for exclusive selection — + * use [Segmented Control](../?path=/docs/segmented-control--overview) instead + * - **Not a toggle group**: Do not use for pressed/toggle states — + * use Toggle Group instead + * - **Not a toolbar**: Does not implement arrow-key navigation. + * For toolbar semantics, use `role="toolbar"` on a parent composite with + * `FocusgroupNavigationController` + * + * ### Best practices + * + * - Provide an `aria-label` when the group's purpose is not clear from surrounding + * content (for example, when used inside a dialog footer) + * - Keep slotted children as `` elements for proper semantic delegation + * - Do not apply `role="radiogroup"` or `role="toolbar"` to this component + */ +export const Accessibility: Story = { + render: () => html` + + Save + Discard + Export + + `, + tags: ['a11y'], +}; + +// ──────────────────────────────── +// LOCAL-ONLY STORIES +// ──────────────────────────────── + +/** + * Combination of all orientations at all sizes — useful for visual regression testing + * during local development. Not included in production documentation. + */ +export const AllCombinations: Story = { + render: () => html` + ${BUTTON_GROUP_ORIENTATIONS.map( + (orientation) => html` + ${BUTTON_GROUP_SIZES.map( + (size) => html` + + ${orientation} ${size.toUpperCase()} + Action + + ` + )} + ` + )} + `, + tags: ['!dev'], +}; +AllCombinations.storyName = 'All combinations'; + +/** + * Tests vertical orientation with alignment variations. Used for local visual + * verification that vertical layout respects alignment in all directions. + */ +export const VerticalAlignment: Story = { + render: () => html` + ${BUTTON_GROUP_ALIGNMENTS.map( + (align) => html` + + Vertical ${align} + Action + + ` + )} + `, + tags: ['!dev'], +}; +VerticalAlignment.storyName = 'Vertical alignment'; diff --git a/2nd-gen/packages/swc/components/button-group/swc-button-group.ts b/2nd-gen/packages/swc/components/button-group/swc-button-group.ts new file mode 100644 index 00000000000..0f969f1ac27 --- /dev/null +++ b/2nd-gen/packages/swc/components/button-group/swc-button-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 { ButtonGroup } from './ButtonGroup.js'; + +declare global { + interface HTMLElementTagNameMap { + 'swc-button-group': ButtonGroup; + } +} + +defineElement('swc-button-group', ButtonGroup); diff --git a/2nd-gen/packages/swc/components/button-group/test/button-group.a11y.spec.ts b/2nd-gen/packages/swc/components/button-group/test/button-group.a11y.spec.ts new file mode 100644 index 00000000000..ccd5a80bebb --- /dev/null +++ b/2nd-gen/packages/swc/components/button-group/test/button-group.a11y.spec.ts @@ -0,0 +1,193 @@ +/** + * 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 AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +import { gotoStory } from '../../../utils/a11y-helpers.js'; + +/** + * Accessibility tests for ButtonGroup component (2nd Generation) + * + * ARIA snapshot tests validate the accessibility tree structure. + * Keyboard interaction tests verify Tab order and key activation. + * aXe WCAG validation ensures no accessibility violations per story. + */ + +test.describe('ButtonGroup - ARIA Snapshots', () => { + test('should have correct accessibility tree for overview', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + await expect(root).toMatchAriaSnapshot(` + - group: + - button "Save" + - button "Cancel" + - button "Reset" + `); + }); + + test('should not set aria-orientation (not valid on role=group)', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-button-group--orientations', + 'swc-button-group' + ); + const verticalGroup = root + .locator('swc-button-group[orientation="vertical"]') + .first(); + await expect(verticalGroup).not.toHaveAttribute('aria-orientation'); + }); + + test('should expose aria-label when provided', async ({ page }) => { + const root = await gotoStory( + page, + 'components-button-group--accessibility', + 'swc-button-group' + ); + await expect(root).toMatchAriaSnapshot(` + - group "Document actions": + - button "Save" + - button "Discard" + - button "Export" + `); + }); +}); + +test.describe('ButtonGroup - Keyboard Interactions', () => { + test('buttons are reachable via Tab in DOM order', async ({ page }) => { + const root = await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + const buttons = root.locator('swc-button'); + const count = await buttons.count(); + + await page.keyboard.press('Tab'); + await expect(buttons.nth(0)).toBeFocused(); + + for (let i = 1; i < count; i++) { + await page.keyboard.press('Tab'); + await expect(buttons.nth(i)).toBeFocused(); + } + }); + + test('buttons are reachable via Shift+Tab in reverse order', async ({ + page, + }) => { + const root = await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + const buttons = root.locator('swc-button'); + const count = await buttons.count(); + + // Focus last button first + await buttons.nth(count - 1).focus(); + await expect(buttons.nth(count - 1)).toBeFocused(); + + for (let i = count - 2; i >= 0; i--) { + await page.keyboard.press('Shift+Tab'); + await expect(buttons.nth(i)).toBeFocused(); + } + }); + + test('group host is NOT focusable', async ({ page }) => { + const root = await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + const group = root.locator('swc-button-group'); + await expect(group).not.toBeFocused(); + await page.keyboard.press('Tab'); + await expect(group).not.toBeFocused(); + }); + + test('button is activatable via Enter key', async ({ page }) => { + await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + + const firstButton = page.locator('swc-button').first(); + await firstButton.focus(); + await expect(firstButton).toBeFocused(); + await page.keyboard.press('Enter'); + }); + + test('button is activatable via Space key', async ({ page }) => { + await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + + const firstButton = page.locator('swc-button').first(); + await firstButton.focus(); + await expect(firstButton).toBeFocused(); + await page.keyboard.press('Space'); + }); +}); + +test.describe('ButtonGroup - aXe Validation', () => { + test('default state has no WCAG violations', async ({ page }) => { + await gotoStory( + page, + 'components-button-group--overview', + 'swc-button-group' + ); + + const results = await new AxeBuilder({ page }) + .include('#storybook-root') + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('disabled state has no WCAG violations', async ({ page }) => { + await gotoStory( + page, + 'components-button-group--disabled', + 'swc-button-group' + ); + + const results = await new AxeBuilder({ page }) + .include('#storybook-root') + .analyze(); + + expect(results.violations).toEqual([]); + }); + + test('vertical state has no WCAG violations', async ({ page }) => { + await gotoStory( + page, + 'components-button-group--orientations', + 'swc-button-group' + ); + + const results = await new AxeBuilder({ page }) + .include('#storybook-root') + .analyze(); + + expect(results.violations).toEqual([]); + }); +}); diff --git a/2nd-gen/packages/swc/components/button-group/test/button-group.test.ts b/2nd-gen/packages/swc/components/button-group/test/button-group.test.ts new file mode 100644 index 00000000000..f012340ae1e --- /dev/null +++ b/2nd-gen/packages/swc/components/button-group/test/button-group.test.ts @@ -0,0 +1,489 @@ +/** + * 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 as Story } from '@storybook/web-components'; + +import { ButtonGroup } from '@adobe/spectrum-wc/button-group'; + +import '@adobe/spectrum-wc/components/button-group/swc-button-group.js'; +import '@adobe/spectrum-wc/components/button/swc-button.js'; + +import { + BUTTON_GROUP_ALIGNMENTS, + BUTTON_GROUP_ORIENTATIONS, + BUTTON_GROUP_SIZES, +} from '../../../../core/components/button-group/ButtonGroup.types.js'; +import { getComponent, withWarningSpy } from '../../../utils/test-utils.js'; +import meta, { + Alignment, + Disabled, + Orientations, + Overview, + Sizes, +} from '../stories/button-group.stories.js'; + +export default { + ...meta, + title: 'Button Group/Tests', + parameters: { + ...meta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// TEST: Defaults +// ────────────────────────────────────────────────────────────── + +export const OverviewTest: Story = { + ...Overview, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step('renders with expected default values', async () => { + expect(group.orientation, 'default orientation').toBe('horizontal'); + expect(group.size, 'default size').toBe('m'); + expect(group.disabled, 'default disabled').toBe(false); + expect(group.align, 'default align').toBe('start'); + }); + + await step('sets correct ARIA attributes', async () => { + expect(group.getAttribute('role'), 'host role').toBe('group'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Properties / Attributes +// ────────────────────────────────────────────────────────────── + +export const OrientationMutationTest: Story = { + render: () => html` + + Action 1 + Action 2 + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step('reflects orientation attribute', async () => { + expect( + group.getAttribute('orientation'), + 'default orientation attribute' + ).toBe('horizontal'); + }); + + await step( + 'updates orientation attribute when changed to vertical', + async () => { + group.orientation = 'vertical'; + await group.updateComplete; + expect( + group.getAttribute('orientation'), + 'orientation after vertical' + ).toBe('vertical'); + } + ); + + await step( + 'reverts orientation attribute when changed back to horizontal', + async () => { + group.orientation = 'horizontal'; + await group.updateComplete; + expect( + group.getAttribute('orientation'), + 'orientation after horizontal' + ).toBe('horizontal'); + } + ); + }, +}; + +export const SizePropagationTest: Story = { + render: () => html` + + Save + Cancel + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + const buttons = canvasElement.querySelectorAll('swc-button'); + + await step('propagates initial size to child buttons', async () => { + for (const button of buttons) { + await (button as HTMLElement & { updateComplete: Promise }) + .updateComplete; + expect( + (button as HTMLElement & { size: string }).size, + 'button receives group size' + ).toBe('m'); + } + }); + + await step('propagates updated size to child buttons', async () => { + group.size = 'l' as ButtonGroup['size']; + await group.updateComplete; + + for (const button of buttons) { + await (button as HTMLElement & { updateComplete: Promise }) + .updateComplete; + expect( + (button as HTMLElement & { size: string }).size, + 'button size after group change' + ).toBe('l'); + } + }); + }, +}; + +export const DisabledPropagationTest: Story = { + render: () => html` + + Save + Cancel + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + const buttons = canvasElement.querySelectorAll('swc-button'); + + await step('buttons are enabled by default', async () => { + for (const button of buttons) { + expect( + (button as HTMLElement & { disabled: boolean }).disabled, + 'button is not disabled' + ).toBe(false); + } + }); + + await step('propagates disabled to child buttons', async () => { + group.disabled = true; + await group.updateComplete; + + for (const button of buttons) { + await (button as HTMLElement & { updateComplete: Promise }) + .updateComplete; + expect( + (button as HTMLElement & { disabled: boolean }).disabled, + 'button is disabled after group disabled' + ).toBe(true); + } + }); + + await step('re-enables buttons when disabled is removed', async () => { + group.disabled = false; + await group.updateComplete; + + for (const button of buttons) { + await (button as HTMLElement & { updateComplete: Promise }) + .updateComplete; + expect( + (button as HTMLElement & { disabled: boolean }).disabled, + 'button is re-enabled' + ).toBe(false); + } + }); + }, +}; + +export const AlignReflectionTest: Story = { + render: () => html` + + Action + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step('reflects align attribute after mutation', async () => { + group.align = 'center'; + await group.updateComplete; + expect( + group.getAttribute('align'), + 'align attribute is center' + ).toBe('center'); + + group.align = 'end'; + await group.updateComplete; + expect( + group.getAttribute('align'), + 'align attribute is end' + ).toBe('end'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Slots +// ────────────────────────────────────────────────────────────── + +export const SlotchangeTest: Story = { + render: () => html` + + Existing + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step( + 'newly slotted buttons receive current size and disabled state', + async () => { + const newButton = document.createElement('swc-button'); + newButton.textContent = 'New'; + group.appendChild(newButton); + + await group.updateComplete; + await new Promise((r) => requestAnimationFrame(r)); + await (newButton as HTMLElement & { updateComplete: Promise }) + .updateComplete; + + expect( + (newButton as HTMLElement & { size: string }).size, + 'new button receives group size' + ).toBe('l'); + expect( + (newButton as HTMLElement & { disabled: boolean }).disabled, + 'new button receives group disabled' + ).toBe(true); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Variants / States +// ────────────────────────────────────────────────────────────── + +export const SizesTest: Story = { + ...Sizes, + play: async ({ canvasElement, step }) => { + await step('renders groups in all valid sizes', async () => { + for (const size of BUTTON_GROUP_SIZES) { + const group = canvasElement.querySelector( + `swc-button-group[size="${size}"]` + ) as ButtonGroup | null; + expect(group, `group with size="${size}" is rendered`).toBeTruthy(); + await group?.updateComplete; + expect(group?.size, `group size property is "${size}"`).toBe(size); + } + }); + }, +}; + +export const OrientationsTest: Story = { + ...Orientations, + play: async ({ canvasElement, step }) => { + await step('renders groups in all valid orientations', async () => { + for (const orientation of BUTTON_GROUP_ORIENTATIONS) { + const group = canvasElement.querySelector( + `swc-button-group[orientation="${orientation}"]` + ) as ButtonGroup | null; + expect( + group, + `group with orientation="${orientation}" is rendered` + ).toBeTruthy(); + await group?.updateComplete; + expect( + group?.orientation, + `group orientation property is "${orientation}"` + ).toBe(orientation); + } + }); + }, +}; + +export const DisabledTest: Story = { + ...Disabled, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + const buttons = canvasElement.querySelectorAll('swc-button'); + + await step('all child buttons are disabled', async () => { + expect(group.disabled, 'group is disabled').toBe(true); + for (const button of buttons) { + await (button as HTMLElement & { updateComplete: Promise }) + .updateComplete; + expect( + (button as HTMLElement & { disabled: boolean }).disabled, + 'button is disabled' + ).toBe(true); + } + }); + }, +}; + +export const AlignmentTest: Story = { + ...Alignment, + play: async ({ canvasElement, step }) => { + await step('renders groups in all valid alignments', async () => { + for (const align of BUTTON_GROUP_ALIGNMENTS) { + const group = canvasElement.querySelector( + `swc-button-group[align="${align}"]` + ) as ButtonGroup | null; + expect( + group, + `group with align="${align}" is rendered` + ).toBeTruthy(); + await group?.updateComplete; + expect(group?.align, `group align property is "${align}"`).toBe(align); + } + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// TEST: Dev mode warnings +// ────────────────────────────────────────────────────────────── + +export const InvalidOrientationWarningTest: Story = { + render: () => html` + + Action + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step( + 'warns when an invalid orientation is set in DEBUG mode', + () => + withWarningSpy(async (warnCalls) => { + group.orientation = + 'diagonal' as unknown as ButtonGroup['orientation']; + await group.updateComplete; + + expect( + warnCalls.length, + 'at least one warning for invalid orientation' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning references orientation' + ).toContain('orientation'); + }) + ); + }, +}; + +export const ValidOrientationNoWarningTest: Story = { + render: () => html` + + Action + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step( + 'does not warn for valid orientation values in DEBUG mode', + () => + withWarningSpy(async (warnCalls) => { + for (const orientation of BUTTON_GROUP_ORIENTATIONS) { + group.orientation = orientation; + await group.updateComplete; + } + + expect( + warnCalls.length, + 'no warnings for valid orientations' + ).toBe(0); + }) + ); + }, +}; + +export const InvalidAlignWarningTest: Story = { + render: () => html` + + Action + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step('warns when an invalid align is set in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + group.align = 'stretch' as unknown as ButtonGroup['align']; + await group.updateComplete; + + expect( + warnCalls.length, + 'at least one warning for invalid align' + ).toBeGreaterThan(0); + expect( + String(warnCalls[0]?.[1] || ''), + 'warning references align' + ).toContain('align'); + }) + ); + }, +}; + +export const ValidAlignNoWarningTest: Story = { + render: () => html` + + Action + + `, + play: async ({ canvasElement, step }) => { + const group = await getComponent( + canvasElement, + 'swc-button-group' + ); + + await step('does not warn for valid align values in DEBUG mode', () => + withWarningSpy(async (warnCalls) => { + for (const align of BUTTON_GROUP_ALIGNMENTS) { + group.align = align; + await group.updateComplete; + } + + expect(warnCalls.length, 'no warnings for valid aligns').toBe(0); + }) + ); + }, +};