跳至正文
组件

Amount Input金额输入

付款、报销、订单金额用的输入框:实时千分位且光标不乱跳,限制小数位与范围,粘贴自动规范化,下方同步显示人民币大写与「约 1.2亿」估读。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/amount-input.json
会连带安装 本站条目 chinese-number。已存在的文件会询问是否覆盖。

用法

components/pay-form.tsx
import { AmountInput } from "@/components/ui/amount-input/amount-input"

export function PayForm() {
  return <AmountInput name="amount" label="付款金额" defaultValue="1280" />
}

不带状态时用 defaultValue 给初始值,组件自己管;需要读值就加 value 与 onValueChange 走受控。设了 name,表单提交的是不带千分位的原始数值("1234.50"),不是输入框里看到的 1,234.50。

输入框是一条方条,和方条开关、分段选择、数值滑块同一套外观与 28 / 32 / 36px 三档高度:label 写在条内左侧,金额靠右;不传 label 时金额从左边开始,标签可以照常写在外面。

值是字符串

value、defaultValue、onValueChange 的参数都是十进制字符串,空串表示没填,没有 null。原因有三:

  • 金额算到万亿级、两位小数共 18 位有效数字,超出 number 的安全范围;
  • 0.1 + 0.2 这类浮点误差不会混进来;
  • 保留用户敲的形态,"1234.50" 与 "1234.5" 是两个不同的字符串,同一个数值。

需要算数时在你自己的代码里转:Number(value),或用 decimal.js 之类的库。两个字符串是否相等不要用 ===,数值相同的 "1234.50" 和 "1234.5" 会被组件判为同一个金额,受控时不会因此改写显示。

输入整理

敲进去的任何文本都先过一遍整理,再显示:

  • 加千分位,小数位超过 decimals(默认 2)的数字直接舍去;
  • 全角数字 123、全角小数点 . 。 换成半角;
  • ¥、空格、字母、逗号这些非法字符丢掉,只保留第一个小数点;
  • 前导零只留一个,直接敲 .5 补成 0.5;
  • 整数部分最多 16 位。

所以粘贴 ¥1,234.5、1234.56、1 234.56元 都能得到合理的结果。粘贴进来的小数超出 decimals 时是舍去而不是四舍五入:输入阶段静悄悄地改变数值比少一位更让人意外。

大写与估读

输入框下方的读数行有两部分:

  • 人民币大写:壹万贰仟叁佰肆拾伍元陆角柒分,用 chinese-number 的 toRmbUppercase;还没有输入时显示提示,行高不会跳;
  • 估读:绝对值达到 approximateFrom(默认 10000)后显示 约 12.3万、约 1.2亿,用 data-display 的 formatStat 压缩。

showUppercase、showApproximate 各自可以关;两个都关,整行读数不渲染。readoutPlacement="above" 把读数行放到输入框上方。

大写按分四舍五入,所以 decimals 设得比 2 大时,大写只反映到分;非人民币场景请关掉 showUppercase。

光标

在千分位文本中间插入、删除、粘贴时,光标留在用户眼里「同一个位置」:

  • 在 1,2|34 中间敲 5,原始文本是 1,25|34,整理成 12,534,光标仍在 5 之后(12,5|34);
  • 退格时光标紧贴逗号(1,|234),会越过逗号删掉前面的数字 1,而不是只删逗号再被整理回来、看起来按键没反应;Delete 同理;
  • 输入非法字符、超出的小数位时,光标不会被甩到末尾。

算法见下方「原理」。

校验

min / max 不拦截输入——敲 5 时不能因为 min={100} 就报错。规则是:

  • 超过 max:立刻显示 aboveMax 提示(再敲只会更大);
  • 低于 min:失焦后才显示 belowMin,避免敲第一位数字就报错;
  • 无论是否显示,越界的值都会通过 setCustomValidity 拦住表单提交,浏览器提示同一句话;
  • required 照常工作,空值被原生校验拦住。

业务规则(余额、日限额)组件不知道,你自己判断,把提示作为 error 传入:有值就进入错误态并显示这句话,优先于越界提示。只想要红框不要文字时传 aria-invalid。

min 为负数时才允许输入负号,比如冲账、退款的场景写 min={-100000}。

静默补零

默认失焦时把小数位补齐到 decimals(1,234.5 → 1,234.50),onValueChange 会随之收到 "1234.50"。不想要就传 padDecimals={false}。聚焦期间不补,敲字时不会冒出零。

大字

付款、转账页上那个最主要的金额用 variant="display":数字放大、下面一道横线,标签写在上方,大写读数变成金额下面的一行小字。size 在大字外观里控制数字字号(24 / 30 / 36px)。

<AmountInput variant="display" label="转账金额" />

手机上

  • 用系统数字键盘:有小数位时 inputMode="decimal",整数用 numeric。不另做虚拟键盘——系统键盘自带震动反馈、读屏和外接键盘支持,更可靠。
  • 触屏设备上方条各加高一档(36 / 40 / 44px),手指好点。
  • 聚焦后浏览器只保证输入框本身露出来,下面的大写行和错误提示常被软键盘挡住;组件会等键盘弹出后,把自己的底边也滚进可见区域。
  • 字号不小于 16px,iOS 聚焦时不会自动放大页面。

示例

手机上的体验

一台按真机尺寸画的「手机」(402 × 874pt)里的转账页:主金额用大字,超过余额时直接给出错误、按钮置灰;「全部转出」把余额填进去。

正在加载…

受控与表单提交

受控的 value 配 onValueChange;设了 name,FormData 里拿到的是原始数值。min / max / required 越界或留空时表单提交会被拦住。

正在加载…

自定义

symbol 换货币符号(传 null 隐藏),decimals={0} 只收整数,showUppercase={false} 关掉大写。汇率这类不是金额的数值,把大写、估读、补零都关掉,只用它的千分位与光标处理。

正在加载…

校验与错误态

第一个输入框用 min / max,初始值 60000 已经越界,红框与提示直接显示;第二个用 error 接入自己的业务判断,余额不足时显示提示。

正在加载…

尺寸与状态

size 三档高度对应按钮的 sm / default / lg(28 / 32 / 36px,触屏设备上 36 / 40 / 44px),同一行并排的控件用同一档。禁用时灰显且不随表单提交,只读可以聚焦、选中、复制,但不能改。

正在加载…

原理

光标

React 受控输入每次渲染都会把光标甩到末尾,千分位逗号的增减又会让「字符位置」失真,所以组件不按字符下标记位置,而是按有效字符的个数:

  1. 事件里拿到用户敲完之后的原始文本与光标下标 caret;
  2. 数出 caret 左边有几个有效字符(数字、小数点、负号,逗号不算),记为 n;整理时被丢掉的字符(非法字符、超出的小数位、被顶替的前导零)不计入;
  3. 整理出新文本后,找到其中第 n 个有效字符,光标放它后面;
  4. 在 useLayoutEffect 里 setSelectionRange,每次提交都用新对象触发,所以即使文本没变(敲了字母被丢掉)光标也会被拉回原位。

输入法组词期间(compositionstart 到 compositionend)不整理,否则会打断候选词,组词结束时统一处理一次。

读屏

读数行做了两件事,取舍是不让读屏在敲字时被反复打断:

  • 大写行的 id 挂在输入框的 aria-describedby 上,聚焦时随字段名一起朗读,想再听一遍重新聚焦即可;
  • 敲字时不逐字播报。停手 800ms 后,一个 role="status"(隐式 polite)的隐藏区域播报一次最新的大写;还在敲就重新计时。

估读行是纯视觉辅助,不进入描述。

API

className 作用于根元素,其余未列出的属性(id、name、required、placeholder、autoFocus、onFocus、onBlur……)都交给 <input>;ref 指向 <input>。name 例外:它加在一个隐藏的 <input type="hidden"> 上,提交原始数值,可见输入框本身不带 name。

不依赖 Base UI 的 Field:组件自带 label、错误提示和描述,放进 Field.Root 里不会自动联动。

labels

type AmountInputLabels = {
  uppercase: string // "大写",也是读屏播报的前缀
  uppercasePlaceholder: string // "填写金额后自动生成大写"
  approximate: string // "约"
  belowMin: string // "金额不能低于 {min}"
  aboveMax: string // "金额不能超过 {max}"
}

{min} / {max} 会替换成带货币符号(symbol 是字符串时)的千分位金额,如 ¥100.00。

amountInputControlVariants

输入框外框的 cva 样式,有 variant(bar / display)和 size 两个变体;做自己的金额控件(带按钮、下拉单位)时可以复用它保持高度一致。

类型

type AmountInputSize = "sm" | "default" | "lg"
type AmountInputVariant = "bar" | "display"

type AmountInputProps = {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  min?: number | string
  max?: number | string
  decimals?: number
  symbol?: React.ReactNode
  label?: React.ReactNode
  variant?: AmountInputVariant
  size?: AmountInputSize
  showUppercase?: boolean
  showApproximate?: boolean
  approximateFrom?: number
  readoutPlacement?: "above" | "below"
  error?: React.ReactNode
  padDecimals?: boolean
  labels?: Partial<AmountInputLabels>
} & Omit<
  React.ComponentProps<"input">,
  /* 以上已定义的属性 */ "value" | "defaultValue"
>

data-slot

元素data-slot
根元素amount-input
条内标签amount-input-label
方条外框amount-input-control
货币符号amount-input-symbol
输入框amount-input-input
读数行amount-input-readout
人民币大写amount-input-uppercase
估读amount-input-approximate
错误提示amount-input-error
提交用的隐藏输入amount-input-value
读屏播报区amount-input-status

根元素带 data-variant、data-size,错误态带 data-invalid,禁用带 data-disabled,只读带 data-readonly。

可访问性

  • 输入框是 type="text" 加 inputMode(有小数位用 decimal,整数用 numeric,允许负数时用 text,因为移动端数字键盘没有减号),不用 type="number":后者不能带千分位,滚轮和方向键还会误改金额。
  • label 写在方条内左侧,通过 htmlFor 与输入框关联,点标签或条内空白都会聚焦输入框;没有可见标签时传 aria-label。
  • 货币符号是装饰,aria-hidden;金额的单位由标签或大写读数传达,所以标签最好写明,如「付款金额(元)」。
  • 错误提示通过 aria-describedby 与输入框关联,输入框同时有 aria-invalid;错误态不只靠红色,还有文字。
  • 读屏策略见「原理」:描述在聚焦时朗读,敲字停顿后 polite 播报一次,不逐字打断。
  • 焦点环加在外框上(focus-within),键盘和鼠标聚焦都可见。

注意事项

  • 触屏设备上方条加高一档(36 / 40 / 44px)。并排的按钮要一起加高才对得齐:本站的 Button 已经在三档尺寸里加了 pointer-coarse:h-9 / h-10 / h-11;如果你用的是 shadcn 默认的按钮,在 button.tsx 的 sm、default、lg 里补上这三个类即可。
  • 组件在服务端输出完整结构与初始文本(含千分位与补零),禁用 JS 时能看到初始值;整理与光标逻辑需要客户端。
  • 受控模式下记得同时处理 onValueChange,否则敲字后显示文本与外部值会出现分歧;外部把 value 改成数值不同的字符串时,显示会同步,数值相同("1234.5" → "1234.50")则不改写。
  • 非受控的表单 reset 会回到 defaultValue;受控的由外部自己重置。
  • min、max 建议定义在组件外或用字符串,避免对象字面量每次渲染重建(数字不存在这个问题)。
  • 撤销(⌘Z):整理会重写输入框内容,浏览器的撤销栈不再可靠,这是用受控输入实现实时千分位的通病。

源码

components/ui/amount-input/amount-input.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { toRmbUppercase } from "@/lib/chinese-number"
import { formatStat } from "@/lib/data-display"

import {
  AmountInputReadout,
  amountInputControlVariants,
} from "./amount-input-parts"
import { DEFAULT_LABELS } from "./config"
import type { AmountInputProps } from "./types"
import {
  compareAmount,
  finalizeText,
  formatAmount,
  sameAmount,
  sanitizeAmount,
  textToValue,
  toDecimalString,
  type SanitizeOptions,
} from "./utils"

/** 读屏播报大写的防抖时间(毫秒):停手这么久才报一次,敲字过程中不打断读屏。 */
const ANNOUNCE_DELAY = 800

/** 软键盘弹出后最多等这么久(毫秒)再检查读数行有没有被挡住 */
const KEYBOARD_WAIT = 600

/**
 * 触屏设备上聚焦后,浏览器只保证输入框本身露出来,下面的大写行和错误提示常被软键盘挡住。
 * 等键盘弹出(visualViewport 缩小)后,把整个组件的底边滚到可见区域里。
 */
function revealBelowKeyboard(root: HTMLElement | null) {
  const viewport = window.visualViewport
  if (!root || !viewport || !window.matchMedia("(pointer: coarse)").matches)
    return
  // 键盘是动画弹出的,resize 会连发几次:停下来 150ms 再量;一直没有 resize(外接键盘、桌面触屏)就到点量一次
  let timer = window.setTimeout(check, KEYBOARD_WAIT)
  const settle = () => {
    window.clearTimeout(timer)
    timer = window.setTimeout(check, 150)
  }
  function check() {
    viewport!.removeEventListener("resize", settle)
    const bottom = root!.getBoundingClientRect().bottom
    const visibleBottom = viewport!.offsetTop + viewport!.height
    const overflow = bottom - visibleBottom + 12
    if (overflow > 0) window.scrollBy({ top: overflow, behavior: "smooth" })
  }
  viewport.addEventListener("resize", settle)
}

/** 金额输入框:实时千分位、光标不乱跳,下方同步显示人民币大写与大数估读。 */
function AmountInput({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  min,
  max,
  decimals: decimalsProp = 2,
  symbol = "¥",
  label,
  variant = "bar",
  size = "default",
  showUppercase = true,
  showApproximate = true,
  approximateFrom = 10_000,
  readoutPlacement = "below",
  error,
  padDecimals = true,
  labels: labelsProp,
  className,
  id: idProp,
  name,
  form,
  disabled,
  readOnly,
  placeholder,
  inputMode,
  "aria-invalid": ariaInvalid,
  "aria-describedby": describedByProp,
  onFocus,
  onBlur,
  onKeyDown,
  ref,
  ...props
}: AmountInputProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp }
  const decimals = Math.max(0, Math.floor(decimalsProp))
  const minText = min === undefined ? undefined : toDecimalString(min)
  const maxText = max === undefined ? undefined : toDecimalString(max)
  const allowNegative = minText !== undefined && minText.startsWith("-")
  const options: SanitizeOptions = { decimals, allowNegative }

  const autoId = React.useId()
  const id = idProp ?? autoId
  const inputRef = React.useRef<HTMLInputElement>(null)
  const rootRef = React.useRef<HTMLDivElement>(null)
  React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)

  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue,
    onChange: onValueChange,
  })
  const [focused, setFocused] = React.useState(false)
  const [touched, setTouched] = React.useState(false)
  const [text, setText] = React.useState(() =>
    formatAmount(value, options, padDecimals),
  )
  // 外部改了 value(受控)才同步显示文本;内部输入产生的变化文本已经是最新的,不会触发。
  // 只在 value 变化的那一次比较,避免 value 的位数超过 decimals 时反复改写
  const [seenValue, setSeenValue] = React.useState(value)
  if (seenValue !== value) {
    setSeenValue(value)
    if (!sameAmount(value, textToValue(text)))
      setText(formatAmount(value, options, padDecimals && !focused))
  }

  // 整理后要恢复的光标位置;每次都是新对象,保证即使文本没变(敲了非法字符)也会重设光标
  const [caret, setCaret] = React.useState<{ position: number } | null>(null)
  React.useLayoutEffect(() => {
    const input = inputRef.current
    if (caret && input && document.activeElement === input)
      input.setSelectionRange(caret.position, caret.position)
  }, [caret])

  const composing = React.useRef(false)
  const [edits, setEdits] = React.useState(0)

  const commit = (raw: string, rawCaret: number) => {
    const next = sanitizeAmount(raw, rawCaret, options)
    setText(next.text)
    setCaret({ position: next.caret })
    setValue(textToValue(next.text))
    setEdits((count) => count + 1)
  }

  const amount = textToValue(text)
  const numeric = amount === "" ? 0 : Number(amount)
  const uppercase = showUppercase && amount !== "" ? toRmbUppercase(amount) : ""
  const approximate =
    showApproximate && amount !== "" && Math.abs(numeric) >= approximateFrom
      ? `${labels.approximate} ${formatStat(numeric, { digits: 1 })}`
      : ""

  // 越界:超过 max 立即提示(再敲只会更大);低于 min 要等失焦,否则敲第一位数字就报错。
  // 无论提示与否,越界值都通过 setCustomValidity 拦住表单提交
  const tooLow =
    amount !== "" && minText !== undefined && compareAmount(amount, minText) < 0
  const tooHigh =
    amount !== "" && maxText !== undefined && compareAmount(amount, maxText) > 0
  const limitText = (limit: string) => {
    const digits = Math.max(decimals, limit.split(".")[1]?.length ?? 0)
    const formatted = formatAmount(
      limit,
      { decimals: digits, allowNegative: true },
      true,
    )
    return `${typeof symbol === "string" ? symbol : ""}${formatted}`
  }
  const rangeMessage = tooHigh
    ? labels.aboveMax.replace("{max}", limitText(maxText!))
    : tooLow
      ? labels.belowMin.replace("{min}", limitText(minText!))
      : ""
  React.useEffect(() => {
    inputRef.current?.setCustomValidity(rangeMessage)
  }, [rangeMessage])

  const errorMessage = error || (tooHigh || touched ? rangeMessage : "")
  const invalid =
    Boolean(errorMessage) || ariaInvalid === true || ariaInvalid === "true"

  // 读屏:大写行通过 aria-describedby 在聚焦时朗读;敲字时不逐字播报,
  // 停手 ANNOUNCE_DELAY 毫秒后用 polite 的状态区报一次最新的大写
  const [announcement, setAnnouncement] = React.useState("")
  React.useEffect(() => {
    if (edits === 0 || !focused) return
    const timer = window.setTimeout(
      () =>
        setAnnouncement(uppercase ? `${labels.uppercase} ${uppercase}` : ""),
      ANNOUNCE_DELAY,
    )
    return () => window.clearTimeout(timer)
  }, [edits, focused, uppercase, labels.uppercase])

  // 非受控的表单重置:回到 defaultValue。监听器只挂一次,通过 ref 拿到最新的设置
  const resetRef = React.useRef(() => {})
  React.useEffect(() => {
    resetRef.current = () => {
      setText(formatAmount(defaultValue, options, padDecimals))
      setValue(defaultValue)
    }
  })
  const controlled = valueProp !== undefined
  React.useEffect(() => {
    const element = inputRef.current?.form
    if (!element || controlled) return
    // reset 事件在表单清空之前触发,等一帧再覆盖
    const reset = () => window.setTimeout(() => resetRef.current())
    element.addEventListener("reset", reset)
    return () => element.removeEventListener("reset", reset)
  }, [controlled])

  const uppercaseId = `${id}-uppercase`
  const errorId = `${id}-error`
  const describedBy =
    [
      describedByProp,
      showUppercase && uppercase ? uppercaseId : undefined,
      errorMessage ? errorId : undefined,
    ]
      .filter(Boolean)
      .join(" ") || undefined

  const display = variant === "display"
  const labelNode = label ? (
    <label
      data-slot="amount-input-label"
      htmlFor={id}
      className={
        display
          ? "text-sm text-muted-foreground"
          : "mr-auto shrink-0 text-muted-foreground max-md:text-sm"
      }
    >
      {label}
    </label>
  ) : null

  const readout =
    showUppercase || approximate ? (
      <AmountInputReadout
        uppercase={uppercase}
        uppercaseId={uppercaseId}
        uppercaseLabel={labels.uppercase}
        placeholder={labels.uppercasePlaceholder}
        showUppercase={showUppercase}
        approximate={approximate}
        // 大字时去掉虚线框,读数就是金额下面的一行小字
        className={display ? "border-0 bg-transparent px-0" : undefined}
      />
    ) : null

  return (
    <div
      ref={rootRef}
      data-slot="amount-input"
      data-variant={variant}
      data-size={size}
      data-invalid={invalid ? "" : undefined}
      data-disabled={disabled ? "" : undefined}
      data-readonly={readOnly ? "" : undefined}
      className={cn("flex w-full flex-col gap-1.5", className)}
    >
      {display && labelNode}
      {readoutPlacement === "above" && readout}
      <div
        data-slot="amount-input-control"
        className={cn(
          amountInputControlVariants({ variant, size }),
          display
            ? invalid
              ? "border-destructive"
              : "border-edge focus-within:border-primary"
            : invalid
              ? "border-destructive ring-3 ring-destructive/20 dark:border-destructive/50 dark:ring-destructive/40"
              : "border-edge focus-within:ring-3 focus-within:ring-ring/50 hover:bg-muted",
          disabled && "cursor-not-allowed opacity-50",
          readOnly && (display ? "border-dashed" : "bg-muted/30"),
        )}
        // 点到条内空白处(标签与数字之间)也聚焦输入框
        onMouseDown={(event) => {
          if (event.target === event.currentTarget) {
            event.preventDefault()
            inputRef.current?.focus()
          }
        }}
      >
        {!display && labelNode}
        {symbol != null && (
          <span
            data-slot="amount-input-symbol"
            aria-hidden="true"
            className={cn(
              "shrink-0 text-muted-foreground select-none",
              // 大字时符号小一号、和数字底部对齐,像收银台上的金额
              display && "text-[0.6em] font-medium",
            )}
          >
            {symbol}
          </span>
        )}
        <input
          {...props}
          ref={inputRef}
          id={id}
          data-slot="amount-input-input"
          type="text"
          inputMode={
            inputMode ??
            (allowNegative ? "text" : decimals > 0 ? "decimal" : "numeric")
          }
          autoComplete="off"
          spellCheck={false}
          value={text}
          placeholder={
            placeholder ?? (decimals > 0 ? `0.${"0".repeat(decimals)}` : "0")
          }
          disabled={disabled}
          readOnly={readOnly}
          aria-invalid={invalid || undefined}
          aria-describedby={describedBy}
          className={cn(
            "min-w-0 bg-transparent tabular-nums outline-none placeholder:font-normal placeholder:text-muted-foreground/60 disabled:cursor-not-allowed",
            display
              ? "flex-1 py-0.5 leading-tight font-semibold"
              : // 有条内标签时金额靠右,与方条开关、数值滑块的「左标签右数值」一致;
                // 输入框按内容收窄,货币符号才会贴着金额而不是贴着标签
                cn(
                  "h-full font-medium",
                  label
                    ? "field-sizing-content max-w-full text-right"
                    : "flex-1",
                ),
          )}
          onChange={(event) => {
            const input = event.currentTarget
            // 输入法组词期间不整理,否则会打断候选词;组词结束时再统一处理
            if (composing.current) return setText(input.value)
            commit(input.value, input.selectionStart ?? input.value.length)
          }}
          onCompositionStart={() => {
            composing.current = true
          }}
          onCompositionEnd={(event) => {
            composing.current = false
            const input = event.currentTarget
            commit(input.value, input.selectionStart ?? input.value.length)
          }}
          onKeyDown={(event) => {
            onKeyDown?.(event)
            const input = event.currentTarget
            const { selectionStart: start, selectionEnd: end } = input
            if (
              event.defaultPrevented ||
              composing.current ||
              disabled ||
              readOnly ||
              start === null ||
              start !== end ||
              event.altKey ||
              event.ctrlKey ||
              event.metaKey
            )
              return
            // 光标紧贴千分位逗号时,退格 / 删除越过逗号去删旁边的数字,
            // 否则只删了逗号、整理后又长回来,用户会觉得按键没反应
            const current = input.value
            if (event.key === "Backspace" && current[start - 1] === ",") {
              event.preventDefault()
              commit(
                current.slice(0, start - 2) + current.slice(start),
                start - 2,
              )
            } else if (event.key === "Delete" && current[start] === ",") {
              event.preventDefault()
              commit(current.slice(0, start) + current.slice(start + 2), start)
            }
          }}
          onFocus={(event) => {
            setFocused(true)
            revealBelowKeyboard(rootRef.current)
            onFocus?.(event)
          }}
          onBlur={(event) => {
            setFocused(false)
            setTouched(true)
            const finished = finalizeText(text, decimals, padDecimals)
            if (finished !== text) {
              setText(finished)
              setValue(textToValue(finished))
            }
            onBlur?.(event)
          }}
        />
      </div>
      {readoutPlacement === "below" && readout}
      {errorMessage && (
        <p
          id={errorId}
          data-slot="amount-input-error"
          className="text-xs text-destructive"
        >
          {errorMessage}
        </p>
      )}
      {name && (
        <input
          type="hidden"
          data-slot="amount-input-value"
          name={name}
          form={form}
          value={amount}
          disabled={disabled}
        />
      )}
      <span data-slot="amount-input-status" role="status" className="sr-only">
        {announcement}
      </span>
    </div>
  )
}

export { AmountInput, amountInputControlVariants }
export type {
  AmountInputProps,
  AmountInputSize,
  AmountInputVariant,
} from "./types"
components/ui/amount-input/amount-input-parts.tsx
import * as React from "react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"

/**
 * 两种外观:
 * - `bar` 方条:与方条开关、分段选择同一套(浅灰底、细边框、标签在条内左侧),放进同一个面板里能对齐。
 *   高度只来自既有三档 28 / 32 / 36px,触屏设备上各加高一档(36 / 40 / 44px)
 * - `display` 大字:付款、转账页上的主金额,数字放大、下面一道横线,标签写在上方
 * 手机上字号至少 16px,iOS 聚焦时才不会自动放大页面
 */
const amountInputControlVariants = cva(
  "flex w-full min-w-0 cursor-text transition-colors",
  {
    variants: {
      variant: {
        bar: "items-center gap-1.5 rounded-md border bg-muted/60 px-3",
        display: "items-baseline gap-1 border-b-2 pb-1 tracking-tight",
      },
      size: { sm: "", default: "", lg: "" },
    },
    compoundVariants: [
      {
        variant: "bar",
        size: "sm",
        class: "h-7 text-base pointer-coarse:h-9 md:text-xs",
      },
      {
        variant: "bar",
        size: "default",
        class: "h-8 text-base pointer-coarse:h-10 md:text-sm",
      },
      {
        variant: "bar",
        size: "lg",
        class: "h-9 text-base pointer-coarse:h-11 md:text-sm",
      },
      { variant: "display", size: "sm", class: "text-2xl" },
      { variant: "display", size: "default", class: "text-3xl" },
      { variant: "display", size: "lg", class: "text-4xl" },
    ],
    defaultVariants: { variant: "bar", size: "default" },
  },
)

function AmountInputReadout({
  className,
  uppercase,
  uppercaseId,
  uppercaseLabel,
  placeholder,
  showUppercase,
  approximate,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  /** 人民币大写;空串时显示 `placeholder` */
  uppercase: string
  uppercaseId: string
  uppercaseLabel: string
  placeholder: string
  showUppercase: boolean
  /** 大数估读,如「约 1.2万」;空串不显示 */
  approximate: string
}) {
  return (
    <div
      data-slot="amount-input-readout"
      className={cn(
        "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",
        className,
      )}
      {...props}
    >
      {showUppercase && (
        <p
          id={uppercaseId}
          data-slot="amount-input-uppercase"
          className="min-w-0 flex-1 leading-5 break-words"
        >
          <span className="mr-2 text-muted-foreground">{uppercaseLabel}</span>
          {uppercase ? (
            <span className="font-medium text-foreground">{uppercase}</span>
          ) : (
            <span className="text-muted-foreground/70">{placeholder}</span>
          )}
        </p>
      )}
      {approximate && (
        <p
          data-slot="amount-input-approximate"
          className="shrink-0 leading-5 text-muted-foreground tabular-nums"
        >
          {approximate}
        </p>
      )}
    </div>
  )
}

export { AmountInputReadout, amountInputControlVariants }
components/ui/amount-input/types.ts
import type * as React from "react"

import type { AmountInputLabels } from "./config"

type AmountInputSize = "sm" | "default" | "lg"

type AmountInputVariant = "bar" | "display"

type AmountInputProps = Omit<
  React.ComponentProps<"input">,
  | "value"
  | "defaultValue"
  | "onChange"
  | "type"
  | "size"
  | "prefix"
  | "min"
  | "max"
  | "children"
> & {
  /**
   * 金额,十进制字符串(`"1234.5"`,不带千分位);空串表示没填。受控时传入。
   * 用字符串而不是 number:万亿级金额和 `0.1 + 0.2` 都不会丢精度,也能保留用户敲的 `1234.50`。
   */
  value?: string
  /**
   * 非受控时的初始金额
   * @default ""
   */
  defaultValue?: string
  /** 金额变化时回调,参数是不带千分位、不带悬空小数点的十进制字符串 */
  onValueChange?: (value: string) => void
  /** 最小值;为负数时才允许输入负号。越界不会拦截输入,失焦后提示并阻止表单提交 */
  min?: number | string
  /** 最大值;超过时立即提示并阻止表单提交 */
  max?: number | string
  /**
   * 小数位上限,超出的位直接舍去;0 表示只能输入整数
   * @default 2
   */
  decimals?: number
  /**
   * 前缀货币符号;传 `null` 隐藏
   * @default "¥"
   */
  symbol?: React.ReactNode
  /** 标签,自动与输入框关联。`bar` 写在条内左侧、金额随之靠右;`display` 写在金额上方 */
  label?: React.ReactNode
  /**
   * 外观:`bar` 方条,和其他表单控件排在一起;`display` 大字,用作付款、转账页上的主金额,标签写在上方
   * @default "bar"
   */
  variant?: AmountInputVariant
  /**
   * 档位:`bar` 是高度 28 / 32 / 36px(触屏设备上 36 / 40 / 44px),`display` 是数字字号 24 / 30 / 36px
   * @default "default"
   */
  size?: AmountInputSize
  /**
   * 显示人民币大写
   * @default true
   */
  showUppercase?: boolean
  /**
   * 金额较大时显示「约 1.2亿」式估读
   * @default true
   */
  showApproximate?: boolean
  /**
   * 绝对值达到这个数才显示估读
   * @default 10000
   */
  approximateFrom?: number
  /**
   * 读数行在输入框的上方还是下方
   * @default "below"
   */
  readoutPlacement?: "above" | "below"
  /** 外部校验的错误提示,有值即进入错误态;与越界提示同时存在时优先显示它 */
  error?: React.ReactNode
  /**
   * 失焦时把小数位补齐到 `decimals`(`1,234.5` → `1,234.50`)
   * @default true
   */
  padDecimals?: boolean
  /** 覆盖用户可见文案 */
  labels?: Partial<AmountInputLabels>
}

export type { AmountInputProps, AmountInputSize, AmountInputVariant }
components/ui/amount-input/utils.ts
/**
 * 金额输入的纯函数:把「用户敲出来的任意文本」整理成千分位文本,并算出整理后光标该在哪。
 * 全程按十进制字符串处理,不转 number,所以万亿级金额和 `0.1 + 0.2` 都不会丢精度。
 */

/** 整数部分最多 16 位,与 chinese-number 的大写范围一致。 */
const MAX_INTEGER_DIGITS = 16

type SanitizeOptions = {
  /** 小数位上限;0 表示只能输入整数 */
  decimals: number
  /** 是否允许开头的负号 */
  allowNegative: boolean
}

type Sanitized = {
  /** 带千分位的显示文本 */
  text: string
  /** 整理后光标所在的位置 */
  caret: number
}

/** 全角数字、全角小数点、各种减号统一成 ASCII;其余字符(逗号、空格、¥、字母)返回空串丢掉。 */
function normalizeChar(ch: string) {
  const code = ch.charCodeAt(0)
  if (code >= 0xff10 && code <= 0xff19)
    return String.fromCharCode(code - 0xfee0)
  if (/[0-9.]/.test(ch)) return ch
  if (".。。".includes(ch)) return "."
  if ("--−﹣‐–".includes(ch)) return "-"
  return ""
}

function groupInteger(int: string) {
  return int.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

/**
 * 整理输入文本,同时换算光标。
 *
 * 光标算法:数出光标左边有几个「有效字符」(数字、小数点、负号,逗号不算),整理完后
 * 在新文本里找到第 n 个有效字符,把光标放在它后面。千分位逗号的增减不影响有效字符个数,
 * 所以在中间插入、删除、粘贴之后光标都留在用户眼里「同一个位置」。
 * 被丢掉的字符(非法字符、超出的小数位)不计数;开头多余的零被后来的数字顶替时,
 * 被顶替的零也一并不计,这样 `0|` 再敲 `5` 得到 `5|`。
 */
function sanitizeAmount(
  raw: string,
  caret: number,
  { decimals, allowNegative }: SanitizeOptions,
): Sanitized {
  const kept: { ch: string; before: boolean }[] = []
  let negative = false
  let hasDot = false
  let int = ""
  let frac = ""

  for (let i = 0; i < raw.length; i++) {
    const ch = normalizeChar(raw[i])
    const before = i < caret
    if (ch === "-") {
      if (allowNegative && kept.length === 0) {
        negative = true
        kept.push({ ch, before })
      }
    } else if (ch === ".") {
      if (decimals > 0 && !hasDot) {
        hasDot = true
        // 直接敲 ".5" 补成 "0.5",补出来的 0 算在小数点所在的一侧
        if (int === "") {
          int = "0"
          kept.push({ ch: "0", before })
        }
        kept.push({ ch, before })
      }
    } else if (ch) {
      if (hasDot) {
        if (frac.length < decimals) {
          frac += ch
          kept.push({ ch, before })
        }
      } else if (int === "0") {
        // 前导零只留一个:0 后面再敲数字就顶替它
        if (ch !== "0") {
          kept.pop()
          int = ch
          kept.push({ ch, before })
        }
      } else if (int.length < MAX_INTEGER_DIGITS) {
        int += ch
        kept.push({ ch, before })
      }
    }
  }

  const text = `${negative ? "-" : ""}${groupInteger(int)}${hasDot ? `.${frac}` : ""}`
  const count = kept.filter((token) => token.before).length
  let position = 0
  if (count > 0) {
    position = text.length
    let seen = 0
    for (let i = 0; i < text.length; i++) {
      if (text[i] !== "," && ++seen === count) {
        position = i + 1
        break
      }
    }
  }
  return { text, caret: position }
}

/** 显示文本 → 原始数值字符串:去千分位、去末尾小数点;空和单独的负号都是 `""`,`-0` 归一成 `0`。 */
function textToValue(text: string) {
  let value = text.replace(/,/g, "")
  if (value === "" || value === "-") return ""
  if (value.endsWith(".")) value = value.slice(0, -1)
  if (/^-0*(\.0*)?$/.test(value)) value = value.slice(1)
  return value
}

/** 失焦时收尾:补齐小数位(或去掉悬空的小数点);只剩一个负号就清空。 */
function finalizeText(text: string, decimals: number, pad: boolean) {
  if (text === "" || text === "-") return ""
  if (decimals > 0 && pad) {
    const [int, frac = ""] = text.split(".")
    return `${int}.${frac.padEnd(decimals, "0")}`
  }
  return text.endsWith(".") ? text.slice(0, -1) : text
}

/** 把外部传入的数值字符串整理成显示文本(`pad` 为真时补齐小数位)。 */
function formatAmount(value: string, options: SanitizeOptions, pad: boolean) {
  const { text } = sanitizeAmount(value, 0, options)
  return finalizeText(text, options.decimals, pad)
}

/** 两个十进制字符串按数值比较:`1234.50` 与 `1234.5` 相等,不经过浮点。 */
function compareAmount(a: string, b: string) {
  const x = splitAmount(a)
  const y = splitAmount(b)
  if (x.negative !== y.negative) return x.negative ? -1 : 1
  const length = Math.max(x.frac.length, y.frac.length)
  const left =
    x.int.padStart(Math.max(x.int.length, y.int.length), "0") +
    x.frac.padEnd(length, "0")
  const right =
    y.int.padStart(Math.max(x.int.length, y.int.length), "0") +
    y.frac.padEnd(length, "0")
  const order = left === right ? 0 : left < right ? -1 : 1
  return x.negative ? -order : order
}

/** 拆成符号、整数、小数(去掉末尾的零);零不带符号。 */
function splitAmount(value: string) {
  const [int = "", frac = ""] = value.replace("-", "").split(".")
  const parts = { int: int.replace(/^0+/, ""), frac: frac.replace(/0+$/, "") }
  return {
    ...parts,
    negative: value.startsWith("-") && (parts.int !== "" || parts.frac !== ""),
  }
}

function sameAmount(a: string, b: string) {
  if (a === "" || b === "") return a === b
  return compareAmount(a, b) === 0
}

/** `min` / `max` 允许传 number 或字符串,统一成不带科学计数法的十进制字符串。 */
function toDecimalString(value: number | string) {
  if (typeof value === "string") return value.replace(/,/g, "").trim()
  return value.toLocaleString("en-US", {
    useGrouping: false,
    maximumFractionDigits: 20,
  })
}

export {
  MAX_INTEGER_DIGITS,
  compareAmount,
  finalizeText,
  formatAmount,
  groupInteger,
  sameAmount,
  sanitizeAmount,
  textToValue,
  toDecimalString,
}
export type { SanitizeOptions, Sanitized }
components/ui/amount-input/config.ts
/** 用户可见文案。`{min}` / `{max}` 会替换成带货币符号的千分位金额。 */
const DEFAULT_LABELS = {
  /** 人民币大写读数前的名称,也是读屏播报的前缀 */
  uppercase: "大写",
  /** 还没有输入金额时,大写行显示的提示 */
  uppercasePlaceholder: "填写金额后自动生成大写",
  /** 大数估读前缀:约 1.2亿 */
  approximate: "约",
  belowMin: "金额不能低于 {min}",
  aboveMax: "金额不能超过 {max}",
}

type AmountInputLabels = typeof DEFAULT_LABELS

export { DEFAULT_LABELS }
export type { AmountInputLabels }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }
lib/data-display.ts
import * as React from "react"

/**
 * 数据展示共用工具:涨跌配色约定、色调、数字与日期格式化。
 * stat-card、mini-chart、chart-card 三个条目共用这一份,所以涨跌色、数字格式只在这里定义。
 */

/**
 * 涨跌配色约定:
 * - `intl`(默认):绿涨红跌;
 * - `cn`:红涨绿跌,国内行情软件的习惯。
 */
type DataConvention = "intl" | "cn"

// 约定只改写 `--stat-up` / `--stat-down` 两个 CSS 变量。变量沿 DOM 向下继承,
// 所以给整块看板的外层设一次 `convention`,里面所有涨跌色(包括图表里的 SVG)都跟着变,
// 不需要 context,服务端组件也能用。没有设置时回落到默认的绿涨红跌。
const CONVENTION_CLASSES: Record<DataConvention, string> = {
  intl: "[--stat-up:var(--flow-green,var(--primary))] [--stat-down:var(--flow-rose,var(--destructive))]",
  cn: "[--stat-up:var(--flow-rose,var(--destructive))] [--stat-down:var(--flow-green,var(--primary))]",
}

/** 把 `convention` 换成要加到根元素上的类名;不传返回 `undefined`,沿用外层的约定。 */
function conventionClass(convention?: DataConvention) {
  return convention ? CONVENTION_CLASSES[convention] : undefined
}

type Trend = "up" | "down" | "flat"

function trendOf(value: number): Trend {
  return value > 0 ? "up" : value < 0 ? "down" : "flat"
}

/** 涨跌文字色(Tailwind 只认完整类名,所以三种写全)。 */
const TREND_TEXT: Record<Trend, string> = {
  up: "text-[color:var(--stat-up,var(--flow-green,var(--primary)))]",
  down: "text-[color:var(--stat-down,var(--flow-rose,var(--destructive)))]",
  flat: "text-muted-foreground",
}

/**
 * 内置色调:
 * - `primary`、`foreground`、`muted`、`destructive` 直接引用主题变量,跟随颜色主题;
 * - `blue` 等多彩色调引用 `--flow-*` 变量,没有安装时回落到主题强调色;
 * - `chart-1` … `chart-5` 引用主题的图表色板;
 * - `up` / `down` 是涨跌色,随 `convention` 变化。
 */
type DataToneName =
  | "primary"
  | "foreground"
  | "muted"
  | "destructive"
  | "blue"
  | "green"
  | "amber"
  | "rose"
  | "violet"
  | "cyan"
  | "chart-1"
  | "chart-2"
  | "chart-3"
  | "chart-4"
  | "chart-5"
  | "up"
  | "down"

/** 色调:内置名,或任意 CSS 颜色(`"var(--brand)"`、`"oklch(0.7 0.2 150)"` 都可以)。 */
type DataTone = DataToneName | (string & {})

const TONES: Record<DataToneName, string> = {
  primary: "var(--primary)",
  foreground: "var(--foreground)",
  muted: "var(--muted-foreground)",
  destructive: "var(--destructive)",
  blue: "var(--flow-blue, var(--primary))",
  green: "var(--flow-green, var(--primary))",
  amber: "var(--flow-amber, var(--primary))",
  rose: "var(--flow-rose, var(--destructive))",
  violet: "var(--flow-violet, var(--primary))",
  cyan: "var(--flow-cyan, var(--primary))",
  "chart-1": "var(--chart-1)",
  "chart-2": "var(--chart-2)",
  "chart-3": "var(--chart-3)",
  "chart-4": "var(--chart-4)",
  "chart-5": "var(--chart-5)",
  up: "var(--stat-up, var(--flow-green, var(--primary)))",
  down: "var(--stat-down, var(--flow-rose, var(--destructive)))",
}

/** 把色调换成可直接用于 `fill` / `stroke` / `color` 的 CSS 颜色。 */
function toneColor(tone: DataTone): string {
  return Object.hasOwn(TONES, tone) ? TONES[tone as DataToneName] : tone
}

/**
 * 多序列图表默认的色调轮换:六种互不相近的多彩色调。
 * 不把主题强调色放进来:强调色会落在蓝、绿、紫、琥珀任意一种附近,
 * 切到对应的颜色主题时会与其中一条序列撞色。单条序列想跟随主题,显式写 `tone="primary"`。
 */
const SERIES_TONES: DataTone[] = [
  "blue",
  "amber",
  "green",
  "violet",
  "rose",
  "cyan",
]

type StatFormatOptions = {
  /**
   * 数字样式:`number` 普通数字,`percent` 百分比(传入的 12.5 表示 12.5%),`currency` 货币
   * @default "number"
   */
  style?: "number" | "percent" | "currency"
  /**
   * BCP 47 区域标识;`zh-CN` 压缩成「万 / 亿」,`en` 压缩成 K / M / B
   * @default "zh-CN"
   */
  locale?: string
  /**
   * 压缩大数(1.2万、3.4亿);百分比不压缩
   * @default true
   */
  compact?: boolean
  /**
   * 货币代码,仅 `style="currency"` 生效;人民币显示 ¥
   * @default "CNY"
   */
  currency?: string
  /**
   * 最多保留的小数位
   * @default 1(压缩与百分比),2(普通数字与货币)
   */
  digits?: number
  /**
   * 正数前加 `+`(零不加),用于涨跌幅
   * @default false
   */
  sign?: boolean
}

const formatters = new Map<string, Intl.NumberFormat>()

/**
 * 格式化统计数字,默认 `zh-CN` 压缩:`12480` → `1.2万`,`345000000` → `3.5亿`;
 * `locale: "en"` 时为 `12K`、`345M`。同参数的 `Intl.NumberFormat` 会被缓存复用。
 */
function formatStat(value: number, options: StatFormatOptions = {}): string {
  const {
    style = "number",
    locale = "zh-CN",
    compact = style !== "percent",
    currency = "CNY",
    sign = false,
  } = options
  const digits = options.digits ?? (compact || style === "percent" ? 1 : 2)
  const key = [style, locale, compact, currency, digits, sign].join("|")
  let formatter = formatters.get(key)
  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, {
      style: style === "number" ? "decimal" : style,
      currency: style === "currency" ? currency : undefined,
      currencyDisplay: style === "currency" ? "narrowSymbol" : undefined,
      notation: compact && style !== "percent" ? "compact" : "standard",
      maximumFractionDigits: digits,
      signDisplay: sign ? "exceptZero" : "auto",
    })
    formatters.set(key, formatter)
  }
  return formatter.format(style === "percent" ? value / 100 : value)
}

type DayStyle = "long" | "short" | "weekday"

const dayFormatters = new Map<string, Intl.DateTimeFormat>()

/**
 * 格式化日期轴文字:`long` → `2月14日`,`short` → `02/14`,`weekday` → `一`(单字星期)。
 * 日期按 UTC 解析与输出,`"2026-02-14"` 这类字符串在任何时区、任何环境下结果一致,不会引起水合差异。
 */
function formatDay(
  input: string | number | Date,
  style: DayStyle = "long",
  locale = "zh-CN",
): string {
  const key = `${style}|${locale}`
  let formatter = dayFormatters.get(key)
  if (!formatter) {
    const options: Intl.DateTimeFormatOptions =
      style === "weekday"
        ? { weekday: "narrow" }
        : style === "short"
          ? { month: "2-digit", day: "2-digit" }
          : { month: "long", day: "numeric" }
    formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" })
    dayFormatters.set(key, formatter)
  }
  return formatter.format(new Date(input))
}

type AxisFormatOptions = Pick<
  StatFormatOptions,
  "style" | "locale" | "currency" | "digits"
>

// 压缩单位表:从大到小,命中第一个不超过轴最大值的单位
const AXIS_UNITS = {
  zh: [
    [1e8, "亿"],
    [1e4, "万"],
  ],
  "zh-trad": [
    [1e8, "億"],
    [1e4, "萬"],
  ],
  en: [
    [1e9, "B"],
    [1e6, "M"],
    [1e3, "K"],
  ],
} as const

/**
 * 按「整条轴」统一单位的刻度格式化:先用轴的最大值选定一个单位,所有刻度都换算成它,
 * 避免 `9000` 与 `1.2万` 混在同一根轴上。例如最大值 12000 时刻度是 `0`、`0.3万`、`0.6万`、`0.9万`、`1.2万`;
 * 最大值 8000 时全部是整数(`2000`、`4000`…)。`en` 同理换成 K / M / B。
 *
 * @param max 轴上数据的最大绝对值;留 15% 余量,因为图表会把坐标轴上沿取整到比数据更大的刻度
 */
function createAxisFormatter(
  max: number,
  options: AxisFormatOptions = {},
): (value: number) => string {
  const { style = "number", locale = "zh-CN", currency = "CNY" } = options
  if (style === "percent")
    return (value) =>
      formatStat(value, { style: "percent", locale, digits: options.digits })
  const family = /^zh-(TW|HK|MO|Hant)/i.test(locale)
    ? "zh-trad"
    : /^zh/i.test(locale)
      ? "zh"
      : "en"
  const reach = Math.abs(max) * 1.15
  const unit = AXIS_UNITS[family].find(([size]) => reach >= size)
  const divisor = unit?.[0] ?? 1
  const suffix = unit?.[1] ?? ""
  const formatter = new Intl.NumberFormat(locale, {
    style: style === "currency" ? "currency" : "decimal",
    currency: style === "currency" ? currency : undefined,
    currencyDisplay: style === "currency" ? "narrowSymbol" : undefined,
    notation: "standard",
    maximumFractionDigits: options.digits ?? 2,
  })
  return (value) =>
    value === 0
      ? formatter.format(0)
      : `${formatter.format(value / divisor)}${suffix}`
}

// 进度条、计量条、分段条、进度环、刻度仪表的入场动画:纯 CSS,服务端直接输出最终状态,
// 所以没有脚本时也完整显示;系统开启「减少动态效果」时不播放。
const MOTION_CSS = `@media (prefers-reduced-motion: no-preference) {
@keyframes dd-reveal { from { clip-path: inset(0 100% 0 0 round 9999px) } to { clip-path: inset(0 0 0 0 round 9999px) } }
@keyframes dd-rise { from { opacity: 0; transform: scaleY(0.35) } }
@keyframes dd-fade { from { opacity: 0 } }
@keyframes dd-arc { from { stroke-dasharray: 0 1 } }
[data-dd-anim="reveal"] { animation: dd-reveal 0.8s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="rise"] { animation: dd-rise 0.3s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="fade"] { animation: dd-fade 0.35s cubic-bezier(0.22, 1, 0.36, 1) both }
[data-dd-anim="arc"] { animation: dd-arc 0.9s cubic-bezier(0.22, 1, 0.36, 1) both }
}`

/**
 * 渲染上面的动画样式。React 19 会按 `href` 去重并提升到 `<head>`,
 * 同一页放多少个组件都只有一份;放在服务端组件里也能用。
 */
function motionStyles() {
  return React.createElement(
    "style",
    { href: "data-display-motion", precedence: "default" },
    MOTION_CSS,
  )
}

export {
  TREND_TEXT,
  SERIES_TONES,
  conventionClass,
  createAxisFormatter,
  formatDay,
  formatStat,
  motionStyles,
  toneColor,
  trendOf,
}
export type {
  AxisFormatOptions,
  DataConvention,
  DataTone,
  DataToneName,
  DayStyle,
  StatFormatOptions,
  Trend,
}