{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "payments",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "The hardest input in fintech.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport {\n  currencyScale,\n  currencySymbol,\n  formatMoneyInput,\n  moneyAriaLabel,\n  parseMoneyInput,\n} from \"@/lib/ledger/format-money\";\nimport type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { Button } from \"@/components/ledger/button\";\nimport { Money } from \"@/components/ledger/money\";\n\nexport interface AmountInputProps\n  extends Omit<React.ComponentPropsWithoutRef<\"input\">, \"value\" | \"onChange\" | \"type\" | \"max\"> {\n  currency: string;\n  error?: string;\n  label?: string;\n  locale?: string;\n  max?: MoneyValue;\n  onMaxClick?: () => void;\n  onValueChange: (minorUnits: number | null) => void;\n  /** Minor units, or null for empty. */\n  value: number | null;\n}\n\n/**\n * The hardest input in fintech.\n *\n * `type=\"text\"` + `inputMode=\"decimal\"`, never `type=\"number\"` — the scroll\n * wheel on a number input will silently change someone's payment amount.\n * Parse permissively, format on blur only: inserting a thousands separator\n * mid-typing moves the caret and is the single most infuriating input bug\n * in this domain. Paste is never blocked. Max is soft — an overdraft may be\n * legitimate, and blocking input to prevent an invalid state prevents valid\n * ones too.\n */\nexport const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(\n  (\n    {\n      className,\n      currency,\n      value,\n      onValueChange,\n      max,\n      onMaxClick,\n      label = \"Amount\",\n      locale = \"en-US\",\n      error,\n      id,\n      ...props\n    },\n    ref,\n  ) => {\n    const reactId = React.useId();\n    const inputId = id ?? reactId;\n    const describedBy = `${inputId}-parsed`;\n    const errorId = `${inputId}-error`;\n\n    const [draft, setDraft] = React.useState(() =>\n      value == null ? \"\" : formatMoneyInput(value, currency, locale),\n    );\n    const [focused, setFocused] = React.useState(false);\n    const scale = currencyScale(currency, locale);\n    const symbol = currencySymbol(currency, locale);\n\n    // Reformat when the value changes from outside (a Max click, a swap flip),\n    // but never while the field is being typed into.\n    React.useEffect(() => {\n      if (focused) return;\n      setDraft(value == null ? \"\" : formatMoneyInput(value, currency, locale));\n    }, [value, currency, locale, focused]);\n\n    const exceedsMax = max != null && value != null && value > Number(max.amount);\n\n    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n      const next = e.target.value;\n      setDraft(next);\n      const parsed = parseMoneyInput(next, currency, locale);\n      onValueChange(next.trim() === \"\" ? null : parsed);\n    };\n\n    const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {\n      setFocused(false);\n      const parsed = parseMoneyInput(draft, currency, locale);\n      if (parsed == null) {\n        setDraft(\"\");\n        onValueChange(null);\n      } else {\n        setDraft(formatMoneyInput(parsed, currency, locale));\n        onValueChange(parsed);\n      }\n      props.onBlur?.(e);\n    };\n\n    const inlineError =\n      error ??\n      (exceedsMax && max\n        ? `Exceeds available balance of ${formatMoneyInput(Number(max.amount), max.currency, locale)} ${max.currency}`\n        : undefined);\n\n    return (\n      <div className={cn(\"w-full\", className)}>\n        <div className=\"flex items-baseline justify-between\">\n          <label className=\"text-label font-medium text-text-secondary\" htmlFor={inputId}>\n            {label}\n          </label>\n          {max ? (\n            <span className=\"text-caption text-text-secondary\">\n              Balance <Money size=\"xs\" tone=\"inherit\" value={max} />\n            </span>\n          ) : null}\n        </div>\n\n        <div\n          className={cn(\n            \"mt-2 flex items-center gap-2 rounded-md border bg-surface px-4\",\n            \"transition-colors duration-1 ease-standard\",\n            inlineError ? \"border-negative\" : \"border-border hover:border-border-strong\",\n            \"focus-within:border-accent\",\n          )}\n        >\n          {/* The symbol is part of the field, not a floating adornment that\n              misaligns as the value grows. */}\n          <span aria-hidden=\"true\" className=\"numeric text-figure-lg text-text-tertiary\">\n            {symbol}\n          </span>\n          <input\n            {...props}\n            aria-describedby={cn(describedBy, inlineError && errorId)}\n            aria-invalid={inlineError ? true : undefined}\n            aria-label={`${label} in ${currency}`}\n            autoComplete=\"off\"\n            /* 16px minimum or iOS Safari zooms the viewport and never zooms back. */\n            className={cn(\n              \"numeric h-16 w-0 min-w-0 flex-1 bg-transparent text-figure-lg font-medium\",\n              \"outline-none placeholder:text-text-disabled\",\n            )}\n            id={inputId}\n            inputMode=\"decimal\"\n            onBlur={handleBlur}\n            onChange={handleChange}\n            onFocus={(e) => {\n              setFocused(true);\n              props.onFocus?.(e);\n            }}\n            placeholder={scale === 0 ? \"0\" : `0.${\"0\".repeat(scale)}`}\n            ref={ref}\n            type=\"text\"\n            value={draft}\n          />\n          {onMaxClick && max ? (\n            <Button onClick={onMaxClick} size=\"sm\" variant=\"ghost\">\n              Max\n            </Button>\n          ) : null}\n        </div>\n\n        {/* The parsed value, announced on blur. */}\n        <span className=\"sr-only\" id={describedBy}>\n          {value == null ? \"No amount entered\" : moneyAriaLabel({ amount: value, currency }, { locale })}\n        </span>\n\n        {inlineError ? (\n          // 140ms opacity + 4px translate. A shake is forbidden: it is a stress\n          // signal in a context where the user is already anxious about money.\n          <p className=\"ledger-crossfade mt-2 text-caption text-negative\" id={errorId} role=\"alert\">\n            {inlineError}\n          </p>\n        ) : null}\n      </div>\n    );\n  },\n);\nAmountInput.displayName = \"AmountInput\";\n",
      "path": "components/ledger/amount-input.tsx",
      "target": "components/ledger/amount-input.tsx",
      "type": "registry:component"
    }
  ],
  "name": "amount-input",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/format-money",
    "@lopifi/money",
    "@lopifi/money.types"
  ],
  "title": "AmountInput",
  "type": "registry:component"
}
