Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7245654
feat: Add deadlock-safe auto-wrap to internal ControlGroup
jperals Oct 6, 2026
4c3222e
test: Cover ControlGroup auto-wrap via integ tests
jperals Oct 6, 2026
c9b4733
test: Assert ControlGroup ghost controls are not focusable
jperals Oct 6, 2026
6dfd469
chore: Simplify ControlGroup responsiveness comments
jperals Oct 6, 2026
d7e5dac
chore: Focus on SpaceBetween case in ControlGroup dev page
jperals Oct 6, 2026
81630a4
fix: Properly align small inline elements in horizontal space-between…
jperals Oct 7, 2026
ff8213c
chore: Add 1px tolerance to select inside popover test (#5104)
jperals Oct 7, 2026
8dc9c6c
chore: Add visual regression test for stacked container with custom b…
jperals Oct 7, 2026
f99c6e5
feat: Add internal ControlGroup test-utils with findControls
jperals Oct 7, 2026
7224f3f
test: Address ControlGroup controls via findControls index
jperals Oct 7, 2026
db2ca7d
chore: Revert Auto option in control group permutations page
jperals Oct 7, 2026
466c294
Merge branch 'main' into dev-v3-jotresse-control-group-responsive
jperals Oct 7, 2026
cbeb020
refactor: Extract ControlGroup responsive logic into a hook
jperals Oct 7, 2026
69b14db
refactor: Generalize responsive measurement into useFitsInline hook
jperals Oct 7, 2026
026474d
refactor: Extract measurement-ghost styles into a shared mixin
jperals Oct 7, 2026
f5095b7
Update snapshots
jperals Oct 7, 2026
729a317
Simplify comment
jperals Oct 7, 2026
27e439f
test: Give control group demo controls fixed widths
jperals Oct 7, 2026
80cd461
Revert "test: Give control group demo controls fixed widths"
jperals Oct 7, 2026
cac599b
refactor: Use dedicated test-classes for ControlGroup test-utils
jperals Oct 7, 2026
d6a4621
test: Wait for layout to settle after resizing in control group integ
jperals Oct 7, 2026
6369c1e
test: Give control group demo a definite clipping width
jperals Oct 7, 2026
3a67675
test: Widen control group so it stacks below the min window width
jperals Oct 7, 2026
6c9c2b4
test: Widen control group with more controls to force stacking
jperals Oct 7, 2026
7dab076
test: Cover ancestor-walk termination branches in useFitsInline
jperals Oct 7, 2026
4a0ed98
Merge branch 'main' into dev-v3-jotresse-control-group-responsive
jperals Oct 7, 2026
465c20f
Update snapshots
jperals Oct 7, 2026
0f72c48
test: Bound control group demo in a clipping container
jperals Oct 8, 2026
4b9a71e
Remove comment
jperals Oct 8, 2026
a3fae6a
test
jperals Oct 8, 2026
e49e569
test
jperals Oct 8, 2026
75bddb4
Merge remote-tracking branch 'origin/main' into dev-v3-jotresse-contr…
jperals Oct 8, 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
77 changes: 77 additions & 0 deletions pages/control-group/responsiveness.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import Box from '~components/box';
import Button from '~components/button';
import Input from '~components/input';
import ControlGroup from '~components/internal/components/control-group';
import Select, { SelectProps } from '~components/select';
import SpaceBetween from '~components/space-between';

import { SimplePage } from '../app/templates';
import FocusTarget from '../common/focus-target';

const operators: SelectProps.Option[] = [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
];

const scenarioContainerStyle: React.CSSProperties = {
inlineSize: '100%',
overflow: 'hidden',
};

// Six controls so the group's required single-row width comfortably exceeds the narrow
// viewport, so it reliably stacks there and is a row at the wide viewport.
function Group() {
const [name, setName] = useState('service');
const [operator, setOperator] = useState<SelectProps.Option>(operators[0]);
const [value, setValue] = useState('production');
const [name2, setName2] = useState('region');
const [operator2, setOperator2] = useState<SelectProps.Option>(operators[0]);
const [value2, setValue2] = useState('us-east-1');
return (
<ControlGroup>
<Input ariaLabel="Label name" value={name} placeholder="Label name" onChange={e => setName(e.detail.value)} />
<Select
ariaLabel="Operator"
selectedOption={operator}
options={operators}
onChange={e => setOperator(e.detail.selectedOption)}
/>
<Input ariaLabel="Label value" value={value} placeholder="Label value" onChange={e => setValue(e.detail.value)} />
<Input ariaLabel="Label name 2" value={name2} placeholder="Label name" onChange={e => setName2(e.detail.value)} />
<Select
ariaLabel="Operator 2"
selectedOption={operator2}
options={operators}
onChange={e => setOperator2(e.detail.selectedOption)}
/>
<Input
ariaLabel="Label value 2"
value={value2}
placeholder="Label value"
onChange={e => setValue2(e.detail.value)}
/>
</ControlGroup>
);
}

export default function ControlGroupResponsiveness() {
return (
<SimplePage
title="Control group responsiveness"
subtitle="An auto group inside a horizontal SpaceBetween: the flexbox deadlock case. Narrowing the viewport stacks all its controls at once; widening it must re-expand them, not leave them stuck stacked."
>
<FocusTarget />
<div data-testid="auto-spacebetween" style={scenarioContainerStyle}>
<SpaceBetween direction="horizontal" size="s">
<Group />
<Box variant="p">Sibling content</Box>
</SpaceBetween>
</div>
<Button data-testid="focus-after">Focus after</Button>
</SimplePage>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ exports[`test-utils selectors 1`] = `
"awsui_button_m5h9f",
"awsui_chart-filter_1px7g",
"awsui_content_x6dl3",
"awsui_control_1gp1v",
"awsui_control_1wepg",
"awsui_custom-content_1p2cx",
"awsui_description_1p2cx",
Expand All @@ -420,8 +421,6 @@ 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 All @@ -440,6 +439,7 @@ exports[`test-utils selectors 1`] = `
"awsui_root_15oj2",
"awsui_root_1b522",
"awsui_root_1fcus",
"awsui_root_1gp1v",
"awsui_root_1kjc7",
"awsui_root_1om0h",
"awsui_root_1qprf",
Expand Down
10 changes: 6 additions & 4 deletions src/input/__tests__/control-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,25 @@ import { PositionProbe } from '../../internal/components/control-group/__tests__
const noop = () => {};

describe('Input in control group', () => {
// The measurement ghost duplicates the children, so prefix/suffix content matches twice;
// the first match is the real control's.
test('resets the context for prefix content', () => {
const { getByTestId } = render(
const { getAllByTestId } = render(
<ControlGroup>
<Input value="" onChange={noop} prefix={<PositionProbe />} />
</ControlGroup>
);

expect(getByTestId('probe')).toHaveTextContent('none');
expect(getAllByTestId('probe')[0]).toHaveTextContent('none');
});

test('resets the context for suffix content', () => {
const { getByTestId } = render(
const { getAllByTestId } = render(
<ControlGroup>
<Input value="" onChange={noop} suffix={<PositionProbe />} />
</ControlGroup>
);

expect(getByTestId('probe')).toHaveTextContent('none');
expect(getAllByTestId('probe')[0]).toHaveTextContent('none');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import createWrapper from '../../../../../lib/components/test-utils/selectors';
import ControlGroupWrapper from '../../../../../lib/components/test-utils/selectors/internal/control-group';

const SCENARIO = '[data-testid="auto-spacebetween"]';

// `findControls` returns only the real controls (the measurement duplicate is excluded), so
// addressing them by 1-based index keeps every finder off the ghost's duplicate markup.
const controls = createWrapper(SCENARIO)
.findComponent(`.${ControlGroupWrapper.rootSelector}`, ControlGroupWrapper)!
.findControls();

const EXPECTED_CONTROL_COUNT = 6;

// The group alternates Input, Select, Input, ... so odd controls are inputs and even ones
// are selects. The focus test tabs across the first few to confirm the real controls are
// reachable in order and the hidden ghost adds no tab stops.
const firstInput = controls.get(1).findInput().findNativeInput().toSelector();
const selectTrigger = controls.get(2).findSelect().findTrigger().toSelector();
const lastInput = controls.get(3).findInput().findNativeInput().toSelector();

const WIDE = { width: 1200, height: 800 };
const NARROW = { width: 360, height: 800 };

// Tolerance for comparing control tops in a row (bottom-aligned fields, sub-pixel rounding).
const ROW_TOP_TOLERANCE = 2;

class ControlGroupPage extends BasePageObject {
// TEMP DIAGNOSTIC (remove once CI is understood): logs the group's resolved class and width
// plus the control count the wrapper finds, to reveal what CI actually renders/measures.
async debugState(label: string) {
const info = await this.browser.execute(scenario => {
const root = document.querySelector(`${scenario} [role="group"]`) as HTMLElement | null;
// The ghost is the absolutely-positioned measurement row (last child of the root).
const ghost = root?.querySelector(':scope > [class*="awsui_ghost"]') as HTMLElement | null;
const realControls = root ? Array.from(root.querySelectorAll(':scope > [class*="awsui_control"]')) : [];
return {
found: !!root,
className: root?.className,
rootWidth: root?.getBoundingClientRect().width,
realControlCount: realControls.length,
// `requiredWidth` is read from this ghost: if it collapsed to ~rootWidth instead of the
// controls' natural single-row width, the group can never detect that it overflows.
ghostWidth: ghost?.getBoundingClientRect().width,
ghostScrollWidth: ghost?.scrollWidth,
realControlWidths: realControls.map(el => Math.round(el.getBoundingClientRect().width)),
};
}, SCENARIO);
console.log(`CONTROL_GROUP_DEBUG ${label}`, JSON.stringify(info));
}

// Bounding boxes of the real controls, in DOM order. `get` is 1-based.
async getControlBoxes() {
const boxes = [];
for (let i = 1; i <= EXPECTED_CONTROL_COUNT; i++) {
boxes.push(await this.getBoundingBox(controls.get(i).toSelector()));
}
return boxes;
}

async expectRow() {
await this.waitForAssertion(async () => {
const boxes = await this.getControlBoxes();
expect(boxes).toHaveLength(EXPECTED_CONTROL_COUNT);
// Same top, increasing left: one row, left-to-right.
for (const box of boxes) {
expect(Math.abs(box.top - boxes[0].top)).toBeLessThanOrEqual(ROW_TOP_TOLERANCE);
}
for (let i = 1; i < boxes.length; i++) {
expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].left);
}
});
}

async expectStacked() {
await this.waitForAssertion(async () => {
const boxes = await this.getControlBoxes();
expect(boxes).toHaveLength(EXPECTED_CONTROL_COUNT);
// Every control is on its own row below the previous one (all-or-nothing stacking).
for (let i = 1; i < boxes.length; i++) {
expect(boxes[i].top).toBeGreaterThan(boxes[i - 1].top);

Check failure on line 85 in src/internal/components/control-group/__integ__/control-group.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 3/4)

ControlGroup responsiveness › auto group inside a horizontal SpaceBetween re-expands after stacking (flexbox deadlock)

expect(received).toBeGreaterThan(expected) Expected: > 351.09375 Received: 351.09375 at src/internal/components/control-group/__integ__/control-group.test.ts:85:30 at RetryOperation._fn (node_modules/p-retry/index.js:50:12)

Check warning on line 85 in src/internal/components/control-group/__integ__/control-group.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 3/4)

RETRY 3: ControlGroup responsiveness › auto group inside a horizontal SpaceBetween re-expands after stacking (flexbox deadlock)

expect(received).toBeGreaterThan(expected) Expected: > 351.09375 Received: 351.09375 at src/internal/components/control-group/__integ__/control-group.test.ts:85:30 at RetryOperation._fn (node_modules/p-retry/index.js:50:12)

Check warning on line 85 in src/internal/components/control-group/__integ__/control-group.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 3/4)

RETRY 2: ControlGroup responsiveness › auto group inside a horizontal SpaceBetween re-expands after stacking (flexbox deadlock)

expect(received).toBeGreaterThan(expected) Expected: > 351.09375 Received: 351.09375 at src/internal/components/control-group/__integ__/control-group.test.ts:85:30 at RetryOperation._fn (node_modules/p-retry/index.js:50:12)

Check warning on line 85 in src/internal/components/control-group/__integ__/control-group.test.ts

View workflow job for this annotation

GitHub Actions / dry-run / Components integration tests shards (React 18, shard 3/4)

RETRY 1: ControlGroup responsiveness › auto group inside a horizontal SpaceBetween re-expands after stacking (flexbox deadlock)

expect(received).toBeGreaterThan(expected) Expected: > 351.09375 Received: 351.09375 at src/internal/components/control-group/__integ__/control-group.test.ts:85:30 at RetryOperation._fn (node_modules/p-retry/index.js:50:12)
}
});
}
}

function setupTest(testFn: (page: ControlGroupPage) => Promise<void>) {
return useBrowser(async browser => {
const page = new ControlGroupPage(browser);
await browser.url('#/control-group/responsiveness');
await page.waitForVisible(SCENARIO);
await testFn(page);
});
}

describe('ControlGroup responsiveness', () => {
test(
'auto group inside a horizontal SpaceBetween re-expands after stacking (flexbox deadlock)',
setupTest(async page => {
await page.setWindowSize(WIDE);
await page.debugState('WIDE');
await page.expectRow();
await page.setWindowSize(NARROW);
await page.debugState('NARROW');
await page.expectStacked();
// The critical case: widening must re-expand it, not leave it stuck stacked.
await page.setWindowSize(WIDE);
await page.expectRow();
})
);

test(
'keyboard focus flows through only the real controls, never a hidden measurement duplicate',
setupTest(async page => {
// Tabbing from the focus target before the group must reach each real control then the
// button after it. A focusable ghost duplicate would add a tab stop and break this.
await page.setWindowSize(WIDE);
await page.click('#focus-target');
await expect(page.isFocused('#focus-target')).resolves.toBe(true);

// Tab across the first three real controls in order.
await page.keys(['Tab']);
await expect(page.isFocused(firstInput)).resolves.toBe(true);
await page.keys(['Tab']);
await expect(page.isFocused(selectTrigger)).resolves.toBe(true);
await page.keys(['Tab']);
await expect(page.isFocused(lastInput)).resolves.toBe(true);

// Tab through the remaining controls; one Tab per control then leaves the group for the
// button after it. A focusable ghost duplicate would add extra tab stops and this final
// target would not be reached in the expected number of presses.
for (let i = 3; i < EXPECTED_CONTROL_COUNT; i++) {
await page.keys(['Tab']);
}
await page.keys(['Tab']);
await expect(page.isFocused('[data-testid="focus-after"]')).resolves.toBe(true);
})
);
});
Loading
Loading