From cae4fe5078071b47a123bba0d0df7d00261135e1 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 23 Oct 2026 21:52:14 -0700 Subject: [PATCH 1/2] fix(SegmentedControl): segments keep their width when the selection changes The selected label is semibold and an unselected one medium, so a segment sized to its own label grew as it was selected and shrank as it was deselected (2.3 px for "Grid" on desktop, 2.7 px on a phone), shifting its neighbors. Each label now shares its grid cell with an aria-hidden twin at the selected weight, so a segment is always as wide as its selected label; the column can still shrink, so a capped control still truncates. A real-engine contract checks every segment's box across selections. --- .changeset/segmented-control-stable-width.md | 6 + .github/workflows/ci.yml | 10 ++ .../SegmentedControl.test.tsx | 35 +++++- .../SegmentedControl/SegmentedControlItem.tsx | 39 ++++++- .../SegmentedWidth.a11y.chromium.spec.ts | 103 ++++++++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 .changeset/segmented-control-stable-width.md create mode 100644 packages/core/src/SegmentedControl/__tests__/SegmentedWidth.a11y.chromium.spec.ts diff --git a/.changeset/segmented-control-stable-width.md b/.changeset/segmented-control-stable-width.md new file mode 100644 index 0000000000000..4cb6c9b952e89 --- /dev/null +++ b/.changeset/segmented-control-stable-width.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] SegmentedControl segments keep their width when the selection changes. The selected label is heavier, so a segment sized to its own label grew as it was selected and shrank as it was deselected (about 2–3 px in a content-sized control), shifting its neighbors. Each label now reserves its selected width. +@vjeux diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83538e83735db..c772cbc3d4179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1668,6 +1668,16 @@ jobs: --project chromium \ --output menu-hover-focus-output + # A SegmentedControl selection must not move or resize any segment: the + # selected title is heavier, and only a real engine lays out the title's + # width-reserving twin; jsdom has no layout. + - name: Run SegmentedControl width contract + run: | + pnpm exec playwright test \ + packages/core/src/SegmentedControl/__tests__/SegmentedWidth.a11y.chromium.spec.ts \ + --project chromium \ + --output segmented-width-output + # A CodeBlock diff row's tint has to follow a long line to the end of its # scroll width; jsdom has no layout, so only a real engine shows whether # the paint stops at the viewport edge when the reader scrolls. diff --git a/packages/core/src/SegmentedControl/SegmentedControl.test.tsx b/packages/core/src/SegmentedControl/SegmentedControl.test.tsx index 1241c8a3ab5ba..5f8d116dfb29d 100644 --- a/packages/core/src/SegmentedControl/SegmentedControl.test.tsx +++ b/packages/core/src/SegmentedControl/SegmentedControl.test.tsx @@ -215,8 +215,10 @@ describe('SegmentedControl', () => { ); const button = screen.getByRole('radio', {name: /very long/}); + // The visible title; its aria-hidden twin carries the same text. const label = screen.getByText( 'A very long segment label that should truncate', + {selector: '[data-segment-title="text"]'}, ); // The button (flex child) must allow shrinking below content size @@ -312,7 +314,9 @@ describe('SegmentedControl', () => { expect(getComputedStyle(segment).flexShrink).not.toBe('0'); const iconWrapper = screen.getByTestId('grid-icon').parentElement!; expect(getComputedStyle(iconWrapper).flexShrink).toBe('0'); - const label = screen.getByText('Grid with thumbnails and captions'); + const label = screen.getByText('Grid with thumbnails and captions', { + selector: '[data-segment-title="text"]', + }); expect(getComputedStyle(label).textOverflow).toBe('ellipsis'); }); @@ -849,3 +853,32 @@ describe('pressed state', () => { ); }); }); + +describe('segment width', () => { + it('reads each title once: the width-reserving twin is hidden from assistive technology', () => { + render( + {}} label="View mode"> + + + *} + isLabelHidden + /> + , + ); + for (const name of ['Grid', 'List']) { + const radio = screen.getByRole('radio', {name}); + const twin = radio.querySelector('[data-segment-title="twin"]'); + expect(twin).toHaveAttribute('aria-hidden', 'true'); + expect(twin?.textContent).toBe(name); + } + // Icon-only segments have no title layers. + expect( + screen + .getByRole('radio', {name: 'Icon'}) + .querySelector('[data-segment-title]'), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/SegmentedControl/SegmentedControlItem.tsx b/packages/core/src/SegmentedControl/SegmentedControlItem.tsx index c2382f99cd776..51d6981c22ff3 100644 --- a/packages/core/src/SegmentedControl/SegmentedControlItem.tsx +++ b/packages/core/src/SegmentedControl/SegmentedControlItem.tsx @@ -6,7 +6,10 @@ * @file SegmentedControlItem.tsx * @input Uses React, StyleX, SegmentedControlContext * @output Exports SegmentedControlItem component and SegmentedControlItemProps type - * @position Child item; renders as a radio button within the segmented control + * @position Child item; renders as a radio button within the segmented control. + * Its title is two layers in one grid cell: the visible title, and an + * aria-hidden twin at the selected weight that reserves the selected width, + * so a selection never changes a segment's width. * * SYNC: When modified, update: * - /packages/core/src/SegmentedControl/SegmentedControl.doc.mjs @@ -139,11 +142,33 @@ const styles = stylex.create({ justifyContent: 'center', flexShrink: 0, }, + // The title's grid cell holds the visible title and its twin, so the cell + // is as wide as the wider of the two: the selected weight's width, whether + // or not the segment is selected. The column can still shrink to nothing, + // which is what lets a capped control truncate. + titleStack: { + display: 'inline-grid', + gridTemplateColumns: 'minmax(0, max-content)', + minWidth: 0, + }, labelText: { + gridRowStart: 1, + gridColumnStart: 1, overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0, }, + // Reserves the selected width; never painted. + titleTwin: { + gridRowStart: 1, + gridColumnStart: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + minWidth: 0, + visibility: 'hidden', + pointerEvents: 'none', + fontWeight: fontWeightVars['--font-weight-semibold'], + }, }); const CONCENTRIC_RADIUS = @@ -263,7 +288,17 @@ export function SegmentedControlItem({ )}> {iconElement} {!isLabelHidden && ( - {label} + + + {label} + + + )} ); diff --git a/packages/core/src/SegmentedControl/__tests__/SegmentedWidth.a11y.chromium.spec.ts b/packages/core/src/SegmentedControl/__tests__/SegmentedWidth.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..09f8fa46be2fd --- /dev/null +++ b/packages/core/src/SegmentedControl/__tests__/SegmentedWidth.a11y.chromium.spec.ts @@ -0,0 +1,103 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SegmentedWidth.a11y.chromium.spec.ts + * @input The `Core/SegmentedControl` Default story in a built Storybook, real + * clicks (desktop) and taps (phone) + * @output Real-engine proof that selecting a segment never moves or resizes + * any segment, and that assistive technology still reads each title once + * @position Run with `pnpm test:a11y-contract`. Layout layer of + * SegmentedControlItem's title twin (jsdom has no layout); the jsdom layer is + * SegmentedControl.test.tsx. + * + * The selected title is semibold and an unselected one medium, so a title + * sized to its own text grows and shrinks with the selection. Each title now + * shares its grid cell with an aria-hidden twin at the selected weight. + */ + +import {expect, test} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; + +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); + +test.afterAll(async () => { + await storybook?.close(); +}); + +const STORY = 'core-segmentedcontrol--default'; + +const CONTEXTS = { + desktop: {viewport: {width: 1024, height: 768}}, + phone: { + viewport: {width: 402, height: 714}, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + }, +} as const; + +for (const [name, options] of Object.entries(CONTEXTS)) { + test(`${name}: selecting a segment never moves or resizes a segment`, async ({ + browser, + }) => { + const context = await browser.newContext(options); + const page = await context.newPage(); + await page.goto( + `${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`, + ); + const group = page.getByRole('radiogroup', {name: 'View mode'}); + await expect(group).toBeVisible(); + await page.evaluate(async () => { + await document.fonts.ready; + }); + const boxes = async () => + group.evaluate(track => + [...track.querySelectorAll('[role="radio"]')].map( + radio => { + const box = radio.getBoundingClientRect(); + return {value: radio.dataset.value, x: box.x, width: box.width}; + }, + ), + ); + const atRest = await boxes(); + expect(atRest).toHaveLength(3); + for (const value of ['list', 'table', 'grid']) { + const radio = group.locator(`[data-value="${value}"]`); + if (name === 'phone') { + await radio.tap(); + } else { + await radio.click(); + } + await expect(radio).toHaveAttribute('aria-checked', 'true'); + const now = await boxes(); + for (const [i, box] of now.entries()) { + expect( + Math.abs(box.x - atRest[i].x) < 0.01 && + Math.abs(box.width - atRest[i].width) < 0.01, + `${box.value} moved when ${value} was selected: ${JSON.stringify({ + before: atRest[i], + after: box, + })}`, + ).toBe(true); + } + } + // The twin is invisible to assistive technology: one name per radio. + await expect(group).toMatchAriaSnapshot(` + - radiogroup "View mode": + - radio "Grid" [checked] + - radio "List" + - radio "Table" + `); + await context.close(); + }); +} From 4efea35ab7b9e897fe38f7606b6ad0cdb5bfb205 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 24 Oct 2026 14:33:46 -0700 Subject: [PATCH 2/2] docs(SegmentedControl): the stable-width changeset says each label renders twice, and how wide the shift was per platform --- .changeset/segmented-control-stable-width.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/segmented-control-stable-width.md b/.changeset/segmented-control-stable-width.md index 4cb6c9b952e89..1da20c7f53c2e 100644 --- a/.changeset/segmented-control-stable-width.md +++ b/.changeset/segmented-control-stable-width.md @@ -2,5 +2,5 @@ '@astryxdesign/core': patch --- -[fix] SegmentedControl segments keep their width when the selection changes. The selected label is heavier, so a segment sized to its own label grew as it was selected and shrank as it was deselected (about 2–3 px in a content-sized control), shifting its neighbors. Each label now reserves its selected width. +[fix] SegmentedControl segments keep their width when the selection changes. The selected label is heavier, so a segment sized to its own label grew as it was selected and shrank as it was deselected, shifting its neighbors: 2–3 px in a content-sized control where the font has no face at the selected weight (as on Linux Chromium), under a pixel with the iOS and Android system fonts. Each label now reserves its selected width. Each label now also renders twice in the markup (the visible title and an aria-hidden twin), so a test that finds a segment with `getByText('Grid')` matches two nodes; find segments by role and name instead (`getByRole('radio', {name: 'Grid'})`). @vjeux