TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { cn } from "@/lib/utils";45export interface SegmentedOption<T extends string> {6 value: T;7 label: React.ReactNode;8 icon?: React.ReactNode;9 disabled?: boolean;10 count?: number;11}1213/**14 * Segmented control (iOS-like). Keyboard: ←/→ move, Enter/Space select. Scrolls horizontally when it overflows.15 */16export function Segmented<T extends string>({ value, onChange, options, size = "md", className, ariaLabel, fill }: { value: T; onChange: (v: T) => void; options: SegmentedOption<T>[]; size?: "sm" | "md" | "lg"; className?: string; ariaLabel?: string; fill?: boolean }) {17 const ref = React.useRef<HTMLDivElement>(null);18 const idx = options.findIndex((o) => o.value === value);19 const onKey = (e: React.KeyboardEvent) => {20 if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;21 e.preventDefault();22 const dir = e.key === "ArrowLeft" ? -1 : 1;23 let i = idx;24 for (let n = 0; n < options.length; n++) {25 i = (i + dir + options.length) % options.length;26 if (!options[i].disabled) break;27 }28 onChange(options[i].value);29 (ref.current?.children[i] as HTMLElement | undefined)?.focus();30 };31 const h = size === "sm" ? "h-8" : size === "lg" ? "h-11" : "h-9";32 const item = size === "sm" ? "h-6 px-2.5 text-[12.5px]" : size === "lg" ? "h-9 px-4 text-[15px]" : "h-7 px-3 text-[13px]";33 return (34 <div ref={ref} role="tablist" aria-label={ariaLabel} onKeyDown={onKey} className={cn("inline-flex max-w-full items-center gap-0.5 overflow-x-auto rounded-lg bg-bg-muted p-1 scrollbar-none", h, fill && "flex w-full", className)}>35 {options.map((o) => {36 const active = o.value === value;37 return (38 <button39 key={o.value}40 role="tab"41 type="button"42 aria-selected={active}43 tabIndex={active ? 0 : -1}44 disabled={o.disabled}45 onClick={() => onChange(o.value)}46 className={cn(47 "tap inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-[background-color,color,box-shadow] duration-150 disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",48 item,49 fill && "flex-1",50 active ? "bg-bg-elevated text-fg shadow-xs" : "text-fg-muted hover:text-fg",51 )}52 >53 {o.icon}54 {o.label}55 {o.count !== undefined ? <span className={cn("tabular-nums text-[11px]", active ? "text-fg-subtle" : "text-fg-subtle/70")}>{o.count}</span> : null}56 </button>57 );58 })}59 </div>60 );61}6263/** Pill filter chips (multi or single select). Horizontal scroll on phones. */64export function ChipRow<T extends string>({ value, onChange, options, className, multiple }: { value: T | T[] | null; onChange: (v: T) => void; options: { value: T; label: React.ReactNode; icon?: React.ReactNode; count?: number }[]; className?: string; multiple?: boolean }) {65 const isActive = (v: T) => (Array.isArray(value) ? value.includes(v) : value === v);66 return (67 <div className={cn("flex gap-1.5 overflow-x-auto scrollbar-none", className)} role={multiple ? "group" : "radiogroup"}>68 {options.map((o) => {69 const active = isActive(o.value);70 return (71 <button72 key={o.value}73 type="button"74 role={multiple ? "checkbox" : "radio"}75 aria-checked={active}76 onClick={() => onChange(o.value)}77 className={cn(78 "tap inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition-colors [&_svg]:size-3.5",79 active ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg",80 )}81 >82 {o.icon}83 {o.label}84 {o.count !== undefined ? <span className={cn("tabular-nums text-[11px]", active ? "opacity-70" : "text-fg-subtle")}>{o.count}</span> : null}85 </button>86 );87 })}88 </div>89 );90}91