跳至正文
组件

Number Stepper数量选择

方条数量选择,支持长按步进、范围约束和异步提交,保存失败自动回滚。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/number-stepper.json
会连带安装 shadcn 组件 button。已存在的文件会询问是否覆盖。

用法

components/quantity.tsx
"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,达到上限后加号不可用。

正在加载…

尺寸与状态

三档高度 28 / 32 / 36px,触屏上为 36 / 40 / 44px;按钮点击区域随之加大。

正在加载…

API

min=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 内部长按节奏。若业务需要更慢的长按速度,需等待其公开配置能力或单独实现交互。

源码

@ui/number-stepper/number-stepper.tsx
"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"
@ui/number-stepper/types.ts
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 }
@ui/number-stepper/use-number-stepper.ts
"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 }