diff --git a/.changeset/segmented-control-stable-width.md b/.changeset/segmented-control-stable-width.md
new file mode 100644
index 0000000000000..1da20c7f53c2e
--- /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, 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
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}
+
+
+ {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();
+ });
+}