{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-stepper",
  "title": "数量选择",
  "description": "方条数量选择：− 数字 +，支持长按步进、范围约束和异步提交，保存失败自动回滚。",
  "dependencies": [
    "@base-ui/react",
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ui/number-stepper/number-stepper.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { NumberField } from \"@base-ui/react/number-field\"\nimport { cva } from \"class-variance-authority\"\nimport { Loader2, Minus, Plus } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nimport type { NumberStepperLabels, NumberStepperProps } from \"./types\"\nimport { useNumberStepper } from \"./use-number-stepper\"\n\nconst DEFAULT_LABELS: NumberStepperLabels = {\n  decrement: \"减少\",\n  increment: \"增加\",\n  input: \"数量\",\n  pending: \"正在保存数量\",\n  commitError: \"保存失败，请重试\",\n}\n\nconst numberStepperControlVariants = cva(\n  \"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 in-data-[slot=grid-toolbar]:h-full! in-data-[slot=grid-toolbar]:rounded-none in-data-[slot=grid-toolbar]:border-0 in-data-[slot=grid-toolbar]:bg-transparent! in-data-[slot=grid-toolbar]:aria-invalid:ring-inset in-data-[slot=grid-toolbar]:has-[[data-slot=number-stepper-input]:focus]:ring-3 in-data-[slot=grid-toolbar]:has-[[data-slot=number-stepper-input]:focus]:ring-ring/50 in-data-[slot=grid-toolbar]:has-[[data-slot=number-stepper-input]:focus]:ring-inset\",\n  {\n    variants: {\n      size: {\n        sm: \"h-7 pointer-coarse:h-9\",\n        default: \"h-8 pointer-coarse:h-10\",\n        lg: \"h-9 pointer-coarse:h-11\",\n      },\n    },\n    defaultVariants: { size: \"default\" },\n  },\n)\n\n/** 带边界、长按步进和异步回滚的方条数量选择。 */\nfunction NumberStepper({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onValueCommit,\n  onCommitError,\n  min = 0,\n  max,\n  step = 1,\n  smallStep = step === \"any\" ? 1 : step,\n  largeStep = step === \"any\" ? 10 : step * 10,\n  allowWheelScrub = false,\n  locale = \"zh-CN\",\n  label,\n  unit,\n  size = \"default\",\n  disabled = false,\n  readOnly = false,\n  error,\n  labels: labelsProp,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": labelledBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-describedby\": describedBy,\n  ...props\n}: NumberStepperProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const autoId = React.useId()\n  const id = idProp ?? autoId\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  // 最近一次是按哪个键改的数：保存时让那个按钮转圈，数字照常显示，用户看得到自己改成了几\n  // 点加减按钮时 Base UI 会把焦点移到中间的输入框；指针带来的焦点隐藏光标，按任意键后恢复。\n  const [pointerFocus, setPointerFocus] = React.useState(false)\n  const [lastAction, setLastAction] = React.useState<\"decrement\" | \"increment\">(\n    \"increment\",\n  )\n  const { value, pending, commitError, change, commit, reset } =\n    useNumberStepper({\n      value: valueProp,\n      defaultValue,\n      onValueChange,\n      onValueCommit,\n      onCommitError,\n    })\n  const resetRef = React.useRef(reset)\n  React.useEffect(() => {\n    resetRef.current = reset\n  })\n  React.useEffect(() => {\n    const form = inputRef.current?.form\n    if (!form || valueProp !== undefined) return\n    let timer: ReturnType<typeof setTimeout> | undefined\n    const onReset = (event: Event) => {\n      timer = setTimeout(() => {\n        if (!event.defaultPrevented) {\n          // 先结束手动编辑，让 NumberField 恢复文本同步；reset 会取消这次失焦提交。\n          inputRef.current?.blur()\n          resetRef.current()\n        }\n      })\n    }\n    form.addEventListener(\"reset\", onReset)\n    return () => {\n      clearTimeout(timer)\n      form.removeEventListener(\"reset\", onReset)\n    }\n  }, [valueProp, props.form])\n\n  const message =\n    error ||\n    (commitError?.cause instanceof Error\n      ? commitError.cause.message || labels.commitError\n      : commitError\n        ? labels.commitError\n        : null)\n  const invalid =\n    Boolean(message) ||\n    (ariaInvalid !== undefined &&\n      ariaInvalid !== false &&\n      ariaInvalid !== \"false\")\n  const errorId = `${id}-error`\n  const description =\n    [describedBy, unit != null ? `${id}-unit` : undefined, message ? errorId : undefined].filter(Boolean).join(\" \") ||\n    undefined\n  const buttonSize =\n    size === \"sm\" ? \"icon-sm\" : size === \"lg\" ? \"icon-lg\" : \"icon\"\n  const buttonClass = buttonVariants({\n    variant: \"ghost\",\n    size: buttonSize,\n    // 按下时只缩小图标给一点触感，按钮底色和尺寸不动；减少动态效果时不缩。\n    // 按钮留在方条边框里面、撑满框内高度：不用负边距伸到边线上，否则按下时的底色和下沉会盖住边框（出错时的红边尤其明显）\n    className:\n      \"h-full pointer-coarse:h-full shrink-0 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\",\n  })\n\n  function handleCommit(next: number | null) {\n    if (disabled || readOnly || pending) return\n    // NumberField 只在步进时 snap，直接输入失焦时在这里按 min 对齐。\n    const snapped =\n      next === null || step === \"any\" || step <= 0\n        ? next\n        : Number((min + Math.round((next - min) / step) * step).toPrecision(15))\n    commit(\n      snapped === null\n        ? null\n        : Math.min(max ?? Infinity, Math.max(min, snapped)),\n    )\n  }\n\n  return (\n    <NumberField.Root\n      {...props}\n      id={id}\n      data-slot=\"number-stepper\"\n      data-size={size}\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly || pending ? \"\" : undefined}\n      aria-busy={pending || undefined}\n      className={cn(\n        \"inline-flex max-w-full flex-col gap-1.5 align-middle\",\n        // 只在有标签时建容器：容器查询会让无标签的自适应宽度塌成 0\n        label && \"@container/number-stepper w-full\",\n        // 工具栏的格子按内容定宽，这里取消容器查询（否则宽度塌成 0），标签始终留在条内\n        \"in-data-[slot=grid-toolbar]:h-full in-data-[slot=grid-toolbar]:w-full in-data-[slot=grid-toolbar]:[container-type:normal]\",\n        className,\n      )}\n      value={value}\n      min={min}\n      max={max}\n      step={step}\n      smallStep={smallStep}\n      largeStep={largeStep}\n      snapOnStep\n      locale={locale}\n      allowWheelScrub={allowWheelScrub}\n      disabled={disabled}\n      readOnly={readOnly || pending}\n      onValueChange={change}\n      onValueCommitted={handleCommit}\n    >\n      {label && (\n        // 容器窄于 20rem 时标签挪到条上方（与方条文本框同一规则），条内的标签只留给读屏；\n        // 这一份不关联输入框，否则可访问名称会把标签念两遍，点它时手动聚焦\n        <span\n          onClick={() => document.getElementById(id)?.focus()}\n          aria-hidden=\"true\"\n          data-slot=\"number-stepper-label-above\"\n          className=\"hidden text-xs text-muted-foreground @max-[20rem]/number-stepper:block\"\n        >\n          {label}\n        </span>\n      )}\n      <NumberField.Group\n        data-slot=\"number-stepper-control\"\n        data-pointer-focus={pointerFocus ? \"\" : undefined}\n        aria-invalid={invalid || undefined}\n        onPointerDown={(event) => {\n          if ((event.target as Element).closest(\"button\")) setPointerFocus(true)\n        }}\n        // 用捕获阶段：方向键会被 Base UI 在输入框上处理掉，冒泡阶段收不到\n        onKeyDownCapture={() => setPointerFocus(false)}\n        onBlur={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget))\n            setPointerFocus(false)\n        }}\n        className={cn(\n          numberStepperControlVariants({ size }),\n          disabled && \"cursor-not-allowed opacity-50\",\n        )}\n      >\n        {label && (\n          <label\n            data-slot=\"number-stepper-label\"\n            htmlFor={id}\n            className=\"mr-auto min-w-0 truncate px-3 text-muted-foreground @max-[20rem]/number-stepper:sr-only\"\n          >\n            {label}\n          </label>\n        )}\n        <NumberField.Decrement\n          data-slot=\"number-stepper-decrement\"\n          onPointerDown={() => setLastAction(\"decrement\")}\n          aria-label={labels.decrement}\n          disabled={pending || readOnly || (value !== null && value <= min)}\n          className={cn(\n            buttonClass,\n            \"border-r border-edge first:rounded-l-[calc(var(--radius-md)-1px)] in-data-[slot=grid-toolbar]:rounded-none! in-data-[slot=grid-toolbar]:border-dashed\",\n          )}\n        >\n          {pending && lastAction === \"decrement\" ? (\n            <Loader2\n              data-slot=\"number-stepper-spinner\"\n              aria-hidden=\"true\"\n              className=\"motion-safe:animate-spin\"\n            />\n          ) : (\n            <Minus aria-hidden=\"true\" />\n          )}\n        </NumberField.Decrement>\n        <div className=\"relative flex min-w-0 shrink items-center justify-center gap-1 px-2 in-data-[slot=grid-toolbar]:flex-1 @max-[20rem]/number-stepper:flex-1\">\n          <NumberField.Input\n            ref={inputRef}\n            data-slot=\"number-stepper-input\"\n            role=\"spinbutton\"\n            aria-roledescription={undefined}\n            aria-valuemin={min}\n            aria-valuemax={max}\n            aria-valuenow={value ?? undefined}\n            aria-label={ariaLabel ?? (label ? undefined : labels.input)}\n            aria-labelledby={labelledBy}\n            aria-invalid={invalid || undefined}\n            aria-describedby={description}\n            inputMode=\"numeric\"\n            form={props.form}\n            className={cn(\n              \"min-h-0 w-16 min-w-0 bg-transparent px-0 text-center text-sm font-medium tabular-nums outline-none in-data-pointer-focus:caret-transparent pointer-coarse:text-base\",\n              pending && \"text-muted-foreground\",\n            )}\n            onKeyDown={(event) => {\n              if ([\"ArrowDown\", \"PageDown\", \"Home\"].includes(event.key))\n                setLastAction(\"decrement\")\n              else if ([\"ArrowUp\", \"PageUp\", \"End\"].includes(event.key))\n                setLastAction(\"increment\")\n              if (\n                event.key === \"Enter\" &&\n                !event.nativeEvent.isComposing &&\n                !disabled &&\n                !readOnly &&\n                !pending\n              ) {\n                event.preventDefault()\n                event.currentTarget.blur()\n              }\n            }}\n          />\n          {unit != null && <span data-slot=\"number-stepper-unit\" aria-hidden=\"true\" className=\"shrink-0 text-xs text-muted-foreground\">{unit}</span>}\n        </div>\n        <NumberField.Increment\n          data-slot=\"number-stepper-increment\"\n          onPointerDown={() => setLastAction(\"increment\")}\n          aria-label={labels.increment}\n          disabled={\n            pending ||\n            readOnly ||\n            (value !== null && max !== undefined && value >= max)\n          }\n          className={cn(\n            buttonClass,\n            \"rounded-r-[calc(var(--radius-md)-1px)] border-l border-edge in-data-[slot=grid-toolbar]:rounded-none! in-data-[slot=grid-toolbar]:border-dashed\",\n          )}\n        >\n          {pending && lastAction === \"increment\" ? (\n            <Loader2\n              data-slot=\"number-stepper-spinner\"\n              aria-hidden=\"true\"\n              className=\"motion-safe:animate-spin\"\n            />\n          ) : (\n            <Plus aria-hidden=\"true\" />\n          )}\n        </NumberField.Increment>\n      </NumberField.Group>\n      {unit != null && <span id={`${id}-unit`} className=\"sr-only\">{unit}</span>}\n      {message && (\n        <p\n          id={errorId}\n          data-slot=\"number-stepper-error\"\n          role=\"alert\"\n          className=\"text-xs text-destructive\"\n        >\n          {message}\n        </p>\n      )}\n      <span role=\"status\" className=\"sr-only\">\n        {pending ? labels.pending : \"\"}\n      </span>\n    </NumberField.Root>\n  )\n}\n\nexport { NumberStepper, numberStepperControlVariants }\nexport type {\n  NumberStepperLabels,\n  NumberStepperProps,\n  NumberStepperSize,\n} from \"./types\"\n",
      "type": "registry:ui",
      "target": "@ui/number-stepper/number-stepper.tsx"
    },
    {
      "path": "src/registry/ui/number-stepper/types.ts",
      "content": "import type * as React from \"react\"\nimport type { NumberField } from \"@base-ui/react/number-field\"\n\ntype NumberStepperSize = \"sm\" | \"default\" | \"lg\"\n\ntype NumberStepperLabels = {\n  decrement: string\n  increment: string\n  input: string\n  pending: string\n  commitError: string\n}\n\ntype NumberStepperProps = Omit<\n  NumberField.Root.Props,\n  | \"value\"\n  | \"defaultValue\"\n  | \"onValueChange\"\n  | \"onValueCommitted\"\n  | \"children\"\n  | \"className\"\n  | \"render\"\n  | \"allowOutOfRange\"\n  | \"snapOnStep\"\n> & {\n  /** 最小数量。\n   * @default 0\n   */\n  min?: number\n  /** 最大数量；达到后加号不可用。 */\n  max?: number\n  /** 步长；失焦按 min 为基准对齐，any 关闭对齐。\n   * @default 1\n   */\n  step?: number | \"any\"\n  /** 聚焦且指针悬停时，是否允许滚轮修改数值。\n   * @default false\n   */\n  allowWheelScrub?: boolean\n  /** 受控数值；null 表示未填写。 */\n  value?: number | null\n  /** 非受控初始值。\n   * @default null\n   */\n  defaultValue?: number | null\n  /** 数值改变时调用；提交失败回滚时也调用。 */\n  onValueChange?: (value: number | null) => void\n  /** 一次操作结束、停手 400ms 后提交；Promise 期间锁定控件，失败回滚。 */\n  onValueCommit?: (value: number | null) => void | Promise<void>\n  /** 提交失败时接收原始错误；Error.message 同时显示在条下。 */\n  onCommitError?: (error: unknown) => void\n  /** 条内左侧标签；不传时控件随内容收窄。 */\n  label?: React.ReactNode\n  /** 数字右侧的单位；独立展示并通过描述关联输入，不参与提交。默认不显示。 */\n  unit?: React.ReactNode\n  /** 高度 28 / 32 / 36px；触屏 36 / 40 / 44px。\n   * @default \"default\"\n   */\n  size?: NumberStepperSize\n  /** 外部校验提示，优先于提交错误。 */\n  error?: React.ReactNode\n  /** 覆盖按钮名称、无标签输入名称和提交提示。 */\n  labels?: Partial<NumberStepperLabels>\n  /** 根元素样式。 */\n  className?: string\n}\n\nexport type { NumberStepperLabels, NumberStepperProps, NumberStepperSize }\n",
      "type": "registry:ui",
      "target": "@ui/number-stepper/types.ts"
    },
    {
      "path": "src/registry/ui/number-stepper/use-number-stepper.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport type { NumberStepperProps } from \"./types\"\n\n/** 合并一次操作中的快速变更；回滚基准是这批操作开始前的值。 */\nfunction useNumberStepper({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onValueCommit,\n  onCommitError,\n}: NumberStepperProps) {\n  const [internalValue, setInternalValue] = React.useState(defaultValue)\n  const value = valueProp === undefined ? internalValue : valueProp\n  const [pending, setPending] = React.useState(false)\n  const [commitError, setCommitError] = React.useState<{\n    cause: unknown\n  } | null>(null)\n  const current = React.useRef(value)\n  const batch = React.useRef<{ before: number | null } | null>(null)\n  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(\n    undefined,\n  )\n  const generation = React.useRef(0)\n  const busy = React.useRef(false)\n\n  React.useEffect(() => {\n    // 外部替换值时，旧请求的结果不能覆盖新值。\n    if (current.current !== value) {\n      clearTimeout(timer.current)\n      generation.current += 1\n      batch.current = null\n      busy.current = false\n      setPending(false)\n    }\n    current.current = value\n  }, [value])\n\n  React.useEffect(() => {\n    return () => {\n      clearTimeout(timer.current)\n      generation.current += 1\n    }\n  }, [])\n\n  function update(next: number | null) {\n    current.current = next\n    setInternalValue(next)\n    onValueChange?.(next)\n  }\n\n  function change(next: number | null) {\n    if (busy.current) return\n    clearTimeout(timer.current)\n    if (next === current.current) return\n    batch.current ??= { before: current.current }\n    setCommitError(null)\n    update(next)\n  }\n\n  function commit(next: number | null) {\n    if (busy.current) return\n    clearTimeout(timer.current)\n    change(next)\n    const transaction = batch.current\n    if (!transaction) return\n    if (transaction.before === next) {\n      batch.current = null\n      return\n    }\n    timer.current = setTimeout(async () => {\n      batch.current = null\n      const request = ++generation.current\n      const fail = (error: unknown) => {\n        if (request !== generation.current) return\n        update(transaction.before)\n        setCommitError({ cause: error })\n        onCommitError?.(error)\n      }\n      try {\n        const result = onValueCommit?.(next)\n        if (result && typeof result.then === \"function\") {\n          busy.current = true\n          setPending(true)\n          await result\n        }\n      } catch (error) {\n        fail(error)\n      } finally {\n        if (request === generation.current) {\n          busy.current = false\n          setPending(false)\n        }\n      }\n    }, 400)\n  }\n\n  function reset() {\n    clearTimeout(timer.current)\n    generation.current += 1\n    batch.current = null\n    busy.current = false\n    setPending(false)\n    setCommitError(null)\n    update(defaultValue)\n  }\n\n  return { value, pending, commitError, change, commit, reset }\n}\n\nexport { useNumberStepper }\n",
      "type": "registry:ui",
      "target": "@ui/number-stepper/use-number-stepper.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
