{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "amount-input",
  "title": "金额输入",
  "description": "付款、报销、订单金额用的输入框：实时千分位且光标不乱跳，限制小数位与范围，粘贴自动规范化，下方同步显示人民币大写与「约 1.2亿」估读。",
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/chinese-number.json",
    "https://yiui.dev/r/bar-field.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/amount-input/amount-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useCaretRestore } from \"@/hooks/use-caret-restore\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { toRmbUppercase } from \"@/lib/chinese-number\"\nimport { formatStat } from \"@/lib/data-display\"\n\nimport {\n  AmountInputReadout,\n  amountInputControlVariants,\n} from \"./amount-input-parts\"\nimport { DEFAULT_LABELS } from \"./config\"\nimport type { AmountInputProps } from \"./types\"\nimport {\n  compareAmount,\n  finalizeText,\n  formatAmount,\n  sameAmount,\n  sanitizeAmount,\n  textToValue,\n  toDecimalString,\n  type SanitizeOptions,\n} from \"./utils\"\n\n/** 读屏播报大写的防抖时间（毫秒）：停手这么久才报一次，敲字过程中不打断读屏。 */\nconst ANNOUNCE_DELAY = 800\n\n/** 软键盘弹出后最多等这么久（毫秒）再检查读数行有没有被挡住 */\nconst KEYBOARD_WAIT = 600\n\n/**\n * 触屏设备上聚焦后，浏览器只保证输入框本身露出来，下面的大写行和错误提示常被软键盘挡住。\n * 等键盘弹出（visualViewport 缩小）后，把整个组件的底边滚到可见区域里。\n */\nfunction revealBelowKeyboard(root: HTMLElement | null) {\n  const viewport = window.visualViewport\n  if (!root || !viewport || !window.matchMedia(\"(pointer: coarse)\").matches)\n    return\n  // 键盘是动画弹出的，resize 会连发几次：停下来 150ms 再量；一直没有 resize（外接键盘、桌面触屏）就到点量一次\n  let timer = window.setTimeout(check, KEYBOARD_WAIT)\n  const settle = () => {\n    window.clearTimeout(timer)\n    timer = window.setTimeout(check, 150)\n  }\n  function check() {\n    viewport!.removeEventListener(\"resize\", settle)\n    const bottom = root!.getBoundingClientRect().bottom\n    const visibleBottom = viewport!.offsetTop + viewport!.height\n    const overflow = bottom - visibleBottom + 12\n    if (overflow > 0) window.scrollBy({ top: overflow, behavior: \"smooth\" })\n  }\n  viewport.addEventListener(\"resize\", settle)\n}\n\n/** 金额输入框：实时千分位、光标不乱跳，下方同步显示人民币大写与大数估读。 */\nfunction AmountInput({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  min,\n  max,\n  decimals: decimalsProp = 2,\n  symbol = \"¥\",\n  label,\n  variant = \"bar\",\n  size = \"default\",\n  showUppercase = true,\n  showApproximate = true,\n  approximateFrom = 10_000,\n  readoutPlacement = \"below\",\n  error,\n  padDecimals = true,\n  labels: labelsProp,\n  className,\n  id: idProp,\n  name,\n  form,\n  disabled,\n  readOnly,\n  placeholder,\n  inputMode,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-describedby\": describedByProp,\n  onFocus,\n  onBlur,\n  onKeyDown,\n  ref,\n  ...props\n}: AmountInputProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const decimals = Math.max(0, Math.floor(decimalsProp))\n  const minText = min === undefined ? undefined : toDecimalString(min)\n  const maxText = max === undefined ? undefined : toDecimalString(max)\n  const allowNegative = minText !== undefined && minText.startsWith(\"-\")\n  const options: SanitizeOptions = { decimals, allowNegative }\n\n  const autoId = React.useId()\n  const id = idProp ?? autoId\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)\n\n  const [value, setValue] = useControllableState({\n    prop: valueProp,\n    defaultProp: defaultValue,\n    onChange: onValueChange,\n  })\n  const [focused, setFocused] = React.useState(false)\n  const [touched, setTouched] = React.useState(false)\n  const [text, setText] = React.useState(() =>\n    formatAmount(value, options, padDecimals),\n  )\n  // 外部改了 value（受控）才同步显示文本；内部输入产生的变化文本已经是最新的，不会触发。\n  // 只在 value 变化的那一次比较，避免 value 的位数超过 decimals 时反复改写\n  const [seenValue, setSeenValue] = React.useState(value)\n  if (seenValue !== value) {\n    setSeenValue(value)\n    if (!sameAmount(value, textToValue(text)))\n      setText(formatAmount(value, options, padDecimals && !focused))\n  }\n\n  // 整理后要恢复的光标位置；每次都是新对象，保证即使文本没变（敲了非法字符）也会重设光标\n  const setCaret = useCaretRestore(inputRef)\n\n  const composing = React.useRef(false)\n  const [edits, setEdits] = React.useState(0)\n\n  const commit = (raw: string, rawCaret: number) => {\n    const next = sanitizeAmount(raw, rawCaret, options)\n    setText(next.text)\n    setCaret({ position: next.caret })\n    setValue(textToValue(next.text))\n    setEdits((count) => count + 1)\n  }\n\n  const amount = textToValue(text)\n  const numeric = amount === \"\" ? 0 : Number(amount)\n  const uppercase = showUppercase && amount !== \"\" ? toRmbUppercase(amount) : \"\"\n  const approximate =\n    showApproximate && amount !== \"\" && Math.abs(numeric) >= approximateFrom\n      ? `${labels.approximate} ${formatStat(numeric, { digits: 1 })}`\n      : \"\"\n\n  // 越界：超过 max 立即提示（再敲只会更大）；低于 min 要等失焦，否则敲第一位数字就报错。\n  // 无论提示与否，越界值都通过 setCustomValidity 拦住表单提交\n  const tooLow =\n    amount !== \"\" && minText !== undefined && compareAmount(amount, minText) < 0\n  const tooHigh =\n    amount !== \"\" && maxText !== undefined && compareAmount(amount, maxText) > 0\n  const limitText = (limit: string) => {\n    const digits = Math.max(decimals, limit.split(\".\")[1]?.length ?? 0)\n    const formatted = formatAmount(\n      limit,\n      { decimals: digits, allowNegative: true },\n      true,\n    )\n    return `${typeof symbol === \"string\" ? symbol : \"\"}${formatted}`\n  }\n  const rangeMessage = tooHigh\n    ? labels.aboveMax.replace(\"{max}\", limitText(maxText!))\n    : tooLow\n      ? labels.belowMin.replace(\"{min}\", limitText(minText!))\n      : \"\"\n  React.useEffect(() => {\n    inputRef.current?.setCustomValidity(rangeMessage)\n  }, [rangeMessage])\n\n  const errorMessage = error || (tooHigh || touched ? rangeMessage : \"\")\n  const invalid =\n    Boolean(errorMessage) || ariaInvalid === true || ariaInvalid === \"true\"\n\n  // 读屏：大写行通过 aria-describedby 在聚焦时朗读；敲字时不逐字播报，\n  // 停手 ANNOUNCE_DELAY 毫秒后用 polite 的状态区报一次最新的大写\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  React.useEffect(() => {\n    if (edits === 0 || !focused) return\n    const timer = window.setTimeout(\n      () =>\n        setAnnouncement(uppercase ? `${labels.uppercase} ${uppercase}` : \"\"),\n      ANNOUNCE_DELAY,\n    )\n    return () => window.clearTimeout(timer)\n  }, [edits, focused, uppercase, labels.uppercase])\n\n  // 非受控的表单重置：回到 defaultValue。监听器只挂一次，通过 ref 拿到最新的设置\n  const resetRef = React.useRef(() => {})\n  React.useEffect(() => {\n    resetRef.current = () => {\n      setText(formatAmount(defaultValue, options, padDecimals))\n      setValue(defaultValue)\n    }\n  })\n  const controlled = valueProp !== undefined\n  React.useEffect(() => {\n    const element = inputRef.current?.form\n    if (!element || controlled) return\n    // reset 事件在表单清空之前触发，等一帧再覆盖\n    const reset = () => window.setTimeout(() => resetRef.current())\n    element.addEventListener(\"reset\", reset)\n    return () => element.removeEventListener(\"reset\", reset)\n  }, [controlled])\n\n  const uppercaseId = `${id}-uppercase`\n  const errorId = `${id}-error`\n  const describedBy =\n    [\n      describedByProp,\n      showUppercase && uppercase ? uppercaseId : undefined,\n      errorMessage ? errorId : undefined,\n    ]\n      .filter(Boolean)\n      .join(\" \") || undefined\n\n  const display = variant === \"display\"\n  const labelNode = label ? (\n    <label\n      data-slot=\"amount-input-label\"\n      htmlFor={id}\n      className={\n        display\n          ? \"text-sm text-muted-foreground\"\n          : \"mr-auto shrink-0 text-muted-foreground max-md:text-sm\"\n      }\n    >\n      {label}\n    </label>\n  ) : null\n\n  const readout =\n    showUppercase || approximate ? (\n      <AmountInputReadout\n        uppercase={uppercase}\n        uppercaseId={uppercaseId}\n        uppercaseLabel={labels.uppercase}\n        placeholder={labels.uppercasePlaceholder}\n        showUppercase={showUppercase}\n        approximate={approximate}\n        // 大字时去掉虚线框，读数就是金额下面的一行小字\n        className={display ? \"border-0 bg-transparent px-0\" : undefined}\n      />\n    ) : null\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"amount-input\"\n      data-variant={variant}\n      data-size={size}\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      className={cn(\"flex w-full flex-col gap-1.5\", !display && \"in-data-[slot=grid-toolbar]:h-full\", className)}\n    >\n      {display && labelNode}\n      {readoutPlacement === \"above\" && readout}\n      <div\n        data-slot=\"amount-input-control\"\n        className={cn(\n          amountInputControlVariants({ variant, size }),\n          display\n            ? invalid\n              ? \"border-destructive\"\n              : \"border-edge focus-within:border-primary\"\n            : invalid\n              ? \"border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40\"\n              : \"border-edge focus-within:border-foreground/25 focus-within:bg-background hover:not-focus-within:bg-muted dark:focus-within:bg-input/30\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          readOnly && (display ? \"border-dashed\" : \"bg-muted/30\"),\n        )}\n        // 点到条内空白处（标签与数字之间）也聚焦输入框\n        onMouseDown={(event) => {\n          if (event.target === event.currentTarget) {\n            event.preventDefault()\n            inputRef.current?.focus()\n          }\n        }}\n      >\n        {!display && labelNode}\n        {symbol != null && (\n          <span\n            data-slot=\"amount-input-symbol\"\n            aria-hidden=\"true\"\n            className={cn(\n              \"shrink-0 text-muted-foreground select-none\",\n              // 大字时符号小一号、和数字底部对齐，像收银台上的金额\n              display && \"text-[0.6em] font-medium\",\n            )}\n          >\n            {symbol}\n          </span>\n        )}\n        <input\n          {...props}\n          ref={inputRef}\n          id={id}\n          data-slot=\"amount-input-input\"\n          type=\"text\"\n          inputMode={\n            inputMode ??\n            (allowNegative ? \"text\" : decimals > 0 ? \"decimal\" : \"numeric\")\n          }\n          autoComplete=\"off\"\n          spellCheck={false}\n          value={text}\n          placeholder={\n            placeholder ?? (decimals > 0 ? `0.${\"0\".repeat(decimals)}` : \"0\")\n          }\n          disabled={disabled}\n          readOnly={readOnly}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          className={cn(\n            \"min-w-0 bg-transparent tabular-nums outline-none placeholder:font-normal placeholder:text-muted-foreground/60 disabled:cursor-not-allowed\",\n            display\n              ? \"flex-1 py-0.5 leading-tight font-semibold\"\n              : // 有条内标签时金额靠右，与方条开关、数值滑块的「左标签右数值」一致；\n                // 输入框按内容收窄，货币符号才会贴着金额而不是贴着标签\n                cn(\n                  \"h-full font-medium\",\n                  label\n                    ? \"field-sizing-content max-w-full text-right\"\n                    : \"flex-1\",\n                ),\n          )}\n          onChange={(event) => {\n            const input = event.currentTarget\n            // 输入法组词期间不整理，否则会打断候选词；组词结束时再统一处理\n            if (composing.current) return setText(input.value)\n            commit(input.value, input.selectionStart ?? input.value.length)\n          }}\n          onCompositionStart={() => {\n            composing.current = true\n          }}\n          onCompositionEnd={(event) => {\n            composing.current = false\n            const input = event.currentTarget\n            commit(input.value, input.selectionStart ?? input.value.length)\n          }}\n          onKeyDown={(event) => {\n            onKeyDown?.(event)\n            const input = event.currentTarget\n            const { selectionStart: start, selectionEnd: end } = input\n            if (\n              event.defaultPrevented ||\n              composing.current ||\n              disabled ||\n              readOnly ||\n              start === null ||\n              start !== end ||\n              event.altKey ||\n              event.ctrlKey ||\n              event.metaKey\n            )\n              return\n            // 光标紧贴千分位逗号时，退格 / 删除越过逗号去删旁边的数字，\n            // 否则只删了逗号、整理后又长回来，用户会觉得按键没反应\n            const current = input.value\n            if (event.key === \"Backspace\" && current[start - 1] === \",\") {\n              event.preventDefault()\n              commit(\n                current.slice(0, start - 2) + current.slice(start),\n                start - 2,\n              )\n            } else if (event.key === \"Delete\" && current[start] === \",\") {\n              event.preventDefault()\n              commit(current.slice(0, start) + current.slice(start + 2), start)\n            }\n          }}\n          onFocus={(event) => {\n            setFocused(true)\n            revealBelowKeyboard(rootRef.current)\n            onFocus?.(event)\n          }}\n          onBlur={(event) => {\n            setFocused(false)\n            setTouched(true)\n            const finished = finalizeText(text, decimals, padDecimals)\n            if (finished !== text) {\n              setText(finished)\n              setValue(textToValue(finished))\n            }\n            onBlur?.(event)\n          }}\n        />\n      </div>\n      {readoutPlacement === \"below\" && readout}\n      {errorMessage && (\n        <p\n          id={errorId}\n          data-slot=\"amount-input-error\"\n          className=\"text-xs text-destructive\"\n        >\n          {errorMessage}\n        </p>\n      )}\n      {name && (\n        <input\n          type=\"hidden\"\n          data-slot=\"amount-input-value\"\n          name={name}\n          form={form}\n          value={amount}\n          disabled={disabled}\n        />\n      )}\n      <span data-slot=\"amount-input-status\" role=\"status\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\n\nexport { AmountInput, amountInputControlVariants }\nexport type {\n  AmountInputProps,\n  AmountInputSize,\n  AmountInputVariant,\n} from \"./types\"\n",
      "type": "registry:ui",
      "target": "@ui/amount-input/amount-input.tsx"
    },
    {
      "path": "src/registry/ui/amount-input/amount-input-parts.tsx",
      "content": "import * as React from \"react\"\nimport { cva } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { barFieldVariants } from \"@/components/ui/bar-field/bar-field\"\n\n/**\n * 两种外观：\n * - `bar` 方条：与方条开关、分段选择同一套（浅灰底、细边框、标签在条内左侧），放进同一个面板里能对齐。\n *   高度只来自既有三档 28 / 32 / 36px，触屏设备上各加高一档（36 / 40 / 44px）\n * - `display` 大字：付款、转账页上的主金额，数字放大、下面一道横线，标签写在上方\n * 手机上字号至少 16px，iOS 聚焦时才不会自动放大页面\n */\nconst amountInputControlVariants = cva(\n  \"flex w-full min-w-0 cursor-text transition-colors\",\n  {\n    variants: {\n      variant: {\n        bar: \"\",\n        display: \"items-baseline gap-1 border-b-2 pb-1 tracking-tight\",\n      },\n      size: { sm: \"\", default: \"\", lg: \"\" },\n    },\n    compoundVariants: [\n      {\n        variant: \"bar\",\n        size: \"sm\",\n        class: cn(barFieldVariants({ size: \"sm\" }), \"gap-1.5\"),\n      },\n      {\n        variant: \"bar\",\n        size: \"default\",\n        class: cn(barFieldVariants({ size: \"default\" }), \"gap-1.5\"),\n      },\n      {\n        variant: \"bar\",\n        size: \"lg\",\n        class: cn(barFieldVariants({ size: \"lg\" }), \"gap-1.5\"),\n      },\n      { variant: \"display\", size: \"sm\", class: \"text-2xl\" },\n      { variant: \"display\", size: \"default\", class: \"text-3xl\" },\n      { variant: \"display\", size: \"lg\", class: \"text-4xl\" },\n    ],\n    defaultVariants: { variant: \"bar\", size: \"default\" },\n  },\n)\n\nfunction AmountInputReadout({\n  className,\n  uppercase,\n  uppercaseId,\n  uppercaseLabel,\n  placeholder,\n  showUppercase,\n  approximate,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** 人民币大写；空串时显示 `placeholder` */\n  uppercase: string\n  uppercaseId: string\n  uppercaseLabel: string\n  placeholder: string\n  showUppercase: boolean\n  /** 大数估读，如「约 1.2万」；空串不显示 */\n  approximate: string\n}) {\n  return (\n    <div\n      data-slot=\"amount-input-readout\"\n      className={cn(\n        \"flex items-start justify-between gap-x-3 gap-y-0.5 rounded-md border border-dashed border-edge bg-muted/30 px-2.5 py-1.5 text-xs max-sm:flex-wrap\",\n        className,\n      )}\n      {...props}\n    >\n      {showUppercase && (\n        <p\n          id={uppercaseId}\n          data-slot=\"amount-input-uppercase\"\n          className=\"min-w-0 flex-1 leading-5 break-words\"\n        >\n          <span className=\"mr-2 text-muted-foreground\">{uppercaseLabel}</span>\n          {uppercase ? (\n            <span className=\"font-medium text-foreground\">{uppercase}</span>\n          ) : (\n            <span className=\"text-muted-foreground/70\">{placeholder}</span>\n          )}\n        </p>\n      )}\n      {approximate && (\n        <p\n          data-slot=\"amount-input-approximate\"\n          className=\"shrink-0 leading-5 text-muted-foreground tabular-nums\"\n        >\n          {approximate}\n        </p>\n      )}\n    </div>\n  )\n}\n\nexport { AmountInputReadout, amountInputControlVariants }\n",
      "type": "registry:ui",
      "target": "@ui/amount-input/amount-input-parts.tsx"
    },
    {
      "path": "src/registry/ui/amount-input/types.ts",
      "content": "import type * as React from \"react\"\n\nimport type { AmountInputLabels } from \"./config\"\n\ntype AmountInputSize = \"sm\" | \"default\" | \"lg\"\n\ntype AmountInputVariant = \"bar\" | \"display\"\n\ntype AmountInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  | \"value\"\n  | \"defaultValue\"\n  | \"onChange\"\n  | \"type\"\n  | \"size\"\n  | \"prefix\"\n  | \"min\"\n  | \"max\"\n  | \"children\"\n> & {\n  /**\n   * 金额，十进制字符串（`\"1234.5\"`，不带千分位）；空串表示没填。受控时传入。\n   * 用字符串而不是 number：万亿级金额和 `0.1 + 0.2` 都不会丢精度，也能保留用户敲的 `1234.50`。\n   */\n  value?: string\n  /**\n   * 非受控时的初始金额\n   * @default \"\"\n   */\n  defaultValue?: string\n  /** 金额变化时回调，参数是不带千分位、不带悬空小数点的十进制字符串 */\n  onValueChange?: (value: string) => void\n  /** 最小值；为负数时才允许输入负号。越界不会拦截输入，失焦后提示并阻止表单提交 */\n  min?: number | string\n  /** 最大值；超过时立即提示并阻止表单提交 */\n  max?: number | string\n  /**\n   * 小数位上限，超出的位直接舍去；0 表示只能输入整数\n   * @default 2\n   */\n  decimals?: number\n  /**\n   * 前缀货币符号；传 `null` 隐藏\n   * @default \"¥\"\n   */\n  symbol?: React.ReactNode\n  /** 标签，自动与输入框关联。`bar` 写在条内左侧、金额随之靠右；`display` 写在金额上方 */\n  label?: React.ReactNode\n  /**\n   * 外观：`bar` 方条，和其他表单控件排在一起；`display` 大字，用作付款、转账页上的主金额，标签写在上方\n   * @default \"bar\"\n   */\n  variant?: AmountInputVariant\n  /**\n   * 档位：`bar` 是高度 28 / 32 / 36px（触屏设备上 36 / 40 / 44px），`display` 是数字字号 24 / 30 / 36px\n   * @default \"default\"\n   */\n  size?: AmountInputSize\n  /**\n   * 显示人民币大写\n   * @default true\n   */\n  showUppercase?: boolean\n  /**\n   * 金额较大时显示「约 1.2亿」式估读\n   * @default true\n   */\n  showApproximate?: boolean\n  /**\n   * 绝对值达到这个数才显示估读\n   * @default 10000\n   */\n  approximateFrom?: number\n  /**\n   * 读数行在输入框的上方还是下方\n   * @default \"below\"\n   */\n  readoutPlacement?: \"above\" | \"below\"\n  /** 外部校验的错误提示，有值即进入错误态；与越界提示同时存在时优先显示它 */\n  error?: React.ReactNode\n  /**\n   * 失焦时把小数位补齐到 `decimals`（`1,234.5` → `1,234.50`）\n   * @default true\n   */\n  padDecimals?: boolean\n  /** 覆盖用户可见文案 */\n  labels?: Partial<AmountInputLabels>\n}\n\nexport type { AmountInputProps, AmountInputSize, AmountInputVariant }\n",
      "type": "registry:ui",
      "target": "@ui/amount-input/types.ts"
    },
    {
      "path": "src/registry/ui/amount-input/utils.ts",
      "content": "/**\n * 金额输入的纯函数：把「用户敲出来的任意文本」整理成千分位文本，并算出整理后光标该在哪。\n * 全程按十进制字符串处理，不转 number，所以万亿级金额和 `0.1 + 0.2` 都不会丢精度。\n */\n\n/** 整数部分最多 16 位，与 chinese-number 的大写范围一致。 */\nconst MAX_INTEGER_DIGITS = 16\n\ntype SanitizeOptions = {\n  /** 小数位上限；0 表示只能输入整数 */\n  decimals: number\n  /** 是否允许开头的负号 */\n  allowNegative: boolean\n}\n\ntype Sanitized = {\n  /** 带千分位的显示文本 */\n  text: string\n  /** 整理后光标所在的位置 */\n  caret: number\n}\n\n/** 全角数字、全角小数点、各种减号统一成 ASCII；其余字符（逗号、空格、¥、字母）返回空串丢掉。 */\nfunction normalizeChar(ch: string) {\n  const code = ch.charCodeAt(0)\n  if (code >= 0xff10 && code <= 0xff19)\n    return String.fromCharCode(code - 0xfee0)\n  if (/[0-9.]/.test(ch)) return ch\n  if (\"．。｡\".includes(ch)) return \".\"\n  if (\"-－−﹣‐–\".includes(ch)) return \"-\"\n  return \"\"\n}\n\nfunction groupInteger(int: string) {\n  return int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\")\n}\n\n/**\n * 整理输入文本，同时换算光标。\n *\n * 光标算法：数出光标左边有几个「有效字符」（数字、小数点、负号，逗号不算），整理完后\n * 在新文本里找到第 n 个有效字符，把光标放在它后面。千分位逗号的增减不影响有效字符个数，\n * 所以在中间插入、删除、粘贴之后光标都留在用户眼里「同一个位置」。\n * 被丢掉的字符（非法字符、超出的小数位）不计数；开头多余的零被后来的数字顶替时，\n * 被顶替的零也一并不计，这样 `0|` 再敲 `5` 得到 `5|`。\n */\nfunction sanitizeAmount(\n  raw: string,\n  caret: number,\n  { decimals, allowNegative }: SanitizeOptions,\n): Sanitized {\n  const kept: { ch: string; before: boolean }[] = []\n  let negative = false\n  let hasDot = false\n  let int = \"\"\n  let frac = \"\"\n\n  for (let i = 0; i < raw.length; i++) {\n    const ch = normalizeChar(raw.charAt(i))\n    const before = i < caret\n    if (ch === \"-\") {\n      if (allowNegative && kept.length === 0) {\n        negative = true\n        kept.push({ ch, before })\n      }\n    } else if (ch === \".\") {\n      if (decimals > 0 && !hasDot) {\n        hasDot = true\n        // 直接敲 \".5\" 补成 \"0.5\"，补出来的 0 算在小数点所在的一侧\n        if (int === \"\") {\n          int = \"0\"\n          kept.push({ ch: \"0\", before })\n        }\n        kept.push({ ch, before })\n      }\n    } else if (ch) {\n      if (hasDot) {\n        if (frac.length < decimals) {\n          frac += ch\n          kept.push({ ch, before })\n        }\n      } else if (int === \"0\") {\n        // 前导零只留一个：0 后面再敲数字就顶替它\n        if (ch !== \"0\") {\n          kept.pop()\n          int = ch\n          kept.push({ ch, before })\n        }\n      } else if (int.length < MAX_INTEGER_DIGITS) {\n        int += ch\n        kept.push({ ch, before })\n      }\n    }\n  }\n\n  const text = `${negative ? \"-\" : \"\"}${groupInteger(int)}${hasDot ? `.${frac}` : \"\"}`\n  const count = kept.filter((token) => token.before).length\n  let position = 0\n  if (count > 0) {\n    position = text.length\n    let seen = 0\n    for (let i = 0; i < text.length; i++) {\n      if (text[i] !== \",\" && ++seen === count) {\n        position = i + 1\n        break\n      }\n    }\n  }\n  return { text, caret: position }\n}\n\n/** 显示文本 → 原始数值字符串：去千分位、去末尾小数点；空和单独的负号都是 `\"\"`，`-0` 归一成 `0`。 */\nfunction textToValue(text: string) {\n  let value = text.replace(/,/g, \"\")\n  if (value === \"\" || value === \"-\") return \"\"\n  if (value.endsWith(\".\")) value = value.slice(0, -1)\n  if (/^-0*(\\.0*)?$/.test(value)) value = value.slice(1)\n  return value\n}\n\n/** 失焦时收尾：补齐小数位（或去掉悬空的小数点）；只剩一个负号就清空。 */\nfunction finalizeText(text: string, decimals: number, pad: boolean) {\n  if (text === \"\" || text === \"-\") return \"\"\n  if (decimals > 0 && pad) {\n    const [int, frac = \"\"] = text.split(\".\")\n    return `${int}.${frac.padEnd(decimals, \"0\")}`\n  }\n  return text.endsWith(\".\") ? text.slice(0, -1) : text\n}\n\n/** 把外部传入的数值字符串整理成显示文本（`pad` 为真时补齐小数位）。 */\nfunction formatAmount(value: string, options: SanitizeOptions, pad: boolean) {\n  const { text } = sanitizeAmount(value, 0, options)\n  return finalizeText(text, options.decimals, pad)\n}\n\n/** 两个十进制字符串按数值比较：`1234.50` 与 `1234.5` 相等，不经过浮点。 */\nfunction compareAmount(a: string, b: string) {\n  const x = splitAmount(a)\n  const y = splitAmount(b)\n  if (x.negative !== y.negative) return x.negative ? -1 : 1\n  const length = Math.max(x.frac.length, y.frac.length)\n  const left =\n    x.int.padStart(Math.max(x.int.length, y.int.length), \"0\") +\n    x.frac.padEnd(length, \"0\")\n  const right =\n    y.int.padStart(Math.max(x.int.length, y.int.length), \"0\") +\n    y.frac.padEnd(length, \"0\")\n  const order = left === right ? 0 : left < right ? -1 : 1\n  return x.negative ? -order : order\n}\n\n/** 拆成符号、整数、小数（去掉末尾的零）；零不带符号。 */\nfunction splitAmount(value: string) {\n  const [int = \"\", frac = \"\"] = value.replace(\"-\", \"\").split(\".\")\n  const parts = { int: int.replace(/^0+/, \"\"), frac: frac.replace(/0+$/, \"\") }\n  return {\n    ...parts,\n    negative: value.startsWith(\"-\") && (parts.int !== \"\" || parts.frac !== \"\"),\n  }\n}\n\nfunction sameAmount(a: string, b: string) {\n  if (a === \"\" || b === \"\") return a === b\n  return compareAmount(a, b) === 0\n}\n\n/** `min` / `max` 允许传 number 或字符串，统一成不带科学计数法的十进制字符串。 */\nfunction toDecimalString(value: number | string) {\n  if (typeof value === \"string\") return value.replace(/,/g, \"\").trim()\n  return value.toLocaleString(\"en-US\", {\n    useGrouping: false,\n    maximumFractionDigits: 20,\n  })\n}\n\nexport {\n  MAX_INTEGER_DIGITS,\n  compareAmount,\n  finalizeText,\n  formatAmount,\n  groupInteger,\n  sameAmount,\n  sanitizeAmount,\n  textToValue,\n  toDecimalString,\n}\nexport type { SanitizeOptions, Sanitized }\n",
      "type": "registry:ui",
      "target": "@ui/amount-input/utils.ts"
    },
    {
      "path": "src/registry/ui/amount-input/config.ts",
      "content": "/** 用户可见文案。`{min}` / `{max}` 会替换成带货币符号的千分位金额。 */\nconst DEFAULT_LABELS = {\n  /** 人民币大写读数前的名称，也是读屏播报的前缀 */\n  uppercase: \"大写\",\n  /** 还没有输入金额时，大写行显示的提示 */\n  uppercasePlaceholder: \"填写金额后自动生成大写\",\n  /** 大数估读前缀：约 1.2亿 */\n  approximate: \"约\",\n  belowMin: \"金额不能低于 {min}\",\n  aboveMax: \"金额不能超过 {max}\",\n}\n\ntype AmountInputLabels = typeof DEFAULT_LABELS\n\nexport { DEFAULT_LABELS }\nexport type { AmountInputLabels }\n",
      "type": "registry:ui",
      "target": "@ui/amount-input/config.ts"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    },
    {
      "path": "src/registry/lib/data-display.ts",
      "content": "import * as React from \"react\"\n\n/**\n * 数据展示共用工具：涨跌配色约定、色调、数字与日期格式化。\n * stat-card、mini-chart、chart-card 三个条目共用这一份，所以涨跌色、数字格式只在这里定义。\n */\n\n/**\n * 涨跌配色约定：\n * - `intl`（默认）：绿涨红跌；\n * - `cn`：红涨绿跌，国内行情软件的习惯。\n */\ntype DataConvention = \"intl\" | \"cn\"\n\n// 约定只改写 `--stat-up` / `--stat-down` 两个 CSS 变量。变量沿 DOM 向下继承，\n// 所以给整块看板的外层设一次 `convention`，里面所有涨跌色（包括图表里的 SVG）都跟着变，\n// 不需要 context，服务端组件也能用。没有设置时回落到默认的绿涨红跌。\nconst CONVENTION_CLASSES: Record<DataConvention, string> = {\n  intl: \"[--stat-up:var(--flow-green,var(--primary))] [--stat-down:var(--flow-rose,var(--destructive))]\",\n  cn: \"[--stat-up:var(--flow-rose,var(--destructive))] [--stat-down:var(--flow-green,var(--primary))]\",\n}\n\n/** 把 `convention` 换成要加到根元素上的类名；不传返回 `undefined`，沿用外层的约定。 */\nfunction conventionClass(convention?: DataConvention) {\n  return convention ? CONVENTION_CLASSES[convention] : undefined\n}\n\ntype Trend = \"up\" | \"down\" | \"flat\"\n\nfunction trendOf(value: number): Trend {\n  return value > 0 ? \"up\" : value < 0 ? \"down\" : \"flat\"\n}\n\n/** 涨跌文字色（Tailwind 只认完整类名，所以三种写全）。 */\nconst TREND_TEXT: Record<Trend, string> = {\n  up: \"text-[color:var(--stat-up,var(--flow-green,var(--primary)))]\",\n  down: \"text-[color:var(--stat-down,var(--flow-rose,var(--destructive)))]\",\n  flat: \"text-muted-foreground\",\n}\n\n/**\n * 内置色调：\n * - `primary`、`foreground`、`muted`、`destructive` 直接引用主题变量，跟随颜色主题；\n * - `blue` 等多彩色调引用 `--flow-*` 变量，没有安装时回落到主题强调色；\n * - `chart-1` … `chart-5` 引用主题的图表色板；\n * - `up` / `down` 是涨跌色，随 `convention` 变化。\n */\ntype DataToneName =\n  | \"primary\"\n  | \"foreground\"\n  | \"muted\"\n  | \"destructive\"\n  | \"blue\"\n  | \"green\"\n  | \"amber\"\n  | \"rose\"\n  | \"violet\"\n  | \"cyan\"\n  | \"chart-1\"\n  | \"chart-2\"\n  | \"chart-3\"\n  | \"chart-4\"\n  | \"chart-5\"\n  | \"up\"\n  | \"down\"\n\n/** 色调：内置名，或任意 CSS 颜色（`\"var(--brand)\"`、`\"oklch(0.7 0.2 150)\"` 都可以）。 */\ntype DataTone = DataToneName | (string & {})\n\nconst TONES: Record<DataToneName, string> = {\n  primary: \"var(--primary)\",\n  foreground: \"var(--foreground)\",\n  muted: \"var(--muted-foreground)\",\n  destructive: \"var(--destructive)\",\n  blue: \"var(--flow-blue, var(--primary))\",\n  green: \"var(--flow-green, var(--primary))\",\n  amber: \"var(--flow-amber, var(--primary))\",\n  rose: \"var(--flow-rose, var(--destructive))\",\n  violet: \"var(--flow-violet, var(--primary))\",\n  cyan: \"var(--flow-cyan, var(--primary))\",\n  \"chart-1\": \"var(--chart-1)\",\n  \"chart-2\": \"var(--chart-2)\",\n  \"chart-3\": \"var(--chart-3)\",\n  \"chart-4\": \"var(--chart-4)\",\n  \"chart-5\": \"var(--chart-5)\",\n  up: \"var(--stat-up, var(--flow-green, var(--primary)))\",\n  down: \"var(--stat-down, var(--flow-rose, var(--destructive)))\",\n}\n\n/** 把色调换成可直接用于 `fill` / `stroke` / `color` 的 CSS 颜色。 */\nfunction toneColor(tone: DataTone): string {\n  return Object.hasOwn(TONES, tone) ? TONES[tone as DataToneName] : tone\n}\n\n/**\n * 多序列图表默认的色调轮换：六种互不相近的多彩色调。\n * 不把主题强调色放进来：强调色会落在蓝、绿、紫、琥珀任意一种附近，\n * 切到对应的颜色主题时会与其中一条序列撞色。单条序列想跟随主题，显式写 `tone=\"primary\"`。\n */\nconst SERIES_TONES: DataTone[] = [\n  \"blue\",\n  \"amber\",\n  \"green\",\n  \"violet\",\n  \"rose\",\n  \"cyan\",\n]\n\ntype StatFormatOptions = {\n  /**\n   * 数字样式：`number` 普通数字，`percent` 百分比（传入的 12.5 表示 12.5%），`currency` 货币\n   * @default \"number\"\n   */\n  style?: \"number\" | \"percent\" | \"currency\"\n  /**\n   * BCP 47 区域标识；`zh-CN` 压缩成「万 / 亿」，`en` 压缩成 K / M / B\n   * @default \"zh-CN\"\n   */\n  locale?: string\n  /**\n   * 压缩大数（1.2万、3.4亿）；百分比不压缩\n   * @default true\n   */\n  compact?: boolean\n  /**\n   * 货币代码，仅 `style=\"currency\"` 生效；人民币显示 ¥\n   * @default \"CNY\"\n   */\n  currency?: string\n  /**\n   * 最多保留的小数位\n   * @default 1（压缩与百分比），2（普通数字与货币）\n   */\n  digits?: number\n  /**\n   * 正数前加 `+`（零不加），用于涨跌幅\n   * @default false\n   */\n  sign?: boolean\n}\n\nconst formatters = new Map<string, Intl.NumberFormat>()\n\n/**\n * 格式化统计数字，默认 `zh-CN` 压缩：`12480` → `1.2万`，`345000000` → `3.5亿`；\n * `locale: \"en\"` 时为 `12K`、`345M`。同参数的 `Intl.NumberFormat` 会被缓存复用。\n */\nfunction formatStat(value: number, options: StatFormatOptions = {}): string {\n  const {\n    style = \"number\",\n    locale = \"zh-CN\",\n    compact = style !== \"percent\",\n    currency = \"CNY\",\n    sign = false,\n  } = options\n  const digits = options.digits ?? (compact || style === \"percent\" ? 1 : 2)\n  const key = [style, locale, compact, currency, digits, sign].join(\"|\")\n  let formatter = formatters.get(key)\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, {\n      style: style === \"number\" ? \"decimal\" : style,\n      currency: style === \"currency\" ? currency : undefined,\n      currencyDisplay: style === \"currency\" ? \"narrowSymbol\" : undefined,\n      notation: compact && style !== \"percent\" ? \"compact\" : \"standard\",\n      maximumFractionDigits: digits,\n      signDisplay: sign ? \"exceptZero\" : \"auto\",\n    })\n    formatters.set(key, formatter)\n  }\n  return formatter.format(style === \"percent\" ? value / 100 : value)\n}\n\ntype DayStyle = \"long\" | \"short\" | \"weekday\"\n\nconst dayFormatters = new Map<string, Intl.DateTimeFormat>()\n\n/**\n * 格式化日期轴文字：`long` → `2月14日`，`short` → `02/14`，`weekday` → `一`（单字星期）。\n * 日期按 UTC 解析与输出，`\"2026-02-14\"` 这类字符串在任何时区、任何环境下结果一致，不会引起水合差异。\n */\nfunction formatDay(\n  input: string | number | Date,\n  style: DayStyle = \"long\",\n  locale = \"zh-CN\",\n): string {\n  const key = `${style}|${locale}`\n  let formatter = dayFormatters.get(key)\n  if (!formatter) {\n    const options: Intl.DateTimeFormatOptions =\n      style === \"weekday\"\n        ? { weekday: \"narrow\" }\n        : style === \"short\"\n          ? { month: \"2-digit\", day: \"2-digit\" }\n          : { month: \"long\", day: \"numeric\" }\n    formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone: \"UTC\" })\n    dayFormatters.set(key, formatter)\n  }\n  return formatter.format(new Date(input))\n}\n\ntype AxisFormatOptions = Pick<\n  StatFormatOptions,\n  \"style\" | \"locale\" | \"currency\" | \"digits\"\n>\n\n// 压缩单位表：从大到小，命中第一个不超过轴最大值的单位\nconst AXIS_UNITS = {\n  zh: [\n    [1e8, \"亿\"],\n    [1e4, \"万\"],\n  ],\n  \"zh-trad\": [\n    [1e8, \"億\"],\n    [1e4, \"萬\"],\n  ],\n  en: [\n    [1e9, \"B\"],\n    [1e6, \"M\"],\n    [1e3, \"K\"],\n  ],\n} as const\n\n/**\n * 按「整条轴」统一单位的刻度格式化：先用轴的最大值选定一个单位，所有刻度都换算成它，\n * 避免 `9000` 与 `1.2万` 混在同一根轴上。例如最大值 12000 时刻度是 `0`、`0.3万`、`0.6万`、`0.9万`、`1.2万`；\n * 最大值 8000 时全部是整数（`2000`、`4000`…）。`en` 同理换成 K / M / B。\n *\n * @param max 轴上数据的最大绝对值；留 15% 余量，因为图表会把坐标轴上沿取整到比数据更大的刻度\n */\nfunction createAxisFormatter(\n  max: number,\n  options: AxisFormatOptions = {},\n): (value: number) => string {\n  const { style = \"number\", locale = \"zh-CN\", currency = \"CNY\" } = options\n  if (style === \"percent\")\n    return (value) =>\n      formatStat(value, { style: \"percent\", locale, digits: options.digits })\n  const family = /^zh-(TW|HK|MO|Hant)/i.test(locale)\n    ? \"zh-trad\"\n    : /^zh/i.test(locale)\n      ? \"zh\"\n      : \"en\"\n  const reach = Math.abs(max) * 1.15\n  const unit = AXIS_UNITS[family].find(([size]) => reach >= size)\n  const divisor = unit?.[0] ?? 1\n  const suffix = unit?.[1] ?? \"\"\n  const formatter = new Intl.NumberFormat(locale, {\n    style: style === \"currency\" ? \"currency\" : \"decimal\",\n    currency: style === \"currency\" ? currency : undefined,\n    currencyDisplay: style === \"currency\" ? \"narrowSymbol\" : undefined,\n    notation: \"standard\",\n    maximumFractionDigits: options.digits ?? 2,\n  })\n  return (value) =>\n    value === 0\n      ? formatter.format(0)\n      : `${formatter.format(value / divisor)}${suffix}`\n}\n\n// 进度条、计量条、分段条、进度环、刻度仪表的入场动画：纯 CSS，服务端直接输出最终状态，\n// 所以没有脚本时也完整显示；系统开启「减少动态效果」时不播放。\nconst MOTION_CSS = `@media (prefers-reduced-motion: no-preference) {\n@keyframes dd-reveal { from { clip-path: inset(0 100% 0 0 round 9999px) } to { clip-path: inset(0 0 0 0 round 9999px) } }\n@keyframes dd-rise { from { opacity: 0; transform: scaleY(0.35) } }\n@keyframes dd-fade { from { opacity: 0 } }\n@keyframes dd-arc { from { stroke-dasharray: 0 1 } }\n[data-dd-anim=\"reveal\"] { animation: dd-reveal 0.8s cubic-bezier(0.22, 1, 0.36, 1) both }\n[data-dd-anim=\"rise\"] { animation: dd-rise 0.3s cubic-bezier(0.22, 1, 0.36, 1) both }\n[data-dd-anim=\"fade\"] { animation: dd-fade 0.35s cubic-bezier(0.22, 1, 0.36, 1) both }\n[data-dd-anim=\"arc\"] { animation: dd-arc 0.9s cubic-bezier(0.22, 1, 0.36, 1) both }\n}`\n\n/**\n * 渲染上面的动画样式。React 19 会按 `href` 去重并提升到 `<head>`，\n * 同一页放多少个组件都只有一份；放在服务端组件里也能用。\n */\nfunction motionStyles() {\n  return React.createElement(\n    \"style\",\n    { href: \"data-display-motion\", precedence: \"default\" },\n    MOTION_CSS,\n  )\n}\n\nexport {\n  TREND_TEXT,\n  SERIES_TONES,\n  conventionClass,\n  createAxisFormatter,\n  formatDay,\n  formatStat,\n  motionStyles,\n  toneColor,\n  trendOf,\n}\nexport type {\n  AxisFormatOptions,\n  DataConvention,\n  DataTone,\n  DataToneName,\n  DayStyle,\n  StatFormatOptions,\n  Trend,\n}\n",
      "type": "registry:ui",
      "target": "lib/data-display.ts"
    },
    {
      "path": "src/registry/hooks/use-caret-restore.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/** 格式化提交后恢复光标；即使文本未变，也用新请求触发恢复。 */\nfunction useCaretRestore(inputRef: React.RefObject<HTMLInputElement | null>) {\n  const [caret, setCaret] = React.useState<{ position: number } | null>(null)\n  React.useLayoutEffect(() => {\n    const input = inputRef.current\n    if (caret && input && document.activeElement === input)\n      input.setSelectionRange(caret.position, caret.position)\n  }, [caret, inputRef])\n  return setCaret\n}\n\nexport { useCaretRestore }\n",
      "type": "registry:ui",
      "target": "hooks/use-caret-restore.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)",
      "flow-blue": "oklch(0.56 0.2 258)",
      "flow-green": "oklch(0.62 0.16 150)",
      "flow-amber": "oklch(0.7 0.16 70)",
      "flow-rose": "oklch(0.62 0.21 5)",
      "flow-violet": "oklch(0.56 0.22 292)",
      "flow-cyan": "oklch(0.66 0.12 215)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)",
      "flow-blue": "oklch(0.72 0.15 255)",
      "flow-green": "oklch(0.77 0.17 152)",
      "flow-amber": "oklch(0.82 0.15 82)",
      "flow-rose": "oklch(0.74 0.16 8)",
      "flow-violet": "oklch(0.74 0.15 295)",
      "flow-cyan": "oklch(0.82 0.11 208)"
    }
  },
  "type": "registry:ui"
}
