1"use client";2
3import React, {4 useCallback,5 useEffect,6 useLayoutEffect,7 useRef,8} from "react";9import { cn } from "@/lib/utils";10
11export type SegmentedOption = {12 value: string;13 label: string;14 15 icon?: React.ReactNode;16 disabled?: boolean;17};18
19export type SegmentedControlProps = {20 options: SegmentedOption[];21 value?: string;22 defaultValue?: string;23 onValueChange?: (value: string) => void;24 size?: "sm" | "md";25 26272829 equalWidth?: boolean;30 31 layoutId?: string;32 className?: string;33 "aria-label"?: string;34};35
3637383940export function SegmentedControl({41 options,42 value: valueProp,43 defaultValue,44 onValueChange,45 size = "md",46 equalWidth = true,47 layoutId: _layoutId,48 className,49 "aria-label": ariaLabel = "Options",50}: SegmentedControlProps) {51 void _layoutId;52 const listRef = useRef<HTMLDivElement>(null);53 const pillRef = useRef<HTMLSpanElement>(null);54 const animatePill = useRef(false);55
56 const isControlled = valueProp !== undefined;57 const [uncontrolled, setUncontrolled] = React.useState(58 defaultValue ?? options[0]?.value ?? "",59 );60 const value = isControlled ? valueProp : uncontrolled;61
62 const setValue = useCallback(63 (next: string, animate = false) => {64 animatePill.current = animate;65 if (!isControlled) setUncontrolled(next);66 onValueChange?.(next);67 },68 [isControlled, onValueChange],69 );70
71 const enabled = options.filter((o) => !o.disabled);72 const activeIndex = enabled.findIndex((o) => o.value === value);73
74 const moveTo = useCallback((tab: HTMLElement | null, animate: boolean) => {75 const pill = pillRef.current;76 if (!pill || !tab) return;77 if (!animate) {78 const prev = pill.style.transition;79 pill.style.transition = "none";80 pill.style.transform = `translateX(${tab.offsetLeft}px)`;81 pill.style.width = `${tab.offsetWidth}px`;82 void pill.offsetWidth;83 pill.style.transition = prev;84 } else {85 pill.style.transform = `translateX(${tab.offsetLeft}px)`;86 pill.style.width = `${tab.offsetWidth}px`;87 }88 }, []);89
90 const activeTabEl = useCallback(() => {91 const bar = listRef.current;92 if (!bar) return null;93 return (94 bar.querySelector<HTMLElement>('.t-tab[aria-selected="true"]') ??95 bar.querySelector<HTMLElement>(".t-tab")96 );97 }, []);98
99 useLayoutEffect(() => {100 moveTo(activeTabEl(), animatePill.current);101 animatePill.current = false;102 }, [value, options, size, equalWidth, moveTo, activeTabEl]);103
104 useEffect(() => {105 const onResize = () => moveTo(activeTabEl(), false);106 window.addEventListener("resize", onResize);107 return () => window.removeEventListener("resize", onResize);108 }, [moveTo, activeTabEl]);109
110 const focusByIndex = (index: number) => {111 const clamped = Math.min(Math.max(index, 0), enabled.length - 1);112 const opt = enabled[clamped];113 if (!opt) return;114 setValue(opt.value, true);115 const el = listRef.current?.querySelector<HTMLButtonElement>(116 `[data-segment="${opt.value}"]`,117 );118 el?.focus();119 };120
121 const onKeyDown = (e: React.KeyboardEvent) => {122 if (e.key === "ArrowRight" || e.key === "ArrowDown") {123 e.preventDefault();124 focusByIndex(activeIndex < 0 ? 0 : activeIndex + 1);125 } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {126 e.preventDefault();127 focusByIndex(activeIndex < 0 ? 0 : activeIndex - 1);128 } else if (e.key === "Home") {129 e.preventDefault();130 focusByIndex(0);131 } else if (e.key === "End") {132 e.preventDefault();133 focusByIndex(enabled.length - 1);134 }135 };136
137 const trackPad = size === "sm" ? "p-0.5" : "p-[3px]";138 const btn =139 size === "sm"140 ? "min-h-7 gap-1 px-2 text-[11px] leading-none"141 : "min-h-8 gap-1.5 px-2.5 text-xs leading-none";142 const trackRadius = size === "sm" ? "rounded-[9px]" : "rounded-[10px]";143 const pillRadius = size === "sm" ? "rounded-[7px]" : "rounded-[8px]";144
145 return (146 <div147 ref={listRef}148 role="radiogroup"149 aria-label={ariaLabel}150 onKeyDown={onKeyDown}151 className={cn(152 "t-tabs relative inline-flex items-stretch",153 equalWidth && "w-full",154 trackRadius,155 trackPad,156 "border border-(--color-rule) bg-(--color-paper-2)/90",157 "shadow-[inset_0_1px_1px_rgba(0,0,0,0.04)]",158 "dark:shadow-[inset_0_1px_1px_rgba(0,0,0,0.25)]",159 className,160 )}161 style={162 {163 display: "flex",164 "--tabs-bar-bg": "transparent",165 "--tabs-pill-bg": "var(--color-paper)",166 "--tabs-text-muted": "var(--color-ink-muted)",167 "--tabs-text-active": "var(--color-ink)",168 } as React.CSSProperties169 }170 >171 <span172 ref={pillRef}173 className={cn(174 "t-tabs-pill",175 pillRadius,176 "border border-(--color-rule)/80",177 "shadow-[0_1px_2px_rgba(0,0,0,0.06),0_1px_0_rgba(255,255,255,0.55)_inset]",178 "dark:shadow-[0_1px_2px_rgba(0,0,0,0.4),0_1px_0_rgba(255,255,255,0.05)_inset]",179 )}180 style={{181 top: size === "sm" ? 2 : 3,182 height: size === "sm" ? 28 : 30,183 background: "var(--color-paper)",184 }}185 aria-hidden="true"186 />187 {options.map((opt) => {188 const active = opt.value === value;189 return (190 <button191 key={opt.value}192 type="button"193 role="radio"194 data-segment={opt.value}195 aria-checked={active}196 aria-selected={active}197 disabled={opt.disabled}198 tabIndex={active ? 0 : -1}199 onClick={() => {200 if (opt.disabled) return;201 setValue(opt.value, true);202 }}203 className={cn(204 "t-tab relative z-1 inline-flex items-center justify-center font-medium tracking-tight",205 "outline-none select-none",206 "active:scale-[0.97]",207 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-1",208 "disabled:pointer-events-none disabled:opacity-40",209 equalWidth && "min-w-0 flex-1",210 btn,211 pillRadius,212 active213 ? "text-(--color-ink)"214 : "text-(--color-ink-muted) hover:text-(--color-ink-2)",215 )}216 style={{217 height: "auto",218 background: "transparent",219 borderRadius: undefined,220 }}221 >222 {opt.icon ? (223 <span className="relative flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.5]">224 {opt.icon}225 </span>226 ) : null}227 <span className="relative truncate">{opt.label}</span>228 </button>229 );230 })}231 </div>232 );233}