全库唯一不建在 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 也接着数。 每个InputOTPSlot靠index去slots[index]取自己那个字符;上面那棵树是0 1 2+ 分隔符 +3 4 5,不是两组 各自从 0 开始。超出maxLength的index取不到东西,只会画一个永远空着的框。
没有 render 通道。 input-otp 用 render 指「一个函数替换掉整行内容、与
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 上还是挂在单个格子上,是两种粒度,都有效:
那个真 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 上有两个类名口子,管的是两个不同的元素:
想改行距、格子间距、整行宽度 —— 全是 containerClassName。className 基本只在需要动
输入行为相关的样式(比如 disabled: 下的光标)时才用得上。两个都是纯字符串:这一家
不是 Base UI 部件,没有函数形态,InputOTPGroup / InputOTPSlot / InputOTPSeparator
三个也一样。
状态属性只有三个,没有一个来自 Base UI:
可见的容器行没有 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
InputOTPGroup / InputOTPSlot / InputOTPSeparator
三个部件都被封装层包过一层:InputOTPSeparator 是为了
cn 合并,InputOTPGroup / InputOTPSlot 是为了无效态桥接。
四个类型一并导出:InputOTPProps / InputOTPGroupProps / InputOTPSlotProps /
InputOTPSeparatorProps。