{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quantity-stepper",
  "type": "registry:ui",
  "title": "Quantity Stepper",
  "description": "Rolls its digits, turns minus into remove at one and explains stock limits.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/quantity-stepper.tsx",
      "content": "\"use client\";\nimport { NumberField } from \"@base-ui/react/number-field\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Minus, Plus, Trash } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype ChangeDetails = NumberField.Root.ChangeEventDetails;\n\nexport type QuantityStepperProps = Omit<\n  NumberField.Root.Props,\n  \"value\" | \"defaultValue\" | \"onValueChange\" | \"min\" | \"max\" | \"step\" | \"format\" | \"className\" | \"children\" | \"render\"\n> & {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** The lowest quantity the steppers reach. */\n  min?: number;\n  /** Units in stock or the per-order limit. Plus stops here and says so. */\n  max?: number;\n  /** When set, the minus turns into a remove button at the minimum. */\n  onRemove?: () => void;\n  /** Accessible name of the remove button, e.g. \"Remove Linen shirt\". */\n  removeLabel?: string;\n  /** Accessible name of the count. */\n  label?: string;\n  /** What the hint says when the count reaches max. */\n  stockMessage?: (max: number) => string;\n  /** Which side of the stepper the stock hint appears on. Use \"bottom\" near the top of a scroll area. */\n  hintSide?: \"top\" | \"bottom\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Bordered surface, or filled with the foreground like a primary button. */\n  variant?: \"secondary\" | \"primary\";\n  /** Skip the stepper's own fill, border and shadow, for when a parent draws the surface (e.g. while morphing). */\n  bare?: boolean;\n  className?: string;\n};\n\nexport function QuantityStepper({\n  value: valueProp,\n  defaultValue = 1,\n  onValueChange,\n  min = 1,\n  max,\n  onRemove,\n  removeLabel = \"Remove\",\n  label = \"Quantity\",\n  stockMessage = (n) => `Only ${n} in stock`,\n  hintSide = \"top\",\n  size = \"md\",\n  variant = \"secondary\",\n  bare = false,\n  disabled: disabledProp,\n  readOnly,\n  className,\n  ...rest\n}: QuantityStepperProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const soldOut = max != null && max < min;\n  const disabled = disabledProp || soldOut;\n  const removing = !!onRemove && value <= min && !disabled && !readOnly;\n\n  // Typing shows the real input; stepping shows rolling digits laid over it.\n  const [editing, setEditing] = useState(false);\n  // Arrow keys change the count on the same frame; presses roll it.\n  const [instant, setInstant] = useState(false);\n  const [hint, setHint] = useState(false);\n  const fromStepper = useRef(false);\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  const flashHint = () => {\n    window.clearTimeout(timer.current);\n    setHint(true);\n    timer.current = window.setTimeout(() => setHint(false), 2600);\n  };\n\n  const change = (next: number | null, details: ChangeDetails) => {\n    // An emptied field keeps the last count; blur restores it.\n    if (next == null) return;\n    const typed = details.reason === \"input-change\" || details.reason === \"input-paste\";\n    setInstant(details.reason === \"keyboard\" || typed);\n    if (typed) setEditing(true);\n    let n = Math.round(next);\n    if (max != null && n > max) {\n      n = max;\n      flashHint();\n    } else if (max != null && n === max && value < max) flashHint();\n    else if (max != null && n < max) setHint(false);\n    setValue(Math.max(min, n));\n  };\n\n  const h = size === \"sm\" ? \"h-7\" : size === \"lg\" ? \"h-9\" : \"h-8\";\n  const square = size === \"sm\" ? \"w-7\" : size === \"lg\" ? \"w-9\" : \"w-8\";\n  const icon = size === \"sm\" ? 14 : 16;\n  const primary = variant === \"primary\";\n  // The count is sized for the largest it can hold (two digits at least) so steppers in a list line up.\n  const digits = Math.max(2, String(Math.max(max ?? 99, value)).length);\n\n  return (\n    <NumberField.Root\n      value={value}\n      onValueChange={change}\n      min={min}\n      max={soldOut ? min : max}\n      step={1}\n      disabled={disabled}\n      readOnly={readOnly}\n      data-size={size}\n      data-variant={variant}\n      data-editing={editing ? \"\" : undefined}\n      data-at-max={max != null && value >= max ? \"\" : undefined}\n      className={cn(\"relative inline-flex\", className)}\n      {...rest}\n    >\n      <NumberField.Group\n        onPointerDownCapture={(e) => {\n          const onStepper = (e.target as HTMLElement).closest(\"button\") != null;\n          fromStepper.current = onStepper;\n          // A press on the count itself means \"let me type\", even when it already has focus.\n          if (!disabled && !readOnly) setEditing(!onStepper);\n        }}\n        className={cn(\n          \"group/qty relative inline-flex w-full items-center rounded-lg border\",\n          \"transition-[border-color,box-shadow] duration-150\",\n          primary ? \"border-transparent text-frame\" : \"text-fg\",\n          !bare && (primary ? \"bg-fg\" : \"border-line-2 bg-raised shadow-[var(--shadow)]\"),\n          bare && !primary && \"border-transparent\",\n          editing && (primary ? \"ring-3 ring-fg/20\" : \"border-fg-4 ring-2 ring-fg/10\"),\n          \"data-disabled:opacity-50\",\n          h,\n        )}\n      >\n        {/* The minus and the remove button share one slot and one icon layer, so the icon morphs\n            in place while the element underneath swaps. */}\n        <span\n          className={cn(\n            \"group/slot relative grid h-full shrink-0 place-items-center rounded-l-[7px] transition-colors duration-150\",\n            primary ? \"hover:bg-frame/10\" : \"hover:bg-hover\",\n            \"has-[[data-disabled]]:bg-transparent!\",\n            square,\n          )}\n        >\n          {removing ? (\n            <button\n              type=\"button\"\n              aria-label={removeLabel}\n              onClick={onRemove}\n              className={cn(\n                // The ring sits inside the stepper's border instead of drawing a second box around the slot.\n                \"absolute inset-[3px] rounded-[5px] outline-none\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n                \"before:absolute before:-inset-x-[3px] before:-inset-y-2 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            />\n          ) : (\n            <NumberField.Decrement\n              aria-label=\"Decrease quantity\"\n              className={cn(\n                \"absolute inset-0 rounded-l-[7px] outline-none data-disabled:cursor-not-allowed\",\n                \"before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            />\n          )}\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-0 grid place-items-center\",\n              \"transition-[color,scale,opacity] duration-150 group-has-[:active]/slot:scale-90 group-has-[:active]/slot:duration-75\",\n              primary ? \"text-frame/75\" : \"text-fg-2\",\n              removing ? \"group-hover/slot:text-danger\" : primary ? \"group-hover/slot:text-frame\" : \"group-hover/slot:text-fg\",\n              \"group-has-[[data-disabled]]/slot:opacity-40\",\n            )}\n          >\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={removing ? \"remove\" : \"minus\"}\n                className=\"absolute inset-0 grid place-items-center\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: removing ? -12 : 0, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, scale: 1, rotate: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n                transition={reduce ? { duration: 0.12 } : spring.pop}\n              >\n                {removing ? <Trash size={icon === 14 ? 14 : 15} /> : <Minus size={icon} />}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        </span>\n\n        <span className=\"relative grid h-full flex-1 place-items-center\" style={{ minWidth: `calc(${digits}ch + 18px)` }}>\n          <NumberField.Input\n            aria-label={label}\n            onFocus={(e) => {\n              // Focus from a click on a stepper stays in \"stepping\" mode; anything else is typing.\n              if (fromStepper.current) return;\n              setEditing(true);\n              e.currentTarget.select();\n            }}\n            onMouseUp={(e) => {\n              // Keep the select-all from the focus above instead of dropping a caret.\n              if (editing && e.currentTarget.selectionStart !== e.currentTarget.selectionEnd) e.preventDefault();\n            }}\n            onBlur={() => {\n              fromStepper.current = false;\n              setEditing(false);\n            }}\n            className={cn(\n              // Absolute, so an input's default intrinsic width never sizes the stepper.\n              \"tabular absolute inset-0 size-full min-w-0 bg-transparent text-center font-medium outline-none\",\n              primary && \"selection:bg-frame selection:text-fg\",\n              \"text-base sm:text-[13px]\",\n              editing ? \"text-current\" : \"text-transparent caret-transparent selection:bg-transparent\",\n              \"disabled:cursor-not-allowed\",\n            )}\n          />\n          <span\n            aria-hidden\n            className={cn(\n              \"tabular pointer-events-none absolute inset-0 grid place-items-center font-medium\",\n              \"text-base sm:text-[13px]\",\n              editing && \"invisible\",\n            )}\n          >\n            <NumberFlow value={value} animated={!instant && !reduce} willChange className=\"leading-none\" />\n          </span>\n        </span>\n\n        {/* A disabled button swallows the press, so at the limit it lets it through to this slot,\n            which says why nothing happened. */}\n        <span\n          onPointerDown={() => {\n            if (max != null && value >= max && !disabled && !readOnly) flashHint();\n          }}\n          className={cn(\n            \"relative grid h-full shrink-0 place-items-center rounded-r-[7px] transition-colors duration-150\",\n            primary ? \"hover:bg-frame/10\" : \"hover:bg-hover\",\n            max != null && value >= max && \"bg-transparent! cursor-not-allowed\",\n            square,\n          )}\n        >\n          <NumberField.Increment\n            aria-label=\"Increase quantity\"\n            className={cn(\n              \"group/inc absolute inset-0 grid place-items-center rounded-r-[7px] outline-none\",\n              \"transition-[color] duration-150 data-disabled:pointer-events-none\",\n              primary ? \"text-frame/75 hover:text-frame data-disabled:text-frame/75\" : \"text-fg-2 hover:text-fg data-disabled:text-fg-2\",\n              \"before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            <Plus\n              size={icon}\n              className=\"transition-[scale,opacity] duration-150 group-active/inc:scale-90 group-active/inc:duration-75 group-data-disabled/inc:scale-100 group-data-disabled/inc:opacity-40\"\n            />\n          </NumberField.Increment>\n        </span>\n      </NumberField.Group>\n\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-x-0 z-(--z-popover) flex justify-center\",\n          hintSide === \"top\" ? \"bottom-[calc(100%+6px)]\" : \"top-[calc(100%+6px)]\",\n        )}\n      >\n        <AnimatePresence>\n          {hint && max != null && (\n            <motion.span\n              className={cn(\n                \"flex items-center gap-1.5 whitespace-nowrap rounded-md border border-line-2 bg-raised px-2 py-1 text-[11.5px] text-fg-2 shadow-pop\",\n                hintSide === \"top\" ? \"origin-bottom\" : \"origin-top\",\n              )}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: hintSide === \"top\" ? 4 : -4, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n              transition={reduce ? { duration: 0.12 } : spring.snappy}\n            >\n              <span className=\"size-1.5 rounded-full bg-warning\" />\n              {stockMessage(max)}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {hint && max != null ? stockMessage(max) : \"\"}\n      </span>\n    </NumberField.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/quantity-stepper"
}
