Context
apps/ui/src/components/metagraphed/view-mode-toggle.tsx (ViewModeToggle, lines 16-60) and apps/ui/src/components/metagraphed/density-toggle.tsx (DensityToggle, lines 11-56) are two independently hand-rolled role="tablist"/role="tab" segmented switches with byte-for-byte identical wrapper classes ("inline-flex items-center rounded-md border border-border bg-card p-0.5") and identical button classes ("inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] font-medium transition-colors min-h-8" with the same active/inactive branch). apps/ui/src/components/metagraphed/settings-popover.tsx has its own local SegmentBtn/SegmentedRow helpers (lines 118-154) implementing the same visual segmented-control look but via aria-pressed toggle buttons rather than role="tablist"/aria-selected tabs — three call sites, no shared base, and two subtly different ARIA contracts for what should be one pattern.
Requirement
Extract a single shared SegmentedToggle primitive (generic over an options array + active value + onChange) that captures the common wrapper/button markup and styling, and have ViewModeToggle and DensityToggle render through it while preserving their existing public props and role="tablist"/role="tab"/aria-selected semantics. SettingsPopover's internal SegmentBtn/SegmentedRow should also switch to the same primitive if it can do so without changing its aria-pressed toggle semantics; if the ARIA shapes can't be reconciled without an API compromise, wire the primitive through ViewModeToggle/DensityToggle only and leave SettingsPopover as a documented follow-up rather than forcing a mismatched semantic onto it.
Deliverable
- Add
apps/ui/src/components/ui/segmented-toggle.tsx (new file, alongside the existing shared-primitive convention in that directory, e.g. popover.tsx, accordion.tsx) exporting a SegmentedToggle component that takes an options list ({ value, label, Icon? }[]), the active value, an onChange, aria-label, and className, and renders the shared wrapper + per-option button markup with role="tablist"/role="tab"/aria-selected (matching the current ViewModeToggle/DensityToggle markup exactly, byte-for-byte, so no visual/behavioral diff).
- Edit
apps/ui/src/components/metagraphed/view-mode-toggle.tsx: replace the inline <div role="tablist">...</div> block (current lines 29-58) with a call into SegmentedToggle, keeping ViewModeToggle's existing exported signature (value, onChange, options?, className) and the ViewMode type untouched so none of its four call sites (apps/ui/src/routes/subnets.index.tsx, apps/ui/src/routes/surfaces.tsx, apps/ui/src/routes/providers.index.tsx, apps/ui/src/routes/endpoints.tsx) need to change.
- Edit
apps/ui/src/components/metagraphed/density-toggle.tsx: replace the inline <div role="tablist">...</div> block (current lines 25-54) with the same SegmentedToggle call, keeping DensityToggle's existing exported signature (value, onChange, className) so its one call site (apps/ui/src/routes/subnets.index.tsx) is unaffected.
- If reconciled per the Requirement: edit
apps/ui/src/components/metagraphed/settings-popover.tsx to route its SegmentBtn/SegmentedRow (lines 118-154) through SegmentedToggle too, or delete those two local helpers if fully subsumed. If not reconciled, leave settings-popover.tsx untouched and say so explicitly in the PR description.
- "Done" = one new primitive file,
ViewModeToggle and DensityToggle both delegating their markup to it, zero changes to any of their four call sites' JSX/props, and no visual regression (identical rendered DOM/classes for the tablist pattern).
Acceptance criteria
Non-goals
Size
Size: S — keep this PR small (aim for ≤10 files / ≤1000 LOC).
Part of #2542.
Context
apps/ui/src/components/metagraphed/view-mode-toggle.tsx(ViewModeToggle, lines 16-60) andapps/ui/src/components/metagraphed/density-toggle.tsx(DensityToggle, lines 11-56) are two independently hand-rolledrole="tablist"/role="tab"segmented switches with byte-for-byte identical wrapper classes ("inline-flex items-center rounded-md border border-border bg-card p-0.5") and identical button classes ("inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] font-medium transition-colors min-h-8"with the same active/inactive branch).apps/ui/src/components/metagraphed/settings-popover.tsxhas its own localSegmentBtn/SegmentedRowhelpers (lines 118-154) implementing the same visual segmented-control look but viaaria-pressedtoggle buttons rather thanrole="tablist"/aria-selectedtabs — three call sites, no shared base, and two subtly different ARIA contracts for what should be one pattern.Requirement
Extract a single shared
SegmentedToggleprimitive (generic over an options array + active value + onChange) that captures the common wrapper/button markup and styling, and haveViewModeToggleandDensityTogglerender through it while preserving their existing public props androle="tablist"/role="tab"/aria-selectedsemantics.SettingsPopover's internalSegmentBtn/SegmentedRowshould also switch to the same primitive if it can do so without changing itsaria-pressedtoggle semantics; if the ARIA shapes can't be reconciled without an API compromise, wire the primitive throughViewModeToggle/DensityToggleonly and leaveSettingsPopoveras a documented follow-up rather than forcing a mismatched semantic onto it.Deliverable
apps/ui/src/components/ui/segmented-toggle.tsx(new file, alongside the existing shared-primitive convention in that directory, e.g.popover.tsx,accordion.tsx) exporting aSegmentedTogglecomponent that takes an options list ({ value, label, Icon? }[]), the active value, anonChange,aria-label, andclassName, and renders the shared wrapper + per-option button markup withrole="tablist"/role="tab"/aria-selected(matching the currentViewModeToggle/DensityTogglemarkup exactly, byte-for-byte, so no visual/behavioral diff).apps/ui/src/components/metagraphed/view-mode-toggle.tsx: replace the inline<div role="tablist">...</div>block (current lines 29-58) with a call intoSegmentedToggle, keepingViewModeToggle's existing exported signature (value,onChange,options?,className) and theViewModetype untouched so none of its four call sites (apps/ui/src/routes/subnets.index.tsx,apps/ui/src/routes/surfaces.tsx,apps/ui/src/routes/providers.index.tsx,apps/ui/src/routes/endpoints.tsx) need to change.apps/ui/src/components/metagraphed/density-toggle.tsx: replace the inline<div role="tablist">...</div>block (current lines 25-54) with the sameSegmentedTogglecall, keepingDensityToggle's existing exported signature (value,onChange,className) so its one call site (apps/ui/src/routes/subnets.index.tsx) is unaffected.apps/ui/src/components/metagraphed/settings-popover.tsxto route itsSegmentBtn/SegmentedRow(lines 118-154) throughSegmentedToggletoo, or delete those two local helpers if fully subsumed. If not reconciled, leavesettings-popover.tsxuntouched and say so explicitly in the PR description.ViewModeToggleandDensityToggleboth delegating their markup to it, zero changes to any of their four call sites' JSX/props, and no visual regression (identical rendered DOM/classes for the tablist pattern).Acceptance criteria
apps/ui/src/components/ui/segmented-toggle.tsxis added and exports a reusableSegmentedTogglecomponentapps/ui/src/components/metagraphed/view-mode-toggle.tsxrenders throughSegmentedToggleinstead of its own inlinerole="tablist"markup, with its exported props/type unchangedapps/ui/src/components/metagraphed/density-toggle.tsxrenders throughSegmentedToggleinstead of its own inlinerole="tablist"markup, with its exported props/type unchangedapps/ui/src/routes/subnets.index.tsx,apps/ui/src/routes/surfaces.tsx,apps/ui/src/routes/providers.index.tsx,apps/ui/src/routes/endpoints.tsx) have theirViewModeToggle/DensityToggleusage changedapps/ui/src/components/metagraphed/settings-popover.tsxto use the new primitive too, or the PR description states explicitly whySettingsPopoverwas left as-is (ARIA-shape mismatch)role="tablist"/role="tab"/aria-selectedand the active/inactive Tailwind classes are byte-identical to current behavior (before/after screenshots of the affected toggles show no visual diff)Non-goals
role="tablist"implementations found elsewhere in the codebase (endpoint-kind-tabs.tsx,endpoint-snippet.tsx,resource-explorer.tsx,analytics/drift-activity.tsx) — those are separate filter-chip/tab patterns with their own visual shape, not named in this issue's title, and pulling them in would blow the size budget below.Size
Size: S — keep this PR small (aim for ≤10 files / ≤1000 LOC).
Part of #2542.