{
  "$schema": "https://cubixflow.ir/schema/registry-item.json",
  "name": "amount-field",
  "type": "registry:ui",
  "title": "Amount Field",
  "description": "A labeled amount field with currency unit, description, and error. Values always display as Persian digits with thousand separators.",
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/cubix/amount-field.tsx",
      "type": "registry:ui",
      "target": "components/cubix/amount-field.tsx",
      "content": "\"use client\"\n\n/*\n  Cubix Amount Field - labeled amount input specialized from Text Field.\n\n  type stays text with inputMode numeric; values always display as Persian\n  digits with thousand separators (٬). AmountFieldCurrency accepts only\n  تومان or ریال. Optional amountInWords on the description spells the\n  amount and flips تومان ↔ ریال (×10 / ÷10).\n  text-right keeps amounts readable in RTL. Weight 400 and tracking-normal\n  keep IRANSans XV readable. Icons use data-icon like Button.\n*/\nimport * as React from \"react\"\nimport { Field as FieldPrimitive } from \"@base-ui/react/field\"\nimport { Input as InputPrimitive } from \"@base-ui/react/input\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\n/*\n  Icon-side inline padding matches Button / vertical icon inset:\n  default: 8px · lg: 12px\n*/\n\nconst amountFieldControlVariants = cva(\n  \"group/amount-field-control relative flex w-full min-w-0 items-center rounded-lg border border-input bg-transparent transition-colors outline-none has-disabled:cursor-not-allowed has-disabled:border-transparent has-disabled:bg-muted has-[[data-slot=amount-field-input]:focus-visible]:border-primary has-[[data-slot=amount-field-input]:focus-visible]:ring-3 has-[[data-slot=amount-field-input]:focus-visible]:ring-primary/20 has-[[data-slot=amount-field-input]:focus-visible]:ring-offset-1 has-[[data-slot=amount-field-input]:focus-visible]:ring-offset-background has-[[data-slot=amount-field-input][aria-invalid=true]]:border-destructive has-[[data-slot=amount-field-input][aria-invalid=true]]:ring-0 has-[[data-slot=amount-field-input][aria-invalid=true]:focus-visible]:border-destructive has-[[data-slot=amount-field-input][aria-invalid=true]:focus-visible]:ring-0 dark:bg-input/30 dark:has-disabled:bg-muted dark:has-[[data-slot=amount-field-input][aria-invalid=true]]:border-destructive/50 dark:has-[[data-slot=amount-field-input][aria-invalid=true]:focus-visible]:border-destructive/50 dark:has-[[data-slot=amount-field-input][aria-invalid=true]:focus-visible]:ring-0 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&>svg]:text-muted-foreground [&_[data-icon]]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4\",\n  {\n    variants: {\n      size: {\n        default:\n          \"h-10 gap-2 px-3 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-6\",\n        lg: \"h-12 gap-2.5 px-3.5 has-data-[icon=inline-end]:pe-3 has-data-[icon=inline-start]:ps-3 [&_svg:not([class*='size-'])]:size-6\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n    },\n  }\n)\n\nconst amountFieldInputVariants = cva(\n  \"w-full min-w-0 rounded-lg border border-input bg-transparent text-right font-normal leading-none tracking-normal text-foreground transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-description file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-3 focus-visible:ring-primary/20 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-transparent disabled:bg-muted disabled:text-muted-foreground disabled:placeholder:text-muted-foreground aria-invalid:border-destructive aria-invalid:ring-0 aria-invalid:focus-visible:border-destructive aria-invalid:focus-visible:ring-0 dark:bg-input/30 dark:disabled:bg-muted dark:aria-invalid:border-destructive/50 dark:aria-invalid:focus-visible:border-destructive/50\",\n  {\n    variants: {\n      size: {\n        default: \"h-10 px-3 text-label md:text-label\",\n        lg: \"h-12 px-3.5 text-label md:text-label\",\n      },\n      inControl: {\n        true: \"h-full flex-1 rounded-none border-0 bg-transparent p-0 shadow-none ring-0 focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-offset-0 aria-invalid:border-0 aria-invalid:ring-0 aria-invalid:ring-offset-0 disabled:bg-transparent dark:bg-transparent dark:disabled:bg-transparent\",\n        false: \"\",\n      },\n    },\n    defaultVariants: {\n      size: \"default\",\n      inControl: false,\n    },\n  }\n)\n\ntype AmountFieldSize = NonNullable<\n  VariantProps<typeof amountFieldInputVariants>[\"size\"]\n>\n\ntype AmountFieldCurrencyUnit = \"تومان\" | \"ریال\"\n\ntype AmountFieldStore = {\n  digits: string\n  currency: AmountFieldCurrencyUnit | \"\"\n  setDigits: (digits: string) => void\n  setCurrency: (currency: AmountFieldCurrencyUnit | \"\") => void\n}\n\nconst AmountFieldSizeContext = React.createContext<AmountFieldSize>(\"default\")\nconst AmountFieldControlContext = React.createContext(false)\nconst AmountFieldStoreContext = React.createContext<AmountFieldStore | null>(null)\n\nconst PERSIAN_DIGITS = \"۰۱۲۳۴۵۶۷۸۹\"\nconst ARABIC_DIGITS = \"٠١٢٣٤٥٦٧٨٩\"\nconst THOUSANDS_SEP = \"٬\"\nconst AMOUNT_DIGIT = /[\\d۰-۹٠-٩]/\nconst AMOUNT_ALLOWED_INSERT = /^[\\d۰-۹٠-٩]+$/\n\nconst ONES = [\n  \"\",\n  \"یک\",\n  \"دو\",\n  \"سه\",\n  \"چهار\",\n  \"پنج\",\n  \"شش\",\n  \"هفت\",\n  \"هشت\",\n  \"نه\",\n]\nconst TEENS = [\n  \"ده\",\n  \"یازده\",\n  \"دوازده\",\n  \"سیزده\",\n  \"چهارده\",\n  \"پانزده\",\n  \"شانزده\",\n  \"هفده\",\n  \"هجده\",\n  \"نوزده\",\n]\nconst TENS = [\n  \"\",\n  \"\",\n  \"بیست\",\n  \"سی\",\n  \"چهل\",\n  \"پنجاه\",\n  \"شصت\",\n  \"هفتاد\",\n  \"هشتاد\",\n  \"نود\",\n]\nconst HUNDREDS = [\n  \"\",\n  \"صد\",\n  \"دویست\",\n  \"سیصد\",\n  \"چهارصد\",\n  \"پانصد\",\n  \"ششصد\",\n  \"هفتصد\",\n  \"هشتصد\",\n  \"نهصد\",\n]\nconst SCALES = [\"\", \"هزار\", \"میلیون\", \"میلیارد\", \"تریلیون\"]\n\nfunction toLatinDigits(value: string) {\n  return value\n    .replace(/[۰-۹]/g, (digit) => String(PERSIAN_DIGITS.indexOf(digit)))\n    .replace(/[٠-٩]/g, (digit) => String(ARABIC_DIGITS.indexOf(digit)))\n}\n\nfunction toPersianDigits(value: string) {\n  return toLatinDigits(value).replace(\n    /\\d/g,\n    (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit\n  )\n}\n\nfunction extractAmountDigits(value: string) {\n  return toLatinDigits(value).replace(/\\D/g, \"\")\n}\n\nfunction formatAmountFieldValue(value: string | number) {\n  const digits = extractAmountDigits(String(value))\n  if (!digits) return \"\"\n  const normalized = digits.replace(/^0+(?=\\d)/, \"\")\n  const grouped = normalized.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\")\n  return toPersianDigits(grouped).replace(/,/g, THOUSANDS_SEP)\n}\n\nfunction parseAmountFieldValue(value: string) {\n  const digits = extractAmountDigits(value)\n  if (!digits) return Number.NaN\n  return Number(digits)\n}\n\nfunction threeDigitsToWords(value: number) {\n  const parts: string[] = []\n  const hundreds = Math.floor(value / 100)\n  const remainder = value % 100\n  if (hundreds > 0) {\n    parts.push(HUNDREDS[hundreds] ?? \"\")\n  }\n  if (remainder >= 10 && remainder < 20) {\n    parts.push(TEENS[remainder - 10] ?? \"\")\n  } else {\n    const tens = Math.floor(remainder / 10)\n    const ones = remainder % 10\n    if (tens > 0) parts.push(TENS[tens] ?? \"\")\n    if (ones > 0) parts.push(ONES[ones] ?? \"\")\n  }\n  return parts.filter(Boolean).join(\" و \")\n}\n\nfunction numberToPersianWordsFromDigits(digits: string) {\n  const normalized = digits.replace(/^0+(?=\\d)/, \"\") || \"0\"\n  if (normalized === \"0\") return \"صفر\"\n\n  const groups: number[] = []\n  for (let end = normalized.length; end > 0; end -= 3) {\n    const start = Math.max(0, end - 3)\n    groups.push(Number(normalized.slice(start, end)))\n  }\n\n  const parts: string[] = []\n  for (let index = groups.length - 1; index >= 0; index -= 1) {\n    const group = groups[index] ?? 0\n    if (group === 0) continue\n    const scale = SCALES[index] ?? \"\"\n    if (!scale) {\n      parts.push(threeDigitsToWords(group))\n      continue\n    }\n    // Native Persian: 1000 is \"هزار\", not \"یک هزار\"\n    if (group === 1 && scale === \"هزار\") {\n      parts.push(\"هزار\")\n      continue\n    }\n    if (group === 1) {\n      parts.push(`یک ${scale}`)\n      continue\n    }\n    parts.push(`${threeDigitsToWords(group)} ${scale}`)\n  }\n  return parts.join(\" و \")\n}\n\nfunction formatAmountFieldAmountInWords(\n  amount: number | string,\n  currency: AmountFieldCurrencyUnit | \"\"\n) {\n  const digits =\n    typeof amount === \"string\"\n      ? extractAmountDigits(amount).replace(/^0+(?=\\d)/, \"\") || \"\"\n      : Number.isFinite(amount) && amount >= 0\n        ? String(Math.trunc(amount))\n        : \"\"\n  if (!digits) return \"\"\n\n  if (currency === \"تومان\") {\n    // 1 تومان = 10 ریال (append zero, avoid Number precision loss)\n    return `${numberToPersianWordsFromDigits(`${digits}0`)} ریال`\n  }\n  if (currency === \"ریال\") {\n    // 10 ریال = 1 تومان\n    const tomans =\n      digits.length <= 1\n        ? \"0\"\n        : digits.slice(0, -1).replace(/^0+(?=\\d)/, \"\") || \"0\"\n    return `${numberToPersianWordsFromDigits(tomans)} تومان`\n  }\n  return \"\"\n}\n\nfunction countDigitsBefore(value: string, caret: number) {\n  let count = 0\n  const end = Math.max(0, Math.min(caret, value.length))\n  for (let index = 0; index < end; index += 1) {\n    if (AMOUNT_DIGIT.test(value[index] ?? \"\")) count += 1\n  }\n  return count\n}\n\nfunction caretFromDigitCount(value: string, digitCount: number) {\n  if (digitCount <= 0) return 0\n  let seen = 0\n  for (let index = 0; index < value.length; index += 1) {\n    if (AMOUNT_DIGIT.test(value[index] ?? \"\")) {\n      seen += 1\n      if (seen >= digitCount) return index + 1\n    }\n  }\n  return value.length\n}\n\nfunction setAmountFieldInputValue(input: HTMLInputElement, next: string) {\n  const descriptor = Object.getOwnPropertyDescriptor(\n    HTMLInputElement.prototype,\n    \"value\"\n  )\n  descriptor?.set?.call(input, next)\n  input.dispatchEvent(new Event(\"input\", { bubbles: true }))\n  input.dispatchEvent(new Event(\"change\", { bubbles: true }))\n}\n\nfunction normalizeAmountPropValue(\n  value: string | number | readonly string[] | undefined | null\n): string | undefined {\n  if (value == null) return undefined\n  if (typeof value === \"object\") {\n    const first = value[0]\n    return first == null ? undefined : formatAmountFieldValue(first)\n  }\n  return formatAmountFieldValue(value)\n}\n\nfunction AmountFieldStoreProvider({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  const [digits, setDigits] = React.useState(\"\")\n  const [currency, setCurrency] = React.useState<AmountFieldCurrencyUnit | \"\">(\n    \"\"\n  )\n  const store = React.useMemo(\n    () => ({ digits, currency, setDigits, setCurrency }),\n    [digits, currency]\n  )\n\n  return (\n    <AmountFieldStoreContext.Provider value={store}>\n      {children}\n    </AmountFieldStoreContext.Provider>\n  )\n}\n\nfunction AmountField({\n  className,\n  size = \"default\",\n  ...props\n}: React.ComponentProps<typeof FieldPrimitive.Root> & {\n  size?: AmountFieldSize\n}) {\n  return (\n    <AmountFieldStoreProvider>\n      <AmountFieldSizeContext.Provider value={size}>\n        <FieldPrimitive.Root\n          data-slot=\"amount-field\"\n          data-size={size}\n          className={cn(\n            \"group/amount-field flex w-full flex-col gap-2 data-disabled:cursor-not-allowed data-disabled:opacity-70\",\n            className\n          )}\n          {...props}\n        />\n      </AmountFieldSizeContext.Provider>\n    </AmountFieldStoreProvider>\n  )\n}\n\nfunction AmountFieldLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof FieldPrimitive.Label>) {\n  return (\n    <FieldPrimitive.Label\n      data-slot=\"amount-field-label\"\n      className={cn(\n        \"flex w-fit items-center gap-2 text-label leading-none font-normal text-foreground select-none group-data-disabled/amount-field:cursor-not-allowed group-data-disabled/amount-field:text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AmountFieldControl({\n  className,\n  size: sizeProp,\n  onClick,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  size?: AmountFieldSize\n}) {\n  const contextSize = React.useContext(AmountFieldSizeContext)\n  const size = sizeProp ?? contextSize\n\n  return (\n    <AmountFieldControlContext.Provider value={true}>\n      <div\n        data-slot=\"amount-field-control\"\n        data-size={size}\n        className={cn(amountFieldControlVariants({ size }), className)}\n        onClick={(event) => {\n          onClick?.(event)\n          if (event.defaultPrevented) return\n          if ((event.target as HTMLElement).closest(\"button,a,input,textarea\")) {\n            return\n          }\n          const input = event.currentTarget.querySelector<HTMLInputElement>(\n            \"[data-slot=amount-field-input]\"\n          )\n          if (!input || input.disabled || input.readOnly) return\n          input.focus()\n        }}\n        {...props}\n      />\n    </AmountFieldControlContext.Provider>\n  )\n}\n\nfunction AmountFieldInput({\n  className,\n  size: sizeProp,\n  autoComplete = \"off\",\n  onBeforeInput,\n  onPaste,\n  onInput,\n  defaultValue,\n  value,\n  ...props\n}: Omit<\n  React.ComponentProps<typeof InputPrimitive>,\n  \"size\" | \"type\" | \"inputMode\" | \"spellCheck\"\n> & {\n  size?: AmountFieldSize\n}) {\n  const contextSize = React.useContext(AmountFieldSizeContext)\n  const size = sizeProp ?? contextSize\n  const inControl = React.useContext(AmountFieldControlContext)\n  const store = React.useContext(AmountFieldStoreContext)\n  const persianDefault =\n    defaultValue === undefined\n      ? undefined\n      : normalizeAmountPropValue(defaultValue)\n  const persianValue =\n    value === undefined ? undefined : normalizeAmountPropValue(value)\n\n  const setDigits = store?.setDigits\n\n  React.useLayoutEffect(() => {\n    if (value === undefined) return\n    setDigits?.(extractAmountDigits(String(persianValue ?? \"\")))\n  }, [value, persianValue, setDigits])\n\n  React.useLayoutEffect(() => {\n    if (value !== undefined) return\n    setDigits?.(extractAmountDigits(String(persianDefault ?? \"\")))\n  }, [value, persianDefault, setDigits])\n\n  const applyFormattedValue = (\n    input: HTMLInputElement,\n    raw: string,\n    caret: number | null\n  ) => {\n    const digitCount =\n      caret == null ? null : countDigitsBefore(raw, caret)\n    const next = formatAmountFieldValue(raw)\n    store?.setDigits(extractAmountDigits(next))\n    if (next === input.value) return\n    setAmountFieldInputValue(input, next)\n    if (digitCount == null) return\n    const nextCaret = caretFromDigitCount(next, digitCount)\n    requestAnimationFrame(() => {\n      input.setSelectionRange(nextCaret, nextCaret)\n    })\n  }\n\n  return (\n    <InputPrimitive\n      data-slot=\"amount-field-input\"\n      data-size={size}\n      className={cn(amountFieldInputVariants({ size, inControl }), className)}\n      {...props}\n      type=\"text\"\n      inputMode=\"numeric\"\n      autoComplete={autoComplete}\n      spellCheck={false}\n      defaultValue={persianDefault}\n      value={persianValue}\n      onBeforeInput={(event) => {\n        onBeforeInput?.(event)\n        if (event.defaultPrevented) return\n        if (typeof event.data !== \"string\" || event.data.length === 0) return\n        if (!AMOUNT_ALLOWED_INSERT.test(event.data)) {\n          event.preventDefault()\n          return\n        }\n        const persian = toPersianDigits(event.data)\n        if (persian === event.data) return\n        event.preventDefault()\n        const input = event.currentTarget\n        const start = input.selectionStart ?? input.value.length\n        const end = input.selectionEnd ?? input.value.length\n        const raw =\n          input.value.slice(0, start) + persian + input.value.slice(end)\n        applyFormattedValue(input, raw, start + persian.length)\n      }}\n      onPaste={(event) => {\n        onPaste?.(event)\n        if (event.defaultPrevented) return\n        const text = event.clipboardData?.getData(\"text\") ?? \"\"\n        const digits = extractAmountDigits(text)\n        if (!digits) {\n          if (text.length > 0) event.preventDefault()\n          return\n        }\n        event.preventDefault()\n        const input = event.currentTarget\n        const start = input.selectionStart ?? input.value.length\n        const end = input.selectionEnd ?? input.value.length\n        const raw =\n          input.value.slice(0, start) + digits + input.value.slice(end)\n        applyFormattedValue(input, raw, start + digits.length)\n      }}\n      onInput={(event) => {\n        onInput?.(event)\n        if (event.defaultPrevented) return\n        const input = event.currentTarget\n        if (value !== undefined) {\n          store?.setDigits(extractAmountDigits(input.value))\n          return\n        }\n        applyFormattedValue(\n          input,\n          input.value,\n          input.selectionStart ?? input.value.length\n        )\n      }}\n    />\n  )\n}\n\nfunction AmountFieldCurrency({\n  className,\n  unit,\n  ...props\n}: Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  unit: AmountFieldCurrencyUnit\n}) {\n  const store = React.useContext(AmountFieldStoreContext)\n  const setCurrency = store?.setCurrency\n\n  React.useLayoutEffect(() => {\n    setCurrency?.(unit)\n    return () => {\n      setCurrency?.(\"\")\n    }\n  }, [unit, setCurrency])\n\n  return (\n    <span\n      data-slot=\"amount-field-currency\"\n      data-icon=\"inline-end\"\n      className={cn(\n        \"shrink-0 select-none text-caption font-normal text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {unit}\n    </span>\n  )\n}\n\nfunction AmountFieldDescription({\n  className,\n  amountInWords = false,\n  children,\n  ...props\n}: React.ComponentProps<typeof FieldPrimitive.Description> & {\n  amountInWords?: boolean\n}) {\n  const store = React.useContext(AmountFieldStoreContext)\n  const words =\n    amountInWords && store?.digits\n      ? formatAmountFieldAmountInWords(store.digits, store.currency)\n      : \"\"\n  const content = amountInWords ? words || children : children\n\n  if (content == null || content === \"\") {\n    return null\n  }\n\n  return (\n    <FieldPrimitive.Description\n      data-slot=\"amount-field-description\"\n      className={cn(\n        \"m-0 text-caption font-normal text-muted-foreground group-data-disabled/amount-field:cursor-not-allowed\",\n        className\n      )}\n      {...props}\n    >\n      {content}\n    </FieldPrimitive.Description>\n  )\n}\n\nfunction AmountFieldError({\n  className,\n  children,\n  match,\n  ...props\n}: React.ComponentProps<typeof FieldPrimitive.Error>) {\n  return (\n    <FieldPrimitive.Validity>\n      {(validity) => {\n        const isInvalid = validity.validity.valid === false\n        if (!isInvalid && match !== true) {\n          return null\n        }\n\n        return (\n          <FieldPrimitive.Error\n            data-slot=\"amount-field-error\"\n            match={match ?? true}\n            className={cn(\n              \"m-0 text-caption font-normal text-destructive\",\n              className\n            )}\n            {...props}\n          >\n            {children}\n          </FieldPrimitive.Error>\n        )\n      }}\n    </FieldPrimitive.Validity>\n  )\n}\n\nexport {\n  AmountField,\n  AmountFieldLabel,\n  AmountFieldControl,\n  AmountFieldInput,\n  AmountFieldCurrency,\n  AmountFieldDescription,\n  AmountFieldError,\n  formatAmountFieldValue,\n  parseAmountFieldValue,\n  formatAmountFieldAmountInWords,\n  amountFieldControlVariants,\n  amountFieldInputVariants,\n}\nexport type { AmountFieldCurrencyUnit }\n"
    }
  ]
}

