Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/segmented-control-stable-width.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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(
<SegmentedControl value="grid" onChange={() => {}} label="View mode">
<SegmentedControlItem value="grid" label="Grid" />
<SegmentedControlItem value="list" label="List" />
<SegmentedControlItem
value="icon"
label="Icon"
icon={<span>*</span>}
isLabelHidden
/>
</SegmentedControl>,
);
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();
});
});
39 changes: 37 additions & 2 deletions packages/core/src/SegmentedControl/SegmentedControlItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -263,7 +288,17 @@ export function SegmentedControlItem({
)}>
{iconElement}
{!isLabelHidden && (
<span {...stylex.props(styles.labelText)}>{label}</span>
<span {...stylex.props(styles.titleStack)}>
<span data-segment-title="text" {...stylex.props(styles.labelText)}>
{label}
</span>
<span
aria-hidden="true"
data-segment-title="twin"
{...stylex.props(styles.titleTwin)}>
{label}
</span>
</span>
)}
</button>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('[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();
});
}
Loading