"use client"
import { ShieldCheck } from "lucide-react"
import { AmountInput } from "@/components/ui/amount-input/amount-input"
/** 默认示例:一张转账卡片,敲数字时下方实时出大写与估读,超限立刻提示。 */
export default function AmountInputDemo() {
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">转账付款</p>
<p className="truncate text-xs text-muted-foreground">
收款方:上海示例科技有限公司
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
CNY
</p>
</div>
<AmountInput
size="lg"
label="付款金额"
defaultValue="128800"
min={0.01}
max={500000}
/>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="size-3" aria-hidden="true" />
单笔限额 ¥500,000.00,超出部分请分笔转账
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/amount-input.json用法
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)里的转账页:主金额用大字,超过余额时直接给出错误、按钮置灰;「全部转出」把余额填进去。
"use client"
import * as React from "react"
import { BatteryFull, ChevronLeft, Signal, Wifi } from "lucide-react"
import { Button } from "@/components/ui/button"
import { AmountInput } from "@/components/ui/amount-input/amount-input"
const BALANCE = 26_480.5
/** 文本框也做成方条:标签在条内左侧、内容靠右,和其他表单控件是一套样式 */
const FIELD =
"flex h-9 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm pointer-coarse:h-11 focus-within:ring-3 focus-within:ring-ring/50"
const FIELD_INPUT =
"min-w-0 flex-1 bg-transparent text-right text-base outline-none placeholder:text-muted-foreground md:text-sm"
/**
* 手机上的体验:一台 6.3 英寸的「手机」(402 × 874pt)里的转账页。
* 主金额用 `variant="display"` 大字,下面实时出大写;超过余额时直接给出错误,按钮置灰。
*/
export default function AmountInputMobileDemo() {
const [amount, setAmount] = React.useState("")
const over = amount !== "" && Number(amount) > BALANCE
return (
<div className="max-w-full rounded-[3.875rem] border border-edge bg-muted p-2 shadow-lg max-sm:contents">
<div className="relative flex aspect-[402/874] w-[402px] max-w-full flex-col overflow-clip rounded-[3.375rem] bg-background max-sm:aspect-auto max-sm:h-[calc(100dvh-3rem)] max-sm:w-full max-sm:rounded-none">
{/* 状态栏 54pt,灵动岛 126 × 37pt;宽度按屏幕比例取,页面比手机窄时一起收窄 */}
<div className="relative flex h-[54px] shrink-0 items-center justify-between px-[8%] pt-1 text-[15px] font-semibold tabular-nums max-sm:hidden">
<span>9:41</span>
<span
aria-hidden="true"
className="absolute top-[11px] left-1/2 h-[37px] w-[31%] -translate-x-1/2 rounded-full bg-foreground dark:bg-background dark:ring-1 dark:ring-border"
/>
<span aria-hidden="true" className="flex items-center gap-1.5">
<Signal className="size-4" strokeWidth={2.5} />
<Wifi className="size-4" strokeWidth={2.5} />
<BatteryFull className="size-5" />
</span>
</div>
<div className="flex h-11 shrink-0 items-center gap-1 border-b border-border px-3">
<ChevronLeft
aria-hidden="true"
className="size-6 text-muted-foreground"
/>
<p className="text-[17px] font-semibold">转账</p>
</div>
<div className="flex flex-1 flex-col gap-6 px-5 pt-5">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium"
>
林
</span>
<div className="min-w-0">
<p className="text-sm font-medium">林小满</p>
<p className="font-mono text-xs text-muted-foreground">
招商银行 · 尾号 6021
</p>
</div>
</div>
<AmountInput
variant="display"
label="转账金额"
value={amount}
onValueChange={setAmount}
min={0.01}
error={over ? "超出可用余额" : undefined}
/>
<p className="-mt-3 text-xs text-muted-foreground tabular-nums">
可用余额 ¥
{BALANCE.toLocaleString("en-US", { minimumFractionDigits: 2 })}
<button
type="button"
className="ml-2 text-primary underline-offset-2 hover:underline"
onClick={() => setAmount(String(BALANCE))}
>
全部转出
</button>
</p>
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">转账说明</span>
<input placeholder="选填,对方可见" className={FIELD_INPUT} />
</label>
</div>
{/* 底部留出 34pt 的主屏指示条安全区 */}
<div className="shrink-0 px-5 pt-3 pb-[34px] max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
type="button"
size="lg"
className="w-full"
disabled={amount === "" || Number(amount) <= 0 || over}
>
确认转账
</Button>
</div>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-2 left-1/2 h-[5px] w-[134px] -translate-x-1/2 rounded-full bg-foreground max-sm:hidden"
/>
</div>
</div>
)
}
受控与表单提交
受控的 value 配 onValueChange;设了 name,FormData 里拿到的是原始数值。min / max / required 越界或留空时表单提交会被拦住。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { AmountInput } from "@/components/ui/amount-input/amount-input"
/** 文本框也做成方条:标签在条内左侧、内容靠右,和金额输入排在一起是一套样式 */
const FIELD =
"flex h-8 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm pointer-coarse:h-10 focus-within:ring-3 focus-within:ring-ring/50"
const FIELD_INPUT =
"min-w-0 flex-1 bg-transparent text-right text-base outline-none placeholder:text-muted-foreground md:text-sm"
/**
* 受控 + 表单提交:`value` 是不带千分位的十进制字符串,
* 设了 `name` 后提交的也是这个原始值,服务端拿到的不会是 "1,234.50"。
*/
export default function AmountInputFormDemo() {
const [amount, setAmount] = React.useState("1234.50")
const [submitted, setSubmitted] = React.useState<{
reason: string
amount: string
} | null>(null)
return (
<form
className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs"
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
setSubmitted({
reason: String(data.get("reason")),
amount: String(data.get("amount")),
})
}}
>
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">差旅报销</p>
<p className="truncate text-xs text-muted-foreground">
提交的是不带千分位的原始值
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
≤ 20,000
</p>
</div>
<div className="flex flex-col gap-2">
<label className={FIELD}>
<span className="shrink-0 text-muted-foreground">事由</span>
<input
name="reason"
defaultValue="上海出差机票"
className={FIELD_INPUT}
/>
</label>
<AmountInput
name="amount"
label="报销金额"
required
min={0.01}
max={20000}
value={amount}
onValueChange={setAmount}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<p className="font-mono text-[11px] text-muted-foreground">
value = {JSON.stringify(amount)}
</p>
<Button type="submit">提交</Button>
</div>
{submitted && (
<dl className="mt-3 rounded-lg border border-dashed border-edge bg-muted/30 p-3 font-mono text-xs">
<dt className="text-muted-foreground">FormData</dt>
<dd>reason = {JSON.stringify(submitted.reason)}</dd>
<dd>amount = {JSON.stringify(submitted.amount)}</dd>
</dl>
)}
</form>
)
}
自定义
symbol 换货币符号(传 null 隐藏),decimals={0} 只收整数,showUppercase={false} 关掉大写。汇率这类不是金额的数值,把大写、估读、补零都关掉,只用它的千分位与光标处理。
import { AmountInput } from "@/components/ui/amount-input/amount-input"
/** 自定义:换货币符号并关掉大写、整数输入、四位小数的汇率。 */
export default function AmountInputCustomDemo() {
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">外币订单</p>
<p className="truncate text-xs text-muted-foreground">
换符号、只收整数、四位小数
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
USD
</p>
</div>
<div className="flex flex-col gap-2">
<AmountInput
label="美元收款"
symbol="$"
showUppercase={false}
defaultValue="25000"
/>
<AmountInput
label="购买数量"
symbol={null}
decimals={0}
showUppercase={false}
showApproximate={false}
defaultValue="1200"
placeholder="件"
/>
<AmountInput
label="汇率"
symbol={null}
decimals={4}
showUppercase={false}
showApproximate={false}
padDecimals={false}
defaultValue="7.2468"
/>
</div>
</div>
)
}
校验与错误态
第一个输入框用 min / max,初始值 60000 已经越界,红框与提示直接显示;第二个用 error 接入自己的业务判断,余额不足时显示提示。
"use client"
import * as React from "react"
import { AmountInput } from "@/components/ui/amount-input/amount-input"
const BALANCE = 8000
/**
* 校验与错误态:`min` / `max` 越界自动提示并拦住表单提交;
* 业务规则(余额)自己判断,用 `error` 传入提示即可。
*/
export default function AmountInputValidationDemo() {
const [withdraw, setWithdraw] = React.useState("12000")
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">转账与提现</p>
<p className="truncate text-xs text-muted-foreground">
单笔 ¥100 – ¥50,000,提现不超过余额
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums">
余额 {BALANCE.toLocaleString("en-US")}
</p>
</div>
<div className="flex flex-col gap-3">
<AmountInput
label="单笔转账"
min={100}
max={50000}
defaultValue="60000"
showApproximate={false}
/>
<AmountInput
label="提现"
value={withdraw}
onValueChange={setWithdraw}
error={
Number(withdraw) > BALANCE
? "余额不足,最多可提现 ¥8,000.00"
: undefined
}
showApproximate={false}
/>
</div>
</div>
)
}
尺寸与状态
size 三档高度对应按钮的 sm / default / lg(28 / 32 / 36px,触屏设备上 36 / 40 / 44px),同一行并排的控件用同一档。禁用时灰显且不随表单提交,只读可以聚焦、选中、复制,但不能改。
import { AmountInput } from "@/components/ui/amount-input/amount-input"
/** 尺寸与状态:三档高度(28 / 32 / 36px)、禁用、只读。 */
export default function AmountInputStatesDemo() {
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">尺寸与状态</p>
<p className="truncate text-xs text-muted-foreground">
高度与方条开关、数值滑块同一套
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
28 / 32 / 36
</p>
</div>
<div className="flex flex-col gap-2">
<AmountInput
size="sm"
label="sm"
defaultValue="1280"
showUppercase={false}
showApproximate={false}
/>
<AmountInput
label="default"
defaultValue="1280"
showUppercase={false}
showApproximate={false}
/>
<AmountInput
size="lg"
label="lg"
defaultValue="1280"
showUppercase={false}
showApproximate={false}
/>
<AmountInput
label="禁用"
disabled
defaultValue="1200"
showUppercase={false}
showApproximate={false}
/>
<AmountInput
label="只读"
readOnly
defaultValue="88.5"
showUppercase={false}
showApproximate={false}
/>
</div>
</div>
)
}
原理
光标
React 受控输入每次渲染都会把光标甩到末尾,千分位逗号的增减又会让「字符位置」失真,所以组件不按字符下标记位置,而是按有效字符的个数:
- 事件里拿到用户敲完之后的原始文本与光标下标
caret; - 数出
caret左边有几个有效字符(数字、小数点、负号,逗号不算),记为n;整理时被丢掉的字符(非法字符、超出的小数位、被顶替的前导零)不计入; - 整理出新文本后,找到其中第
n个有效字符,光标放它后面; - 在
useLayoutEffect里setSelectionRange,每次提交都用新对象触发,所以即使文本没变(敲了字母被丢掉)光标也会被拉回原位。
输入法组词期间(compositionstart 到 compositionend)不整理,否则会打断候选词,组词结束时统一处理一次。
读屏
读数行做了两件事,取舍是不让读屏在敲字时被反复打断:
- 大写行的
id挂在输入框的aria-describedby上,聚焦时随字段名一起朗读,想再听一遍重新聚焦即可; - 敲字时不逐字播报。停手 800ms 后,一个
role="status"(隐式 polite)的隐藏区域播报一次最新的大写;还在敲就重新计时。
估读行是纯视觉辅助,不进入描述。
API
金额,十进制字符串("1234.5",不带千分位);空串表示没填。受控时传入。
用字符串而不是 number:万亿级金额和 0.1 + 0.2 都不会丢精度,也能保留用户敲的 1234.50。
string | undefined非受控时的初始金额
类型string | undefined""金额变化时回调,参数是不带千分位、不带悬空小数点的十进制字符串
类型((value: string) => void) | undefined最小值;为负数时才允许输入负号。越界不会拦截输入,失焦后提示并阻止表单提交
类型string | number | undefined最大值;超过时立即提示并阻止表单提交
类型string | number | undefined小数位上限,超出的位直接舍去;0 表示只能输入整数
类型number | undefined2前缀货币符号;传 null 隐藏
React.ReactNode"¥"标签,自动与输入框关联。bar 写在条内左侧、金额随之靠右;display 写在金额上方
React.ReactNode外观:bar 方条,和其他表单控件排在一起;display 大字,用作付款、转账页上的主金额,标签写在上方
AmountInputVariant | undefined"bar"档位:bar 是高度 28 / 32 / 36px(触屏设备上 36 / 40 / 44px),display 是数字字号 24 / 30 / 36px
AmountInputSize | undefined"default"显示人民币大写
类型boolean | undefinedtrue金额较大时显示「约 1.2亿」式估读
类型boolean | undefinedtrue绝对值达到这个数才显示估读
类型number | undefined10000读数行在输入框的上方还是下方
类型"above" | "below" | undefined"below"外部校验的错误提示,有值即进入错误态;与越界提示同时存在时优先显示它
类型React.ReactNode失焦时把小数位补齐到 decimals(1,234.5 → 1,234.50)
boolean | undefinedtrue覆盖用户可见文案
类型Partial<{ uppercase: string; uppercasePlaceholder: string; approximate: string; belowMin: string; aboveMax: string; }> | undefinedclassName 作用于根元素,其余未列出的属性(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):整理会重写输入框内容,浏览器的撤销栈不再可靠,这是用受控输入实现实时千分位的通病。
源码
"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"
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 }
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 }
/**
* 金额输入的纯函数:把「用户敲出来的任意文本」整理成千分位文本,并算出整理后光标该在哪。
* 全程按十进制字符串处理,不转 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 }
/** 用户可见文案。`{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 }
"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 }
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,
}