"use client"
import * as React from "react"
import { ShieldCheck } from "lucide-react"
import {
PhoneInput,
PhoneInputSendButton,
maskPhone,
} from "@/components/ui/phone-input/phone-input"
/** 默认示例:登录卡片。号码不完整时「获取验证码」置灰,发送后倒计时;右上角是打码后的号码。 */
export default function PhoneInputDemo() {
const [phone, setPhone] = React.useState("13800138000")
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 tabular-nums">
{phone.length === 11 ? maskPhone(phone) : "+86"}
</p>
</div>
<PhoneInput
label="手机号"
size="lg"
value={phone}
onValueChange={setPhone}
action={
<PhoneInputSendButton
cooldown={60}
onSend={() => new Promise((resolve) => setTimeout(resolve, 600))}
/>
}
/>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="size-3" aria-hidden="true" />
粘贴「+86 138-0013-8000」也能自动整理
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/phone-input.json用法
import {
PhoneInput,
PhoneInputSendButton,
} from "@/components/ui/phone-input/phone-input"
export function LoginForm() {
return (
<PhoneInput
name="phone"
label="手机号"
required
action={<PhoneInputSendButton onSend={() => sendCode()} />}
/>
)
}value、defaultValue、onValueChange 的值都是 11 位纯数字字符串("13800138000"),没填是空串;界面上看到的 138 0013 8000 只是显示。设了 name,表单提交的也是纯数字。
输入整理
- 只收数字,全角数字转半角,其他字符丢掉;最多 11 位。
- 粘贴或自动填充带区号、横线、空格的号码(
+86 138-0013-8000、0086 13800138000)会去掉区号和符号,剩下 11 位。 - 增删时光标停在同一个数字后面;光标紧贴分隔空格时按退格,删的是旁边的数字,不会删了空格又长回来。
校验
号码要是 11 位、以 13–19 开头。敲字时不打扰,失焦后不合格才提示「请输入正确的手机号」;required 时没填会拦住表单提交。自己的业务校验(比如「该号码已注册」)判断后传 error,和组件自带的提示在同一个位置。
获取验证码
action 放在条内右侧,通常放 PhoneInputSendButton:
- 号码不合格时自动置灰;点了先执行
onSend,返回 Promise 时显示「发送中…」,完成后倒计时「56 秒后重发」,结束变成「重新获取」。 onSend抛错时不进入倒计时,可以马上重试;错误提示由你自己处理。- 剩余秒数按截止时间算,页面切到后台计时器变慢也不会多等。
- 单独使用(比如邮箱输入旁边)时传
variant="button",外观和高度与按钮一致;需要完全自己画按钮时用useSendCode(onSend, cooldown),它返回{ state, remaining, send }。
手机上
type="tel"加inputMode="numeric":iOS 弹纯数字键盘,自动填充也认得出这是电话号码(autocomplete="tel-national")。- 触屏设备上方条各加高一档(36 / 40 / 44px),字号不小于 16px,聚焦时页面不会被放大。
示例
手机上的体验
一台按真机尺寸画的「手机」里的验证码登录页:填号码 → 获取验证码 → 输入 6 位(演示用 246810)→ 登录。验证码格子直接用 Base UI 的 OTPField(shadcn 的 input-otp 同理),样式照方条写,见示例源码。
"use client"
import * as React from "react"
import { OTPField } from "@base-ui/react/otp-field"
import { BatteryFull, ChevronLeft, Signal, Wifi } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
PhoneInput,
PhoneInputSendButton,
maskPhone,
} from "@/components/ui/phone-input/phone-input"
const CORRECT = "246810"
/**
* 验证码格子直接用 Base UI 的 OTPField(shadcn 的 input-otp 同理),样式照方条写:
* 浅灰底、细边框,聚焦加焦点环,出错红边加淡红光晕;触屏上放大一档
*/
const SLOT =
"h-13 w-11 min-w-0 rounded-md border border-edge bg-muted/60 text-center text-2xl font-medium tabular-nums caret-foreground outline-none transition-[background-color,box-shadow] hover:bg-muted focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 pointer-coarse:h-14 pointer-coarse:w-12"
/**
* 手机上的体验:一台 6.3 英寸的「手机」(402 × 874pt)里的验证码登录页。
* 填好号码 → 获取验证码 → 输入 6 位(正确答案 246810)→ 登录;真机上打开时外框收起、铺满屏幕。
*/
export default function PhoneInputMobileDemo() {
const [phone, setPhone] = React.useState("")
const [sent, setSent] = React.useState(false)
const [code, setCode] = React.useState("")
const [state, setState] = React.useState<"idle" | "error" | "done">("idle")
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 px-3">
<ChevronLeft
aria-hidden="true"
className="size-6 text-muted-foreground"
/>
</div>
<div className="flex flex-1 flex-col gap-3 px-6 pt-4">
<p className="text-2xl font-semibold">验证码登录</p>
<p className="-mt-2 mb-3 text-sm text-muted-foreground">
未注册的手机号验证后自动创建账号
</p>
<PhoneInput
label="手机号"
size="lg"
value={phone}
onValueChange={setPhone}
action={
<PhoneInputSendButton
onSend={async () => {
await new Promise((resolve) => setTimeout(resolve, 600))
setSent(true)
setCode("")
setState("idle")
}}
/>
}
/>
{sent && (
<div className="mt-3 flex flex-col gap-2 text-center">
<p className="text-sm text-muted-foreground">
验证码已发送至 {maskPhone(phone)}
</p>
<OTPField.Root
length={6}
aria-label="短信验证码"
aria-describedby={
state === "error" ? "login-code-error" : undefined
}
value={code}
onValueChange={(next) => {
setCode(next)
if (state === "error") setState("idle")
}}
onValueComplete={(next) => {
if (next === CORRECT) setState("done")
else {
setState("error")
setCode("")
}
}}
className="flex justify-center gap-2"
>
{Array.from({ length: 6 }, (_, index) => (
<OTPField.Input
key={index}
aria-label={index === 0 ? undefined : `第 ${index + 1} 位`}
aria-invalid={state === "error" || undefined}
className={SLOT}
/>
))}
</OTPField.Root>
{state === "error" && (
<p id="login-code-error" className="text-xs text-destructive">
验证码错误,请重新输入
</p>
)}
<p className="text-xs text-muted-foreground">
{state === "done"
? "验证通过,可以登录了"
: "演示用验证码:246810"}
</p>
</div>
)}
</div>
{/* 底部留出 34pt 的主屏指示条安全区 */}
<div className="shrink-0 px-6 pt-3 pb-[34px] max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
type="button"
size="lg"
className="w-full"
disabled={state !== "done"}
>
登录
</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>
)
}
尺寸
三档高度和方条开关、金额输入一致(28 / 32 / 36px,触屏上 36 / 40 / 44px),带不带「获取验证码」都对得齐。最后一行是单独使用的发送按钮。
"use client"
import {
PhoneInput,
PhoneInputSendButton,
} from "@/components/ui/phone-input/phone-input"
const send = () => new Promise<void>((resolve) => setTimeout(resolve, 600))
/** 三档高度(28 / 32 / 36px,触屏上 36 / 40 / 44px),带不带「获取验证码」都能对齐;最后一条是单独使用的发送按钮。 */
export default function PhoneInputSizesDemo() {
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">
<PhoneInput size="sm" label="sm" defaultValue="13800138000" />
<PhoneInput
label="default"
defaultValue="13800138000"
action={<PhoneInputSendButton onSend={send} />}
/>
<PhoneInput
size="lg"
label="lg"
defaultValue="13800138000"
action={<PhoneInputSendButton onSend={send} />}
/>
{/* 发送按钮也能单独用,比如放在邮箱输入旁边:高度与默认档的方条一致 */}
<div className="flex gap-2">
<label className="flex h-8 min-w-0 flex-1 items-center gap-3 rounded-md border border-edge bg-muted/60 px-3 text-sm focus-within:ring-3 focus-within:ring-ring/50 pointer-coarse:h-10">
<span className="shrink-0 text-muted-foreground">邮箱</span>
<input
type="email"
defaultValue="hi@yiui.dev"
className="min-w-0 flex-1 bg-transparent text-right text-base outline-none md:text-sm"
/>
</label>
<PhoneInputSendButton variant="button" onSend={send} />
</div>
</div>
</div>
)
}
表单与校验
试试输入 128 开头的号码,或只填一半再点别处。
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { PhoneInput } from "@/components/ui/phone-input/phone-input"
/**
* 表单提交:设了 `name`,FormData 里是 11 位纯数字;号码不完整或号段不对时,
* 失焦后提示、提交也会被浏览器拦下。
*/
export default function PhoneInputFormDemo() {
const [submitted, setSubmitted] = React.useState<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()
setSubmitted(String(new FormData(event.currentTarget).get("phone")))
}}
>
<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">
试试输入 128 开头,或只填一半再点别处
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
required
</p>
</div>
<PhoneInput label="手机号" name="phone" required />
<div className="mt-3 flex items-center justify-between gap-2">
<p className="font-mono text-[11px] text-muted-foreground">
{submitted === null
? "未提交"
: `phone = ${JSON.stringify(submitted)}`}
</p>
<Button type="submit">提交</Button>
</div>
</form>
)
}
API
手机号,11 位纯数字字符串(不带空格、区号);空串表示没填。受控时传入
类型string | undefined非受控时的初始值
类型string | undefined""号码变化时回调,参数是纯数字字符串
类型((value: string) => void) | undefined写在条内左侧的标签,自动与输入框关联;传入后号码靠右
类型React.ReactNode档位:高度 28 / 32 / 36px,触屏设备上 36 / 40 / 44px
类型PhoneInputSize | undefined"default"条内右侧的附加内容,通常放 PhoneInputSendButton(获取验证码)
React.ReactNode错误提示:显示在条下方,条变红。不传时组件自己校验,失焦后位数不够或号段不对会提示
类型React.ReactNodePartial<{ placeholder: string; invalid: string; required: string; send: string; sending: string; countdown: string; resend: string; }> | undefinedclassName 作用于根元素,其余未列出的属性(id、required、placeholder、autoFocus、onFocus、onBlur……)交给 <input>;ref 指向 <input>。name 例外:它加在一个隐藏输入上,提交纯数字。
PhoneInputSendButton
点击后执行的发送动作,返回 Promise 时等它完成再开始倒计时;抛错时不进入倒计时,可以马上重试
类型() => void | Promise<void>冷却时间(秒)
类型number | undefined60inline 放在手机号方条里(左侧一道竖线、主题色文字);button 单独使用,和按钮同一外观
"button" | "inline" | undefined"inline"button 外观的高度档位,与按钮一致:28 / 32 / 36px
"default" | "lg" | "sm" | undefined"default"Partial<{ placeholder: string; invalid: string; required: string; send: string; sending: string; countdown: string; resend: string; }> | undefined工具函数
formatPhone("13800138000") // "138 0013 8000"
maskPhone("13800138000") // "138 **** 8000",第二个参数换打码符号
isValidPhone("13800138000") // true:11 位、13–19 开头
useSendCode(onSend, 60) // { state: "idle" | "sending" | "counting" | "again", remaining, send }labels
type PhoneInputLabels = {
placeholder: string // "请输入手机号"
invalid: string // "请输入正确的手机号"
required: string // "请输入手机号"
send: string // "获取验证码"
sending: string // "发送中…"
countdown: string // "{seconds} 秒后重发"
resend: string // "重新获取"
}PhoneInput 和 PhoneInputSendButton 各自接受 labels。
data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | phone-input |
| 方条外框 | phone-input-control |
| 条内标签 | phone-input-label |
| 输入框 | phone-input-input |
| 获取验证码按钮 | phone-input-send |
| 错误提示 | phone-input-error |
| 提交用的隐藏输入 | phone-input-value |
根元素带 data-size,出错时带 data-invalid,禁用带 data-disabled;发送按钮带 data-state(idle / sending / counting / again)。
可访问性
label写在方条内左侧,通过htmlFor与输入框关联,点标签或条内空白都会聚焦输入框;没有可见标签时传aria-label。- 错误提示通过
aria-describedby关联,输入框同时带aria-invalid;错误态不只靠红色,还有文字。 - 发送按钮是原生
button,倒计时期间disabled;秒数每秒都在变,不用aria-live逐秒播报,读屏聚焦时会念出当前文字。
注意事项
- 触屏设备上方条加高一档(36 / 40 / 44px)。并排的按钮要一起加高才对得齐:本站的
Button已经在三档尺寸里加了pointer-coarse:h-9 / h-10 / h-11;如果你用的是 shadcn 默认的按钮,在button.tsx的sm、default、lg里补上这三个类即可。 - 只处理中国大陆手机号(11 位)。需要选国家 / 地区的国际号码,请用专门的国际号码库。
- 受控模式下记得同时处理
onValueChange;外部传入的值会先去掉非数字再显示。 - 非受控的表单
reset会回到defaultValue;受控的由外部自己重置。 - 撤销(
⌘Z):整理会重写输入框内容,浏览器的撤销栈不再可靠,这是实时格式化输入的通病。 - 出错时传
aria-invalid或error,方条变红,和其他表单控件的错误态一致。
源码
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { DEFAULT_LABELS, PHONE_LENGTH } from "./config"
import {
PhoneInputContext,
PhoneInputSendButton,
phoneInputControlVariants,
useSendCode,
} from "./phone-input-parts"
import type { PhoneInputProps } from "./types"
import {
digitsOf,
formatPhone,
isValidPhone,
maskPhone,
sanitizePhone,
} from "./utils"
/**
* 中国大陆手机号输入:边输边按 3-4-4 分隔,光标不乱跳;粘贴、自动填充带区号或横线的号码也能整理。
* 值是 11 位纯数字,表单提交的也是它。条内右侧可以放「获取验证码」。
*/
function PhoneInput({
value: valueProp,
defaultValue = "",
onValueChange,
label,
size = "default",
action,
error,
labels: labelsProp,
className,
id: idProp,
name,
form,
required,
disabled,
readOnly,
placeholder,
"aria-invalid": ariaInvalid,
"aria-describedby": describedByProp,
onFocus,
onBlur,
ref,
...props
}: PhoneInputProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const autoId = React.useId()
const id = idProp ?? autoId
const errorId = `${id}-error`
const inputRef = React.useRef<HTMLInputElement>(null)
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement)
const [value, setValue] = useControllableState({
prop: valueProp,
defaultProp: digitsOf(defaultValue).slice(0, PHONE_LENGTH),
onChange: onValueChange,
})
const text = formatPhone(value)
const [touched, setTouched] = React.useState(false)
// 整理后要恢复的光标位置;每次都是新对象,保证文本没变(敲了非法字符)时也会重设光标
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 commit = (raw: string, rawCaret: number) => {
const next = sanitizePhone(raw, rawCaret)
setValue(next.digits)
setCaret({ position: next.caret })
}
const valid = isValidPhone(value)
const ownMessage =
value === ""
? required
? labels.required
: ""
: valid
? ""
: labels.invalid
// 浏览器的表单校验也拦住:号码不完整时提交会被挡下
React.useEffect(() => {
inputRef.current?.setCustomValidity(ownMessage)
}, [ownMessage])
const errorMessage = error || (touched && value !== "" ? ownMessage : "")
const invalid =
Boolean(errorMessage) || ariaInvalid === true || ariaInvalid === "true"
const describedBy =
[describedByProp, errorMessage ? errorId : undefined]
.filter(Boolean)
.join(" ") || undefined
// 非受控时表单重置回到初始值;reset 事件在表单清空之前触发,等一帧再覆盖
const controlled = valueProp !== undefined
const resetRef = React.useRef(() => {})
React.useEffect(() => {
resetRef.current = () => {
setValue(digitsOf(defaultValue).slice(0, PHONE_LENGTH))
setTouched(false)
}
})
React.useEffect(() => {
const element = inputRef.current?.form
if (!element || controlled) return
const reset = () => window.setTimeout(() => resetRef.current())
element.addEventListener("reset", reset)
return () => element.removeEventListener("reset", reset)
}, [controlled])
return (
<div
data-slot="phone-input"
data-size={size}
data-invalid={invalid ? "" : undefined}
data-disabled={disabled ? "" : undefined}
// 容器查询:窄于 20rem 时缩小间距和按钮内边距,窄于 17rem 时再收起条内标签(读屏仍可读),号码不被挤掉
className={cn(
"@container/phone-input flex w-full flex-col gap-1.5",
className,
)}
>
<div
data-slot="phone-input-control"
className={cn(
phoneInputControlVariants({ size }),
"@max-[20rem]/phone-input:gap-2",
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 && "bg-muted/30",
// 条内有按钮时右边不留内边距,按钮自己贴边
action && "overflow-hidden",
)}
// 点到条内空白处(标签与号码之间)也聚焦输入框
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault()
inputRef.current?.focus()
}
}}
>
{label && (
<label
data-slot="phone-input-label"
htmlFor={id}
className="shrink-0 text-muted-foreground max-md:text-sm @max-[17rem]/phone-input:sr-only"
>
{label}
</label>
)}
<input
{...props}
ref={inputRef}
id={id}
data-slot="phone-input-input"
// tel + numeric:iOS 弹纯数字键盘,自动填充也认得这是电话号码
type="tel"
inputMode="numeric"
autoComplete="tel-national"
spellCheck={false}
value={text}
placeholder={placeholder ?? labels.placeholder}
disabled={disabled}
readOnly={readOnly}
required={required}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
className={cn(
"h-full min-w-0 flex-1 bg-transparent font-medium tabular-nums outline-none placeholder:font-normal placeholder:text-muted-foreground disabled:cursor-not-allowed",
// 有条内标签时号码靠右,与方条开关、金额输入的「左标签右数值」一致
label && "text-right",
)}
onChange={(event) => {
const input = event.currentTarget
commit(input.value, input.selectionStart ?? input.value.length)
}}
onKeyDown={(event) => {
props.onKeyDown?.(event)
const input = event.currentTarget
const { selectionStart: start, selectionEnd: end } = input
if (
event.defaultPrevented ||
readOnly ||
start === null ||
start !== end
)
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={onFocus}
onBlur={(event) => {
setTouched(true)
onBlur?.(event)
}}
/>
{action && (
<PhoneInputContext.Provider value={{ valid }}>
{action}
</PhoneInputContext.Provider>
)}
</div>
{errorMessage && (
<p
id={errorId}
data-slot="phone-input-error"
className="text-xs text-destructive"
>
{errorMessage}
</p>
)}
{name && (
<input
type="hidden"
data-slot="phone-input-value"
name={name}
form={form}
value={value}
disabled={disabled}
/>
)}
</div>
)
}
export {
PhoneInput,
PhoneInputSendButton,
formatPhone,
isValidPhone,
maskPhone,
phoneInputControlVariants,
useSendCode,
}
export type {
PhoneInputProps,
PhoneInputSendButtonProps,
PhoneInputSize,
} from "./types"
"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { DEFAULT_COOLDOWN, DEFAULT_LABELS } from "./config"
import type { PhoneInputSendButtonProps } from "./types"
/**
* 手机号方条:与方条开关、金额输入同一套外观(浅灰底、细边框、标签在条内左侧)。
* 高度只来自既有三档 28 / 32 / 36px,触屏设备上各加高一档(36 / 40 / 44px);
* 手机上字号保持 16px,iOS 聚焦时才不会自动放大页面
*/
const phoneInputControlVariants = cva(
"flex w-full min-w-0 cursor-text items-center gap-3 rounded-md border bg-muted/60 px-3 transition-colors",
{
variants: {
size: {
sm: "h-7 text-base pointer-coarse:h-9 md:text-xs",
default: "h-8 text-base pointer-coarse:h-10 md:text-sm",
lg: "h-9 text-base pointer-coarse:h-11 md:text-sm",
},
},
defaultVariants: { size: "default" },
},
)
/** 手机号方条把「号码是否有效」告诉条内的发送按钮:号码不完整时按钮自动置灰 */
const PhoneInputContext = React.createContext<{ valid: boolean } | null>(null)
type SendState = "idle" | "sending" | "counting" | "again"
/**
* 「获取验证码」的状态:发送中 → 倒计时 → 可以重新获取。
* 剩余秒数按截止时间算,页面切到后台计时器变慢也不会多等。
*/
function useSendCode(onSend: () => void | Promise<void>, cooldown: number) {
const [state, setState] = React.useState<SendState>("idle")
const [deadline, setDeadline] = React.useState(0)
const [now, setNow] = React.useState(0)
React.useEffect(() => {
if (state !== "counting") return
const timer = window.setInterval(() => {
const current = Date.now()
setNow(current)
if (current >= deadline) setState("again")
}, 250)
return () => window.clearInterval(timer)
}, [state, deadline])
const send = async () => {
if (state === "sending" || state === "counting") return
const previous = state
setState("sending")
try {
await onSend()
} catch {
// 发送失败不进入倒计时,用户可以马上重试;错误提示由调用方处理
setState(previous)
return
}
const start = Date.now()
setNow(start)
setDeadline(start + cooldown * 1000)
setState("counting")
}
const remaining = Math.max(0, Math.ceil((deadline - now) / 1000))
return { state, remaining, send }
}
/** 获取验证码按钮:放进手机号方条的 `action`,也可以单独用在邮箱等别的输入旁边 */
function PhoneInputSendButton({
onSend,
cooldown = DEFAULT_COOLDOWN,
variant = "inline",
size = "default",
labels: labelsProp,
disabled,
className,
...props
}: PhoneInputSendButtonProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const context = React.useContext(PhoneInputContext)
const { state, remaining, send } = useSendCode(onSend, cooldown)
const busy = state === "sending" || state === "counting"
const text =
state === "sending"
? labels.sending
: state === "counting"
? labels.countdown.replace("{seconds}", String(remaining))
: state === "again"
? labels.resend
: labels.send
return (
<button
type="button"
data-slot="phone-input-send"
data-state={state}
disabled={disabled || busy || context?.valid === false}
// 倒计时每秒都在变,不用 aria-live 逐秒播报;读屏聚焦时会念出当前文字
onClick={() => void send()}
className={cn(
variant === "inline"
? // 条内:撑满条高、左侧一道竖线隔开号码,文字用主题色,像一段可点的链接
"-mr-3 flex h-full shrink-0 items-center border-l border-edge px-3 text-primary tabular-nums transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-transparent max-md:text-sm @max-[20rem]/phone-input:px-2.5"
: cn(buttonVariants({ variant: "outline", size }), "tabular-nums"),
className,
)}
{...props}
>
{text}
</button>
)
}
export {
PhoneInputContext,
PhoneInputSendButton,
phoneInputControlVariants,
useSendCode,
}
import type * as React from "react"
import type { PhoneInputLabels } from "./config"
type PhoneInputSize = "sm" | "default" | "lg"
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"value" | "defaultValue" | "onChange" | "type" | "size" | "children"
> & {
/** 手机号,11 位纯数字字符串(不带空格、区号);空串表示没填。受控时传入 */
value?: string
/**
* 非受控时的初始值
* @default ""
*/
defaultValue?: string
/** 号码变化时回调,参数是纯数字字符串 */
onValueChange?: (value: string) => void
/** 写在条内左侧的标签,自动与输入框关联;传入后号码靠右 */
label?: React.ReactNode
/**
* 档位:高度 28 / 32 / 36px,触屏设备上 36 / 40 / 44px
* @default "default"
*/
size?: PhoneInputSize
/** 条内右侧的附加内容,通常放 `PhoneInputSendButton`(获取验证码) */
action?: React.ReactNode
/**
* 错误提示:显示在条下方,条变红。不传时组件自己校验,失焦后位数不够或号段不对会提示
*/
error?: React.ReactNode
labels?: Partial<PhoneInputLabels>
}
type PhoneInputSendButtonProps = Omit<
React.ComponentProps<"button">,
"onClick" | "children"
> & {
/**
* 点击后执行的发送动作,返回 Promise 时等它完成再开始倒计时;抛错时不进入倒计时,可以马上重试
*/
onSend: () => void | Promise<void>
/**
* 冷却时间(秒)
* @default 60
*/
cooldown?: number
/**
* `inline` 放在手机号方条里(左侧一道竖线、主题色文字);`button` 单独使用,和按钮同一外观
* @default "inline"
*/
variant?: "inline" | "button"
/**
* `button` 外观的高度档位,与按钮一致:28 / 32 / 36px
* @default "default"
*/
size?: "sm" | "default" | "lg"
labels?: Partial<PhoneInputLabels>
}
export type { PhoneInputProps, PhoneInputSendButtonProps, PhoneInputSize }
import { PHONE_LENGTH } from "./config"
/** 全角数字转半角,其余非数字字符丢掉 */
function digitsOf(text: string) {
let out = ""
for (const ch of text) {
const code = ch.charCodeAt(0)
if (code >= 0xff10 && code <= 0xff19)
out += String.fromCharCode(code - 0xfee0)
else if (ch >= "0" && ch <= "9") out += ch
}
return out
}
/**
* 去掉国际区号:「+86 138…」「0086 138…」「86138…」都剩下 11 位。
* 只在数字超过 11 位时才剥,免得把正在输入的号码误删。
*/
function stripCountryCode(digits: string) {
if (digits.length > PHONE_LENGTH && digits.startsWith("0086"))
return digits.slice(4)
if (digits.length > PHONE_LENGTH && digits.startsWith("86"))
return digits.slice(2)
return digits
}
/** 11 位数字按 3-4-4 加空格:`13800138000` → `138 0013 8000`;不足 11 位时按已有位数分 */
function formatPhone(digits: string) {
const d = digits.slice(0, PHONE_LENGTH)
if (d.length <= 3) return d
if (d.length <= 7) return `${d.slice(0, 3)} ${d.slice(3)}`
return `${d.slice(0, 3)} ${d.slice(3, 7)} ${d.slice(7)}`
}
/** 号码中间四位打码:`13800138000` → `138 **** 8000`;不是 11 位时原样返回 */
function maskPhone(digits: string, mask = "****") {
const d = digitsOf(digits)
if (d.length !== PHONE_LENGTH) return digits
return `${d.slice(0, 3)} ${mask} ${d.slice(7)}`
}
/** 是否是有效的中国大陆手机号:11 位,以 13–19 开头 */
function isValidPhone(digits: string) {
return /^1[3-9]\d{9}$/.test(digits)
}
/**
* 整理一次输入:返回格式化后的文本、换算后的光标位置和纯数字值。
* 光标按「它前面有几位数字」换算,所以增删空格、粘贴带横线的号码时光标都落在同一个数字后面。
*/
function sanitizePhone(raw: string, caret: number) {
const before = digitsOf(raw.slice(0, caret)).length
const all = digitsOf(raw)
const stripped = stripCountryCode(all)
const removed = all.length - stripped.length
const digits = stripped.slice(0, PHONE_LENGTH)
const text = formatPhone(digits)
const want = Math.min(Math.max(before - removed, 0), digits.length)
let position = 0
let seen = 0
while (position < text.length && seen < want) {
if (text[position] !== " ") seen++
position++
}
return { text, caret: position, digits }
}
export { digitsOf, formatPhone, isValidPhone, maskPhone, sanitizePhone }
/** 用户可见文案。`{seconds}` 会被替换成剩余秒数。 */
const DEFAULT_LABELS = {
/** 输入框的提示文字 */
placeholder: "请输入手机号",
/** 位数不够或号段不对时的提示,失焦后显示 */
invalid: "请输入正确的手机号",
/** 必填却没填时的提示 */
required: "请输入手机号",
/** 发送验证码按钮:初始 */
send: "获取验证码",
/** 发送中 */
sending: "发送中…",
/** 倒计时中 */
countdown: "{seconds} 秒后重发",
/** 倒计时结束 */
resend: "重新获取",
}
type PhoneInputLabels = typeof DEFAULT_LABELS
/** 中国大陆手机号的位数 */
const PHONE_LENGTH = 11
/** 发送验证码后的默认冷却时间(秒) */
const DEFAULT_COOLDOWN = 60
export { DEFAULT_COOLDOWN, DEFAULT_LABELS, PHONE_LENGTH }
export type { PhoneInputLabels }
"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 }