Cadenza
EN

一次性验证码输入 —— 全库唯一不建在 Base UI 上的控件,一个隐形的真 input 横跨所有格子

全库唯一不建在 Base UI 上的控件。 底下是 input-otp 包:一个真的 <input>,视觉上完全隐形,横跨所有格子铺满整行;你看见的那一排方框 只是按它的值画出来的 div

这个结构不是为了好看 —— 它正是短信自动填充和整串粘贴能用的原因。焦点、选区、输入法、 autocomplete="one-time-code"(默认就带)全都由那个真 input 承担:自动填充和粘贴看见的 是一个完整字段,不是六个各管一位的小框。

⚠️ 协议因此也是 React DOM 的,不是这个库其他控件那套:value / onChange,而 onChange 收到的是字符串,不是事件;这个家族里没有任何 eventDetails, 也没有 cancel()

使用

import {
  Field,
  FieldLabel,
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from '@gedatou/cadenza-ui'
<Field>
  <FieldLabel htmlFor="code">短信验证码</FieldLabel>
  <InputOTP id="code" maxLength={6} name="code">
    <InputOTPGroup>
      <InputOTPSlot index={0} />
      <InputOTPSlot index={1} />
      <InputOTPSlot index={2} />
    </InputOTPGroup>
    <InputOTPSeparator />
    <InputOTPGroup>
      <InputOTPSlot index={3} />
      <InputOTPSlot index={4} />
      <InputOTPSlot index={5} />
    </InputOTPGroup>
  </InputOTP>
</Field>

组成

分组纯粹是组合出来的:一段连续的格子包一个 InputOTPGroup(圆角画在组的首尾), 组与组之间放一个 InputOTPSeparator。上面 hero 的形态是:

InputOTP
├── InputOTPGroup
│   ├── InputOTPSlot   index={0}
│   ├── InputOTPSlot   index={1}
│   └── InputOTPSlot   index={2}
├── InputOTPSeparator
└── InputOTPGroup
    ├── InputOTPSlot   index={3}
    ├── InputOTPSlot   index={4}
    └── InputOTPSlot   index={5}

两条硬规矩:

  • maxLength 必填,而且要和你渲染的格子数一致。它是那个真 input 的 maxlength, 也是内部 slots 数组的长度。
  • index 从 0 起连续编号,跨 group 也接着数。 每个 InputOTPSlotindexslots[index] 取自己那个字符;上面那棵树是 0 1 2 + 分隔符 + 3 4 5,不是两组 各自从 0 开始。超出 maxLengthindex 取不到东西,只会画一个永远空着的框。

没有 render 通道。 input-otprender 指「一个函数替换掉整行内容、与 children 互斥」,而这个库其余地方的 render 是 Base UI 的元素替换(收一个 ReactElement)—— 一个名字两种含义,猜错了报出来的是没人读得懂的联合类型错误。 封装层把它从公开面上 Omit 掉了,分组一律走组合。

标签

id 落在那个隐形的真 input 上,所以 FieldLabel htmlFor 照常成立 —— 一条 htmlFor 同时给出 无障碍名和「点文字聚焦」,和普通输入框没有区别。hero 用的就是这条。全库四条标签通道 的总表在 Field,InputOTP 属于最普通的那条。

模式

pattern 限定能输进来的字符,收的是正则源码串(不是 RegExp 字面量),内部交给 new RegExp()

<InputOTP maxLength={6} pattern="^\d+$">

</InputOTP>
  • 不匹配就整段拒绝,不是把非法字符过滤掉:逐字输入时那一下 onChange 不推进, 粘贴时整串(先过 pasteTransformer)测一次,不过就一个字都不进。
  • 编译出来的正则的 source 还会写到真 input 的原生 pattern 属性上,浏览器的原生 表单校验也认这一条。
  • input-otp 包自己导出三个现成的串 —— REGEXP_ONLY_DIGITS^\d+$)、 REGEXP_ONLY_CHARS^[a-zA-Z]+$)、REGEXP_ONLY_DIGITS_AND_CHARS^[a-zA-Z0-9]+$)。封装层没有把它们转出来,就三个字符串,直接写字面量即可。

移动端键盘另有一条线:inputMode 默认 'numeric',收字母时记得一并改成 'text'

分隔符

InputOTPGroup / InputOTPSlot / InputOTPSeparator 三个都被封装层重新包过,只有 InputOTP 是原样转出(一次类型 cast)。前两个包的是无效态桥接(见无效态), InputOTPSeparator 包的是一次 cn 合并。它渲染的是一个 IconMinus,带 role="separator"

原因是 vendored 那份把自己的 className 写在 props 展开的前面

// vendored(改不得)
<div className="flex items-center …" role="separator" {...props} />

于是外部传一个 className 进来,不是叠加,而是把整段布局类整个顶掉 —— 图标当场 失去自己的盒子和尺寸。封装层在外面套一层做一次 cn 合并,把「冲突时你的赢、不冲突时 我的还在」这条普通规矩还回来。

禁用

disabled 是原生属性,落在真 input 上:不能输入、不能聚焦,容器的 has-disabled:opacity-50 让整行(格子和分隔符一起)降为半透明,光标也从「文本」 回到默认箭头。

它只管控件自己那一格。要让标签、描述那一列文字跟着变灰,按 Field 的约定在 Field 上手动挂 data-disabled

受控

  • onChange(value: string) —— 参数直接是当前整串值。不是事件,所以没有 event.target.value,也没有第二参。
  • onComplete(value: string) —— 长度从「不满」变成「正好等于 maxLength」的那一刻响 一次。粘贴一整串同样会触发。

只给 defaultValue(普通字符串)也能跑,值确实会显示出来 —— 但 input-otp 会把这个 prop 连同它自己的 value 一起摊到真 input 上,React 因此在开发模式下报一句 「contains an input with both value and defaultValue props」。要干净就用受控,上面 demo 那样。

无效态

aria-invalid 挂在 InputOTP 上还是挂在单个格子上,是两种粒度,都有效:

挂在哪效果
InputOTParia-invalid整行格子画 destructive 边框与环 —— 属性落在那个隐形真 input 上,容器的 .cn-input-otp:has(input[aria-invalid="true"]) 把状态桥接给每个 group 和 slot
InputOTPSlotaria-invalid这个格子画 destructive 边框;它所在的 InputOTPGrouphas-aria-invalid: 整组画出 destructive 边框与环
Fielddata-invalid整列文字变 destructive 色,见 Field

那个真 input 是可见容器里 InputOTPGroup兄弟而不是后代,vendored 那套 has-aria-invalid: 配方够不着它;封装层给 InputOTPGroup / InputOTPSlot 各补了一组 in-[.cn-input-otp:has(input[aria-invalid="true"])] 类,把这条桥接上。所以表单库的常规 接线(aria-invalid 给控件本身)就能点亮整行,不用手动铺给每个 InputOTPSlot

表单

原生的:name 落在那个真 input 上,整串验证码作为一个字段正常参与表单提交, FormData 里就是一条 code=123456。控件渲染在 <form> 之外时用 form 指回表单 id。

状态与 className

InputOTP 上有两个类名口子,管的是两个不同的元素:

Prop落在哪
className那个隐形的真 input
containerClassName可见的那一行(容器 div,格子和分隔符都在里面)

想改行距、格子间距、整行宽度 —— 全是 containerClassNameclassName 基本只在需要动 输入行为相关的样式(比如 disabled: 下的光标)时才用得上。两个都是纯字符串:这一家 不是 Base UI 部件,没有函数形态,InputOTPGroup / InputOTPSlot / InputOTPSeparator 三个也一样。

状态属性只有三个,没有一个来自 Base UI:

属性效果
data-active封装层自动挂在每个 InputOTPSlot 上,光标所在的那格描边加环、抬到 z-10,并画出闪烁的假光标
aria-invalid你自己挂:挂在 InputOTP 上点亮整行,挂在单个格子上只红那一格,见无效态
disabled你自己挂在 InputOTP 上,容器整行半透明,见禁用
data-slot是什么
input-otp那个隐形的真 input
input-otp-group一组连续的格子
input-otp-slot单个格子
input-otp-separator组间分隔符,同时带 role="separator"

可见的容器行没有 data-slot,它带的是 input-otp 包自己的 data-input-otp-container

data-active 是值形布尔。 当前光标所在的格子标的是 data-active="true",其余的是 data-active="false" —— 属性一直在,只是值在变。这和本库其余部分的空串形态 (data-checked="",靠属性在不在判断)不是一回事:vendored 那份直接把布尔值写进属性, 而它是逐字节对齐上游的,改不得。

所以这里的选择器要写 data-[active=true]:

<InputOTPSlot className="data-[active=true]:ring-4" index={0} />

styles.css 里那个 data-active 自定义变体特意写宽容了 ([data-active]:not([data-active="false"])),所以 data-active: 也能匹配到这里 —— 但整个家族的既有类名都是 data-[active=true]: 写法,跟着写省得两种风格混用。

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className

InputOTP

Prop类型默认值说明
maxLengthnumber必填。总字符数,要和格子数一致
childrenReactNode格子与分隔符。唯一的内容通道,render 不在公开面上,见组成
defaultValuestring''非受控初始值。会触发 React 的 both value/defaultValue 告警,见受控
valuestring受控值
onChange(newValue: string) => unknown值变化回调。参数是字符串,不是事件
onComplete(value: string) => unknown填满 maxLength 时响一次
patternstring正则源码串,交给 new RegExp()(如 ^\d+$)。不匹配的输入与粘贴整段拒绝,见模式
pasteTransformer(pasted: string) => string粘贴内容入库前先过一道(去空格、去连字符之类)
inputModestring'numeric'移动端键盘类型,透传给真 input
autoCompletestring'one-time-code'短信自动填充靠它,别随手覆盖
disabledbooleanfalse原生属性;容器有 has-disabled:opacity-50 跟着变灰,见禁用
namestring表单字段名
formstring真 input 归属的表单 id,控件渲染在表单外时用
idstring落在真 input 上,FieldLabel htmlFor 指这里
textAlign'left' | 'center' | 'right''left'隐形 input 里文本(也就是光标停靠)的对齐方式
pushPasswordManagerStrategy'increase-width' | 'none''increase-width'检测到密码管理器图标时把容器撑宽一点,别让图标压在格子上
noScriptCSSFallbackstring | null内置一段 CSS无 JS 时用的 <noscript> 样式兜底;传 null 不渲染
containerClassNamestring可见那一行的类名
classNamestring隐形真 input 的类名
其余原生 input 属性透传给隐形 input(含 ref——它就是那个 input)

InputOTPGroup / InputOTPSlot / InputOTPSeparator

Prop类型默认值说明
indexnumberInputOTPSlot 必填,从 0 起连续,跨 group 也连续
classNamestring三个都是纯 div,只收字符串
其余原生 div 属性透传(含 ref

三个部件都被封装层包过一层:InputOTPSeparator 是为了 cn 合并InputOTPGroup / InputOTPSlot 是为了无效态桥接

四个类型一并导出:InputOTPProps / InputOTPGroupProps / InputOTPSlotProps / InputOTPSeparatorProps