Skip to main content

Add a new Segmented component (WEB-1382)

Version: 0.10.0 · Type: ✨ Feature

WEB-1382 · implemented in WEB-1501

Problem​

The design system had no "segmented control" component for switching between a small, mutually-exclusive set of options (e.g. view modes, filter dimensions) with a single always-selected state and no "all unselected" intermediate state. Tabs binds selection to a content panel, which is the wrong shape for a plain value picker.

Changed Files​

  • packages/design/src/components/Segmented/Segmented.tsx
  • packages/design/src/components/Segmented/styles.ts
  • packages/design/src/components/Segmented/index.ts
  • packages/design/src/components/Segmented/SegmentedDesignSpec.md
  • packages/design/src/components/Segmented/__tests__/Segmented.test.tsx
  • packages/design/src/components/index.ts
  • packages/design-site/docs/components/segmented.mdx
  • packages/design-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/components/segmented.mdx
  • packages/design-site/src/theme/ReactLiveScope/index.tsx

Changes​

  1. New Segmented component built directly on @radix-ui/react-toggle-group (type="single"), bypassing ui/toggle-group — its default toggleVariants visual conflicts with Segmented, and reusing it would first require adding unstyledVisual support to the toggle family, out of scope for this iteration.
  2. Declarative options API with bare-string shorthand ('a' ≡ { value: 'a', label: 'a' }), covering three content shapes (text only / icon + text / icon only) via getSegmentedItemTokenClass, with the icon wrapper centered (inline-flex items-center justify-center) so icons stay vertically aligned with the item and adjacent text. Icon-only options require icon and aria-label at the type level (discriminated union), preventing an icon button from shipping without an accessible name.
  3. Supports both controlled (value) and uncontrolled (defaultValue, falling back to the first option) usage, plus block (stretch to fill the parent, options split evenly via flex-1) and whole-control / per-item disabled.
  4. Radix's type="single" fires onValueChange with an empty string when the already-selected item is clicked (its native "clear selection" behavior); Segmented intercepts the empty value in handleValueChange so it never updates state or fires onChange.
  5. Selected state uses Foregrounds-White / Foregrounds-Black rather than semantic tokens like Backgrounds-Primary / Labels-Primary, because Foregrounds stays pure white/black in both light and dark mode — matching the Figma intent that the selected pill never flips with the theme.
  6. Unselected state text uses Labels/Secondary (#3D3D3D light / #D6D6D6 dark) rather than the visually lighter Labels/Tertiary (#6B6B6B): Tertiary measures only 4.47:1 (default) / 3.67:1 (hover) against the container background, both below the WCAG 2.1 AA text contrast threshold of 4.5:1, while Secondary measures 9.11:1 (light) / 8.69:1 (dark) default, 7.47:1 (both themes) hover, and the selected state (Foregrounds-Black on Foregrounds-White) measures 21:1 in both themes.

Not Implemented​

  • antd's size, shape, and vertical are not implemented — Figma has no corresponding variant for any of them yet.
  • Container padding: 2px and radius: 7px are hardcoded fallback values; design-tokens has no matching token (only Radius_5 / Radius_12 / Radius_20 / Rounded).