Segmented Control
下沉的轨道里并排几个选项,选中的一项是白色贴纸拇指、图标变粉;底下是单选组的语义。
segmented-control-demo.tsx
"use client"
import { Columns2Icon, EyeIcon, PencilLineIcon } from "lucide-react"
import { useState } from "react"
import { SegmentedControl } from "@/components/ui/segmented-control"
const VIEWS = [
{ value: "write", label: "编写", icon: PencilLineIcon },
{ value: "split", label: "分栏", icon: Columns2Icon },
{ value: "preview", label: "预览", icon: EyeIcon },
] as const
type View = (typeof VIEWS)[number]["value"]
export default function SegmentedControlDemo() {
const [view, setView] = useState<View>("split")
return <SegmentedControl aria-label="编辑器视图" options={VIEWS} value={view} onValueChange={setView} />
}安装
npx shadcn@latest add @anibt/segmented-control第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install radix-ui - 3. 把下面的文件放进项目components/ui/segmented-control.tsx
"use client" import * as React from "react" import { RadioGroup as RadioGroupPrimitive } from "radix-ui" import { cn } from "@/lib/utils" /** * SegmentedControl — a few mutually exclusive choices side by side * (AniBT 「贴纸手账」): 编写 / 分栏 / 预览, 列表 / 网格, 全部 / 简中 / 繁中. * * A's segmented track: the sunk `--muted` well on radius 10 with 4px of * padding and 24px items in 12/600 `--anibt-ink2`; the chosen one is a white * sticker thumb in ink with its glyph in pink. It is a Radix radio group * underneath — one tab stop, arrow keys move the choice, a screen reader * hears "radio, 2 of 3" — so it carries a form value (`name`) and always has * exactly one choice. For buttons that toggle on and off, or several at once, * use `ToggleGroup`. * * `size="default"` keeps a thumb-sized 36px item on a phone and tightens to * A's 24px from `sm`; `compact` is 24px everywhere (a toolbar). `iconOnly` * hides the words and keeps each as the item's accessible name and title. * The paint is the `anibt-seg` recipe spelled out in classes, so a caller's * classes merge. */ type SegmentedControlOption<TValue extends string> = { value: TValue label: string icon?: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" }> disabled?: boolean } type SegmentedControlProps<TValue extends string> = Omit< React.ComponentProps<typeof RadioGroupPrimitive.Root>, "value" | "defaultValue" | "onValueChange" | "children" | "dir" | "orientation" > & { options: ReadonlyArray<SegmentedControlOption<TValue>> value?: TValue defaultValue?: TValue onValueChange?: (value: TValue) => void /** A visible label above the track; it names the group. */ label?: React.ReactNode /** Hides the words (each option needs an `icon`). */ iconOnly?: boolean size?: "default" | "compact" /** Stretch the track to its container, the items sharing it equally. */ block?: boolean } function SegmentedControl<TValue extends string>({ options, value, defaultValue, onValueChange, label, iconOnly = false, size = "default", block = false, className, "aria-labelledby": labelledBy, ...props }: SegmentedControlProps<TValue>) { const labelId = React.useId() const track = ( <RadioGroupPrimitive.Root data-slot="segmented-control" data-size={size} orientation="horizontal" value={value} defaultValue={defaultValue} onValueChange={(next) => onValueChange?.(next as TValue)} aria-labelledby={label ? labelId : labelledBy} className={cn( "max-w-full items-center gap-0.5 rounded-[10px] bg-muted p-1 shadow-[inset_0_0_0_1px_var(--border)]", block ? "grid w-full auto-cols-fr grid-flow-col" : "inline-flex w-fit", !label && className )} {...props} > {options.map((option) => { const Icon = option.icon return ( <RadioGroupPrimitive.Item key={option.value} value={option.value} disabled={option.disabled} title={iconOnly ? option.label : undefined} aria-label={iconOnly ? option.label : undefined} data-slot="segmented-control-item" className={cn( "inline-flex min-w-0 cursor-pointer touch-manipulation items-center justify-center gap-1 rounded-[6px] bg-transparent text-[12px] leading-none font-semibold whitespace-nowrap text-anibt-ink2 outline-hidden transition-[color,background-color,box-shadow] duration-200 ease-anibt-out", "hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground", "data-[state=checked]:bg-card data-[state=checked]:text-foreground data-[state=checked]:shadow-(--anibt-shadow-sticker) data-[state=checked]:[&_svg]:text-anibt-mark", "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid", "disabled:cursor-not-allowed disabled:text-muted-foreground", size === "compact" ? "h-6" : "h-9 sm:h-6", iconOnly ? (size === "compact" ? "w-6" : "w-9 sm:w-6") : "px-2.5 sm:px-2" )} > {Icon ? <Icon aria-hidden="true" /> : null} {iconOnly && Icon ? null : <span className="truncate">{option.label}</span>} </RadioGroupPrimitive.Item> ) })} </RadioGroupPrimitive.Root> ) if (!label) return track return ( <div data-slot="segmented-control-field" className={cn("flex min-w-0 flex-col gap-1.5", className)}> {/* The label tier (13/600 ink2) on a span: it names the group through `aria-labelledby`; a <label> would point at nothing. */} <span id={labelId} data-slot="segmented-control-label" className="text-[13px] leading-5 font-semibold text-anibt-ink2"> {label} </span> {track} </div> ) } export { SegmentedControl } export type { SegmentedControlOption, SegmentedControlProps }
用法
用法
import { Columns2Icon, EyeIcon, PencilLineIcon } from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control"
const VIEWS = [
{ value: "write", label: "编写", icon: PencilLineIcon },
{ value: "split", label: "分栏", icon: Columns2Icon },
{ value: "preview", label: "预览", icon: EyeIcon },
] as const
<SegmentedControl aria-label="编辑器视图" options={VIEWS} value={view} onValueChange={setView} />options 的 value 类型会传给 onValueChange,不用自己再断言。
变体
排序
segmented-control-variants.tsx
"use client"
import { LayoutGridIcon, ListIcon, RowsIcon } from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control"
export default function SegmentedControlVariants() {
return (
<div className="flex w-full max-w-md flex-col gap-5">
<SegmentedControl
label="排序"
name="sort"
defaultValue="latest"
options={[
{ value: "latest", label: "最新" },
{ value: "seeders", label: "做种最多" },
{ value: "size", label: "体积" },
]}
/>
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl
aria-label="列表样式"
size="compact"
iconOnly
defaultValue="grid"
options={[
{ value: "list", label: "列表", icon: ListIcon },
{ value: "rows", label: "紧凑", icon: RowsIcon },
{ value: "grid", label: "海报墙", icon: LayoutGridIcon },
]}
/>
<SegmentedControl
aria-label="语言"
size="compact"
defaultValue="all"
options={[
{ value: "all", label: "全部" },
{ value: "chs", label: "简中" },
{ value: "cht", label: "繁中" },
{ value: "raw", label: "生肉", disabled: true },
]}
/>
</div>
<SegmentedControl
aria-label="时间范围"
block
defaultValue="week"
options={[
{ value: "day", label: "今天" },
{ value: "week", label: "本周" },
{ value: "season", label: "本季" },
]}
/>
</div>
)
}label:轨道上方的标签(13/600),并用它给整组命名;没有可见标签时给aria-label。size="compact":处处 24 高,放进工具栏。默认档在手机上是 36 高的拇指目标,sm起收回 24。iconOnly:只显示图标,选项文字成为每项的可读名称和悬停提示。block:撑满容器,各项等宽。- 选项可以单独
disabled。
和 ToggleGroup 的区别
分段控件底下是 Radix 的单选组:整组一个 Tab 位置、方向键切换、读屏念「单选,第 2 项,共 3 项」,永远选着一项,可以带 name 进表单。按下能再弹起、或者能同时按下几个的,用 Toggle Group。在页面之间切换用 Tabs。
属性
SegmentedControl
options{ value, label, icon?, disabled? }[]默认 —选项;label 是文字,也是 iconOnly 时的可读名称
valueTValue默认 —受控的选中值
defaultValueTValue默认 —非受控的初始值
onValueChange(value: TValue) => void默认 —选中改变时调用
labelReactNode默认 —轨道上方的可见标签
size"default" | "compact"默认 "default"手机 36 / 其余 24,或处处 24
iconOnlyboolean默认 false只显示图标(每项都要有 icon)
blockboolean默认 false撑满容器、各项等宽
namestring默认 —放进 form 时提交的字段名
