From d5ff3160242986d2fac80ed9a915390b5c69b6e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 11:24:00 +0000 Subject: [PATCH 1/5] feat(ui): give SegmentedControl an option hint slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisite for the filter contract rollout (docs/filter-contract.md, PR #1847). The four one-of-N mode rails that are about to converge onto this primitive — differentials, medication, applications, specifiers — all carry a per-option count, and the primitive had nowhere to put one. Baking it into `label` would fold the number into the truncating span and lose the tabular alignment. Landing it separately because it depends on none of the contract work, and it makes each adoption a pure call-site change. Two things worth knowing: - The hint needs an explicit aria-label. Adjacent label and hint spans concatenate to "All62" in the computed accessible name, and a text node separator does not survive — the name computation normalises inter-element whitespace away. The button is therefore named "All (62)", the shape the differentials rail already used. Both behaviours are pinned by tests, including that a hintless option's name does not drift. - No .design-sync regeneration is needed, contrary to expectation: dtsPropsFor.SegmentedControl references SegmentedControlOption by name rather than expanding it, so adding a field leaves the pinned props string unchanged. Verified — all 56 design-sync tests pass. Note for the adoption PRs: ResultFilterSheet renders option.hint the same adjacent-span way, so its options announce "Crisis12" today. Same fix, tracked with the rollout rather than smuggled in here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J --- src/components/ui/segmented-control.tsx | 27 ++++++++++++++++++ tests/ui-v2-components.dom.test.tsx | 38 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/components/ui/segmented-control.tsx b/src/components/ui/segmented-control.tsx index 41d1097ebc..b3b2747b96 100644 --- a/src/components/ui/segmented-control.tsx +++ b/src/components/ui/segmented-control.tsx @@ -10,6 +10,18 @@ export type SegmentedControlOption = { label: string; icon?: LucideIcon; disabled?: boolean; + /** + * Trailing detail, almost always a count — "Presentations 41". + * + * Exists because the one-of-N rails this control replaces across the modes all + * carry a count, and baking it into `label` would fold the number into the + * truncating span and lose the tabular alignment. Never the only thing + * distinguishing two options: it joins the accessible name, so an option whose + * label is not unique without its hint reads as a near-duplicate to a screen + * reader. Mirrors `ResultFilterOption.hint`, so a mode can build one option + * array and hand it to both the desktop rail and the phone sheet. + */ + hint?: string; }; type AccessibleName = { label: string; ariaLabelledBy?: never } | { label?: never; ariaLabelledBy: string }; @@ -100,6 +112,11 @@ export function SegmentedControl({ type="button" role="radio" aria-checked={checked} + // Without this the label and hint spans concatenate to "All62" in + // the accessible name — inter-element whitespace is normalised away + // by the name computation, so a text-node separator cannot fix it. + // Matches the `${label} (${count})` shape the mode rails used. + aria-label={option.hint ? `${option.label} (${option.hint})` : undefined} tabIndex={option.value === tabStopValue ? 0 : -1} disabled={option.disabled} data-segment-value={option.value} @@ -116,6 +133,16 @@ export function SegmentedControl({ > {Icon ?