From 4c6be352dd5384290382dc1a5be0b36b0d01dfed Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 1 Oct 2026 15:53:47 +0200 Subject: [PATCH 01/21] feat: Add inlineLabelText to internal control group --- pages/control-group/permutations.page.tsx | 21 ++++++++++++++ .../__tests__/control-group.test.tsx | 27 ++++++++++++++++++ .../components/control-group/index.tsx | 28 ++++++++++++++++++- .../components/control-group/styles.scss | 13 +++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/pages/control-group/permutations.page.tsx b/pages/control-group/permutations.page.tsx index 435e78be2f..e276d19d28 100644 --- a/pages/control-group/permutations.page.tsx +++ b/pages/control-group/permutations.page.tsx @@ -119,6 +119,23 @@ const permutations = createPermutations([ }, ]); +const inlineLabelPermutations = createPermutations([ + { + inlineLabelText: ['Threshold'], + children: [ + <> + {input} + {select} + , + <> + {select} + {input} + {multiselect} + , + ], + }, +]); + export default function ControlGroupPermutations() { // The name `direction` is already taken for the LTR/RTL URL parameter const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>(); @@ -145,6 +162,10 @@ export default function ControlGroupPermutations() { permutations={permutations} render={permutation => } /> + } + /> ); } diff --git a/src/internal/components/control-group/__tests__/control-group.test.tsx b/src/internal/components/control-group/__tests__/control-group.test.tsx index cc36ac14a4..cb89b703c8 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -105,4 +105,31 @@ describe('Control group', () => { expect(getByTestId('direction')).toHaveTextContent('vertical'); }); }); + + test('renders the inline label and wires it to the group via aria-labelledby', () => { + const { getByRole, getByText } = render( + + + + + ); + + const group = getByRole('group'); + const label = getByText('Threshold'); + + expect(label.id).toBeTruthy(); + expect(group.getAttribute('aria-labelledby')).toBe(label.id); + }); + + test('omits the inline label and aria-labelledby when inlineLabelText is not set', () => { + const { getByRole, queryByText } = render( + + + + + ); + + expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); + expect(queryByText('Threshold')).toBeNull(); + }); }); diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 1ee3ed8122..440fb71931 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -3,6 +3,8 @@ import React from 'react'; import clsx from 'clsx'; +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + import { BaseComponentProps } from '../../../types/base-component'; import { getBaseProps } from '../../base-component'; import { @@ -17,19 +19,22 @@ import styles from './styles.css.js'; export interface InternalControlGroupProps extends BaseComponentProps { children?: React.ReactNode; direction?: GroupedControlDirection; + inlineLabelText?: string; } export default function InternalControlGroup({ children, direction = 'horizontal', + inlineLabelText, ...props }: InternalControlGroupProps) { const baseProps = getBaseProps(props); + const labelId = useUniqueId('control-group-label'); const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; - return ( + const controls = (
{flattenedChildren.map((child, index) => { const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; @@ -46,4 +51,25 @@ export default function InternalControlGroup({ })}
); + + if (inlineLabelText) { + return ( +
+ +
+
+ {controls} +
+
+
+ ); + } + + return ( +
+ {controls} +
+ ); } diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index 54c7f93695..fa8be70985 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -5,6 +5,7 @@ @use '../../styles' as styles; @use '../../styles/tokens' as awsui; +@use '../../styles/forms/mixins' as forms; .root { @include styles.styles-reset; @@ -51,3 +52,15 @@ } } } + +.inline-label { + @include forms.inline-label; +} + +.inline-label-wrapper { + @include forms.inline-label-wrapper; +} + +.inline-label-trigger-wrapper { + @include forms.inline-label-trigger-wrapper; +} From d8837b51d97ff1cd365aa20197cb18af317fbae4 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 1 Oct 2026 18:06:53 +0200 Subject: [PATCH 02/21] fix: Keep control group label above focus rings and below dropdowns --- pages/control-group/permutations.page.tsx | 12 +++++++++++- src/internal/components/control-group/styles.scss | 6 ++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pages/control-group/permutations.page.tsx b/pages/control-group/permutations.page.tsx index e276d19d28..0a66ecc898 100644 --- a/pages/control-group/permutations.page.tsx +++ b/pages/control-group/permutations.page.tsx @@ -42,7 +42,15 @@ const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { v const enteredTextLabel = (value: string) => `Use: ${value}`; const input = ; -const select = +); const multiselect = ( ); const segmentedControl = ; @@ -60,6 +69,7 @@ const autosuggest = ( onChange={noop} options={suggestions} enteredTextLabel={enteredTextLabel} + expandToViewport={true} /> ); diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index fa8be70985..addb2a7e8d 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -55,10 +55,16 @@ .inline-label { @include forms.inline-label; + // Raise the label above the focused slot (which sits at z-index 1) so a + // control's focus ring paints under the label notch instead of over it. + z-index: 2; } .inline-label-wrapper { @include forms.inline-label-wrapper; + // Contain the label and the control slots in one stacking context so their + // z-indexes resolve against each other (label 2 over focused slot 1). + isolation: isolate; } .inline-label-trigger-wrapper { From c02d7e00d705a5bc48f2f50086972c56d8a450ac Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Thu, 1 Oct 2026 18:09:49 +0200 Subject: [PATCH 03/21] test: Add control group inline label dev page --- pages/control-group/labels.page.tsx | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 pages/control-group/labels.page.tsx diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx new file mode 100644 index 0000000000..617a90c584 --- /dev/null +++ b/pages/control-group/labels.page.tsx @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { Box, SpaceBetween } from '~components'; +import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; +import Input from '~components/input'; +import ControlGroup from '~components/internal/components/control-group'; +import Multiselect, { MultiselectProps } from '~components/multiselect'; +import Select, { SelectProps } from '~components/select'; + +import { SimplePage } from '../app/templates'; + +const noop = () => { + /* empty handler to suppress controlled-component warnings */ +}; + +const operators: SelectProps.Option[] = [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, +]; + +const multiOptions: MultiselectProps.Option[] = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, +]; + +const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { value: 'MemoryUtilization' }]; + +const enteredTextLabel = (value: string) => `Use: ${value}`; + +export default function ControlGroupLabels() { + return ( + + + Control groups with label + + + + + + + + + + + Standalone components with inline label + + + @@ -50,14 +65,14 @@ export default function ControlGroupLabels() { Date: Tue, 6 Oct 2026 17:29:46 +0200 Subject: [PATCH 05/21] Minor refactor --- .../control-group/__tests__/control-group.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/components/control-group/__tests__/control-group.test.tsx b/src/internal/components/control-group/__tests__/control-group.test.tsx index cb89b703c8..e94869efd4 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -108,10 +108,10 @@ describe('Control group', () => { test('renders the inline label and wires it to the group via aria-labelledby', () => { const { getByRole, getByText } = render( - + - +
); const group = getByRole('group'); @@ -123,10 +123,10 @@ describe('Control group', () => { test('omits the inline label and aria-labelledby when inlineLabelText is not set', () => { const { getByRole, queryByText } = render( - + - +
); expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); From 3a67d277b9ae9c967224e10c257805dee2595323 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 17:36:22 +0200 Subject: [PATCH 06/21] chore: Dedupe control group dev page fixtures Extract the shared noop handler, operator/multiselect option lists, and enteredTextLabel into pages/control-group/common.tsx and import them from both the permutations and labels dev pages. Restore the permutations page to match main aside from the dedup: drop the inline label permutations (covered by the dedicated labels page) and the expandToViewport additions. --- pages/control-group/common.tsx | 20 +++++++++ pages/control-group/labels.page.tsx | 17 +------ pages/control-group/permutations.page.tsx | 54 ++--------------------- 3 files changed, 25 insertions(+), 66 deletions(-) create mode 100644 pages/control-group/common.tsx diff --git a/pages/control-group/common.tsx b/pages/control-group/common.tsx new file mode 100644 index 0000000000..f1731730e4 --- /dev/null +++ b/pages/control-group/common.tsx @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { MultiselectProps } from '~components/multiselect'; +import { SelectProps } from '~components/select'; + +export const noop = () => { + /* empty handler to suppress controlled-component warnings */ +}; + +export const operators: SelectProps.Option[] = [ + { value: '=', label: '=' }, + { value: '!=', label: '!=' }, +]; + +export const multiOptions: MultiselectProps.Option[] = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, +]; + +export const enteredTextLabel = (value: string) => `Use: ${value}`; diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx index ff177a1d98..d15ebcd94a 100644 --- a/pages/control-group/labels.page.tsx +++ b/pages/control-group/labels.page.tsx @@ -10,20 +10,7 @@ import Multiselect, { MultiselectProps } from '~components/multiselect'; import Select, { SelectProps } from '~components/select'; import { SimplePage } from '../app/templates'; - -const noop = () => { - /* empty handler to suppress controlled-component warnings */ -}; - -const operators: SelectProps.Option[] = [ - { value: '=', label: '=' }, - { value: '!=', label: '!=' }, -]; - -const multiOptions: MultiselectProps.Option[] = [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, -]; +import { enteredTextLabel, multiOptions, noop, operators } from './common'; const suggestions: AutosuggestProps.Option[] = Array.from({ length: 20 }, (_, i) => ({ value: `Metric ${i + 1}`, @@ -42,8 +29,6 @@ const longMultiOptions: MultiselectProps.Option[] = Array.from({ length: 20 }, ( label: `Option ${i + 1}`, })); -const enteredTextLabel = (value: string) => `Use: ${value}`; - export default function ControlGroupLabels() { return ( diff --git a/pages/control-group/permutations.page.tsx b/pages/control-group/permutations.page.tsx index 0a66ecc898..3f453d9289 100644 --- a/pages/control-group/permutations.page.tsx +++ b/pages/control-group/permutations.page.tsx @@ -6,32 +6,19 @@ import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; import FormField from '~components/form-field'; import Input from '~components/input'; import ControlGroup, { InternalControlGroupProps } from '~components/internal/components/control-group'; -import Multiselect, { MultiselectProps } from '~components/multiselect'; +import Multiselect from '~components/multiselect'; import RadioGroup from '~components/radio-group'; import SegmentedControl, { SegmentedControlProps } from '~components/segmented-control'; -import Select, { SelectProps } from '~components/select'; +import Select from '~components/select'; import { useAppContext } from '../app/app-context'; import { PermutationsPage } from '../app/templates'; import createPermutations from '../utils/permutations'; import PermutationsView from '../utils/permutations-view'; +import { enteredTextLabel, multiOptions, noop, operators } from './common'; type Direction = NonNullable; -const noop = () => { - /* empty handler to suppress controlled-component warnings */ -}; - -const operators: SelectProps.Option[] = [ - { value: '=', label: '=' }, - { value: '!=', label: '!=' }, -]; - -const multiOptions: MultiselectProps.Option[] = [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, -]; - const segments: SegmentedControlProps.Option[] = [ { id: 'and', text: 'AND' }, { id: 'or', text: 'OR' }, @@ -39,18 +26,8 @@ const segments: SegmentedControlProps.Option[] = [ const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { value: 'MemoryUtilization' }]; -const enteredTextLabel = (value: string) => `Use: ${value}`; - const input = ; -const select = ( - ; const multiselect = ( ); const segmentedControl = ; @@ -69,7 +45,6 @@ const autosuggest = ( onChange={noop} options={suggestions} enteredTextLabel={enteredTextLabel} - expandToViewport={true} /> ); @@ -129,23 +104,6 @@ const permutations = createPermutations([ }, ]); -const inlineLabelPermutations = createPermutations([ - { - inlineLabelText: ['Threshold'], - children: [ - <> - {input} - {select} - , - <> - {select} - {input} - {multiselect} - , - ], - }, -]); - export default function ControlGroupPermutations() { // The name `direction` is already taken for the LTR/RTL URL parameter const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>(); @@ -172,10 +130,6 @@ export default function ControlGroupPermutations() { permutations={permutations} render={permutation => } /> - } - /> ); } From bb6b761be39676798761ff8fa5e40627175418c0 Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 17:42:41 +0200 Subject: [PATCH 07/21] chore: Add test ids to grouped select and multiselect on labels page Give the grouped Select and Multiselect stable data-testid hooks so visual regression tests can target them unambiguously; both grouped and standalone controls share aria-labels, so aria-label alone cannot disambiguate them. --- pages/control-group/labels.page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx index d15ebcd94a..a6a4a1388c 100644 --- a/pages/control-group/labels.page.tsx +++ b/pages/control-group/labels.page.tsx @@ -37,6 +37,7 @@ export default function ControlGroupLabels() { + + + + + + + Standalone components with inline label From 4b628cf70b8fd4bb01f6ca3e31eba618dc8c72fc Mon Sep 17 00:00:00 2001 From: Joan Perals Tresserra Date: Tue, 6 Oct 2026 17:49:49 +0200 Subject: [PATCH 09/21] chore: Add description, focus target, and direction toggle to labels page Replace the h2 section headings with a page-level description, add the shared FocusTarget so keyboard tests can tab into the controls, and extract the permutations page's direction settings into common.tsx (useControlGroupDirection hook + DirectionSettings) so the labels page can also render vertically. --- pages/control-group/common.tsx | 40 +++++++++++++++++++++++ pages/control-group/labels.page.tsx | 30 ++++++++++------- pages/control-group/permutations.page.tsx | 24 ++------------ 3 files changed, 62 insertions(+), 32 deletions(-) diff --git a/pages/control-group/common.tsx b/pages/control-group/common.tsx index f1731730e4..fd8caa0af7 100644 --- a/pages/control-group/common.tsx +++ b/pages/control-group/common.tsx @@ -1,8 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import FormField from '~components/form-field'; +import { InternalControlGroupProps } from '~components/internal/components/control-group'; import { MultiselectProps } from '~components/multiselect'; +import RadioGroup from '~components/radio-group'; import { SelectProps } from '~components/select'; +import { useAppContext } from '../app/app-context'; + export const noop = () => { /* empty handler to suppress controlled-component warnings */ }; @@ -18,3 +25,36 @@ export const multiOptions: MultiselectProps.Option[] = [ ]; export const enteredTextLabel = (value: string) => `Use: ${value}`; + +export type Direction = NonNullable; + +// Reads/writes the control group layout direction from the `controlGroupDirection` +// URL param. The name `direction` is already taken for the LTR/RTL URL parameter. +export function useControlGroupDirection() { + const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>(); + const direction: Direction = urlParams.controlGroupDirection === 'vertical' ? 'vertical' : 'horizontal'; + const setDirection = (value: Direction) => setUrlParams({ controlGroupDirection: value }); + return { direction, setDirection }; +} + +// Shared "Direction" settings control reused across the control group dev pages. +export function DirectionSettings({ + direction, + setDirection, +}: { + direction: Direction; + setDirection: (value: Direction) => void; +}) { + return ( + + setDirection(detail.value as Direction)} + items={[ + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' }, + ]} + /> + + ); +} diff --git a/pages/control-group/labels.page.tsx b/pages/control-group/labels.page.tsx index 62a77677ac..4d17fe7122 100644 --- a/pages/control-group/labels.page.tsx +++ b/pages/control-group/labels.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Box, SpaceBetween } from '~components'; +import { SpaceBetween } from '~components'; import Autosuggest, { AutosuggestProps } from '~components/autosuggest'; import Input from '~components/input'; import ControlGroup from '~components/internal/components/control-group'; @@ -10,7 +10,8 @@ import Multiselect, { MultiselectProps } from '~components/multiselect'; import Select, { SelectProps } from '~components/select'; import { SimplePage } from '../app/templates'; -import { enteredTextLabel, multiOptions, noop, operators } from './common'; +import FocusTarget from '../common/focus-target'; +import { DirectionSettings, enteredTextLabel, multiOptions, noop, operators, useControlGroupDirection } from './common'; const suggestions: AutosuggestProps.Option[] = Array.from({ length: 20 }, (_, i) => ({ value: `Metric ${i + 1}`, @@ -30,12 +31,21 @@ const longMultiOptions: MultiselectProps.Option[] = Array.from({ length: 20 }, ( })); export default function ControlGroupLabels() { + const { direction, setDirection } = useControlGroupDirection(); + return ( - + } + screenshotArea={{}} + > + - Control groups with label - - + - Standalone components with inline label - ; @@ -121,6 +124,28 @@ describe('Control group', () => { expect(group.getAttribute('aria-labelledby')).toBe(label.id); }); + test('applies the inline-token clearance class on the label when a child is a Multiselect', () => { + const { getByText } = render( + + + + + ); + + expect(getByText('Threshold')).toHaveClass(styles['inline-label-inline-tokens']); + }); + + test('does not apply the inline-token clearance class when no child is a Multiselect', () => { + const { getByText } = render( + + + + + ); + + expect(getByText('Threshold')).not.toHaveClass(styles['inline-label-inline-tokens']); + }); + test('omits the inline label and aria-labelledby when inlineLabelText is not set', () => { const { getByRole, queryByText } = render( diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index 440fb71931..6a0a86232f 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -34,6 +34,19 @@ export default function InternalControlGroup({ const flattenedChildren = flattenChildren(children, 'ControlGroup'); const controlCount = flattenedChildren.length; + // A grouped Multiselect always renders its tokens inline in the trigger, which + // sits higher than normal trigger text. The group's single inline label must + // drop the shared mixin's bottom padding and nudge up to clear that token row; + // otherwise it overlaps the tokens. Only Multiselect does this inside a group, + // so detecting its presence (by displayName, to stay decoupled from its module) + // is enough. Plain-trigger groups keep the base offset. + const hasInlineTokenControl = flattenedChildren.some( + child => + React.isValidElement(child) && + typeof child.type !== 'string' && + (child.type as { displayName?: string }).displayName === 'Multiselect' + ); + const controls = (
{flattenedChildren.map((child, index) => { @@ -55,7 +68,10 @@ export default function InternalControlGroup({ if (inlineLabelText) { return (
-