元素
OtpInput
一次性验证码输入框接收固定位数的数字(短信里的验证码、身份验证器的验证码),并把它们显示在格子里。行为由它负责:一个透明的原生输入框覆盖在格子之上,接收键盘输入,因此打字、输入法、粘贴、数字键盘以及系统的验证码自动填充(iOS 的“来自信息”、Android 的短信验证码建议)都是平台自己的,屏幕阅读器也只看到一个文本框。只有数字能输入,最多 length 位;退格键删除最后一位,填满时 onComplete 通知应用。外观由应用决定:OtpInput 没有自己的颜色、尺寸、边框或文字。它的部件是格子、格子分成的组和光标,应用为每个部件设置样式。
import { Clipboard, cleanup, mount, signal, Text, View } from "nsh";
import { Button, OtpInput, Switch } from "nsh/base";
import { button, buttonText, desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";
// The parts: geometry and motion in classes, colors from the palette. The
// cell that takes the next digit gets the keyboard's focus ring (`focus:`,
// the palette's `info` ring) while the code has the focus, and the caret
// blinks in it (steady with reduced motion: the input's own). Cells are
// 44 points on a touch screen, 36 with a pointer; a pressed or hovered
// button and the switches' rows take the palette's states.
const CODE = "246810";
const cell = desktop ? "w-9 h-11 text-lg" : "w-11 h-13 text-xl";
const cellClass = `${cell} items-center justify-center rounded-lg font-semibold text-foreground transition-colors motion-reduce:transition-none focus:outline-2 focus:outline-info focus:-outline-offset-2 disabled:opacity-40`;
const row = `-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`;
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";
function Option(props: { label: string; checked: boolean; onChange: (next: boolean) => void }) {
return (
<Switch
checked={props.checked}
onChange={props.onChange}
class={row}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
trackClass={track}
trackStyle={props.checked ? ui().accentFill : ui().inset}
thumbClass={thumb}
thumbStyle={props.checked ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>{props.label}</Text>
</Switch>
);
}
function Action(props: { label: string; disabled?: boolean; onPress: () => void }) {
return (
<Button onPress={props.onPress} disabled={props.disabled} {...button("secondary", { style: desktop ? {} : { flex: 1 } })}>
<Text style={buttonText()}>{props.label}</Text>
</Button>
);
}
function App() {
const code = signal("");
const masked = signal(false);
const disabled = signal(false);
const status = signal<"typing" | "checking" | "right" | "wrong">("typing");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 6));
// Checking the code takes a moment, as a server's answer does.
let check: ReturnType<typeof setTimeout> | undefined;
cleanup(() => clearTimeout(check));
const verify = (value: string) => {
status.set("checking");
clearTimeout(check);
check = setTimeout(() => status.set(value === CODE ? "right" : "wrong"), 600);
};
const message = () =>
({
typing: { text: `${code().length} of 6 digits`, color: ui().muted },
checking: { text: "Checking…", color: ui().muted },
right: { text: "Verified", color: ui().up },
wrong: { text: "That code is not right", color: ui().down },
})[status()];
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>otp input</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click the code and type it, or paste it; Tab reaches it too" : "Tap the code to type it, or paste it"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="value · masked · disabled">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
{code() ? `value “${masked() ? "•".repeat(code().length) : code()}”` : "value “” (empty)"}
</Text>
<Option
label="Masked"
checked={masked()}
onChange={(next) => {
masked.set(next);
log(next ? "masked: bullets, a secure field" : "masked off");
}}
/>
<Option
label="Disabled"
checked={disabled()}
onChange={(next) => {
disabled.set(next);
log(next ? "disabled: no focus" : "enabled");
}}
/>
</Panel>
<Log title="onChange · onComplete · onFocus · onBlur" lines={events()} minHeight={120} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 20 : 16, gap: 16, alignItems: "center" }}>
<View style={{ gap: 4, alignItems: "center" }}>
<Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Enter the code</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{`We sent it to •••• 4821 (it is ${CODE})`}</Text>
</View>
<OtpInput
length={6}
groups={2}
value={code()}
masked={masked()}
disabled={disabled()}
onChange={(next) => {
code.set(next);
status.set("typing");
log(`onChange “${next}”`);
}}
onComplete={({ value }) => {
log(`onComplete “${value}”`);
verify(value);
}}
onFocus={() => log("onFocus")}
onBlur={() => log("onBlur")}
accessibilityLabel="Verification code, 6 digits"
class="flex-row items-center gap-3"
groupClass="flex-row gap-1.5"
cellClass={cellClass}
cellStyle={ui().inset}
caretClass="w-0.5 h-6 rounded-full"
caretStyle={{ backgroundColor: ui().text }}
/>
<Text style={{ fontSize: size.detail, color: message().color }}>{message().text}</Text>
<View class={desktop ? "flex-row gap-2" : "flex-row gap-2 self-stretch"}>
<Action
label="Copy the code"
onPress={() => {
Clipboard.writeText(CODE);
log("copied: paste it into the code");
}}
/>
<Action
label="Clear"
disabled={code() === ""}
onPress={() => {
code.set("");
status.set("typing");
log("value set to “”");
}}
/>
</View>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{OtpInputCompleteEvent, otp_input, theme};
use nsh_core::prelude::*;
fn verification(verify: impl Fn(&str) + 'static) -> impl IntoElement {
let code = signal(SharedString::default());
otp_input()
.length(6)
.groups(2)
.value(move || Some(code.get()))
.on_change(move |next: &SharedString| code.set(next.clone()))
.on_complete(move |e: &OtpInputCompleteEvent| verify(&e.value))
.accessibility_label("验证码,6 位数字")
.flex_row()
.gap_3()
.group_style(|s| s.flex_row().gap_2())
.cell_style(|s| {
s.w(px(40.0))
.h(px(48.0))
.items_center()
.justify_center()
.rounded(px(8.0))
.border_1()
.border_color(move || theme().input)
.text_xl()
})
// 输入框获得焦点时,接收下一位数字的格子。
.cell_class("focus:border-ring")
.caret_style(|s| s.w(px(2.0)).h(px(24.0)).bg(move || theme().foreground))
}import { mount, signal } from "nsh";
import { OtpInput } from "nsh/base";
function App() {
const code = signal("");
const verify = (value: string) => console.log(`checking ${value}`);
return (
<OtpInput
length={6}
groups={2}
value={code()}
onChange={(next) => code.set(next)}
onComplete={({ value }) => verify(value)}
accessibilityLabel="验证码,6 位数字"
class="flex-row gap-3"
groupClass="flex-row gap-2"
cellClass="w-10 h-12 items-center justify-center rounded-lg border border-input text-xl focus:border-ring"
caretClass="w-0.5 h-6 bg-foreground"
/>
);
}
mount(App);OtpInput 来自 nsh/base,在 Rust 中是 nsh-base crate。在 Rust 中,otp_input() 创建一个输入框,每个 prop 有一个 setter(.length(..)、.cell_style(..)),每个事件有一个监听方法(.on_complete(..)),输入框本身用带类型的样式方法设置样式。
#验证码
- 受控:传入
value并在onChange中更新它。格子显示应用传入的value,原样显示;应用自己设置的值(输错后清空验证码)不会被报告。 - 非受控:传入
defaultValue(或不传),从onChange和onComplete读取验证码。
用户的操作从输入框读取,光标始终在末尾:
| 用户 | 验证码 |
|---|---|
| 输入一位数字 | 验证码短于 length 时加到末尾;填满后不再变化。 |
输入全角数字(0–9,东亚输入法输入的) | 加入对应的 ASCII 数字。 |
| 输入其他字符 | 不变;输入框丢弃它。 |
| 按退格键 | 删除最后一位。 |
| 粘贴,或系统自动填充验证码 | 其中的数字同样加入;含有 length 位或更多数字的文本(123-456、短信里的验证码)用它的前 length 位替换验证码。 |
onChange 报告每次变化及新的验证码;变化填满最后一个格子时,紧接着是 onComplete,应用在这里校验验证码。
#Props
| Prop | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
length | .length(..) | number | 必填 | 验证码的位数:每位一个格子。 |
value | .value(..) | string | 无 | 由应用控制的验证码(原样显示)。不传时输入框自己保存。 |
defaultValue | .default_value(..) | string | 无 | 未设置 value 时的初始验证码。 |
masked | .masked(..) | boolean | false | 已填的格子显示圆点而不是数字,输入框是安全输入框:屏幕阅读器不读出验证码,系统也不会把它放进建议里。 |
disabled | .disabled(..) | boolean | false | 不能获得焦点,不能编辑;disabled: 样式作用于输入框和它的部件,屏幕阅读器读出它不可用。 |
groups | .groups(..) | number | 1 | 格子尽量平均地分成几组(六位分两组是 3 + 3);至少一组,最多每个格子一组。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器为输入框读出的内容:验证码的用途和位数。 |
cellClass、cellStyle | .cell_class(..)、.cell_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 每个格子:尺寸、边框和文字。 |
groupClass、groupStyle | .group_class(..)、.group_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 每组格子(flex-row gap-2)。 |
caretClass、caretStyle | .caret_class(..)、.caret_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 光标:宽度、高度和颜色。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 输入框本身:包住各组的行(或列)。 |
#事件
| 事件 | Rust | 参数 | 时机 |
|---|---|---|---|
onChange | .on_change(|value: &SharedString| ..) | (value: string) | 用户改变了验证码:输入、删除、粘贴或自动填充。 |
onComplete | .on_complete(|e: &OtpInputCompleteEvent| ..) | { value } | 用户的改动填满了最后一个格子,在它的 onChange 之后。 |
onFocus / onBlur | .on_focus / .on_blur | 无 | 输入框获得 / 失去键盘焦点。 |
#Ref 方法
| 方法 | Rust | 说明 |
|---|---|---|
focus() | focus()(OtpInputApi) | 获得键盘焦点并弹出软键盘,与按下输入框相同。 |
#部件
| 部件 | 位于 | 样式 |
|---|---|---|
| 输入框 | class、style | |
| 组 | 输入框内,每组一个 | groupClass、groupStyle;disabled: 跟随输入框 |
| 格子 | 所在的组内,每位数字一个 | cellClass、cellStyle;输入框有焦点时,接收下一位数字的格子带 focus:,disabled: 跟随输入框 |
| 数字 | 已填的格子内 | 格子的文字样式(text-xl、font-semibold);masked 时为 • |
| 光标 | 输入框有焦点时,接收下一位数字的空格子内 | caretClass、caretStyle |
接收下一位数字的格子是最后一位数字之后的格子,验证码填满后是最后一个格子。光标像文本框的光标一样闪烁(亮半秒、灭半秒,每次编辑后保持亮一会儿);偏好减少动态效果时保持不动。按下输入框的任何位置都会让它获得焦点:输入框覆盖在格子之上。
#无障碍
- 屏幕阅读器只看到一个文本框,名称来自
accessibilityLabel(说明验证码的用途和位数:“验证码,6 位数字”),值是验证码;格子对屏幕阅读器隐藏。masked时输入框是安全输入框,验证码不会被读出。 - 键盘是平台的:手机上是数字键盘,桌面上是任意键盘(只有数字能输入),以及退格键。Tab 键像对任何文本框一样到达它。
disabled时不能获得焦点,屏幕阅读器读出它不可用。
标签、角色和播报的一般用法见无障碍。
#平台说明
- iOS:输入框是验证码输入框(
textContentType为oneTimeCode),QuickType 栏会提供来自“信息”或“邮件”的验证码;弹出数字键盘。masked的输入框失去焦点后再次输入时会从头开始,与 iOS 的安全输入框一样。 - Android:
autofillHints为smsOTPCode,键盘和自动填充服务可以提供短信里的验证码;弹出数字键盘。 - macOS、Windows、Linux 和网页:输入和粘贴进入输入框(在浏览器中带
inputmode="numeric",手机上弹出数字键盘),格子上显示 I 形指针。