1"use client";2
3import React, { useCallback, useId, useRef } from "react";4import { motion, useReducedMotion } from "motion/react";5import { cn } from "@/lib/utils";6
7export type SegmentedOption = {8 value: string;9 label: string;10 11 icon?: React.ReactNode;12 disabled?: boolean;13};14
15export type SegmentedControlProps = {16 options: SegmentedOption[];17 value?: string;18 defaultValue?: string;19 onValueChange?: (value: string) => void;20 size?: "sm" | "md";21 22232425 equalWidth?: boolean;26 27 layoutId?: string;28 className?: string;29 "aria-label"?: string;30};31
32const SPRING = { type: "spring" as const, bounce: 0, duration: 0.22 };33const SPRING_REDUCED = { duration: 0.1 };34
3536373839export function SegmentedControl({40 options,41 value: valueProp,42 defaultValue,43 onValueChange,44 size = "md",45 equalWidth = true,46 layoutId: layoutIdProp,47 className,48 "aria-label": ariaLabel = "Options",49}: SegmentedControlProps) {50 const reduce = useReducedMotion();51 const reactId = useId();52 const layoutId = layoutIdProp ?? `segment-pill-${reactId}`;53 const listRef = useRef<HTMLDivElement>(null);54
55 const isControlled = valueProp !== undefined;56 const [uncontrolled, setUncontrolled] = React.useState(57 defaultValue ?? options[0]?.value ?? "",58 );59 const value = isControlled ? valueProp : uncontrolled;60
61 const setValue = useCallback(62 (next: string) => {63 if (!isControlled) setUncontrolled(next);64 onValueChange?.(next);65 },66 [isControlled, onValueChange],67 );68
69 const enabled = options.filter((o) => !o.disabled);70 const activeIndex = enabled.findIndex((o) => o.value === value);71
72 const focusByIndex = (index: number) => {73 const clamped = Math.min(Math.max(index, 0), enabled.length - 1);74 const opt = enabled[clamped];75 if (!opt) return;76 setValue(opt.value);77 const el = listRef.current?.querySelector<HTMLButtonElement>(78 `[data-segment="${opt.value}"]`,79 );80 el?.focus();81 };82
83 const onKeyDown = (e: React.KeyboardEvent) => {84 if (e.key === "ArrowRight" || e.key === "ArrowDown") {85 e.preventDefault();86 focusByIndex(activeIndex < 0 ? 0 : activeIndex + 1);87 } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {88 e.preventDefault();89 focusByIndex(activeIndex < 0 ? 0 : activeIndex - 1);90 } else if (e.key === "Home") {91 e.preventDefault();92 focusByIndex(0);93 } else if (e.key === "End") {94 e.preventDefault();95 focusByIndex(enabled.length - 1);96 }97 };98
99 const trackPad = size === "sm" ? "p-0.5" : "p-[3px]";100 const btn =101 size === "sm"102 ? "min-h-7 gap-1 px-2 text-[11px] leading-none"103 : "min-h-8 gap-1.5 px-2.5 text-xs leading-none";104 const trackRadius = size === "sm" ? "rounded-[9px]" : "rounded-[10px]";105 const pillRadius = size === "sm" ? "rounded-[7px]" : "rounded-[8px]";106
107 return (108 <div109 ref={listRef}110 role="radiogroup"111 aria-label={ariaLabel}112 onKeyDown={onKeyDown}113 className={cn(114 "relative inline-flex items-stretch",115 equalWidth && "w-full",116 trackRadius,117 trackPad,118 "border border-(--color-rule) bg-(--color-paper-2)/90",119 "shadow-[inset_0_1px_1px_rgba(0,0,0,0.04)]",120 "dark:shadow-[inset_0_1px_1px_rgba(0,0,0,0.25)]",121 className,122 )}123 >124 {options.map((opt) => {125 const active = opt.value === value;126 return (127 <button128 key={opt.value}129 type="button"130 role="radio"131 data-segment={opt.value}132 aria-checked={active}133 disabled={opt.disabled}134 tabIndex={active ? 0 : -1}135 onClick={() => !opt.disabled && setValue(opt.value)}136 className={cn(137 "relative z-1 inline-flex items-center justify-center font-medium tracking-tight",138 "outline-none select-none",139 "transition-[color,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",140 "active:scale-[0.97]",141 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-1",142 "disabled:pointer-events-none disabled:opacity-40",143 equalWidth && "min-w-0 flex-1",144 btn,145 pillRadius,146 active147 ? "text-(--color-ink)"148 : "text-(--color-ink-muted) hover:text-(--color-ink-2)",149 )}150 >151 {active ? (152 <motion.span153 layoutId={layoutId}154 className={cn(155 "absolute inset-0 -z-10",156 pillRadius,157 "bg-(--color-paper)",158 "border border-(--color-rule)/80",159 "shadow-[0_1px_2px_rgba(0,0,0,0.06),0_1px_0_rgba(255,255,255,0.55)_inset]",160 "dark:shadow-[0_1px_2px_rgba(0,0,0,0.4),0_1px_0_rgba(255,255,255,0.05)_inset]",161 )}162 transition={reduce ? SPRING_REDUCED : SPRING}163 />164 ) : null}165 {opt.icon ? (166 <span className="relative flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.5]">167 {opt.icon}168 </span>169 ) : null}170 <span className="relative truncate">{opt.label}</span>171 </button>172 );173 })}174 </div>175 );176}