diff --git a/pages/control-group/responsiveness.page.tsx b/pages/control-group/responsiveness.page.tsx new file mode 100644 index 0000000000..4230d7c27d --- /dev/null +++ b/pages/control-group/responsiveness.page.tsx @@ -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(operators[0]); + const [value, setValue] = useState('production'); + const [name2, setName2] = useState('region'); + const [operator2, setOperator2] = useState(operators[0]); + const [value2, setValue2] = useState('us-east-1'); + return ( + + setName(e.detail.value)} /> + setValue(e.detail.value)} /> + setName2(e.detail.value)} /> + setValue2(e.detail.value)} + /> + + ); +} + +export default function ControlGroupResponsiveness() { + return ( + + +
+ + + Sibling content + +
+ +
+ ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 7f63f4f3fe..395d984f1f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -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", @@ -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", @@ -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", diff --git a/src/input/__tests__/control-group.test.tsx b/src/input/__tests__/control-group.test.tsx index c4c3f019a0..63457da270 100644 --- a/src/input/__tests__/control-group.test.tsx +++ b/src/input/__tests__/control-group.test.tsx @@ -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( } /> ); - expect(getByTestId('probe')).toHaveTextContent('none'); + expect(getAllByTestId('probe')[0]).toHaveTextContent('none'); }); test('resets the context for suffix content', () => { - const { getByTestId } = render( + const { getAllByTestId } = render( } /> ); - expect(getByTestId('probe')).toHaveTextContent('none'); + expect(getAllByTestId('probe')[0]).toHaveTextContent('none'); }); }); diff --git a/src/internal/components/control-group/__integ__/control-group.test.ts b/src/internal/components/control-group/__integ__/control-group.test.ts new file mode 100644 index 0000000000..4e4cda0102 --- /dev/null +++ b/src/internal/components/control-group/__integ__/control-group.test.ts @@ -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); + } + }); + } +} + +function setupTest(testFn: (page: ControlGroupPage) => Promise) { + 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); + }) + ); +}); 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 4416eb9e3f..4c12ab64b3 100644 --- a/src/internal/components/control-group/__tests__/control-group.test.tsx +++ b/src/internal/components/control-group/__tests__/control-group.test.tsx @@ -18,9 +18,10 @@ describe('Control group', () => { const alpha = ; const beta = ; - const { getByTestId, rerender } = render({[alpha, beta]}); + const { getAllByTestId, rerender } = render({[alpha, beta]}); - const alphaInput = getByTestId('alpha'); + // The measurement duplicate matches the test id too; the first match is the real control. + const alphaInput = getAllByTestId('alpha')[0]; alphaInput.focus(); expect(document.activeElement).toBe(alphaInput); @@ -28,23 +29,24 @@ describe('Control group', () => { rerender({[beta, alpha]}); // The same DOM node is still focused; it was moved, not remounted. - expect(getByTestId('alpha')).toBe(alphaInput); + expect(getAllByTestId('alpha')[0]).toBe(alphaInput); expect(document.activeElement).toBe(alphaInput); }); describe('position', () => { + // Each probe matches twice (real + measurement duplicate); the first match is the real one. test('exposes the "only" position to a single child control', () => { - const { getByTestId } = render( + const { getAllByTestId } = render( ); - expect(getByTestId('probe')).toHaveTextContent('only'); + expect(getAllByTestId('probe')[0]).toHaveTextContent('only'); }); test('exposes first / middle / last positions to each child in order', () => { - const { getByTestId } = render( + const { getAllByTestId } = render( @@ -52,15 +54,15 @@ describe('Control group', () => { ); - expect(getByTestId('a')).toHaveTextContent('first'); - expect(getByTestId('b')).toHaveTextContent('middle'); - expect(getByTestId('c')).toHaveTextContent('last'); + expect(getAllByTestId('a')[0]).toHaveTextContent('first'); + expect(getAllByTestId('b')[0]).toHaveTextContent('middle'); + expect(getAllByTestId('c')[0]).toHaveTextContent('last'); }); test('resets the grouped position for content wrapped in ResetGroupedControlContext', () => { // Mirrors a nested control rendered inside a control's custom slot (e.g. // Autosuggest `empty`): it must not inherit the surrounding group position. - const { getByTestId } = render( + const { getAllByTestId } = render( @@ -68,25 +70,26 @@ describe('Control group', () => { ); - expect(getByTestId('probe')).toHaveTextContent('none'); + expect(getAllByTestId('probe')[0]).toHaveTextContent('none'); }); }); describe('direction', () => { + // In jsdom there's no layout, so auto never stacks and defaults to horizontal. test('defaults the direction to "horizontal" and exposes it to each child', () => { - const { getByTestId } = render( + const { getAllByTestId } = render( ); - expect(getByTestId('a')).toHaveTextContent('horizontal'); - expect(getByTestId('b')).toHaveTextContent('horizontal'); + expect(getAllByTestId('a')[0]).toHaveTextContent('horizontal'); + expect(getAllByTestId('b')[0]).toHaveTextContent('horizontal'); }); test('exposes direction="vertical" to each child when the group is vertical', () => { - const { getByTestId } = render( + const { getAllByTestId } = render( @@ -94,13 +97,13 @@ describe('Control group', () => { ); - expect(getByTestId('a')).toHaveTextContent('vertical'); - expect(getByTestId('b')).toHaveTextContent('vertical'); - expect(getByTestId('c')).toHaveTextContent('vertical'); + expect(getAllByTestId('a')[0]).toHaveTextContent('vertical'); + expect(getAllByTestId('b')[0]).toHaveTextContent('vertical'); + expect(getAllByTestId('c')[0]).toHaveTextContent('vertical'); }); test('ResetGroupedControlContext preserves the group direction', () => { - const { getByTestId } = render( + const { getAllByTestId } = render( @@ -108,7 +111,7 @@ describe('Control group', () => { ); - expect(getByTestId('direction')).toHaveTextContent('vertical'); + expect(getAllByTestId('direction')[0]).toHaveTextContent('vertical'); }); }); @@ -137,6 +140,7 @@ describe('Control group', () => { ); expect(getByRole('group').getAttribute('aria-labelledby')).toBeNull(); - expect(findControlGroup(container)).toBeNull(); + // The wrapper still roots at the always-present group root, but there is no inline label. + expect(findControlGroup(container)!.findInlineLabel()).toBeNull(); }); }); diff --git a/src/internal/components/control-group/index.tsx b/src/internal/components/control-group/index.tsx index ea7bc2a1ed..a6f58ff6b0 100644 --- a/src/internal/components/control-group/index.tsx +++ b/src/internal/components/control-group/index.tsx @@ -1,9 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { forwardRef } from 'react'; import clsx from 'clsx'; -import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; +import { useMergeRefs, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; import { BaseComponentProps } from '../../../types/base-component'; import { getBaseProps } from '../../base-component'; @@ -12,57 +12,109 @@ import { GroupedControlDirection, GroupedControlPosition, } from '../../context/control-group-context'; +import { useFitsInline } from '../../hooks/use-fits-inline'; import { flattenChildren } from '../../utils/flatten-children'; import styles from './styles.css.js'; +import testUtilStyles from './test-classes/styles.css.js'; + +// `'auto'` exists only at the prop boundary; it resolves to a concrete axis before reaching +// context, classes, or SCSS. +type ControlGroupDirection = GroupedControlDirection | 'auto'; export interface InternalControlGroupProps extends BaseComponentProps { children?: React.ReactNode; - direction?: GroupedControlDirection; + /** + * The axis along which the controls are laid out. + * - `'auto'` (default): measures the available width and lays the controls out as a single + * row when they fit, stacking them vertically when they do not. + * - `'horizontal'` / `'vertical'`: forces that axis and skips measurement. + */ + direction?: ControlGroupDirection; + /** + * When set, renders an inline label above the group and associates it with the + * group via `aria-labelledby`. + */ inlineLabelText?: string; } -export default function InternalControlGroup({ - children, - direction = 'horizontal', - inlineLabelText, - ...props -}: InternalControlGroupProps) { - const baseProps = getBaseProps(props); - const labelId = useUniqueId('control-group-label'); +const InternalControlGroup = forwardRef( + ({ children, direction = 'auto', inlineLabelText, ...props }, ref) => { + const baseProps = getBaseProps(props); + const labelId = useUniqueId('control-group-label'); - const flattenedChildren = flattenChildren(children, 'ControlGroup'); - const controlCount = flattenedChildren.length; + const flattenedChildren = flattenChildren(children, 'ControlGroup'); + const controlCount = flattenedChildren.length; - const controls = flattenedChildren.map((child, index) => { - const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; - const position: GroupedControlPosition = - controlCount === 1 ? 'only' : index === 0 ? 'first' : index === controlCount - 1 ? 'last' : 'middle'; - return ( -
- {child} -
- ); - }); + // Only `'auto'` measures; a forced direction wins and the ghost is not rendered. + const { overflows, rootRef, ghostRef } = useFitsInline(); + const mergedRootRef = useMergeRefs(ref, rootRef); + const resolvedDirection: GroupedControlDirection = + direction === 'auto' ? (overflows ? 'vertical' : 'horizontal') : direction; - if (inlineLabelText) { - return ( -
- -
-
- {controls} + const renderControlSlots = () => + flattenedChildren.map((child, index) => { + const key = child && typeof child === 'object' ? (child as Record<'key', unknown>).key : undefined; + const position: GroupedControlPosition = + controlCount === 1 ? 'only' : index === 0 ? 'first' : index === controlCount - 1 ? 'last' : 'middle'; + return ( +
+ + {child} +
-
+ ); + }); + + // The role="group" subtree is identical whether or not a label is present: it keeps our root + // ref, test class, structural classes, and the measurement ghost (only in `auto` mode). We + // build it once and place it either bare (spreading baseProps) or inside the inline-label + // wrapper (where baseProps live on the outer wrapper instead). `extraProps` carries whatever + // the chosen branch puts on the group div (baseProps when bare, aria-labelledby when labeled) + // and `className` is any extra class to merge with the structural ones. + const renderGroup = (extraProps: Record, className?: string) => ( +
+ {renderControlSlots()} + + {/* + Hidden row that duplicates the controls to measure their single-row width, out of + flow so its width is stable when the visible group stacks. Only needed in `auto` mode. + */} + {direction === 'auto' && ( + + )}
); + + if (inlineLabelText) { + // When labeled, the consumer's props/className land on the outer wrapper (matching main); + // the inner role="group" div keeps the structural classes, ref, and test class. + return ( +
+ +
{renderGroup({ 'aria-labelledby': labelId })}
+
+ ); + } + + // When unlabeled, the consumer's props/className land directly on the role="group" root. + return renderGroup(baseProps as Record, baseProps.className); } +); - return ( -
- {controls} -
- ); -} +export default InternalControlGroup; diff --git a/src/internal/components/control-group/styles.scss b/src/internal/components/control-group/styles.scss index 106fe9f2e4..0f5a742842 100644 --- a/src/internal/components/control-group/styles.scss +++ b/src/internal/components/control-group/styles.scss @@ -10,6 +10,11 @@ .root { @include styles.styles-reset; display: flex; + // Keep the group at its natural width in a flex parent so the ancestor-walk can measure + // the real available width instead of the group's own shrunk width. + flex-shrink: 0; + // Anchor the absolutely-positioned ghost. + position: relative; &-horizontal { flex-direction: row; @@ -53,6 +58,19 @@ } } +// Measurement ghost (see index.tsx): out of flow and `max-content` wide so it reports the +// controls' natural single-row width. Not `display: none` (that would measure zero). +.ghost { + @include styles.measurement-ghost; + // Lay out the controls as a single row sized to their content, so the measured width is the + // width they need inline. + inline-size: max-content; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: flex-end; +} + .inline-label { @include forms.inline-label; // A grouped Multiselect always renders inline tokens, whose row sits higher diff --git a/src/internal/components/control-group/test-classes/styles.scss b/src/internal/components/control-group/test-classes/styles.scss new file mode 100644 index 0000000000..862cd3ca5e --- /dev/null +++ b/src/internal/components/control-group/test-classes/styles.scss @@ -0,0 +1,11 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.root, +.control, +.inline-label, +.inline-label-wrapper { + /* used in test-utils */ +} diff --git a/src/internal/hooks/__tests__/use-fits-inline.test.ts b/src/internal/hooks/__tests__/use-fits-inline.test.ts new file mode 100644 index 0000000000..4277d2c185 --- /dev/null +++ b/src/internal/hooks/__tests__/use-fits-inline.test.ts @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { measureConstrainingAncestorWidth } from '../../../../lib/components/internal/hooks/use-fits-inline'; + +interface FakeAncestor { + // Content-box width reported via clientWidth (padding is assumed 0 here). + width: number; + // When true, the element clips/scrolls (overflow !== visible). + clips?: boolean; + // When true, the element's content overflows it (scrollWidth > clientWidth). + scrolls?: boolean; +} + +// Builds a `root` element whose ancestor chain (closest first) has the given geometry. The +// root itself is given a fixed width; the walk compares ancestors against it. +function buildChain(rootWidth: number, ancestors: FakeAncestor[]): HTMLElement { + const makeEl = ({ width, clips, scrolls }: FakeAncestor) => { + const el = document.createElement('div'); + Object.defineProperty(el, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(el, 'scrollWidth', { configurable: true, value: scrolls ? width + 100 : width }); + el.style.overflowX = clips ? 'hidden' : 'visible'; + el.style.overflow = clips ? 'hidden' : 'visible'; + return el; + }; + + const root = document.createElement('div'); + root.getBoundingClientRect = () => ({ width: rootWidth }) as DOMRect; + + let parent = root; + for (const ancestor of ancestors) { + const el = makeEl(ancestor); + el.appendChild(parent); + parent = el; + } + return root; +} + +beforeEach(() => { + // No inline padding, and echo back the element's own overflow styles. + jest.spyOn(window, 'getComputedStyle').mockImplementation( + (el: Element) => + ({ + paddingLeft: '0px', + paddingRight: '0px', + overflowX: (el as HTMLElement).style.overflowX, + overflow: (el as HTMLElement).style.overflow, + }) as unknown as CSSStyleDeclaration + ); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('measureConstrainingAncestorWidth', () => { + test('returns null when the element has no ancestors', () => { + const root = document.createElement('div'); + root.getBoundingClientRect = () => ({ width: 600 }) as DOMRect; + expect(measureConstrainingAncestorWidth(root)).toBeNull(); + }); + + test('stops at the first ancestor wider than the content', () => { + // Immediate parent is wider than the 600px content, so it defines the available width. + const root = buildChain(600, [{ width: 800 }]); + expect(measureConstrainingAncestorWidth(root)).toBe(800); + }); + + test('stops at an ancestor that clips, using its narrower width', () => { + // The parent shrink-wraps to the content (same width, overflow visible) and is skipped; + // the next ancestor is no wider but clips, so it constrains the content. + const root = buildChain(600, [{ width: 600 }, { width: 400, clips: true }]); + expect(measureConstrainingAncestorWidth(root)).toBe(400); + }); + + test('stops at an ancestor the content overflows (scrollWidth > clientWidth)', () => { + const root = buildChain(600, [{ width: 400, scrolls: true }]); + expect(measureConstrainingAncestorWidth(root)).toBe(400); + }); + + test('skips shrink-wrapping ancestors until it finds a wider one', () => { + // Two shrink-wrapping ancestors (same width, no clip/scroll) are skipped to reach the + // wider one that actually defines the available width. + const root = buildChain(600, [{ width: 600 }, { width: 600 }, { width: 900 }]); + expect(measureConstrainingAncestorWidth(root)).toBe(900); + }); +}); diff --git a/src/internal/hooks/use-fits-inline.ts b/src/internal/hooks/use-fits-inline.ts new file mode 100644 index 0000000000..5a1a5067f0 --- /dev/null +++ b/src/internal/hooks/use-fits-inline.ts @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +import { useContainerQuery } from '@cloudscape-design/component-toolkit'; +import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; + +const MAX_ANCESTORS = 20; + +interface UseFitsInlineResult { + // `true` when the content does not fit the available width (the caller should wrap/stack). + // `false` until both widths have been measured. + overflows: boolean; + // Attach to the element whose available width is measured. + rootRef: React.Ref; + // Attach to a hidden ghost that renders the content as a single row; its width is the + // required width. The ghost subtree is marked inert so it adds no tab stops. + ghostRef: React.Ref; +} + +/** + * Default available-width measurement: walk up past shrink-wrapping ancestors to the first + * one that actually constrains `root`. That ancestor's width doesn't follow `root`'s own + * collapse, which is what lets a wrapped layout re-expand when space returns (otherwise the + * parent and child deadlock, each waiting on the other to grow). + */ +export function measureConstrainingAncestorWidth(root: HTMLElement): number | null { + const rootWidth = root.getBoundingClientRect().width; + let container: HTMLElement | null = root.parentElement; + let available: number | null = null; + for (let i = 0; container && i < MAX_ANCESTORS; i++) { + const style = getComputedStyle(container); + // Content-box width: clientWidth minus inline padding. + const paddingInline = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const contentWidth = container.clientWidth - paddingInline; + available = contentWidth; + // Wider than the content: this ancestor defines the available width. + if (contentWidth > rootWidth + 1) { + break; + } + // Same width but clips/scrolls (or the content overflows it): it constrains the content. + const clipsOrScrolls = + style.overflowX !== 'visible' || style.overflow !== 'visible' || container.scrollWidth > container.clientWidth; + if (clipsOrScrolls) { + break; + } + // Otherwise it just shrink-wraps to the content: keep looking. + container = container.parentElement; + } + return available; +} + +/** + * Decides whether content fits the width available to it, by comparing two independent widths + * so the content's own collapse can't feed back into the decision: + * - the available width, derived from `root` (by default the first constraining ancestor, + * overridable via `getAvailableWidth` when a layout needs different walk logic), and + * - the required width, measured from a hidden ghost that always renders the content inline. + * + * Returns `overflows` plus the refs to attach to the root and the ghost. + */ +export function useFitsInline( + getAvailableWidth: (root: HTMLElement) => number | null = measureConstrainingAncestorWidth +): UseFitsInlineResult { + const [availableWidth, setAvailableWidth] = useState(null); + const [requiredWidth, ghostWidthRef] = useContainerQuery(entry => entry.contentBoxWidth); + + const rootElRef = useRef(null); + const observerRef = useRef(null); + + const measureAvailableWidth = useCallback(() => { + if (rootElRef.current) { + setAvailableWidth(getAvailableWidth(rootElRef.current)); + } + }, [getAvailableWidth]); + + const measureRootRef = useCallback( + (node: T | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + rootElRef.current = node; + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + // Re-run the measurement whenever the root or any ancestor resizes. + const observer = new ResizeObserver(() => measureAvailableWidth()); + observer.observe(node); + for ( + let el: HTMLElement | null = node.parentElement, i = 0; + el && i < MAX_ANCESTORS; + i++, el = el.parentElement + ) { + observer.observe(el); + } + observerRef.current = observer; + measureAvailableWidth(); + }, + [measureAvailableWidth] + ); + + useLayoutEffect(() => () => observerRef.current?.disconnect(), []); + + // Mark the ghost subtree `inert` so its duplicated content adds no tab stops and is hidden + // from assistive tech. Set via ref because `inert` isn't rendered by React < 19. + const ghostElRef = useRef(null); + useLayoutEffect(() => { + if (ghostElRef.current) { + ghostElRef.current.inert = true; + } + }); + + // The `-1` tolerance avoids flipping on sub-pixel rounding; report "fits" until both widths + // have been measured. + const overflows = availableWidth !== null && requiredWidth !== null ? availableWidth < requiredWidth - 1 : false; + + return { + overflows, + rootRef: measureRootRef, + ghostRef: useMergeRefs(ghostWidthRef, ghostElRef), + }; +} diff --git a/src/internal/styles/utils/mixins.scss b/src/internal/styles/utils/mixins.scss index 350b06ee56..7e1d22f790 100644 --- a/src/internal/styles/utils/mixins.scss +++ b/src/internal/styles/utils/mixins.scss @@ -10,6 +10,19 @@ inset-inline-start: -9999px !important; } +// A measurement ghost: out of flow and visually hidden, but still laid out so its size can be +// read (unlike `display: none`, which measures zero). Non-interactive and inert; the caller +// adds the layout being measured (for example the single-row flex it compares against). +@mixin measurement-ghost { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + block-size: 0; + overflow: hidden; + visibility: hidden; + pointer-events: none; +} + @mixin text-wrapping { // When using with Flexbox, a flex item has min-width set to "auto" by default, which // prevents the text wrapping. We need to override the min-width by setting it to "0" diff --git a/src/multiselect/__tests__/control-group.test.tsx b/src/multiselect/__tests__/control-group.test.tsx index 2bc7c9fb35..c8bec7c0d9 100644 --- a/src/multiselect/__tests__/control-group.test.tsx +++ b/src/multiselect/__tests__/control-group.test.tsx @@ -13,7 +13,7 @@ const noop = () => {}; describe('Multiselect in control group', () => { test('resets the context for a custom dropdown footer', () => { - const { container, getByTestId } = render( + const { container, getAllByTestId } = render( { ); createWrapper(container).findMultiselect()!.openDropdown(); - expect(getByTestId('probe')).toHaveTextContent('none'); + // The measurement ghost duplicates the children, so the footer renders twice; the first + // match is the real control's. + expect(getAllByTestId('probe')[0]).toHaveTextContent('none'); }); it('renders inline tokens even if `inlineTokens` is not set', () => { diff --git a/src/test-utils/dom/internal/control-group.ts b/src/test-utils/dom/internal/control-group.ts index 29d4e7b935..bd262c6796 100644 --- a/src/test-utils/dom/internal/control-group.ts +++ b/src/test-utils/dom/internal/control-group.ts @@ -1,16 +1,36 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; +import { ComponentWrapper, createWrapper, ElementWrapper, usesDom } from '@cloudscape-design/test-utils-core/dom'; -import styles from '../../../internal/components/control-group/styles.selectors.js'; +import testUtilStyles from '../../../internal/components/control-group/test-classes/styles.selectors.js'; export default class ControlGroupWrapper extends ComponentWrapper { - static rootSelector: string = styles['inline-label-wrapper']; + // Root at the group root, which the component always renders (the inline-label wrapper only + // exists when a label is set). This keeps the wrapper usable for both labeled and unlabeled + // groups; `findControls` searches its descendants and `findInlineLabel` walks up to the label. + static rootSelector: string = testUtilStyles.root; + + /** + * Returns the individual fused controls. The direct-child selector excludes the hidden + * measurement duplicate, whose controls are nested under the ghost. + */ + findControls(): Array { + return this.findAll(`:scope > .${testUtilStyles.control}`); + } /** * Returns the visible inline label element, or null if no label is set. + * + * The label is a sibling of an ancestor of the group root + * (inline-label-wrapper > label + inline-label-trigger-wrapper > group), so a root-anchored + * descendant search cannot reach it. We instead walk up the DOM to the inline-label wrapper and + * find the label inside it; when no wrapper exists (unlabeled group) there is no label. This + * upward walk is DOM-only (`@usesDom`), so it is omitted from the selectors wrapper, where CSS + * selectors cannot express an ancestor lookup. */ + @usesDom findInlineLabel(): ElementWrapper | null { - return this.findByClassName(styles['inline-label']); + const wrapper = this.getElement().closest(`.${testUtilStyles['inline-label-wrapper']}`); + return wrapper ? createWrapper(wrapper).findByClassName(testUtilStyles['inline-label']) : null; } }