Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4c6be35
feat: Add inlineLabelText to internal control group
jperals Oct 1, 2026
d8837b5
fix: Keep control group label above focus rings and below dropdowns
jperals Oct 1, 2026
c02d7e0
test: Add control group inline label dev page
jperals Oct 1, 2026
7fadbbe
test: Lengthen control group dropdowns on labels dev page
jperals Oct 1, 2026
9b94699
Minor refactor
jperals Oct 6, 2026
3a67d27
chore: Dedupe control group dev page fixtures
jperals Oct 6, 2026
bb6b761
chore: Add test ids to grouped select and multiselect on labels page
jperals Oct 6, 2026
97cf027
chore: Add autosuggest- and input-led grouped controls to labels page
jperals Oct 6, 2026
4b628cf
chore: Add description, focus target, and direction toggle to labels …
jperals Oct 6, 2026
32304a2
fix: Clear inline-token overlap on control group label
jperals Oct 6, 2026
c0b2485
Raise inline label uncoditionally to prevent it overlapping with inli…
jperals Oct 6, 2026
cd23767
Refine comment
jperals Oct 6, 2026
f9cfecc
Refine comment
jperals Oct 6, 2026
6e7b034
Add standalone select at the top of the page
jperals Oct 6, 2026
e1238f5
Simplify subtitle
jperals Oct 6, 2026
41adb31
fix: Portal grouped control dropdowns
jperals Oct 7, 2026
87437b1
test: Assert grouped controls portal their dropdowns
jperals Oct 7, 2026
1ab5cb2
Fix duplicated attributes
jperals Oct 7, 2026
71b46af
chore: Provide default i18n on control group labels page
jperals Oct 7, 2026
0146db3
test: Add internal control group findInlineLabel test util
jperals Oct 7, 2026
e495ca6
Update selector snapshots
jperals Oct 7, 2026
014ffb8
Merge branch 'main' into dev-v3-jotresse-control-group-label
jperals Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions pages/control-group/common.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parts extracted from pages/control-group/permutations.page.tsx for reuse

// 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 */
};

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}`;

export type Direction = NonNullable<InternalControlGroupProps['direction']>;

export function useControlGroupDirection() {
const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>(); // The name `direction` is already taken for the LTR/RTL URL parameter
const direction: Direction = urlParams.controlGroupDirection === 'vertical' ? 'vertical' : 'horizontal';
const setDirection = (value: Direction) => setUrlParams({ controlGroupDirection: value });
return { direction, setDirection };
}

export function DirectionSettings({
direction,
setDirection,
}: {
direction: Direction;
setDirection: (value: Direction) => void;
}) {
return (
<FormField label="Direction">
<RadioGroup
value={direction}
onChange={({ detail }) => setDirection(detail.value as Direction)}
items={[
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
]}
/>
</FormField>
);
}
124 changes: 124 additions & 0 deletions pages/control-group/labels.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';

import { 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';
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}`,
}));

// Long option lists so the control groups' open dropdowns extend far enough down to overlap the standalone controls below,
// to test that dropdowns are rendered over labels and not the other way around.
const longOperators: SelectProps.Option[] = Array.from({ length: 20 }, (_, i) => ({
value: `op-${i + 1}`,
label: `Operator ${i + 1}`,
}));

const longMultiOptions: MultiselectProps.Option[] = Array.from({ length: 20 }, (_, i) => ({
value: `${i + 1}`,
label: `Option ${i + 1}`,
}));

export default function ControlGroupLabels() {
const { direction, setDirection } = useControlGroupDirection();

return (
<SimplePage
title="Control group labels"
subtitle="Labels should be displayed above focus rings but below dropdowns."
settings={<DirectionSettings direction={direction} setDirection={setDirection} />}
i18n={{}}
screenshotArea={{}}
>
<FocusTarget />
<SpaceBetween size="l">
<Select
data-testid="standalone-select"
inlineLabelText="Namespace"
ariaLabel="Namespace"
selectedOption={longOperators[0]}
options={longOperators}
onChange={noop}
/>
<ControlGroup inlineLabelText="Filter by metric" direction={direction}>
<Select
data-testid="grouped-select"
ariaLabel="Operator"
selectedOption={longOperators[0]}
options={longOperators}
onChange={noop}
/>
<Input ariaLabel="Value" value="service" onChange={noop} />
</ControlGroup>

<ControlGroup inlineLabelText="Threshold" direction={direction}>
<Multiselect
data-testid="grouped-multiselect"
ariaLabel="Labels"
inlineTokens={true}
selectedOptions={[longMultiOptions[0]]}
options={longMultiOptions}
onChange={noop}
/>
<Autosuggest
ariaLabel="Metric"
value=""
onChange={noop}
options={suggestions}
enteredTextLabel={enteredTextLabel}
/>
</ControlGroup>

<ControlGroup inlineLabelText="Metric name" direction={direction}>
<Autosuggest
data-testid="grouped-autosuggest"
ariaLabel="Metric"
value="CPU"
onChange={noop}
options={suggestions}
enteredTextLabel={enteredTextLabel}
/>
<Select ariaLabel="Operator" selectedOption={longOperators[0]} options={longOperators} onChange={noop} />
</ControlGroup>

<ControlGroup inlineLabelText="Service" direction={direction}>
<Input data-testid="grouped-input" ariaLabel="Value" value="service" onChange={noop} />
<Autosuggest
ariaLabel="Metric"
value=""
onChange={noop}
options={suggestions}
enteredTextLabel={enteredTextLabel}
/>
</ControlGroup>

<Input inlineLabelText="Service name" ariaLabel="Service name" value="service" onChange={noop} />
<Select
inlineLabelText="Operator"
ariaLabel="Operator"
selectedOption={operators[0]}
options={operators}
onChange={noop}
/>
<Multiselect
inlineLabelText="Labels"
ariaLabel="Labels"
inlineTokens={true}
selectedOptions={[multiOptions[0]]}
options={multiOptions}
onChange={noop}
/>
</SpaceBetween>
</SimplePage>
);
}
43 changes: 5 additions & 38 deletions pages/control-group/permutations.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,16 @@
import React from 'react';

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 RadioGroup from '~components/radio-group';
import Multiselect from '~components/multiselect';
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';

type Direction = NonNullable<InternalControlGroupProps['direction']>;

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 { DirectionSettings, enteredTextLabel, multiOptions, noop, operators, useControlGroupDirection } from './common';

const segments: SegmentedControlProps.Option[] = [
{ id: 'and', text: 'AND' },
Expand All @@ -39,8 +21,6 @@ const segments: SegmentedControlProps.Option[] = [

const suggestions: AutosuggestProps.Option[] = [{ value: 'CPUUtilization' }, { value: 'MemoryUtilization' }];

const enteredTextLabel = (value: string) => `Use: ${value}`;

const input = <Input ariaLabel="Value" value="service" onChange={noop} />;
const select = <Select ariaLabel="Operator" selectedOption={operators[0]} options={operators} onChange={noop} />;
const multiselect = (
Expand Down Expand Up @@ -120,26 +100,13 @@ const permutations = createPermutations<InternalControlGroupProps>([
]);

export default function ControlGroupPermutations() {
// The name `direction` is already taken for the LTR/RTL URL parameter
const { urlParams, setUrlParams } = useAppContext<'controlGroupDirection'>();
const direction: Direction = urlParams.controlGroupDirection === 'vertical' ? 'vertical' : 'horizontal';
const { direction, setDirection } = useControlGroupDirection();

return (
<PermutationsPage
title="Control group permutations"
i18n={{}}
settings={
<FormField label="Direction">
<RadioGroup
value={direction}
onChange={({ detail }) => setUrlParams({ controlGroupDirection: detail.value as Direction })}
items={[
{ value: 'horizontal', label: 'Horizontal' },
{ value: 'vertical', label: 'Vertical' },
]}
/>
</FormField>
}
settings={<DirectionSettings direction={direction} setDirection={setDirection} />}
>
<PermutationsView
permutations={permutations}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,8 @@ exports[`test-utils selectors 1`] = `
"awsui_header_dgs8z",
"awsui_highlighted_15o6u",
"awsui_icon_x6dl3",
"awsui_inline-label-wrapper_o08jm",
"awsui_inline-label_o08jm",
"awsui_inner-list-item_10ipo",
"awsui_key_10ipo",
"awsui_label-tag_1p2cx",
Expand Down
18 changes: 18 additions & 0 deletions src/autosuggest/__tests__/control-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,22 @@ describe('Autosuggest in control group', () => {

expect(getByTestId('probe')).toHaveTextContent('none');
});

test('renders the dropdown in a portal even if `expandToViewport` is not set', () => {
const { container } = render(
<ControlGroup>
<Autosuggest
value=""
onChange={noop}
options={[{ value: '1', label: 'One' }]}
enteredTextLabel={value => `Use: ${value}`}
/>
</ControlGroup>
);
const autosuggest = createWrapper(container).findAutosuggest()!;
autosuggest.focus();

expect(autosuggest.findDropdown({ expandToViewport: true }).findOpenDropdown()).not.toBeNull();
expect(autosuggest.findDropdown().findOpenDropdown()).toBeNull();
});
});
8 changes: 7 additions & 1 deletion src/autosuggest/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { BaseChangeDetail } from '../input/interfaces';
import AutosuggestInput, { AutosuggestInputRef } from '../internal/components/autosuggest-input';
import DropdownFooter from '../internal/components/dropdown-footer';
import { useDropdownStatus } from '../internal/components/dropdown-status';
import { useGroupedControlContext } from '../internal/context/control-group-context';
import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events';
import checkControlled from '../internal/hooks/check-controlled';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
Expand Down Expand Up @@ -170,6 +171,11 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
};

const formFieldContext = useFormFieldContext(restProps);
const groupedControlProps = useGroupedControlContext();
// When grouped, the dropdown is portaled regardless of the public
// `expandToViewport` prop so it escapes the control slot's stacking context
// and paints over the group's inline label (which the focus ring stays under).
const effectiveExpandToViewport = expandToViewport || !!groupedControlProps.position;
const selfControlId = useUniqueId('input');
const footerControlId = useUniqueId('footer');
const controlId = formFieldContext.controlId ?? selfControlId;
Expand Down Expand Up @@ -220,7 +226,7 @@ const InternalAutosuggest = React.forwardRef((props: InternalAutosuggestProps, r
ariaRequired={ariaRequired}
clearAriaLabel={clearAriaLabel}
disableBrowserAutocorrect={disableBrowserAutocorrect}
expandToViewport={expandToViewport}
expandToViewport={effectiveExpandToViewport}
ariaControls={listId}
ariaActivedescendant={highlightedOptionId}
dropdownExpanded={shouldRenderDropdownContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ import { render } from '@testing-library/react';

import ControlGroup from '../../../../../lib/components/internal/components/control-group';
import { ResetGroupedControlContext } from '../../../../../lib/components/internal/context/control-group-context';
import ControlGroupWrapper from '../../../../../lib/components/test-utils/dom/internal/control-group';
import { DirectionProbe, PositionProbe } from './common';

function findControlGroup(container: HTMLElement) {
const element = container.querySelector<HTMLElement>(`.${ControlGroupWrapper.rootSelector}`);
return element && new ControlGroupWrapper(element);
}

describe('Control group', () => {
test('keeps focus on a control when the children are reordered', () => {
const alpha = <input key="alpha" data-testid="alpha" />;
Expand Down Expand Up @@ -105,4 +111,32 @@ describe('Control group', () => {
expect(getByTestId('direction')).toHaveTextContent('vertical');
});
});

test('renders the inline label and wires it to the group via aria-labelledby', () => {
const { container, getByRole } = render(
<ControlGroup inlineLabelText="Threshold">
<input data-testid="alpha" />
<input data-testid="beta" />
</ControlGroup>
);

const group = getByRole('group');
const label = findControlGroup(container)!.findInlineLabel()!.getElement();

expect(label).toHaveTextContent('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 { container, getByRole } = render(
<ControlGroup>
<input data-testid="alpha" />
<input data-testid="beta" />
</ControlGroup>
);

expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull();
expect(findControlGroup(container)).toBeNull();
});
});
Loading
Loading