{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quantity-picker",
  "title": "Quantity Picker",
  "description": "A compound quantity control with limits, direct input, and stock feedback.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input",
    "label",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/quantity-picker.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { Minus, Plus } from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type KeyboardEvent,\n  useContext,\n  useEffect,\n  useId,\n  useState,\n} from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const QUANTITY_PICKER_VARIANTS = [\"rounded\", \"pill\", \"compact\"] as const;\n\nexport type QuantityPickerVariant = (typeof QUANTITY_PICKER_VARIANTS)[number];\n\nexport type QuantityPickerProps = ComponentProps<\"div\"> & {\n  variant?: QuantityPickerVariant;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  disabled?: boolean;\n};\n\nfunction quantityPickerChrome(variant: QuantityPickerVariant) {\n  const compact = variant === \"compact\";\n  const pill = variant === \"pill\";\n\n  return {\n    labelClass: compact ? \"mb-1 text-[11.5px] leading-4\" : \"mb-1.5 text-[12px] leading-4\",\n    controlClass: compact ? \"gap-0.5 p-0.5\" : \"gap-1 p-1\",\n    controlRadius: pill ? \"rounded-full\" : compact ? \"rounded-xl\" : \"rounded-[14px]\",\n    buttonClass: compact ? \"size-7\" : \"size-8\",\n    buttonRadius: pill ? \"rounded-full\" : compact ? \"rounded-lg\" : \"rounded-[10px]\",\n    inputClass: compact\n      ? \"h-7 w-10 text-[12.5px] leading-4 md:text-[12.5px] md:leading-4\"\n      : \"h-8 w-12 text-[13px] leading-[18px] md:text-[13px] md:leading-[18px]\",\n    messageClass: compact ? \"mt-1.5 text-[11px] leading-4\" : \"mt-2 text-[11.5px] leading-4\",\n    iconClass: compact ? \"size-3\" : \"size-3.5\",\n  };\n}\n\nfunction normalizeQuantity(value: number, min: number, max: number) {\n  if (!Number.isFinite(value)) return min;\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction addQuantity(value: number, delta: number) {\n  return Number((value + delta).toFixed(10));\n}\n\nfunction validateQuantityRange(min: number, max: number, step: number) {\n  if (!Number.isFinite(min) || !Number.isFinite(max)) {\n    throw new Error(\"QuantityPicker min and max must be finite numbers\");\n  }\n  if (max < min) throw new Error(\"QuantityPicker max must be greater than or equal to min\");\n  if (!Number.isFinite(step) || step <= 0) {\n    throw new Error(\"QuantityPicker step must be a finite number greater than zero\");\n  }\n}\n\ntype QuantityPickerContextValue = {\n  draft: string;\n  setDraft: (value: string) => void;\n  beginEditing: () => void;\n  commitDraft: () => void;\n  resetDraft: () => void;\n  decrease: () => void;\n  increase: () => void;\n  canDecrease: boolean;\n  canIncrease: boolean;\n  disabled: boolean;\n  min: number;\n  max: number;\n  step: number;\n  inputId: string;\n  labelId: string;\n  messageId: string;\n  chrome: ReturnType<typeof quantityPickerChrome>;\n};\n\nconst QuantityPickerContext = createContext<QuantityPickerContextValue | null>(null);\n\nfunction useQuantityPicker(name: string) {\n  const context = useContext(QuantityPickerContext);\n  if (!context) throw new Error(`${name} must be used within QuantityPicker`);\n  return context;\n}\n\nconst quantityPickerVariants = cva(\"inline-grid max-w-full text-foreground\", {\n  variants: { variant: { rounded: \"\", pill: \"\", compact: \"\" } },\n});\n\nexport function QuantityPicker({\n  variant = \"rounded\",\n  value,\n  defaultValue = 1,\n  onValueChange,\n  min = 1,\n  max = 99,\n  step = 1,\n  disabled = false,\n  children,\n  className,\n  ...props\n}: QuantityPickerProps) {\n  validateQuantityRange(min, max, step);\n\n  const inputId = useId();\n  const labelId = useId();\n  const messageId = useId();\n  const chrome = quantityPickerChrome(variant);\n  const [internalValue, setInternalValue] = useState(() =>\n    normalizeQuantity(defaultValue, min, max),\n  );\n  const quantity = normalizeQuantity(value ?? internalValue, min, max);\n  const [draft, setDraft] = useState(String(quantity));\n  const [editing, setEditing] = useState(false);\n\n  useEffect(() => {\n    if (!editing) setDraft(String(quantity));\n  }, [editing, quantity]);\n\n  const setQuantity = (nextValue: number) => {\n    const normalizedValue = normalizeQuantity(nextValue, min, max);\n    setDraft(String(normalizedValue));\n    if (value === undefined) setInternalValue(normalizedValue);\n    if (normalizedValue !== quantity) onValueChange?.(normalizedValue);\n  };\n\n  const context: QuantityPickerContextValue = {\n    draft,\n    setDraft,\n    beginEditing: () => {\n      setDraft(String(quantity));\n      setEditing(true);\n    },\n    commitDraft: () => {\n      setEditing(false);\n      const parsedValue = Number(draft);\n      if (draft.trim() === \"\" || !Number.isFinite(parsedValue)) {\n        setDraft(String(quantity));\n        return;\n      }\n      setQuantity(parsedValue);\n    },\n    resetDraft: () => setDraft(String(quantity)),\n    decrease: () => setQuantity(addQuantity(quantity, -step)),\n    increase: () => setQuantity(addQuantity(quantity, step)),\n    canDecrease: !disabled && quantity > min,\n    canIncrease: !disabled && quantity < max,\n    disabled,\n    min,\n    max,\n    step,\n    inputId,\n    labelId,\n    messageId,\n    chrome,\n  };\n\n  return (\n    <QuantityPickerContext.Provider value={context}>\n      <div\n        data-slot=\"quantity-picker\"\n        data-variant={variant}\n        data-disabled={disabled || undefined}\n        className={cn(quantityPickerVariants({ variant }), disabled && \"opacity-55\", className)}\n        {...props}\n      >\n        {children}\n      </div>\n    </QuantityPickerContext.Provider>\n  );\n}\n\nexport type QuantityPickerLabelProps = ComponentProps<\"label\">;\n\nexport function QuantityPickerLabel({\n  children = \"Quantity\",\n  className,\n  ...props\n}: QuantityPickerLabelProps) {\n  const context = useQuantityPicker(\"QuantityPickerLabel\");\n\n  return (\n    <Label\n      data-slot=\"quantity-picker-label\"\n      className={cn(\n        \"block font-medium text-muted-foreground select-auto\",\n        context.chrome.labelClass,\n        className,\n      )}\n      {...props}\n      id={context.labelId}\n      htmlFor={context.inputId}\n    >\n      {children}\n    </Label>\n  );\n}\n\nexport type QuantityPickerControlProps = ComponentProps<\"div\">;\n\nexport function QuantityPickerControl({\n  children,\n  className,\n  ...props\n}: QuantityPickerControlProps) {\n  const context = useQuantityPicker(\"QuantityPickerControl\");\n\n  return (\n    <div\n      data-slot=\"quantity-picker-control\"\n      className={cn(\n        \"grid grid-cols-[auto_minmax(40px,1fr)_auto] items-center bg-muted transition-[background-color,box-shadow] duration-[120ms] ease-out focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_18%,transparent)] motion-reduce:transition-none\",\n        context.chrome.controlClass,\n        context.chrome.controlRadius,\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\ntype QuantityPickerButtonProps = ComponentProps<\"button\">;\n\ntype QuantityPickerStepButtonProps = QuantityPickerButtonProps & {\n  direction: \"decrease\" | \"increase\";\n};\n\nfunction QuantityPickerStepButton({\n  direction,\n  children,\n  className,\n  disabled,\n  onClick,\n  \"aria-label\": ariaLabel,\n  ...props\n}: QuantityPickerStepButtonProps) {\n  const context = useQuantityPicker(\n    direction === \"decrease\" ? \"QuantityPickerDecrease\" : \"QuantityPickerIncrease\",\n  );\n  const decreasing = direction === \"decrease\";\n  const isDisabled = disabled || (decreasing ? !context.canDecrease : !context.canIncrease);\n  const Icon = decreasing ? Minus : Plus;\n  const label = ariaLabel ?? (decreasing ? \"Decrease quantity\" : \"Increase quantity\");\n  const changeQuantity = decreasing ? context.decrease : context.increase;\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon\"\n      data-slot={decreasing ? \"quantity-picker-decrease\" : \"quantity-picker-increase\"}\n      className={cn(\n        \"inline-flex shrink-0 items-center justify-center bg-transparent text-muted-foreground transition-[scale,background-color,color] duration-[140ms] ease-out-quint hover:bg-foreground/8 hover:text-foreground focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid active:scale-[0.94] disabled:pointer-events-auto disabled:cursor-not-allowed disabled:bg-transparent disabled:text-subtle-foreground/55 disabled:opacity-100 disabled:active:scale-100 motion-reduce:transition-none motion-reduce:active:scale-100 dark:hover:bg-foreground/8\",\n        context.chrome.buttonClass,\n        context.chrome.buttonRadius,\n        className,\n      )}\n      {...props}\n      disabled={isDisabled}\n      aria-label={label}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) changeQuantity();\n      }}\n    >\n      {children ?? (\n        <Icon className={context.chrome.iconClass} strokeWidth={1.75} aria-hidden=\"true\" />\n      )}\n    </Button>\n  );\n}\n\nexport function QuantityPickerDecrease(props: QuantityPickerButtonProps) {\n  return <QuantityPickerStepButton {...props} direction=\"decrease\" />;\n}\n\nexport type QuantityPickerInputProps = Omit<\n  ComponentProps<\"input\">,\n  \"defaultValue\" | \"max\" | \"min\" | \"onChange\" | \"step\" | \"type\" | \"value\"\n>;\n\nexport function QuantityPickerInput({\n  className,\n  disabled,\n  onBlur,\n  onFocus,\n  onKeyDown,\n  ...props\n}: QuantityPickerInputProps) {\n  const context = useQuantityPicker(\"QuantityPickerInput\");\n  const isDisabled = context.disabled || disabled;\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented || event.nativeEvent.isComposing) return;\n\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      event.currentTarget.blur();\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      context.resetDraft();\n      event.currentTarget.select();\n    }\n  };\n\n  return (\n    <Input\n      data-slot=\"quantity-picker-input\"\n      className={cn(\n        \"min-w-0 appearance-none rounded-none border-0 bg-transparent p-0 text-center font-medium tabular-nums shadow-none outline-none selection:bg-primary/32 selection:text-foreground focus-visible:ring-0 focus-visible:outline-none disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-100 dark:bg-transparent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\",\n        context.chrome.inputClass,\n        className,\n      )}\n      {...props}\n      id={context.inputId}\n      type=\"number\"\n      value={context.draft}\n      min={context.min}\n      max={context.max}\n      step={context.step}\n      inputMode={context.step % 1 === 0 ? \"numeric\" : \"decimal\"}\n      disabled={isDisabled}\n      aria-labelledby={context.labelId}\n      aria-describedby={context.messageId}\n      onChange={(event) => context.setDraft(event.target.value)}\n      onFocus={(event) => {\n        onFocus?.(event);\n        context.beginEditing();\n        event.currentTarget.select();\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        context.commitDraft();\n      }}\n      onKeyDown={handleKeyDown}\n    />\n  );\n}\n\nexport function QuantityPickerIncrease(props: QuantityPickerButtonProps) {\n  return <QuantityPickerStepButton {...props} direction=\"increase\" />;\n}\n\nexport const QUANTITY_PICKER_MESSAGE_TONES = [\"muted\", \"warning\", \"danger\"] as const;\n\nexport type QuantityPickerMessageTone = (typeof QUANTITY_PICKER_MESSAGE_TONES)[number];\n\nexport type QuantityPickerMessageProps = ComponentProps<\"p\"> & {\n  tone?: QuantityPickerMessageTone;\n};\n\nconst quantityPickerMessageToneClass: Record<QuantityPickerMessageTone, string> = {\n  muted: \"text-subtle-foreground\",\n  warning: \"text-warning-ink\",\n  danger: \"text-destructive-ink\",\n};\n\nexport function QuantityPickerMessage({\n  tone = \"muted\",\n  children,\n  className,\n  ...props\n}: QuantityPickerMessageProps) {\n  const context = useQuantityPicker(\"QuantityPickerMessage\");\n\n  return (\n    <p\n      data-slot=\"quantity-picker-message\"\n      data-tone={tone}\n      className={cn(context.chrome.messageClass, quantityPickerMessageToneClass[tone], className)}\n      {...props}\n      id={context.messageId}\n    >\n      {children}\n    </p>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/quantity-picker.tsx"
    }
  ],
  "categories": [
    "commerce",
    "forms"
  ],
  "type": "registry:ui"
}