{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quantity-button",
  "title": "Quantity Button",
  "description": "An Add button that becomes a stepper in the same footprint once there is something to count, and turns back when the last one is removed.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/quantity-button/quantity-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Minus, Plus, Trash2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type QuantityButtonProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\" | \"defaultValue\" | \"children\"\n> & {\n  /** What is being counted, such as Margherita pizza. Used in every name. */\n  label: string\n  value?: number\n  defaultValue?: number\n  /**\n   * A promise holds the new count as pending until it settles. A rejection\n   * puts the last confirmed count back.\n   */\n  onValueChange?: (value: number) => void | Promise<void>\n  /** The count Add jumps to, and the count where minus becomes remove. */\n  min?: number\n  max?: number\n  addLabel?: string\n  disabled?: boolean\n}\n\nconst ROLL = { duration: 220, easing: \"cubic-bezier(0.2, 0.8, 0.2, 1)\" }\n\nfunction isPromise(value: unknown): value is Promise<void> {\n  return (\n    typeof value === \"object\" &&\n    value !== null &&\n    typeof (value as Promise<void>).then === \"function\"\n  )\n}\n\n/**\n * An Add button that turns into a stepper in the same footprint once there is\n * something to count. Removing the last one turns it back, so a list of items\n * keeps one size whatever is in the basket.\n */\nexport function QuantityButton({\n  label,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 1,\n  max = Number.POSITIVE_INFINITY,\n  addLabel = \"Add\",\n  disabled,\n  className,\n  ...rootProps\n}: QuantityButtonProps) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const [requested, setRequested] = React.useState<number | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const committed = value ?? uncontrolled\n  const count = requested ?? committed\n  const filled = count > 0\n  const pending = requested !== null\n\n  const latest = React.useRef<number | null>(null)\n  const inFlight = React.useRef(false)\n  const root = React.useRef<HTMLDivElement>(null)\n  const addButton = React.useRef<HTMLButtonElement>(null)\n  const plusButton = React.useRef<HTMLButtonElement>(null)\n  const digits = React.useRef<HTMLSpanElement>(null)\n\n  const [rolled, setRolled] = React.useState({ from: count, to: count })\n  if (rolled.to !== count) setRolled({ from: rolled.to, to: count })\n\n  const describe = (next: number) =>\n    next === 0 ? `Removed ${label}` : `${label}, ${next}`\n\n  const wait = (next: number, result: Promise<void>) => {\n    inFlight.current = true\n    result.then(\n      () => {\n        if (value === undefined) setUncontrolled(next)\n        const queued = latest.current\n        if (queued !== null && queued !== next) {\n          wait(\n            queued,\n            new Promise((resolve) => resolve(onValueChange?.(queued)))\n          )\n          return\n        }\n        latest.current = null\n        inFlight.current = false\n        setRequested(null)\n      },\n      () => {\n        latest.current = null\n        inFlight.current = false\n        setRequested(null)\n        setAnnouncement(`Could not change ${label}.`)\n      }\n    )\n  }\n\n  const change = (raw: number) => {\n    if (disabled) return\n    const next = raw <= 0 ? 0 : Math.min(max, Math.max(min, raw))\n    if (next === count) return\n\n    setAnnouncement(describe(next))\n\n    // Taps made while a change is on its way are folded into one: only the\n    // newest count is sent once the current request settles.\n    if (inFlight.current) {\n      latest.current = next\n      setRequested(next)\n      return\n    }\n\n    const result = onValueChange?.(next)\n    if (isPromise(result)) {\n      latest.current = next\n      setRequested(next)\n      wait(next, result)\n    } else if (value === undefined) {\n      setUncontrolled(next)\n    }\n  }\n\n  // Focus follows the shape only when it was already inside, so a count that\n  // changes from elsewhere never pulls focus here.\n  const shape = React.useRef(filled)\n  React.useEffect(() => {\n    if (shape.current === filled) return\n    shape.current = filled\n    if (!root.current?.contains(document.activeElement)) return\n    ;(filled ? plusButton : addButton).current?.focus()\n  }, [filled])\n\n  React.useLayoutEffect(() => {\n    const strip = digits.current\n    if (!strip || rolled.from === 0 || rolled.to === 0) return\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return\n\n    const direction = rolled.to > rolled.from ? 1 : -1\n    for (const place of strip.querySelectorAll<HTMLElement>(\"[data-changed]\")) {\n      place.querySelector(\"[data-digit=current]\")?.animate?.(\n        [\n          { transform: `translateY(${direction * 100}%)`, opacity: 0 },\n          { transform: \"translateY(0)\", opacity: 1 },\n        ],\n        ROLL\n      )\n      place.querySelector(\"[data-digit=previous]\")?.animate?.(\n        [\n          { transform: \"translateY(0)\", opacity: 1 },\n          { transform: `translateY(${-direction * 100}%)`, opacity: 0 },\n        ],\n        ROLL\n      )\n    }\n  }, [rolled])\n\n  // Nothing rolls in from zero or out to it: the whole count fades instead.\n  const now = String(rolled.to || rolled.from)\n  const before = rolled.from && rolled.to ? String(rolled.from) : now\n  const places = Math.max(now.length, before.length)\n  const atMin = count <= min\n  const atMax = count >= max\n\n  const part =\n    \"focus-visible:ring-ring relative inline-flex size-11 shrink-0 items-center justify-center rounded-full transition-[opacity,transform] duration-200 ease-out focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40\"\n\n  return (\n    <div\n      ref={root}\n      role=\"group\"\n      aria-label={label}\n      aria-busy={pending || undefined}\n      data-slot=\"quantity-button\"\n      data-state={filled ? \"filled\" : \"empty\"}\n      data-pending={pending ? \"\" : undefined}\n      className={cn(\n        \"bg-primary text-primary-foreground relative inline-flex h-11 w-[7.5rem] items-center overflow-hidden rounded-full\",\n        disabled && \"opacity-50\",\n        className\n      )}\n      {...rootProps}\n    >\n      <button\n        ref={addButton}\n        type=\"button\"\n        data-slot=\"quantity-button-add\"\n        aria-label={`${addLabel} ${label}`}\n        aria-hidden={filled || undefined}\n        tabIndex={filled ? -1 : undefined}\n        disabled={disabled}\n        onClick={() => change(min)}\n        className={cn(\n          \"focus-visible:ring-ring absolute inset-0 inline-flex items-center justify-center gap-1.5 rounded-full text-sm font-semibold transition-[opacity,transform] duration-200 ease-out focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none\",\n          filled ? \"pointer-events-none scale-95 opacity-0\" : \"opacity-100\"\n        )}\n      >\n        <Plus aria-hidden=\"true\" size={16} strokeWidth={2.5} />\n        {addLabel}\n      </button>\n\n      <div\n        aria-hidden={!filled || undefined}\n        className={cn(\n          \"flex w-full items-center justify-between\",\n          !filled && \"pointer-events-none\"\n        )}\n      >\n        <button\n          type=\"button\"\n          data-slot=\"quantity-button-decrement\"\n          tabIndex={filled ? undefined : -1}\n          aria-label={atMin ? `Remove ${label}` : `One fewer ${label}`}\n          aria-disabled={disabled || undefined}\n          onClick={() => change(atMin ? 0 : count - 1)}\n          className={cn(\n            part,\n            filled ? \"translate-x-0 opacity-100\" : \"translate-x-8 opacity-0\"\n          )}\n        >\n          <Minus\n            aria-hidden=\"true\"\n            size={16}\n            strokeWidth={2.5}\n            className={cn(\n              \"absolute transition-[opacity,transform] duration-150 motion-reduce:transition-none\",\n              atMin ? \"scale-50 opacity-0\" : \"opacity-100\"\n            )}\n          />\n          <Trash2\n            aria-hidden=\"true\"\n            size={16}\n            strokeWidth={2.25}\n            className={cn(\n              \"absolute transition-[opacity,transform] duration-150 motion-reduce:transition-none\",\n              atMin ? \"opacity-100\" : \"scale-50 opacity-0\"\n            )}\n          />\n        </button>\n\n        <span\n          role=\"spinbutton\"\n          tabIndex={filled ? 0 : -1}\n          aria-label={`${label} quantity`}\n          aria-valuenow={count}\n          aria-valuemin={min}\n          aria-valuemax={Number.isFinite(max) ? max : undefined}\n          aria-disabled={disabled || undefined}\n          data-slot=\"quantity-button-count\"\n          onKeyDown={(event) => {\n            const moves: Record<string, number> = {\n              ArrowUp: count + 1,\n              ArrowRight: count + 1,\n              ArrowDown: Math.max(min, count - 1),\n              ArrowLeft: Math.max(min, count - 1),\n              Home: min,\n            }\n            if (Number.isFinite(max)) moves.End = max\n            if (!(event.key in moves)) return\n            event.preventDefault()\n            change(moves[event.key]!)\n          }}\n          className={cn(\n            \"focus-visible:ring-ring min-w-6 rounded-md px-1 text-center text-sm font-semibold tabular-nums transition-opacity duration-200 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\",\n            filled ? \"opacity-100\" : \"opacity-0\",\n            pending && \"opacity-70\"\n          )}\n        >\n          <span\n            ref={digits}\n            aria-hidden=\"true\"\n            className=\"inline-flex justify-center\"\n          >\n            {Array.from({ length: places }, (_, place) => {\n              const current = now[now.length - places + place] ?? \"\"\n              const previous = before[before.length - places + place] ?? \"\"\n              return (\n                <span\n                  key={places - place}\n                  data-changed={current !== previous ? \"\" : undefined}\n                  className=\"relative inline-block overflow-hidden\"\n                >\n                  <span data-digit=\"current\" className=\"inline-block\">\n                    {current}\n                  </span>\n                  <span\n                    data-digit=\"previous\"\n                    className=\"absolute inset-0 opacity-0\"\n                  >\n                    {previous}\n                  </span>\n                </span>\n              )\n            })}\n          </span>\n        </span>\n\n        <button\n          ref={plusButton}\n          type=\"button\"\n          data-slot=\"quantity-button-increment\"\n          tabIndex={filled ? undefined : -1}\n          aria-label={`One more ${label}`}\n          aria-disabled={disabled || atMax || undefined}\n          onClick={() => change(count + 1)}\n          className={cn(\n            part,\n            filled ? \"translate-x-0 opacity-100\" : \"-translate-x-8 opacity-0\"\n          )}\n        >\n          <Plus aria-hidden=\"true\" size={16} strokeWidth={2.5} />\n        </button>\n      </div>\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "button",
    "form",
    "input",
    "control"
  ],
  "type": "registry:ui"
}