元素
OtpInput
一次性驗證碼輸入元件接受固定位數的數字(簡訊中的驗證碼、驗證器產生的代碼),並將這些數字顯示在格子中。它負責處理行為:一個透明的原生文字欄位覆蓋在格子上方接收鍵盤輸入,因此輸入、輸入法、貼上、數字鍵盤,以及系統的驗證碼自動填入(iOS 的「From Messages」、Android 的簡訊建議)都由平台自行處理,螢幕閱讀器也只會找到一個文字欄位。只有數字能輸入,最多到 length;Backspace 會移除最後一位數字,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). A cell that
// holds a digit, a bullet too, lifts out of its well: a card's fill under a
// firmer edge (`data-filled:`), faded in (`transition-colors`). 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 border border-line bg-inset font-semibold text-foreground transition-colors motion-reduce:transition-none data-filled:border-input data-filled:bg-card 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={({ checked }) => props.onChange(checked)}
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={({ value: 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={{ boxShadow: ui().inset.boxShadow }}
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("Verification code, 6 digits")
.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()
})
// The cell that takes the next digit, while the code has the focus,
// and the cells that hold a digit.
.cell_class("focus:border-ring data-filled:bg-muted")
.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={({ value }) => code.set(value)}
onComplete={({ value }) => verify(value)}
accessibilityLabel="Verification code, 6 digits"
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 data-filled:bg-muted"
caretClass="w-0.5 h-6 bg-foreground"
/>
);
}
mount(App);OtpInput 來自 nsh/base,也就是 Rust 中的 nsh-base crate。在 Rust 中,otp_input() 會建立一個實例,每個屬性對應一個 setter(.length(..)、.cell_style(..)),每個事件對應一個監聽方法(.on_complete(..)),以及用於設定輸入元件本身的具型別樣式方法。
#驗證碼
- 受控:傳入
value,並在onChange中更新它。格子會原樣顯示應用程式傳入的value;應用程式自行設定的值(例如驗證碼錯誤後清空)不會被回報。 - 非受控:傳入
defaultValue(或不傳),並從onChange與onComplete讀取驗證碼。
使用者的操作是從欄位讀取的,游標永遠位於結尾:
| 使用者操作 | 驗證碼的變化 |
|---|---|
| 輸入一個數字 | 當驗證碼短於 length 時,數字會加在結尾;已填滿時則不會有任何變化。 |
輸入全形數字(0–9,東亞輸入法所輸入的) | 會加入對應的 ASCII 數字。 |
| 輸入其他任何內容 | 不會有任何變化;欄位會捨棄它。 |
| 按下 Backspace | 移除最後一位數字。 |
| 貼上,或由系統自動填入驗證碼 | 其中的數字會以相同方式加入;含有 length 位以上數字的文字(123-456,例如簡訊中的驗證碼)會以其前 length 位數字取代原有的驗證碼。 |
onChange 會連同新的驗證碼回報每次變更;當變更填滿最後一個格子時,onComplete 會接著觸發,應用程式便可在此時驗證驗證碼。
#屬性
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
length | .length(..) | number | 必填 | 驗證碼的位數:每位數字一個格子。 |
value | .value(..) | string | 無 | 由應用程式控制的驗證碼(原樣顯示)。未設定時,輸入元件會自行保存。 |
defaultValue | .default_value(..) | string | 無 | 在未設定 value 時,驗證碼的初始值。 |
masked | .masked(..) | boolean | false | 已填入的格子會顯示圓點而非數字,且該欄位為安全欄位:螢幕閱讀器不會讀出驗證碼,系統也不會將其列入建議。 |
disabled | .disabled(..) | boolean | false | 無法取得焦點也無法編輯;disabled: 樣式會套用於輸入元件及其各部分,螢幕閱讀器會說明它已停用(dimmed)。 |
groups | .groups(..) | number | 1 | 格子被分成幾個群組,盡可能平均分配(六位數分成兩組時為 3 + 3);至少一組,最多每個格子各一組。 |
accessibilityLabel | .accessibility_label(..) | string | 無 | 螢幕閱讀器為此欄位朗讀的內容:驗證碼的用途與位數。 |
cellClass、cellStyle | .cell_class(..)、.cell_style(|s| ..) | string、StyleProp<TextStyle> | 無 | 每個格子的尺寸、邊框與文字(color、fontSize,其中的數字會繼承這些設定);focus: 與 data-filled: 用於設定其狀態樣式(見組成部分)。 |
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| ..) | (e: { 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: 用於接收下一個數字的格子,data-filled: 用於每個已有數字的格子,disabled: 跟隨輸入元件 |
| 數字 | 已填入的格子 | 格子的文字樣式(text-xl、font-semibold);當 masked 時使用 • |
| 游標 | 輸入元件擁有焦點時,接收下一個數字的空格子 | caretClass、caretStyle |
接收下一個數字的格子,是最後一個數字之後的那一格;驗證碼填滿後則是最後一個格子。游標的閃爍方式與文字欄位相同(亮半秒、暗半秒,每次編輯後會短暫維持不閃爍),透過其不透明度以原生方式實現,同時在版面配置中保持原位;啟用減少動態效果時,游標會保持靜止。按一下輸入元件的任何位置都會使它取得焦點:欄位覆蓋在格子上方。
已有數字的格子(無論是否遮蔽)具有屬性 data-filled,因此 data-filled: 類別可用來設定使用者已填入的格子的樣式(更粗的邊框、填色:data-filled:border-foreground data-filled:bg-muted),寫法與 shadcn/ui 設定其狀態的方式相同。當 cellClass 中有 group 時,group-data-filled: 類別可用來設定已填入格子內部的內容,也就是其中的數字。格子只會改變其類別,而不會改變其內容,因此每次按鍵只會重新設定一個格子的樣式,不會重新繪製任何內容。
#無障礙
- 螢幕閱讀器只會找到一個文字欄位,由
accessibilityLabel命名(說明驗證碼的用途與長度,例如「Verification code, 6 digits」),並以驗證碼作為其值;格子則對螢幕閱讀器隱藏。當masked時,該欄位為安全欄位,因此驗證碼不會被朗讀出來。 - 鍵盤由平台提供:手機上是數字鍵盤,桌面上可使用任何鍵盤(只有數字能輸入),以及 Backspace。Tab 鍵可像任何文字欄位一樣移到輸入元件。
- 當
disabled時,它無法取得焦點,螢幕閱讀器會說明它已停用(dimmed)。
關於標籤、角色與通知的一般說明,請參閱無障礙。
#平台備註
- iOS:該欄位是驗證碼欄位(
textContentTypeoneTimeCode),因此 QuickType 列會提供來自 Messages 或 Mail 的驗證碼;系統會顯示數字鍵盤。masked欄位在失去焦點後再次輸入時會重新開始,與 iOS 的安全欄位行為相同。 - Android:
autofillHintssmsOTPCode讓鍵盤與自動填入服務能提供簡訊中的驗證碼;系統會顯示數字鍵盤。 - macOS、Windows、Linux 與網頁:輸入與貼上都會送到該欄位(在瀏覽器中,搭配
inputmode="numeric"以顯示手機的數字鍵盤),並且在格子上方會顯示 I 形指標。