import { GitBranch } from "lucide-react"
import {
Checklist,
ChecklistItem,
ChecklistList,
ChecklistProgress,
} from "@/components/ui/checklist/checklist"
// 每条自带一种划线色调,复选框的填充跟着变色;行尾的耗时用 `meta` 显示
const TASKS = [
{ text: "类型检查与代码规范通过", meta: "12s", tone: "blue" },
{ text: "端到端测试全部通过", meta: "48s", tone: "violet" },
{ text: "无障碍与键盘操作自查", meta: "手动", tone: "cyan" },
{ text: "更新日志与迁移说明", meta: "手动", tone: "amber" },
{ text: "合并到主分支并打标签", meta: "5s", tone: "rose" },
] as const
// 自动播放的默认清单:服务端直接输出划到第 3 条的画面,进入视口后逐条往下划,划完停一会儿再重来
export default function ChecklistDemo() {
return (
<Checklist
autoplay
className="w-full max-w-md overflow-hidden rounded-xl border border-edge bg-card shadow-xs"
>
<div className="flex flex-col gap-3.5 border-b border-edge bg-[linear-gradient(to_bottom,color-mix(in_oklab,var(--flow-green)_9%,transparent),transparent)] px-5 py-4">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="grid size-8 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--flow-green)_14%,var(--background))] text-(--flow-green) ring-1 ring-(--flow-green)/30"
>
<GitBranch className="size-4" />
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">发布前检查清单</p>
<p className="truncate font-mono text-[11px] text-muted-foreground">
release/v2.4.0 → main
</p>
</div>
</div>
<ChecklistProgress total={TASKS.length} tone="green" />
</div>
<ChecklistList className="px-5 py-3">
{TASKS.map((task) => (
<ChecklistItem key={task.text} tone={task.tone} meta={task.meta}>
{task.text}
</ChecklistItem>
))}
</ChecklistList>
</Checklist>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/checklist.json用法
Checklist 提供尺寸、色调与自动播放,ChecklistList 放条目,ChecklistItem 是一条带复选框的任务。勾选时文字变淡,并被一道略有起伏的手写划线划掉。划线宽度量自文字本身,中文、英文、数字都能完整覆盖。
import {
Checklist,
ChecklistItem,
ChecklistList,
ChecklistProgress,
} from "@/components/ui/checklist/checklist"
export function ChecklistExample() {
return (
<Checklist tone="rose">
<ChecklistProgress total={3} />
<ChecklistList>
<ChecklistItem defaultChecked>写完设计稿</ChecklistItem>
<ChecklistItem>补上暗色截图</ChecklistItem>
<ChecklistItem>发布</ChecklistItem>
</ChecklistList>
</Checklist>
)
}ChecklistItem 同时支持受控与非受控:传 checked 与 onCheckedChange 由你管理状态,只传 defaultChecked 则组件自己管。ChecklistItem 要作为 ChecklistList 的直接子级(用 map 生成也可以),自动播放按子级顺序编号。页面顶部的预览给 Checklist 加了 autoplay,进入视口后会自己逐条划掉;每条用 tone 指定不同的划线色,用 meta 在行尾放耗时之类的辅助信息。
示例
交互式
非受控条目配合进度条,点击任意一条划掉或恢复,计数实时更新。划线与勾选后的复选框填充用 tone 换成蓝色;进度条的色调单独用 ChecklistProgress 的 tone 指定。
import {
Checklist,
ChecklistItem,
ChecklistList,
ChecklistProgress,
} from "@/components/ui/checklist/checklist"
const TASKS = [
{ label: "整理本周的设计稿", done: true },
{ label: "回复三封合作邮件", done: true },
{ label: "给组件库补上暗色截图", done: false },
{ label: "写完《线条网格》那篇文章", done: false },
{ label: "周五前把 OSS 部署跑通", done: false },
{ label: "买一盆新的绿植", done: false },
]
// 非受控:`defaultChecked` 给初始状态,点击任意一条划掉或恢复,进度条自动统计
export default function ChecklistInteractiveDemo() {
return (
<Checklist tone="blue" className="w-full max-w-md gap-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium">本周待办</p>
<ChecklistProgress total={TASKS.length} tone="blue" className="w-32" />
</div>
<ChecklistList>
{TASKS.map(({ label, done }) => (
<ChecklistItem key={label} defaultChecked={done}>
{label}
</ChecklistItem>
))}
</ChecklistList>
</Checklist>
)
}
紧凑款
size="sm" 缩小复选框与字号,适合侧栏和小卡片。划线与复选框换成玫红,像红笔批改。
import {
Checklist,
ChecklistItem,
ChecklistList,
} from "@/components/ui/checklist/checklist"
const ITEMS = [
{ label: "护照与签证", done: true },
{ label: "充电器和转换插头", done: true },
{ label: "下载离线地图", done: false },
{ label: "兑换少量现金", done: false },
]
// `size="sm"` 的紧凑款,适合塞进侧栏或小卡片;划线换成玫红,像红笔批改
export default function ChecklistCompactDemo() {
return (
<Checklist size="sm" tone="rose" className="w-full max-w-xs gap-2">
<p className="text-xs font-medium text-muted-foreground">出发前检查</p>
<ChecklistList>
{ITEMS.map(({ label, done }) => (
<ChecklistItem key={label} defaultChecked={done}>
{label}
</ChecklistItem>
))}
</ChecklistList>
</Checklist>
)
}
API
Checklist
autoplay 让清单按顺序自动划掉,全部完成后停 resetDelay 毫秒再清空重来。播放期间条目只读;进入视口才开始,离屏、paused、减少动态效果时停在静态快照(划掉约六成)。
自动播放:按顺序逐条划掉,全部完成后停一会儿再重置,循环往复。
播放期间条目只读;进入视口才播放,离屏、paused、减少动态效果时停在静态快照
boolean | undefinedfalse自动播放时两条之间的间隔(毫秒)
类型number | undefined1400全部划完后停留多久再重置(毫秒)
类型number | undefined2400暂停自动播放
类型boolean | undefinedfalse尺寸:sm 更紧凑,适合侧栏、小卡片
ChecklistSize | undefined"default"划线与进度条的默认色调
类型FlowTone | undefined"foreground"ChecklistList
渲染 <ul> 并给每个子级编号,同时把条目数告诉 Checklist。接受 ul 的全部属性。
ChecklistList 没有自定义 Props;该组件继承原生元素的全部属性。
ChecklistItem
受控的勾选状态
类型boolean | undefined非受控的初始勾选状态
类型boolean | undefinedfalse勾选状态变化时回调
类型((checked: boolean) => void) | undefined禁用
类型boolean | undefinedfalse只读:能看到状态,但不能点击切换。自动播放时条目自动只读
类型boolean | undefinedfalse这一条划线的色调,不传则用 Checklist 的 tone
FlowTone | undefined行尾的辅助信息(耗时、负责人等),等宽小字、靠右对齐,不参与划线
类型React.ReactNodeChecklistProgress
「已完成 / 总数」加进度条,手动勾选和自动播放都会实时更新。总数默认等条目挂载后统计,传 total 则服务端输出时也能直接显示准确计数。
总条数;服务端输出时也想显示准确计数就传它,不传则等条目挂载后自动统计
类型number | undefined进度条填充的色调
类型FlowTone | undefined"primary"读屏名称
类型string | undefined"完成进度"可访问性
- 复选框使用 Base UI 的 Checkbox,整行是
<label>,点击文字也能切换;焦点环可见,空格键切换。 - 划线只是视觉装饰,对读屏隐藏;是否完成由复选框的选中状态传达,文字同时变淡,不只靠划线。
- 进度条带
role="progressbar"与aria-valuenow,label可以改读屏名称。 - 自动播放的条目为只读状态;系统开启「减少动态效果」时不播放,划线直接显示结果。
注意事项
- 划线用 SVG 路径加
pathLength做描绘动画,纯 CSS 过渡,不依赖脚本驱动每一帧。路径的起伏来自条目序号,服务端与浏览器生成同一条线,水合不会出现差异。 - 条目文字不换行(
whitespace-nowrap),过长的文字请自行截断或缩短。 - 色调变量依赖
flow-diagram条目,受控 / 非受控逻辑依赖use-controllable-state,安装时都会一并提供。
源码
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { useInView, 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"
import {
ChecklistContext,
ChecklistRowContext,
initialStep,
type ChecklistSize,
} from "./context"
import { Scribble } from "./scribble"
type ChecklistProps = React.ComponentProps<"div"> & {
/**
* 自动播放:按顺序逐条划掉,全部完成后停一会儿再重置,循环往复。
* 播放期间条目只读;进入视口才播放,离屏、`paused`、减少动态效果时停在静态快照
* @default false
*/
autoplay?: boolean
/**
* 自动播放时两条之间的间隔(毫秒)
* @default 1400
*/
interval?: number
/**
* 全部划完后停留多久再重置(毫秒)
* @default 2400
*/
resetDelay?: number
/**
* 暂停自动播放
* @default false
*/
paused?: boolean
/**
* 尺寸:`sm` 更紧凑,适合侧栏、小卡片
* @default "default"
*/
size?: ChecklistSize
/**
* 划线与进度条的默认色调
* @default "foreground"
*/
tone?: FlowTone
}
/** 清单容器:提供尺寸、色调、自动播放和进度汇总,条目放在 `ChecklistList` 里。 */
function Checklist({
className,
autoplay = false,
interval = 1400,
resetDelay = 2400,
paused = false,
size = "default",
tone = "foreground",
...props
}: ChecklistProps) {
const ref = React.useRef<HTMLDivElement>(null)
const inView = useInView(ref, { amount: 0.3 })
const reduced = useReducedMotion()
const [total, setTotal] = React.useState(0)
const [step, setStep] = React.useState<number | null>(null)
const [reported, setReported] = React.useState<Record<number, boolean>>({})
const report = React.useCallback((index: number, checked: boolean) => {
setReported((prev) =>
prev[index] === checked ? prev : { ...prev, [index]: checked },
)
}, [])
// 每走完一步重新排下一步:划到最后一条后停 resetDelay,再清空重来
React.useEffect(() => {
if (!autoplay || paused || reduced || !inView || total === 0) return
const current = step ?? initialStep(total)
const id = window.setTimeout(
() => setStep(current >= total ? 0 : current + 1),
current >= total ? resetDelay : interval,
)
return () => window.clearTimeout(id)
}, [autoplay, paused, reduced, inView, total, step, interval, resetDelay])
const value = React.useMemo(
() => ({
size,
tone,
autoplay,
step,
total,
checked: Object.values(reported).filter(Boolean).length,
setTotal,
report,
}),
[size, tone, autoplay, step, total, reported, report],
)
return (
<ChecklistContext.Provider value={value}>
<div
ref={ref}
data-slot="checklist"
data-size={size}
data-autoplay={autoplay || undefined}
className={cn("group/checklist flex flex-col", className)}
{...props}
/>
</ChecklistContext.Provider>
)
}
/** 条目列表:给每个子级编号(自动播放按编号排序),并把条目数回报给 `Checklist`。 */
function ChecklistList({
className,
children,
...props
}: React.ComponentProps<"ul">) {
const ctx = React.useContext(ChecklistContext)
const items = React.Children.toArray(children)
const count = items.length
const setTotal = ctx?.setTotal
React.useEffect(() => setTotal?.(count), [setTotal, count])
return (
<ul
data-slot="checklist-list"
className={cn("flex flex-col", className)}
{...props}
>
{items.map((child, index) => (
<ChecklistRowContext.Provider
key={React.isValidElement(child) ? (child.key ?? index) : index}
value={{ index, count }}
>
{child}
</ChecklistRowContext.Provider>
))}
</ul>
)
}
type ChecklistItemProps = Omit<
React.ComponentProps<"li">,
"onChange" | "defaultChecked"
> & {
/** 受控的勾选状态 */
checked?: boolean
/**
* 非受控的初始勾选状态
* @default false
*/
defaultChecked?: boolean
/** 勾选状态变化时回调 */
onCheckedChange?: (checked: boolean) => void
/**
* 禁用
* @default false
*/
disabled?: boolean
/**
* 只读:能看到状态,但不能点击切换。自动播放时条目自动只读
* @default false
*/
readOnly?: boolean
/** 这一条划线的色调,不传则用 `Checklist` 的 `tone` */
tone?: FlowTone
/** 行尾的辅助信息(耗时、负责人等),等宽小字、靠右对齐,不参与划线 */
meta?: React.ReactNode
}
/** 清单条目:Base UI 复选框配手写划线,勾选时文字被划掉并变淡。 */
function ChecklistItem({
className,
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
disabled = false,
readOnly = false,
tone,
meta,
children,
style,
...props
}: ChecklistItemProps) {
const ctx = React.useContext(ChecklistContext)
const row = React.useContext(ChecklistRowContext)
const [own, setOwn] = useControllableState({
prop: checkedProp,
defaultProp: defaultChecked,
onChange: onCheckedChange,
})
// 自动播放时由步数决定;服务端和首次渲染用静态快照,水合后不会闪烁
const driven = ctx?.autoplay && row
const checked = driven
? row.index < (ctx.step ?? initialStep(row.count))
: own
const report = ctx?.report
const index = row?.index
React.useEffect(() => {
if (!report || index === undefined) return
report(index, checked)
return () => report(index, false)
}, [report, index, checked])
const locked = readOnly || !!driven
const itemTone = tone ?? ctx?.tone ?? "foreground"
return (
<li
data-slot="checklist-item"
data-checked={checked || undefined}
data-readonly={locked || undefined}
data-disabled={disabled || undefined}
className={cn("group/item", className)}
// 默认色调下复选框沿用主题强调色;指定了色调时,勾选后的填充也跟着划线变色
style={{
...flowToneStyle(itemTone),
...(itemTone !== "foreground" && {
"--checklist-fill": "var(--flow-tone)",
}),
...style,
}}
{...props}
>
<label
className={cn(
"flex items-center gap-3 py-2 text-sm group-data-[size=sm]/checklist:gap-2.5 group-data-[size=sm]/checklist:py-1.5 group-data-[size=sm]/checklist:text-[13px]",
locked || disabled ? "cursor-default" : "cursor-pointer",
disabled && "opacity-50",
)}
>
<CheckboxPrimitive.Root
checked={checked}
onCheckedChange={(next) => setOwn(next)}
disabled={disabled}
readOnly={locked}
data-slot="checklist-checkbox"
className="group/box relative flex size-5 shrink-0 items-center justify-center rounded-md border border-input bg-background text-primary-foreground transition-colors outline-none group-data-[size=sm]/checklist:size-4 group-data-[size=sm]/checklist:rounded-[5px] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none data-checked:border-[var(--checklist-fill,var(--primary))] data-checked:bg-[var(--checklist-fill,var(--primary))]"
>
<CheckboxPrimitive.Indicator
keepMounted
data-slot="checklist-indicator"
className="grid place-content-center"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="size-3.5 group-data-[size=sm]/checklist:size-3"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
>
<path
d="M3.5 8.5l3 3 6-7"
pathLength={1}
className="[stroke-dasharray:1_2] [stroke-dashoffset:1] [transition:stroke-dashoffset_0.25s_ease-out] group-data-checked/box:[transition-delay:0.12s] group-data-checked/box:[stroke-dashoffset:0] motion-reduce:[transition:none]"
/>
</svg>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<span className="relative inline-block min-w-0 whitespace-nowrap">
<span
className={cn(
"transition-colors duration-500 group-data-checked/item:text-muted-foreground motion-reduce:transition-none",
)}
>
{children}
</span>
<Scribble checked={checked} seed={index ?? 0} />
</span>
{meta ? (
<span
data-slot="checklist-meta"
className="ml-auto shrink-0 pl-3 font-mono text-[11px] text-muted-foreground/70"
>
{meta}
</span>
) : null}
</label>
</li>
)
}
type ChecklistProgressProps = Omit<React.ComponentProps<"div">, "children"> & {
/** 总条数;服务端输出时也想显示准确计数就传它,不传则等条目挂载后自动统计 */
total?: number
/**
* 进度条填充的色调
* @default "primary"
*/
tone?: FlowTone
/**
* 读屏名称
* @default "完成进度"
*/
label?: string
}
/** 进度汇总:「已完成 / 总数」与进度条,手动勾选和自动播放都会实时更新。 */
function ChecklistProgress({
className,
total: totalProp,
tone = "primary",
label = "完成进度",
style,
...props
}: ChecklistProgressProps) {
const ctx = React.useContext(ChecklistContext)
const total = totalProp ?? ctx?.total ?? 0
const checked = ctx?.autoplay
? Math.min(total, ctx.step ?? initialStep(total))
: (ctx?.checked ?? 0)
const percent = total ? (checked / total) * 100 : 0
return (
<div
data-slot="checklist-progress"
className={cn("flex items-center gap-3", className)}
style={{ ...flowToneStyle(tone), ...style }}
{...props}
>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={checked}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
>
<div
data-slot="checklist-progress-fill"
className="h-full rounded-full bg-(--flow-tone) transition-[width] duration-500 ease-out motion-reduce:transition-none"
style={{ width: `${percent}%` }}
/>
</div>
<span className="min-w-[2.5ch] text-right font-mono text-xs text-muted-foreground tabular-nums">
{total ? `${checked}/${total}` : null}
</span>
</div>
)
}
export { Checklist, ChecklistItem, ChecklistList, ChecklistProgress }
export type {
ChecklistItemProps,
ChecklistProgressProps,
ChecklistProps,
ChecklistSize,
}
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
// 只用整数运算的伪随机数:服务端与浏览器生成同一条路径,水合时不会出现差异
function createRandom(seed: number) {
let state = (seed * 2654435761 + 1013904223) >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const HEIGHT = 16
// 量到真实宽度之前(服务端输出、首次渲染)用的占位宽度,随后会被量到的值替换
const FALLBACK_WIDTH = 120
/** 把折线点平滑成二次曲线:每个中间点作控制点,相邻点的中点作端点。 */
function smooth(points: [number, number][]) {
const fmt = (n: number) => n.toFixed(1)
const [first, ...rest] = points
let d = `M${fmt(first[0])} ${fmt(first[1])}`
rest.forEach((point, i) => {
const next = rest[i + 1]
d += next
? ` Q${fmt(point[0])} ${fmt(point[1])} ${fmt((point[0] + next[0]) / 2)} ${fmt((point[1] + next[1]) / 2)}`
: ` L${fmt(point[0])} ${fmt(point[1])}`
})
return d
}
/**
* 生成「手写划掉」的路径:一道略有起伏的长划线,右端折回,再往回补半截。
* 起伏来自 seed,同一个 seed 路径固定,不同条目的划线各不相同。
*/
function scribblePath(width: number, seed: number) {
const random = createRandom(seed)
const jitter = (amount: number) => (random() - 0.5) * 2 * amount
const mid = HEIGHT / 2
const steps = Math.max(3, Math.round(width / 40))
const forward: [number, number][] = Array.from(
{ length: steps + 1 },
(_, i) => [-3 + ((width + 6) * i) / steps, mid - 1 + jitter(1.8)],
)
const back: [number, number][] = [
[width + 3, mid + 2.5 + jitter(0.8)],
[width * 0.72, mid + 2 + jitter(1.4)],
[width * 0.5, mid + 3 + jitter(1)],
]
return smooth([...forward, ...back])
}
/**
* 盖在文字上的手写划线:宽度量自文字本身,所以中文、英文、数字都能完整划过。
* 用 `pathLength` 归一化后以 dash 偏移做描绘动画,系统开启「减少动态效果」时直接显示结果。
*/
function Scribble({
checked,
seed = 0,
className,
...props
}: Omit<React.ComponentProps<"svg">, "children"> & {
/** 是否已划掉 */
checked: boolean
/** 路径种子,决定起伏 */
seed?: number
}) {
const ref = React.useRef<SVGSVGElement>(null)
const [width, setWidth] = React.useState(FALLBACK_WIDTH)
React.useLayoutEffect(() => {
const svg = ref.current
if (!svg) return
const measure = () => {
const next = Math.round(svg.getBoundingClientRect().width)
if (next > 0) setWidth(next)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(svg)
return () => observer.disconnect()
}, [])
return (
<svg
ref={ref}
data-slot="checklist-scribble"
data-checked={checked || undefined}
aria-hidden="true"
viewBox={`0 0 ${width} ${HEIGHT}`}
preserveAspectRatio="none"
className={cn(
"pointer-events-none absolute inset-x-0 top-1/2 h-4 w-full -translate-y-1/2 overflow-visible",
className,
)}
{...props}
>
<path
d={scribblePath(width, seed)}
pathLength={1}
fill="none"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"stroke-(--flow-tone)/80 [stroke-dasharray:1_2] motion-reduce:[transition:none]",
checked
? "opacity-100 [stroke-dashoffset:0] [transition:stroke-dashoffset_0.7s_cubic-bezier(0.4,0,0.2,1),opacity_0s]"
: "opacity-0 [stroke-dashoffset:1] [transition:stroke-dashoffset_0.4s_ease-in,opacity_0s_0.4s]",
)}
/>
</svg>
)
}
export { Scribble, scribblePath }
import * as React from "react"
import type { FlowTone } from "@/components/ui/flow-diagram/tone"
type ChecklistSize = "default" | "sm"
type ChecklistContextValue = {
size: ChecklistSize
tone: FlowTone
autoplay: boolean
/** 自动播放时已划掉的条数;还没开始走时为 null,用 `initialStep` 兜底 */
step: number | null
/** 列表里的条目数,由 `ChecklistList` 回报 */
total: number
/** 手动模式下已勾选的条数 */
checked: number
setTotal: (total: number) => void
report: (index: number, checked: boolean) => void
}
const ChecklistContext = React.createContext<ChecklistContextValue | null>(null)
/** 条目在列表里的位置,由 `ChecklistList` 逐个提供,自动播放按它排序。 */
const ChecklistRowContext = React.createContext<{
index: number
count: number
} | null>(null)
/** 自动播放开始前(服务端输出、减少动态效果)显示的静态快照:划掉约六成。 */
function initialStep(count: number) {
return Math.ceil(count * 0.6)
}
export { ChecklistContext, ChecklistRowContext, initialStep }
export type { ChecklistContextValue, ChecklistSize }
"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 }