"use client"
import * as React from "react"
import { LayoutGrid, List, SquareKanban } from "lucide-react"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
const VIEW_OPTIONS = [
{ value: "list", label: "列表", icon: <List /> },
{ value: "grid", label: "网格", icon: <LayoutGrid /> },
{ value: "board", label: "看板", icon: <SquareKanban /> },
] as const
/** 固定的素材数据,六条刚好排满三种视图 */
const ASSETS = [
{ name: "品牌手册", meta: "12 页", tone: "blue" },
{ name: "首页插画", meta: "8 层", tone: "violet" },
{ name: "图标集", meta: "48 个", tone: "amber" },
{ name: "字体样本", meta: "6 字重", tone: "green" },
{ name: "动效片段", meta: "2.4 秒", tone: "rose" },
{ name: "素材打包", meta: "320 MB", tone: "cyan" },
] as const
// 看板三栏:每栏两条,素材按下标分配
const COLUMNS = [
{ title: "草稿", tint: "bg-foreground/15" },
{ title: "审核中", tint: "bg-(--flow-amber)" },
{ title: "已发布", tint: "bg-(--flow-green)" },
] as const
function AssetList() {
return (
<ul className="flex h-full flex-col justify-between gap-1.5">
{ASSETS.map((asset) => (
<li key={asset.name} className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="size-5 shrink-0 rounded-sm bg-(--flow-tone)/15 ring-1 ring-(--flow-tone)/30 ring-inset"
style={flowToneStyle(asset.tone)}
/>
<span className="min-w-0 flex-1 truncate text-xs">{asset.name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{asset.meta}
</span>
</li>
))}
</ul>
)
}
function AssetGrid() {
return (
<ul className="grid h-full grid-cols-3 grid-rows-2 gap-1.5">
{ASSETS.map((asset) => (
<li key={asset.name} className="flex min-w-0 flex-col gap-1">
<span
aria-hidden="true"
className="min-h-0 flex-1 rounded-sm bg-(--flow-tone)/15 ring-1 ring-(--flow-tone)/30 ring-inset"
style={flowToneStyle(asset.tone)}
/>
<span className="truncate text-[11px] text-muted-foreground">
{asset.name}
</span>
</li>
))}
</ul>
)
}
function AssetBoard() {
return (
<ul className="grid h-full grid-cols-3 gap-1.5">
{COLUMNS.map((column, columnIndex) => (
<li
key={column.title}
className="flex min-w-0 flex-col gap-1.5 rounded-sm bg-muted/40 p-1.5"
>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 shrink-0 rounded-full ${column.tint}`}
/>
<span className="truncate">{column.title}</span>
</span>
{[0, 1].map((row) => {
const asset = ASSETS[columnIndex * 2 + row]
return (
<span
key={asset.name}
className="truncate rounded-sm border border-edge bg-background px-1.5 py-1 text-[10px]"
>
{asset.name}
</span>
)
})}
</li>
))}
</ul>
)
}
/**
* 默认示例:素材库的视图切换。三个带图标的分段驱动下方的预览区,
* 列表、网格、看板三套排布用的是同一份固定数据。
*/
export default function SegmentedControlDemo() {
const [view, setView] = React.useState<string>("list")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">素材库</p>
<p className="font-mono text-[11px] text-muted-foreground">06 / 128</p>
</div>
<SegmentedControl
aria-label="视图"
options={VIEW_OPTIONS}
value={view}
onValueChange={setView}
/>
<div className="mt-3 h-44 overflow-hidden rounded-lg border border-edge bg-muted/20 p-2.5">
{view === "grid" ? (
<AssetGrid />
) : view === "board" ? (
<AssetBoard />
) : (
<AssetList />
)}
</div>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/segmented-control.json用法
import { LayoutGrid, List } from "lucide-react"
import { useState } from "react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
const OPTIONS = [
{ value: "list", label: "列表", icon: <List /> },
{ value: "grid", label: "网格", icon: <LayoutGrid /> },
]
export function ViewSwitch() {
const [view, setView] = useState("list")
return (
<SegmentedControl
aria-label="视图"
options={OPTIONS}
value={view}
onValueChange={setView}
/>
)
}options 是必需项,值是字符串。传 value 与 onValueChange 走受控;只传 defaultValue(或都不传,此时落在第一项)由组件自己管。
结构与语义
外框是方条:细边框(border-edge)、小圆角、bg-muted/60 的淡底,和数值滑块同一副外观。每个选项是一个 Radio.Root,整块可点;选中时选项背后滑入一块 segmented-control-indicator,用 Motion 的共享布局动画在选项之间滑动,不会闪一下再出现。
label 是方条左侧的纯文字(与数值滑块、开关的标签同一种写法),选项则收进右侧一道微微下凹的槽里,选中项是槽里凸起的一块,字段名与可选项一眼分得开。label 同时作为整个单选组的可访问名称(aria-labelledby),写了它就不必再写 aria-label;不写 label 时整条都是选项,没有这道槽。
<SegmentedControl label="对齐" options={ALIGN_OPTIONS} defaultValue="left" />高度与色调
variant 与 size 共同决定高度,两档与数值滑块完全一致,放在同一个面板里能对齐:
variant | sm | default | lg |
|---|---|---|---|
bar | 28px | 32px | 36px |
compact | 18px | 22px | 24px |
compact 同时把字号收到 text-xs,适合挤在调整面板里的一排排参数。
tone 给选中块染上一档淡色。它会把 --flow-tone 写在根元素上,所以子树里其他读同一个变量的图形也会跟着变色。不传 tone 时选中块用背景色加一圈细描边,在浅色底上像一块浮起的白色贴片。
<SegmentedControl
aria-label="时间范围"
tone="blue"
size="sm"
options={OPTIONS}
/>选项里的小样
icon 是选项文字前的小样,放图标、色点、材质方块都行;它包在 aria-hidden 的容器里,读屏只念 label。图标尺寸默认收到 14px(size-3.5),自己给 size-* 可以覆盖。
const OPTIONS = [
{ value: "cool", label: "冷色", icon: <Swatch tones={["blue", "cyan"]} /> },
{ value: "warm", label: "暖色", icon: <Swatch tones={["amber", "rose"]} /> },
]示例
色调
访问量卡片上的时间范围切换。tone="blue" 只给选中块染上一档淡蓝;下面那条柱状图自己拿 --flow-tone 取色,和选中块用的是同一个变量。
"use client"
import * as React from "react"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
/** 三个时间范围各一份固定数据,柱子高度是百分比 */
const RANGES = [
{
value: "7d",
label: "7 天",
bars: [38, 52, 44, 68, 58, 82, 74],
total: "1,284",
},
{
value: "30d",
label: "30 天",
bars: [46, 62, 55, 71, 64, 88, 79],
total: "5,610",
},
{
value: "1y",
label: "一年",
bars: [24, 34, 30, 48, 44, 62, 58],
total: "68,942",
},
] as const
const OPTIONS = RANGES.map((range) => ({
value: range.value,
label: range.label,
}))
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"]
/**
* 色调示例:访问量卡片上的时间范围切换。`tone="blue"` 只给选中块染上一档淡蓝,
* 根元素上的 `--flow-tone` 也让柱子跟着变——下面这条柱状图自己读取同一个变量。
*/
export default function SegmentedControlToneDemo() {
const [range, setRange] = React.useState<string>("30d")
const active = RANGES.find((item) => item.value === range) ?? RANGES[0]
// 最高的一根用实色,其余淡一档
const peak = Math.max(...active.bars)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">访问量</p>
<p className="font-mono text-xs tabular-nums">{active.total}</p>
</div>
<div className="mt-3">
<SegmentedControl
aria-label="时间范围"
tone="blue"
size="sm"
options={OPTIONS}
value={range}
onValueChange={setRange}
/>
</div>
<div
className="mt-3 flex h-24 items-end gap-1.5"
style={flowToneStyle("blue")}
>
{active.bars.map((height, index) => (
<span
key={WEEKDAYS[index]}
className={`flex-1 rounded-t-[2px] ${
height === peak ? "bg-(--flow-tone)" : "bg-(--flow-tone)/30"
}`}
style={{ height: `${height}%` }}
/>
))}
</div>
<div className="mt-1.5 flex gap-1.5">
{WEEKDAYS.map((day) => (
<span
key={day}
className="flex-1 text-center text-[10px] text-muted-foreground"
>
{day}
</span>
))}
</div>
</div>
)
}
左侧标签
label 是左侧的字段名,选项在右侧的槽里,读起来像「字段名 + 取值」。下面把对齐方式直接套在段落上,看得见这一行确实在起作用。
"use client"
import * as React from "react"
import { AlignCenter, AlignLeft, AlignRight } from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
const ALIGN_OPTIONS = [
{ value: "left", label: "左", icon: <AlignLeft /> },
{ value: "center", label: "居中", icon: <AlignCenter /> },
{ value: "right", label: "右", icon: <AlignRight /> },
] as const
const ALIGN_CLASS = {
left: "text-left",
center: "text-center",
right: "text-right",
} as const
/**
* 标签示例:`label` 画在最左边的格子里,与选项之间隔一条竖线,
* 同时作为单选组的可访问名称——不再需要额外的 `aria-label`。
* 下面直接把对齐方式套在段落上,看得见这一行确实在起作用。
*/
export default function SegmentedControlLabelDemo() {
const [align, setAlign] = React.useState<string>("left")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<SegmentedControl
label="对齐"
options={ALIGN_OPTIONS}
value={align}
onValueChange={setAlign}
/>
<div className="mt-3 rounded-lg border border-edge bg-muted/20 p-3">
<p
className={`text-xs leading-6 text-muted-foreground ${ALIGN_CLASS[align as keyof typeof ALIGN_CLASS]}`}
>
线条网格把内容区切成固定的几栏,文字贴着栏边排布时,
整页的重心才会落在同一根竖线上。
</p>
</div>
</div>
)
}
小样
选项文字前的 icon 换成两个色点,「中性 / 冷色 / 暖色」一眼可辨。色点对读屏隐藏,信息仍由文字承担。
"use client"
import * as React from "react"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
/** 两个色点拼成的小样,给每个选项说明它把界面调成什么颜色 */
function Swatch({ tones }: { tones: readonly FlowTone[] }) {
return (
<span aria-hidden="true" className="flex items-center gap-0.5">
{tones.map((tone) => (
<span
key={tone}
className="size-2 rounded-full bg-(--flow-tone)"
style={flowToneStyle(tone)}
/>
))}
</span>
)
}
const PRESETS = [
{
value: "neutral",
label: "中性",
primary: "foreground" as FlowTone,
tones: ["foreground", "muted"] as const,
},
{
value: "cool",
label: "冷色",
primary: "blue" as FlowTone,
tones: ["blue", "cyan"] as const,
},
{
value: "warm",
label: "暖色",
primary: "amber" as FlowTone,
tones: ["amber", "rose"] as const,
},
] as const
const OPTIONS = PRESETS.map((preset) => ({
value: preset.value,
label: preset.label,
icon: <Swatch tones={preset.tones} />,
}))
/**
* 小样示例:字体前的 `icon` 可以放任何东西,这里放两个色点,
* 选项文字仍是纯中文,色点只是让「中性 / 冷色 / 暖色」一眼可辨(`aria-hidden`,不参与读屏)。
*/
export default function SegmentedControlSwatchDemo() {
const [preset, setPreset] = React.useState<string>("cool")
const active = PRESETS.find((item) => item.value === preset) ?? PRESETS[1]
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">配色方案</p>
<p className="text-[11px] text-muted-foreground">界面预览</p>
</div>
<SegmentedControl
aria-label="配色方案"
options={OPTIONS}
value={preset}
onValueChange={setPreset}
/>
<div
className="mt-3 overflow-hidden rounded-lg border border-edge bg-background"
style={flowToneStyle(active.primary)}
>
<div className="flex items-center gap-1.5 border-b border-edge bg-(--flow-tone)/10 px-3 py-2">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-(--flow-tone)"
/>
<span className="h-1.5 w-16 rounded-full bg-(--flow-tone)/50" />
</div>
<div className="flex flex-col gap-1.5 p-3">
<span className="h-2 w-3/5 rounded-full bg-(--flow-tone)/60" />
<span className="h-2 w-4/5 rounded-full bg-foreground/12" />
<span className="h-2 w-2/5 rounded-full bg-foreground/12" />
<div className="mt-2 flex gap-1.5">
<span className="h-6 w-16 rounded-md bg-(--flow-tone)/20 ring-1 ring-(--flow-tone)/40 ring-inset" />
<span className="h-6 w-12 rounded-md bg-foreground/8" />
</div>
</div>
</div>
</div>
)
}
API
SegmentedControl
readonly SegmentedControlOption[]受控值
类型string | undefined非受控时的初始值;不传时取第一项
类型string | undefined((value: string) => void) | undefined画在最左侧格子里的标签,同时作为单选组的可访问名称
类型React.ReactNode高度档:bar 与数值滑块默认同高,compact 是面板里用的中等高度
SegmentedControlVariant | undefined"bar"尺寸档
类型SegmentedControlSize | undefined"default"色调:选中块染上 --flow-tone 的淡色;不传时选中块用背景色加描边
FlowTone | undefined标记为「已改动」:标签改用前景色,根元素带 data-modified
boolean | undefinedfalse除上表外,其余属性直接交给 Base UI 的 RadioGroup.Root:disabled(整组禁用,选中块与文字一起淡到 50%)、aria-label、required 等。
segmentedControlVariants
cva 生成的根元素样式函数,有 size 与 variant 两个变体。写自己的包装层时可以复用它,高度通过根元素上的 --segmented-control-height 变量传给内部。
const segmentedControlVariants: (props?: {
size?: SegmentedControlSize
variant?: SegmentedControlVariant
}) => string类型
type SegmentedControlSize = "sm" | "default" | "lg"
type SegmentedControlVariant = "bar" | "compact"
type SegmentedControlOption = {
value: string
label: React.ReactNode
/** 文字前的小样:图标、色点、材质方块 */
icon?: React.ReactNode
disabled?: boolean
}
type SegmentedControlProps = Omit<
React.ComponentProps<typeof RadioGroup>,
"value" | "defaultValue" | "onValueChange" | "children"
> & {
options: readonly SegmentedControlOption[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
label?: React.ReactNode
variant?: SegmentedControlVariant
size?: SegmentedControlSize
tone?: FlowTone
modified?: boolean
className?: string
style?: React.CSSProperties
}data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | segmented-control |
| 左侧标签 | segmented-control-label |
选项槽(有 label 时带凹槽底色) | segmented-control-options |
| 选项 | segmented-control-item |
| 选中块 | segmented-control-indicator |
根元素始终带 data-variant(bar / compact),modified 为真时带 data-modified,禁用时带 data-disabled;选项上是 Base UI 给的 data-checked、data-disabled。
可访问性
- 语义是单选组:根元素是
role="radiogroup",每个选项是role="radio",选中状态由aria-checked表达。方向键在选项间移动并直接选中(不是「移过去再空格确认」)。 - 组的名称取自
label(aria-labelledby);没写label时请务必给aria-label,否则读屏只能念出一个没有名字的单选组。 - 选项的小样是装饰,
aria-hidden;选项文字才是名称。 - 键盘焦点在选项上时有一圈
focus-visible焦点环,并临时抬高z-index,不会被旁边滑过来的选中块盖住。 modified只有配label时才有可见效果(标签从次要色变成前景色);没有label时只留下data-modified,供外界用选择器自己加样式。
注意事项
- 同一页里可能有多个分段选择,选中块的共享布局 id 用
React.useId()各自生成,动画不会串到另一个组上。 - 系统开启「减少动态效果」时,选中块直接跳到位,不做滑动。
- 选项文字不换行(
whitespace-nowrap+truncate),选项太多太窄时优先缩短文字,不要靠缩小字号。 - 值是字符串。需要数字或其他类型时在
onValueChange里自己转,或者用String(value)存选项的value。 - 受控 / 非受控共用一个
useControllableState;options变化后如果当前值已不存在,组件不会自动改选,请在父级同步。 tone的颜色变量由flow-diagram条目提供,安装时会一并拉取。
源码
"use client"
import * as React from "react"
import { Radio } from "@base-ui/react/radio"
import { RadioGroup } from "@base-ui/react/radio-group"
import { cva } from "class-variance-authority"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
/**
* 外框与数值滑块同一套方条:细边框、小圆角、淡底。
* 高度表也与数值滑块一致(bar 28 / 32 / 36px,compact 18 / 22 / 24px),放在同一个面板里能对齐。
*/
const segmentedControlVariants = cva(
"group/segmented-control relative flex h-(--segmented-control-height) w-full min-w-0 items-stretch overflow-hidden rounded-md border border-edge bg-muted/60 select-none",
{
variants: {
size: {
sm: "text-xs",
default: "text-sm",
lg: "text-sm",
},
variant: {
bar: "",
compact: "text-xs",
},
},
compoundVariants: [
{
variant: "bar",
size: "sm",
class: "[--segmented-control-height:1.75rem]",
},
{
variant: "bar",
size: "default",
class: "[--segmented-control-height:2rem]",
},
{
variant: "bar",
size: "lg",
class: "[--segmented-control-height:2.25rem]",
},
{
variant: "compact",
size: "sm",
class: "[--segmented-control-height:1.125rem]",
},
{
variant: "compact",
size: "default",
class: "[--segmented-control-height:1.375rem]",
},
{
variant: "compact",
size: "lg",
class: "[--segmented-control-height:1.5rem]",
},
],
defaultVariants: {
size: "default",
variant: "bar",
},
},
)
type SegmentedControlSize = "sm" | "default" | "lg"
type SegmentedControlVariant = "bar" | "compact"
type SegmentedControlOption = {
value: string
label: React.ReactNode
/** 文字前的小样:图标、色点、材质方块 */
icon?: React.ReactNode
disabled?: boolean
}
type SegmentedControlProps = Omit<
React.ComponentProps<typeof RadioGroup>,
"value" | "defaultValue" | "onValueChange" | "children"
> & {
options: readonly SegmentedControlOption[]
/** 受控值 */
value?: string
/** 非受控时的初始值;不传时取第一项 */
defaultValue?: string
onValueChange?: (value: string) => void
/** 画在最左侧格子里的标签,同时作为单选组的可访问名称 */
label?: React.ReactNode
/**
* 高度档:`bar` 与数值滑块默认同高,`compact` 是面板里用的中等高度
* @default "bar"
*/
variant?: SegmentedControlVariant
/**
* 尺寸档
* @default "default"
*/
size?: SegmentedControlSize
/** 色调:选中块染上 `--flow-tone` 的淡色;不传时选中块用背景色加描边 */
tone?: FlowTone
/**
* 标记为「已改动」:标签改用前景色,根元素带 `data-modified`
* @default false
*/
modified?: boolean
}
/**
* 分段选择:一排互斥选项,选中块在选项之间滑动。
* 语义是单选组(`role="radiogroup"`),方向键在选项间移动并直接选中。
*/
function SegmentedControl({
options,
value: valueProp,
defaultValue,
onValueChange,
label,
variant = "bar",
size = "default",
tone,
modified = false,
disabled,
className,
style,
...props
}: SegmentedControlProps) {
const [value, setValue] = useControllableState<string>({
prop: valueProp,
defaultProp: defaultValue ?? options[0]?.value ?? "",
onChange: onValueChange,
})
const labelId = React.useId()
// 同一页里可能有多个分段选择,选中块的共享布局 id 必须各自独立
const layoutId = React.useId()
const reduced = useReducedMotion()
const hasTone = tone !== undefined
return (
<RadioGroup
{...props}
value={value}
onValueChange={(next) => setValue(next as string)}
disabled={disabled}
aria-labelledby={label !== undefined ? labelId : undefined}
data-slot="segmented-control"
data-variant={variant}
data-modified={modified ? "" : undefined}
className={cn(
segmentedControlVariants({ size, variant }),
"data-[disabled]:opacity-50",
className,
)}
style={{ ...flowToneStyle(tone), ...style }}
>
{label !== undefined ? (
<span
id={labelId}
data-slot="segmented-control-label"
className="flex shrink-0 items-center ps-3 pe-2.5 font-medium whitespace-nowrap text-muted-foreground transition-colors group-data-[modified]/segmented-control:text-foreground"
>
{label}
</span>
) : null}
{/*
有标签时,选项收进右侧一道微微下凹的槽里:左边是纯文字的字段名(与数值滑块、开关的标签同一种写法),
右边才是可选的东西,标签不会被误认成第一个选项。没有标签时整条都是选项,不需要这道槽。
*/}
<span
data-slot="segmented-control-options"
className={cn(
"relative flex min-w-0 flex-1 items-stretch",
label !== undefined &&
"my-[3px] me-[3px] rounded-sm bg-foreground/5 dark:bg-foreground/10",
)}
>
{options.map((option) => {
const selected = option.value === value
return (
<Radio.Root
key={option.value}
value={option.value}
disabled={option.disabled}
data-slot="segmented-control-item"
className="relative flex min-w-0 flex-auto cursor-pointer items-center justify-center gap-1.5 px-2 whitespace-nowrap text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked]:text-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
>
{selected ? (
<motion.span
layoutId={layoutId}
aria-hidden="true"
data-slot="segmented-control-indicator"
className={cn(
"absolute inset-0.5 rounded-sm",
hasTone
? "bg-(--flow-tone)/20 dark:bg-(--flow-tone)/35"
: "bg-background shadow-xs dark:bg-foreground/15",
)}
transition={
reduced
? { duration: 0 }
: { type: "spring", bounce: 0.18, visualDuration: 0.25 }
}
/>
) : null}
{option.icon ? (
<span
aria-hidden="true"
className="relative flex shrink-0 items-center [&_svg:not([class*='size-'])]:size-3.5"
>
{option.icon}
</span>
) : null}
<span className="relative truncate">{option.label}</span>
</Radio.Root>
)
})}
</span>
</RadioGroup>
)
}
export { SegmentedControl, segmentedControlVariants }
export type {
SegmentedControlOption,
SegmentedControlProps,
SegmentedControlSize,
SegmentedControlVariant,
}
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }