"use client" declare const process: { env: { NODE_ENV?: string } } | undefined import * as React from "react" import { cn } from "../lib/utils" /** * Visually lightweight segmented control for single-value selection. * * Uses role="radiogroup" / role="radio" because this is a value-selector * with no associated panels — not a tab interface. Arrow keys navigate * between options (with wrapping), Home/End jump to first/last. * * **Accessibility:** Provide either `aria-label` or `aria-labelledby` — the * ARIA spec requires every radiogroup to have an accessible name. A dev-mode * console warning fires when both are omitted. * * Design rules baked into the default variant: * - Only the SELECTED segment shows a surface colour + accent text. * - Unselected segments have NO background — they're plain-text labels * that darken on hover. This keeps the selected segment as the * single visual anchor instead of the whole group competing with * itself. */ export interface SegmentedControlOption { value: T label: React.ReactNode /** Rendered right of the label, typically a count or status pill. */ adornment?: React.ReactNode } export interface SegmentedControlProps extends Pick, "id" | "className" | "aria-label" | "aria-labelledby"> { value: T onValueChange: (value: T) => void options: SegmentedControlOption[] /** * Layout: * - "row" — horizontal pill bar (default, fits in a header region) * - "tabs" — horizontal with a bottom border so the selected pill * reads as a classic tab (used on the Team page) */ variant?: "row" | "tabs" } export function SegmentedControl({ value, onValueChange, options, variant = "row", className, ...rest }: SegmentedControlProps) { if ( typeof process !== "undefined" && process?.env?.NODE_ENV !== "production" && !rest["aria-label"] && !rest["aria-labelledby"] ) { console.warn( '[SegmentedControl] role="radiogroup" requires either aria-label or aria-labelledby for accessibility.', ) } const optionRefs = React.useRef>(new Map()) const hasMatch = options.some((o) => o.value === value) const handleKeyDown = (e: React.KeyboardEvent) => { if (options.length === 0) return // When no option matches value, start navigation from the first option let idx = options.findIndex((o) => o.value === value) if (idx === -1) idx = 0 let next: number | undefined if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % options.length else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + options.length) % options.length else if (e.key === "Home") next = 0 else if (e.key === "End") next = options.length - 1 if (next !== undefined) { e.preventDefault() if (options[next].value !== value) { onValueChange(options[next].value) } optionRefs.current.get(options[next].value)?.focus() } } return (
{options.map((option, i) => { const active = option.value === value const focusable = active || (!hasMatch && i === 0) return ( ) })}
) }