"use client"
import * as React from "react"
import { Users } from "lucide-react"
import { NumberStepper } from "@/@ui/number-stepper/number-stepper"
export default function NumberStepperDemo() {
const [adults, setAdults] = React.useState<number | null>(2)
const [children, setChildren] = React.useState<number | null>(0)
return (
<div
data-slot="number-stepper-demo"
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">
<div className="min-w-0">
<p className="text-sm font-medium">预约人数</p>
<p className="text-xs text-muted-foreground">
选好同行人数,一起出发
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
共 {(adults ?? 0) + (children ?? 0)} 人
</p>
</div>
<div className="space-y-2">
<NumberStepper
label="出行人数"
value={adults}
onValueChange={setAdults}
min={1}
max={6}
/>
<NumberStepper
label="儿童"
value={children}
onValueChange={setChildren}
max={4}
/>
</div>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Users className="size-3" aria-hidden="true" />
出行人数 1–6 位,儿童最多 4 位
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/number-stepper.json用法
"use client"
import * as React from "react"
import { NumberStepper } from "@/components/ui/number-stepper/number-stepper"
export function Quantity() {
const [value, setValue] = React.useState<number | null>(1)
return (
<NumberStepper
label="数量"
name="quantity"
value={value}
onValueChange={setValue}
min={1}
max={6}
step={1}
/>
)
}value、defaultValue 和回调都使用 number | null,null 表示未填写。受控时同时处理 onValueChange;非受控时传 defaultValue。name、form 交给 NumberField,表单提交原始数值。
范围与步长
min 默认 0,step 默认 1。到达边界后,对应按钮不可用;输入过程中可暂时为空,失焦或按 Enter 后整理显示并限制范围。非空值按 min 为基准对齐到最近的 step,再限制到 min/max;空值保留 null,需要必填时传 required。
NumberField 自带长按连续步进:等待约 400ms 后,每 60ms 改变一次,没有加速。该节奏由 Base UI 内部固定,组件不额外叠加计时器。默认小步长与 step 一致,避免整数数量在按 Alt 时变成小数;可用 smallStep 覆盖。
异步提交与回滚
<NumberStepper
label="数量"
defaultValue={1}
max={6}
onValueCommit={async (quantity) => {
const response = await fetch("/api/cart/quantity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quantity }),
})
if (!response.ok) throw new Error("保存失败,请重试")
}}
onCommitError={(error) => console.error(error)}
/>onValueChange 实时更新;onValueCommit 在按钮松开、键盘步进、失焦或 Enter 后,再等 400ms 执行。连续快速操作合并成一次提交,长按期间不提交;改回这批操作之前的数值时跳过提交。
返回 Promise 时,数字区显示转圈,输入变成只读,加减按钮锁定,根元素带 aria-busy。失败后回到这批操作开始前的值,同时调用 onValueChange 和可选的 onCommitError;Error.message 显示在条下,外部 error 优先。同步抛错同样回滚。外部主动替换 value 或非受控表单重置后,旧请求结果不会覆盖新值。
为什么默认关闭滚轮
滚动页面时容易碰到已聚焦的数量输入。allowWheelScrub 默认 false,避免无意中修改购物车;确有需要可显式开启。
示例
购物车
无 label 时随内容收窄,适合放在商品行右侧。示例用 600ms Promise 模拟保存,第二件商品库存为 3,达到上限后加号不可用。
"use client"
import * as React from "react"
import { BookOpen, NotebookPen } from "lucide-react"
import { NumberStepper } from "@/@ui/number-stepper/number-stepper"
const PRODUCTS = [
{ name: "随行笔记本", price: 39, max: 9, Icon: NotebookPen },
{ name: "城市漫游手册", price: 68, max: 3, Icon: BookOpen },
]
function currency(value: number) {
return value.toLocaleString("zh-CN", { style: "currency", currency: "CNY" })
}
export default function NumberStepperCartDemo() {
const [quantities, setQuantities] = React.useState<(number | null)[]>([1, 3])
const total = PRODUCTS.reduce(
(sum, product, index) => sum + product.price * (quantities[index] ?? 0),
0,
)
return (
<div
data-slot="number-stepper-cart-demo"
className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs"
>
<p className="text-sm font-medium">我的购物车</p>
<p className="mt-0.5 text-xs text-muted-foreground">数量停手后自动保存</p>
<div className="mt-3 divide-y divide-edge">
{PRODUCTS.map(({ name, price, max, Icon }, index) => (
<div key={name} className="flex items-center gap-2 py-3 first:pt-0">
<div
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-md border border-edge bg-primary/10 text-primary"
>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p
id={`cart-item-${index}`}
className="truncate text-xs font-medium"
>
{name}
</p>
<p className="mt-1 text-[11px] text-muted-foreground tabular-nums">
{currency(price)}
</p>
{/* 库存提示单独一行:窄屏下和价格挤一行会被截断,这是关键信息 */}
{index === 1 ? (
<p className="text-[11px] text-muted-foreground">仅余 3 件</p>
) : null}
</div>
<NumberStepper
size="sm"
aria-labelledby={`cart-item-${index}`}
value={quantities[index]}
max={max}
onValueChange={(value) =>
setQuantities((current) =>
current.map((quantity, item) =>
item === index ? value : quantity,
),
)
}
onValueCommit={() =>
new Promise<void>((resolve) => setTimeout(resolve, 600))
}
/>
</div>
))}
</div>
<div className="flex items-baseline justify-between border-t border-edge pt-3">
<span className="text-xs text-muted-foreground">合计</span>
<span className="text-lg font-semibold tabular-nums">
{currency(total)}
</span>
</div>
</div>
)
}
尺寸与状态
三档高度 28 / 32 / 36px,触屏上为 36 / 40 / 44px;按钮点击区域随之加大。
"use client"
import * as React from "react"
import { NumberStepper } from "@/@ui/number-stepper/number-stepper"
/** 当前时段还能预约几位:超过就报错,调回范围内错误自动消失 */
const AVAILABLE = 4
/** 三档尺寸、禁用,以及随值变化的出错态(初始 5 位超出可约名额,减到 4 位错误就消失)。 */
export default function NumberStepperSizesDemo() {
const [count, setCount] = React.useState<number | null>(5)
const over = count !== null && count > AVAILABLE
return (
<div
data-slot="number-stepper-sizes-demo"
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">
<div className="min-w-0">
<p className="text-sm font-medium">尺寸与状态</p>
<p className="truncate text-xs text-muted-foreground">
高度与其他方条控件同一套
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
28 / 32 / 36
</p>
</div>
<div className="flex flex-col gap-2">
<NumberStepper label="小号" size="sm" defaultValue={2} max={9} />
<NumberStepper label="默认" defaultValue={2} max={9} />
<NumberStepper label="大号" size="lg" defaultValue={2} max={9} />
<NumberStepper label="暂不可选" defaultValue={2} disabled />
<NumberStepper
label="预约数量"
value={count}
onValueChange={setCount}
max={9}
error={
over ? `当前时段最多可预约 ${AVAILABLE} 位,请调整人数` : undefined
}
/>
</div>
</div>
)
}
API
The minimum value of the input element. 最小数量。
类型number | undefined0The maximum value of the input element. 最大数量;达到后加号不可用。
类型number | undefinedAmount to increment and decrement with the buttons and arrow keys, or to scrub with pointer movement in the scrub area.
To always enable step validation on form submission, specify the min prop explicitly in conjunction with this prop.
Specify step="any" to always disable step validation; interactive stepping then uses a base amount of 1, while the alt and shift keys still step by smallStep and largeStep.
步长;失焦按 min 为基准对齐,any 关闭对齐。
number | "any" | undefined1Whether to allow the user to scrub the input value with the mouse wheel while focused and hovering over the input. 聚焦且指针悬停时,是否允许滚轮修改数值。
类型boolean | undefinedfalse受控数值;null 表示未填写。
类型number | null | undefined非受控初始值。
类型number | null | undefinednull数值改变时调用;提交失败回滚时也调用。
类型((value: number | null) => void) | undefined一次操作结束、停手 400ms 后提交;Promise 期间锁定控件,失败回滚。
类型((value: number | null) => void | Promise<void>) | undefined提交失败时接收原始错误;Error.message 同时显示在条下。
类型((error: unknown) => void) | undefined条内左侧标签;不传时控件随内容收窄。
类型React.ReactNode高度 28 / 32 / 36px;触屏 36 / 40 / 44px。
类型NumberStepperSize | undefined"default"外部校验提示,优先于提交错误。
类型React.ReactNode覆盖按钮名称、无标签输入名称和提交提示。
类型Partial<NumberStepperLabels> | undefined根元素样式。
类型string | undefinedmin=0、step=1、allowWheelScrub=false、locale="zh-CN"。未列出的属性透传至 NumberField.Root,包括 name、form、required、format、smallStep、largeStep、根元素事件和 ref。ref 指向根元素;Base UI 的 inputRef 指向提交用的隐藏输入。
id、aria-label、aria-labelledby、aria-describedby、aria-invalid 作用于可见输入。className 作用于根容器。onValueCommitted 由组件接管,对外用 onValueCommit;render、children、allowOutOfRange、snapOnStep 不开放,保证组合结构和提交规则一致。
类型与 labels
type NumberStepperSize = "sm" | "default" | "lg"
type NumberStepperLabels = {
decrement: string // "减少"
increment: string // "增加"
input: string // "数量",未提供 label / aria-label 时使用
pending: string // "正在保存数量"
commitError: string // "保存失败,请重试",非 Error 的异常兜底
}导出 NumberStepperProps、NumberStepperSize、NumberStepperLabels,以及 numberStepperControlVariants({ size })。通过 labels?: Partial<NumberStepperLabels> 覆盖文案。
data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | number-stepper |
| 方条 | number-stepper-control |
| 条内标签 | number-stepper-label |
| 减号按钮 | number-stepper-decrement |
| 输入框 | number-stepper-input |
| 加号按钮 | number-stepper-increment |
| 加载图标 | number-stepper-spinner |
| 错误提示 | number-stepper-error |
根元素带 data-size;出错、禁用、只读时分别带 data-invalid、data-disabled、data-readonly。
可访问性
- Base UI 当前可见输入默认是 text,并未声明 spinbutton;本组件补充
role="spinbutton"和aria-valuemin/max/now。键盘与步进逻辑仍交给 NumberField。 - ↑ / ↓ 增减;Shift 配合上下键用
largeStep(默认step × 10),Alt 用smallStep。Home 跳到 min,End 在设置 max 时跳到 max;Enter 整理并提交,同时让输入失焦。 - 按钮沿用 NumberField 的
tabIndex=-1,键盘通过输入框操作,触屏读屏仍可访问加减按钮。按钮名称可用labels覆盖。 - 条内
label与输入关联;无可见标签时传aria-label,否则默认为「数量」。错误通过aria-describedby关联并播报,提交状态通过role="status"提示。 - 转圈仅在允许动画时旋转,减少动态效果时保留静态图标。
注意事项
- 主要用于整数数量;小数步长可搭配
format使用,numeric 软键盘可能没有小数点键。 step应大于 0,或传"any"关闭步长对齐;max应不小于min。外部受控值与初始值应在范围内。- 非受控表单 reset 回到
defaultValue;受控表单由外部重置。 disabled不参与表单提交;readOnly保留提交值。保存期间只读,避免重叠请求覆盖回滚基准。- 不修改 Base UI 内部长按节奏。若业务需要更慢的长按速度,需等待其公开配置能力或单独实现交互。
源码
"use client"
import * as React from "react"
import { NumberField } from "@base-ui/react/number-field"
import { cva } from "class-variance-authority"
import { Loader2, Minus, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import type { NumberStepperLabels, NumberStepperProps } from "./types"
import { useNumberStepper } from "./use-number-stepper"
const DEFAULT_LABELS: NumberStepperLabels = {
decrement: "减少",
increment: "增加",
input: "数量",
pending: "正在保存数量",
commitError: "保存失败,请重试",
}
const numberStepperControlVariants = cva(
"flex min-w-0 items-center rounded-md border border-edge bg-muted/60 text-sm transition-colors not-aria-invalid:has-[[data-slot=number-stepper-input]:focus]:border-foreground/25 not-aria-invalid:has-[[data-slot=number-stepper-input]:focus]:bg-background dark:not-aria-invalid:has-[[data-slot=number-stepper-input]:focus]:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
{
variants: {
size: {
sm: "h-7 pointer-coarse:h-9",
default: "h-8 pointer-coarse:h-10",
lg: "h-9 pointer-coarse:h-11",
},
},
defaultVariants: { size: "default" },
},
)
/** 带边界、长按步进和异步回滚的方条数量选择。 */
function NumberStepper({
value: valueProp,
defaultValue = null,
onValueChange,
onValueCommit,
onCommitError,
min = 0,
max,
step = 1,
smallStep = step === "any" ? 1 : step,
largeStep = step === "any" ? 10 : step * 10,
allowWheelScrub = false,
locale = "zh-CN",
label,
size = "default",
disabled = false,
readOnly = false,
error,
labels: labelsProp,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-labelledby": labelledBy,
"aria-invalid": ariaInvalid,
"aria-describedby": describedBy,
...props
}: NumberStepperProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const autoId = React.useId()
const id = idProp ?? autoId
const inputRef = React.useRef<HTMLInputElement>(null)
// 最近一次是按哪个键改的数:保存时让那个按钮转圈,数字照常显示,用户看得到自己改成了几
// 点加减按钮时 Base UI 会把焦点移到中间的输入框;指针带来的焦点隐藏光标,按任意键后恢复。
const [pointerFocus, setPointerFocus] = React.useState(false)
const [lastAction, setLastAction] = React.useState<"decrement" | "increment">(
"increment",
)
const { value, pending, commitError, change, commit, reset } =
useNumberStepper({
value: valueProp,
defaultValue,
onValueChange,
onValueCommit,
onCommitError,
})
const resetRef = React.useRef(reset)
React.useEffect(() => {
resetRef.current = reset
})
React.useEffect(() => {
const form = inputRef.current?.form
if (!form || valueProp !== undefined) return
let timer: ReturnType<typeof setTimeout> | undefined
const onReset = (event: Event) => {
timer = setTimeout(() => {
if (!event.defaultPrevented) {
// 先结束手动编辑,让 NumberField 恢复文本同步;reset 会取消这次失焦提交。
inputRef.current?.blur()
resetRef.current()
}
})
}
form.addEventListener("reset", onReset)
return () => {
clearTimeout(timer)
form.removeEventListener("reset", onReset)
}
}, [valueProp, props.form])
const message =
error ||
(commitError?.cause instanceof Error
? commitError.cause.message || labels.commitError
: commitError
? labels.commitError
: null)
const invalid =
Boolean(message) ||
(ariaInvalid !== undefined &&
ariaInvalid !== false &&
ariaInvalid !== "false")
const errorId = `${id}-error`
const description =
[describedBy, message ? errorId : undefined].filter(Boolean).join(" ") ||
undefined
const buttonSize =
size === "sm" ? "icon-sm" : size === "lg" ? "icon-lg" : "icon"
const buttonClass = buttonVariants({
variant: "ghost",
size: buttonSize,
// 按下时只缩小图标给一点触感,按钮底色和尺寸不动;减少动态效果时不缩。
// 按钮留在方条边框里面、撑满框内高度:不用负边距伸到边线上,否则按下时的底色和下沉会盖住边框(出错时的红边尤其明显)
className:
"h-full touch-manipulation self-stretch focus-visible:ring-inset [&_svg]:transition-transform [&_svg]:duration-100 [&_svg]:ease-out active:[&_svg]:scale-[0.8] motion-reduce:[&_svg]:transition-none motion-reduce:active:[&_svg]:scale-100 rounded-none border-0 border-edge text-muted-foreground transition-colors active:not-aria-[haspopup]:translate-y-0 aria-disabled:pointer-events-none aria-disabled:opacity-40 disabled:opacity-40",
})
function handleCommit(next: number | null) {
if (disabled || readOnly || pending) return
// NumberField 只在步进时 snap,直接输入失焦时在这里按 min 对齐。
const snapped =
next === null || step === "any" || step <= 0
? next
: Number((min + Math.round((next - min) / step) * step).toPrecision(15))
commit(
snapped === null
? null
: Math.min(max ?? Infinity, Math.max(min, snapped)),
)
}
return (
<NumberField.Root
{...props}
id={id}
data-slot="number-stepper"
data-size={size}
data-invalid={invalid ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-readonly={readOnly || pending ? "" : undefined}
aria-busy={pending || undefined}
className={cn(
"inline-flex max-w-full flex-col gap-1.5 align-middle",
label && "w-full",
className,
)}
value={value}
min={min}
max={max}
step={step}
smallStep={smallStep}
largeStep={largeStep}
snapOnStep
locale={locale}
allowWheelScrub={allowWheelScrub}
disabled={disabled}
readOnly={readOnly || pending}
onValueChange={change}
onValueCommitted={handleCommit}
>
<NumberField.Group
data-slot="number-stepper-control"
data-pointer-focus={pointerFocus ? "" : undefined}
aria-invalid={invalid || undefined}
onPointerDown={(event) => {
if ((event.target as Element).closest("button")) setPointerFocus(true)
}}
// 用捕获阶段:方向键会被 Base UI 在输入框上处理掉,冒泡阶段收不到
onKeyDownCapture={() => setPointerFocus(false)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setPointerFocus(false)
}}
className={cn(
numberStepperControlVariants({ size }),
disabled && "cursor-not-allowed opacity-50",
)}
>
{label && (
<label
data-slot="number-stepper-label"
htmlFor={id}
className="mr-auto min-w-0 truncate px-3 text-muted-foreground"
>
{label}
</label>
)}
<NumberField.Decrement
data-slot="number-stepper-decrement"
onPointerDown={() => setLastAction("decrement")}
aria-label={labels.decrement}
disabled={pending || readOnly || (value !== null && value <= min)}
className={cn(
buttonClass,
"border-r border-edge first:rounded-l-[calc(var(--radius-md)-1px)]",
)}
>
{pending && lastAction === "decrement" ? (
<Loader2
data-slot="number-stepper-spinner"
aria-hidden="true"
className="motion-safe:animate-spin"
/>
) : (
<Minus aria-hidden="true" />
)}
</NumberField.Decrement>
<div className="relative min-w-0">
<NumberField.Input
ref={inputRef}
data-slot="number-stepper-input"
role="spinbutton"
aria-roledescription={undefined}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value ?? undefined}
aria-label={ariaLabel ?? (label ? undefined : labels.input)}
aria-labelledby={labelledBy}
aria-invalid={invalid || undefined}
aria-describedby={description}
inputMode="numeric"
form={props.form}
className={cn(
"field-sizing-content min-h-0 max-w-24 min-w-10 bg-transparent px-2 text-center text-sm font-medium tabular-nums outline-none in-data-pointer-focus:caret-transparent pointer-coarse:text-base",
pending && "text-muted-foreground",
)}
onKeyDown={(event) => {
if (["ArrowDown", "PageDown", "Home"].includes(event.key))
setLastAction("decrement")
else if (["ArrowUp", "PageUp", "End"].includes(event.key))
setLastAction("increment")
if (
event.key === "Enter" &&
!event.nativeEvent.isComposing &&
!disabled &&
!readOnly &&
!pending
) {
event.preventDefault()
event.currentTarget.blur()
}
}}
/>
</div>
<NumberField.Increment
data-slot="number-stepper-increment"
onPointerDown={() => setLastAction("increment")}
aria-label={labels.increment}
disabled={
pending ||
readOnly ||
(value !== null && max !== undefined && value >= max)
}
className={cn(
buttonClass,
"rounded-r-[calc(var(--radius-md)-1px)] border-l border-edge",
)}
>
{pending && lastAction === "increment" ? (
<Loader2
data-slot="number-stepper-spinner"
aria-hidden="true"
className="motion-safe:animate-spin"
/>
) : (
<Plus aria-hidden="true" />
)}
</NumberField.Increment>
</NumberField.Group>
{message && (
<p
id={errorId}
data-slot="number-stepper-error"
role="alert"
className="text-xs text-destructive"
>
{message}
</p>
)}
<span role="status" className="sr-only">
{pending ? labels.pending : ""}
</span>
</NumberField.Root>
)
}
export { NumberStepper, numberStepperControlVariants }
export type {
NumberStepperLabels,
NumberStepperProps,
NumberStepperSize,
} from "./types"
import type * as React from "react"
import type { NumberField } from "@base-ui/react/number-field"
type NumberStepperSize = "sm" | "default" | "lg"
type NumberStepperLabels = {
decrement: string
increment: string
input: string
pending: string
commitError: string
}
type NumberStepperProps = Omit<
NumberField.Root.Props,
| "value"
| "defaultValue"
| "onValueChange"
| "onValueCommitted"
| "children"
| "className"
| "render"
| "allowOutOfRange"
| "snapOnStep"
> & {
/** 最小数量。
* @default 0
*/
min?: number
/** 最大数量;达到后加号不可用。 */
max?: number
/** 步长;失焦按 min 为基准对齐,any 关闭对齐。
* @default 1
*/
step?: number | "any"
/** 聚焦且指针悬停时,是否允许滚轮修改数值。
* @default false
*/
allowWheelScrub?: boolean
/** 受控数值;null 表示未填写。 */
value?: number | null
/** 非受控初始值。
* @default null
*/
defaultValue?: number | null
/** 数值改变时调用;提交失败回滚时也调用。 */
onValueChange?: (value: number | null) => void
/** 一次操作结束、停手 400ms 后提交;Promise 期间锁定控件,失败回滚。 */
onValueCommit?: (value: number | null) => void | Promise<void>
/** 提交失败时接收原始错误;Error.message 同时显示在条下。 */
onCommitError?: (error: unknown) => void
/** 条内左侧标签;不传时控件随内容收窄。 */
label?: React.ReactNode
/** 高度 28 / 32 / 36px;触屏 36 / 40 / 44px。
* @default "default"
*/
size?: NumberStepperSize
/** 外部校验提示,优先于提交错误。 */
error?: React.ReactNode
/** 覆盖按钮名称、无标签输入名称和提交提示。 */
labels?: Partial<NumberStepperLabels>
/** 根元素样式。 */
className?: string
}
export type { NumberStepperLabels, NumberStepperProps, NumberStepperSize }
"use client"
import * as React from "react"
import type { NumberStepperProps } from "./types"
/** 合并一次操作中的快速变更;回滚基准是这批操作开始前的值。 */
function useNumberStepper({
value: valueProp,
defaultValue = null,
onValueChange,
onValueCommit,
onCommitError,
}: NumberStepperProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue)
const value = valueProp === undefined ? internalValue : valueProp
const [pending, setPending] = React.useState(false)
const [commitError, setCommitError] = React.useState<{
cause: unknown
} | null>(null)
const current = React.useRef(value)
const batch = React.useRef<{ before: number | null } | null>(null)
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const generation = React.useRef(0)
const busy = React.useRef(false)
React.useEffect(() => {
// 外部替换值时,旧请求的结果不能覆盖新值。
if (current.current !== value) {
clearTimeout(timer.current)
generation.current += 1
batch.current = null
busy.current = false
setPending(false)
}
current.current = value
}, [value])
React.useEffect(() => {
return () => {
clearTimeout(timer.current)
generation.current += 1
}
}, [])
function update(next: number | null) {
current.current = next
setInternalValue(next)
onValueChange?.(next)
}
function change(next: number | null) {
if (busy.current) return
clearTimeout(timer.current)
if (next === current.current) return
batch.current ??= { before: current.current }
setCommitError(null)
update(next)
}
function commit(next: number | null) {
if (busy.current) return
clearTimeout(timer.current)
change(next)
const transaction = batch.current
if (!transaction) return
if (transaction.before === next) {
batch.current = null
return
}
timer.current = setTimeout(async () => {
batch.current = null
const request = ++generation.current
const fail = (error: unknown) => {
if (request !== generation.current) return
update(transaction.before)
setCommitError({ cause: error })
onCommitError?.(error)
}
try {
const result = onValueCommit?.(next)
if (result && typeof result.then === "function") {
busy.current = true
setPending(true)
await result
}
} catch (error) {
fail(error)
} finally {
if (request === generation.current) {
busy.current = false
setPending(false)
}
}
}, 400)
}
function reset() {
clearTimeout(timer.current)
generation.current += 1
batch.current = null
busy.current = false
setPending(false)
setCommitError(null)
update(defaultValue)
}
return { value, pending, commitError, change, commit, reset }
}
export { useNumberStepper }