{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-input","type":"registry:ui","title":"Number Input","description":"A number field with − / + steppers, arrow-key steps, limits and locale formatting — and MoneyInput for amounts.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-motion.json","https://dashral.com/r/field.json"],"files":[{"path":"registry/dashral/number-input.tsx","type":"registry:file","target":"components/dashral/number-input.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { MinusIcon, PlusIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cssDuration, reducedMotion } from \"./lib/motion\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldOwnProps } from \"./field\"\n\ntype NumberInputControlProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"min\" | \"max\" | \"step\" | \"prefix\"> & {\n  /** Controlled value; null when empty. Omit it (and use defaultValue) for an uncontrolled field. */\n  value?: number | null\n  defaultValue?: number | null\n  /** Called when a typed value is committed (blur or Enter) and on every step. */\n  onValueChange?: (value: number | null) => void\n  min?: number\n  max?: number\n  /** How much the arrow keys and the − / + buttons change the value. Shift and Page Up/Down change it ten times as much. */\n  step?: number\n  /** BCP 47 locale for formatting and for reading what's typed (\"1.234,5\" in id-ID), e.g. \"id-ID\". */\n  locale?: string\n  /** How the value is shown while the field isn't being edited, e.g. `{ style: \"percent\" }` or `{ maximumFractionDigits: 2 }`. */\n  formatOptions?: Intl.NumberFormatOptions\n  /** Show − and + buttons. */\n  stepper?: boolean\n  /** Shown inside the field before or after the number, e.g. a currency symbol or a unit. */\n  prefix?: React.ReactNode\n  suffix?: React.ReactNode\n}\n\ntype NumberInputProps = NumberInputControlProps &\n  FieldOwnProps & {\n    classNames?: FieldClassNames\n  }\n\n/** The locale's decimal separator, \".\" or \",\". */\nconst decimalSeparator = (locale: string) => new Intl.NumberFormat(locale).formatToParts(1.1).find((p) => p.type === \"decimal\")?.value ?? \".\"\n\n/** Reads a number typed in the locale's format; null for nothing or something unreadable. */\nfunction parse(text: string, locale: string) {\n  const separator = decimalSeparator(locale)\n  const cleaned = text.replace(new RegExp(`[^0-9\\\\-${separator === \".\" ? \"\\\\.\" : \",\"}]`, \"g\"), \"\").replace(separator, \".\")\n  if (cleaned === \"\" || cleaned === \"-\") return null\n  const value = Number(cleaned)\n  return Number.isFinite(value) ? value : null\n}\n\n/**\n * A number field, formatted for the locale. What's typed is read when you leave the field or press Enter (grouping\n * separators and symbols are ignored, so \"1.234,5\" works in id-ID). Arrow keys step the value, and it stays within min\n * and max. Follows the WAI-ARIA spinbutton pattern.\n */\nfunction NumberInput(props: NumberInputProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <NumberInputControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction NumberInputControl({ classNames: _classNames, ...controlProps }: NumberInputControlProps & { classNames?: FieldClassNames }) {\n  const {\n    value,\n    defaultValue = null,\n    onValueChange,\n    min,\n    max,\n    step = 1,\n    locale: localeProp,\n    formatOptions,\n    stepper = true,\n    prefix,\n    suffix,\n    className,\n    disabled,\n    onBlur,\n    onKeyDown,\n    ref,\n    ...props\n  } = useFieldControl(controlProps)\n  const { t, locale: contextLocale } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const [internal, setInternal] = React.useState<number | null>(defaultValue)\n  const current = value === undefined ? internal : value\n  const [editing, setEditing] = React.useState<string | null>(null)\n  const format = React.useMemo(() => new Intl.NumberFormat(locale, formatOptions), [locale, formatOptions])\n  const digits = format.resolvedOptions().maximumFractionDigits ?? 20\n  // A percent shows the value × 100 (0.45 as 45%), so it reads typed text ÷ 100 and keeps two more decimals than it\n  // shows; a finer step (0.005) keeps its own.\n  const percent = formatOptions?.style === \"percent\"\n  const precision = Math.min(20, Math.max(digits + (percent ? 2 : 0), (String(step).split(\".\")[1] ?? \"\").length))\n  const read = (text: string) => {\n    const typed = parse(text, locale)\n    return typed !== null && percent ? typed / 100 : typed\n  }\n\n  function clamp(next: number) {\n    const rounded = Number(next.toFixed(precision))\n    return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, rounded))\n  }\n\n  function commit(next: number | null) {\n    const clamped = next === null ? null : clamp(next)\n    if (value === undefined) setInternal(clamped)\n    if (clamped !== current) onValueChange?.(clamped)\n    return clamped\n  }\n\n  // The input, for the step animation, and still the ref you passed.\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const setRefs = React.useCallback(\n    (node: HTMLInputElement | null) => {\n      inputRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [ref]\n  )\n\n  /** The new number slides in from where it came: from below when it went up, from above when it went down. */\n  function slide(direction: 1 | -1) {\n    const input = inputRef.current\n    if (!input || reducedMotion()) return\n    const ease = getComputedStyle(document.documentElement).getPropertyValue(\"--ease-enter\").trim() || \"ease-out\"\n    input.animate([{ transform: `translateY(${direction * 55}%)`, opacity: 0 }, { transform: \"none\", opacity: 1 }], {\n      duration: cssDuration(\"--duration-standard\", 240),\n      easing: ease,\n    })\n  }\n\n  function stepTo(next: number) {\n    const before = current\n    const after = commit(next)\n    setEditing(null)\n    if (after !== null && after !== before) slide(before === null || after > before ? 1 : -1)\n  }\n\n  function stepBy(amount: number) {\n    const base = editing !== null ? read(editing) : current\n    stepTo((base ?? min ?? 0) + amount)\n  }\n\n  const atMin = current !== null && min !== undefined && current <= min\n  const atMax = current !== null && max !== undefined && current >= max\n  const stepButton = \"flex h-full w-8 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5\"\n\n  return (\n    <div\n      data-slot=\"number-input\"\n      data-disabled={disabled || undefined}\n      className={cn(\n        \"flex h-9 w-full min-w-0 items-center overflow-hidden rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] md:text-sm\",\n        \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15 has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/15\",\n        \"data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n        className\n      )}\n    >\n      {stepper && (\n        <button type=\"button\" tabIndex={-1} aria-label={t(\"decrease\")} className={cn(stepButton, \"border-e\")} disabled={disabled || atMin} onClick={() => stepBy(-step)}>\n          <MinusIcon aria-hidden />\n        </button>\n      )}\n      {prefix && <span className=\"ps-3 text-muted-foreground select-none\">{prefix}</span>}\n      <input\n        ref={setRefs}\n        type=\"text\"\n        inputMode={digits === 0 ? \"numeric\" : \"decimal\"}\n        role=\"spinbutton\"\n        autoComplete=\"off\"\n        aria-valuenow={current ?? undefined}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuetext={current === null ? undefined : format.format(current)}\n        disabled={disabled}\n        className={cn(\"h-full w-full min-w-0 bg-transparent px-3 tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\", prefix ? \"ps-1.5\" : \"\", suffix ? \"pe-1.5\" : \"\", stepper && \"text-center\")}\n        value={editing ?? (current === null ? \"\" : format.format(current))}\n        onChange={(event) => setEditing(event.target.value)}\n        onBlur={(event) => {\n          if (editing !== null) commit(read(editing))\n          setEditing(null)\n          onBlur?.(event)\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event)\n          if (event.defaultPrevented) return\n          const big = step * 10\n          const moves: Record<string, number | undefined> = {\n            ArrowUp: event.shiftKey ? big : step,\n            ArrowDown: event.shiftKey ? -big : -step,\n            PageUp: big,\n            PageDown: -big,\n          }\n          if (event.key in moves) {\n            event.preventDefault()\n            stepBy(moves[event.key]!)\n          } else if ((event.key === \"Home\" && min !== undefined) || (event.key === \"End\" && max !== undefined)) {\n            event.preventDefault()\n            stepTo(event.key === \"Home\" ? min! : max!)\n          } else if (event.key === \"Enter\" && editing !== null) {\n            commit(read(editing))\n            setEditing(null)\n          }\n        }}\n        {...props}\n      />\n      {suffix && <span className=\"pe-3 text-muted-foreground select-none\">{suffix}</span>}\n      {stepper && (\n        <button type=\"button\" tabIndex={-1} aria-label={t(\"increase\")} className={cn(stepButton, \"border-s\")} disabled={disabled || atMax} onClick={() => stepBy(step)}>\n          <PlusIcon aria-hidden />\n        </button>\n      )}\n    </div>\n  )\n}\n\n/**\n * An amount of money: the currency's symbol in front and its usual decimals (\"Rp 25.000\", \"$25,000.00\"). Built on\n * NumberInput, so it takes the same props; never goes below 0 unless you set `min`.\n */\nfunction MoneyInput({\n  currency,\n  locale: localeProp,\n  min = 0,\n  step,\n  ...props\n}: Omit<NumberInputProps, \"prefix\" | \"formatOptions\"> & {\n  /** ISO 4217 code: \"USD\", \"IDR\", \"EUR\", … */\n  currency: string\n}) {\n  const { locale: contextLocale } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const { symbol, digits } = React.useMemo(() => {\n    const format = new Intl.NumberFormat(locale, { style: \"currency\", currency })\n    return {\n      symbol: format.formatToParts(0).find((p) => p.type === \"currency\")?.value ?? currency,\n      digits: format.resolvedOptions().maximumFractionDigits ?? 2,\n    }\n  }, [locale, currency])\n  return (\n    <NumberInput\n      locale={locale}\n      min={min}\n      step={step ?? (digits === 0 ? 1000 : 1)}\n      stepper={false}\n      prefix={symbol}\n      formatOptions={{ minimumFractionDigits: digits, maximumFractionDigits: digits }}\n      {...props}\n    />\n  )\n}\n\nexport { NumberInput, MoneyInput }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}