Elementi
OtpInput
Un input per codici monouso accetta un numero fisso di cifre (un codice di verifica ricevuto via SMS, il codice di un’app di autenticazione) e le mostra in celle. Gestisce da sé il comportamento: un solo campo di testo nativo, trasparente e posto sopra le celle, riceve la tastiera, quindi digitazione, metodi di input, incolla, tastierino numerico e compilazione automatica dei codici del sistema (“Da Messaggi” di iOS, suggerimenti SMS di Android) sono quelli nativi della piattaforma, e gli screen reader trovano un solo campo di testo. Entrano solo cifre, fino a length; Backspace rimuove l’ultima, e onComplete segnala quando il codice è completo. L’aspetto è dell’app: un OtpInput non ha colori, dimensioni, bordi né etichetta propri. Le sue parti sono le celle, i gruppi in cui sono suddivise e un cursore, e l’app stilizza ciascuna.
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 proviene da nsh/base, il crate nsh-base in Rust. In Rust, otp_input() ne crea uno con un setter per ogni prop (.length(..), .cell_style(..)), un metodo listener per ogni evento (.on_complete(..)) e i metodi di stile tipizzati per l’input stesso.
#Il codice
- Controllato: passa
valuee aggiornalo inonChange. Le celle mostrano ilvaluepassato dall’app, così com’è; un valore impostato dall’app (per esempio azzerare il codice dopo un errore) non viene mai notificato. - Non controllato: passa
defaultValue(o niente) e leggi il codice daonChangeeonComplete.
Ciò che fa l’utente viene letto dal campo, con il cursore sempre alla fine:
| L’utente | Il codice |
|---|---|
| Digita una cifra | Viene aggiunta in fondo finché il codice è più corto di length; una volta completo, non cambia nulla. |
Digita una cifra a larghezza piena (0–9, di un metodo di input dell’Asia orientale) | Viene aggiunta la cifra ASCII. |
| Digita qualsiasi altro carattere | Non cambia nulla; il campo lo scarta. |
| Preme Backspace | L’ultima cifra viene rimossa. |
| Incolla, o il sistema compila automaticamente un codice | Le sue cifre vengono aggiunte allo stesso modo; un testo con length cifre o più (123-456, il codice di un messaggio) sostituisce il codice con le sue prime length cifre. |
onChange notifica ogni modifica con il nuovo codice, e onComplete la segue quando la modifica ha riempito l’ultima cella, così l’app verifica il codice in quel punto.
#Props
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
length | .length(..) | number | obbligatorio | Quante cifre ha il codice: una cella ciascuna. |
value | .value(..) | string | nessuno | Il codice, controllato dall’app (mostrato così com’è). Senza di esso l’input mantiene un proprio codice. |
defaultValue | .default_value(..) | string | nessuno | Il codice iniziale, finché value non è impostato. |
masked | .masked(..) | boolean | false | Le celle riempite mostrano un pallino al posto della cifra, e il campo è sicuro: gli screen reader non leggono il codice e il sistema lo tiene fuori dai suoi suggerimenti. |
disabled | .disabled(..) | boolean | false | Nessun focus e nessuna modifica; gli stili disabled: si applicano all’input e alle sue parti, e gli screen reader lo annunciano come disattivato. |
groups | .groups(..) | number | 1 | In quanti gruppi sono suddivise le celle, nel modo più uniforme possibile (3 + 3 per sei cifre in due gruppi); almeno uno, al massimo uno per cella. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che gli screen reader dicono per il campo: a cosa serve il codice e quante cifre ha. |
cellClass, cellStyle | .cell_class(..), .cell_style(|s| ..) | string, StyleProp<TextStyle> | nessuno | Ogni cella: dimensione, bordo e testo (color, fontSize, che la sua cifra eredita); focus: e data-filled: stilizzano i suoi stati (vedi Parti). |
groupClass, groupStyle | .group_class(..), .group_style(|s| ..) | string, StyleProp<ViewStyle> | nessuno | Ogni gruppo di celle (flex-row gap-2). |
caretClass, caretStyle | .caret_class(..), .caret_style(|s| ..) | string, StyleProp<ViewStyle> | nessuno | Il cursore: larghezza, altezza e colore. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | L’input stesso: la riga (o colonna) attorno ai suoi gruppi. |
#Eventi
| Evento | Rust | Payload | Quando |
|---|---|---|---|
onChange | .on_change(|value: &SharedString| ..) | (e: { value: string }) | L’utente ha cambiato il codice: digitando, cancellando, incollando o con la compilazione automatica. |
onComplete | .on_complete(|e: &OtpInputCompleteEvent| ..) | { value } | La modifica dell’utente ha riempito l’ultima cella, dopo il relativo onChange. |
onFocus / onBlur | .on_focus / .on_blur | nessuno | L’input ha ricevuto / perso il focus della tastiera. |
#Metodi del ref
| Metodo | Rust | Descrizione |
|---|---|---|
focus() | focus() (OtpInputApi) | Riceve il focus della tastiera e mostra la tastiera virtuale, come fa una pressione sull’input. |
#Parti
| Parte | Dentro | Stilizzata con |
|---|---|---|
| L’input | class, style | |
| Un gruppo | l’input, uno per gruppo | groupClass, groupStyle; disabled: segue l’input |
| Una cella | il suo gruppo, una per cifra | cellClass, cellStyle; focus: sulla cella che riceve la cifra successiva mentre l’input ha il focus, data-filled: su ogni cella che contiene una cifra, disabled: segue l’input |
| La cifra | una cella riempita | gli stili di testo della cella (text-xl, font-semibold); • mentre masked |
| Il cursore | la cella vuota che riceve la cifra successiva, mentre l’input ha il focus | caretClass, caretStyle |
La cella che riceve la cifra successiva è quella dopo l’ultima cifra, oppure l’ultima cella quando il codice è completo. Il cursore lampeggia come quello di un campo di testo (mezzo secondo acceso, mezzo spento, e fisso per un istante dopo ogni modifica), tramite la sua opacità, in modo nativo, mantenendo il proprio posto nel layout; con il movimento ridotto resta fermo. Una pressione in qualsiasi punto dell’input gli dà il focus: il campo si trova sopra le celle.
Una cella che contiene una cifra, mascherata o no, ha l’attributo data-filled, quindi le classi data-filled: stilizzano le celle che l’utente ha riempito (un bordo più marcato, uno sfondo: data-filled:border-foreground data-filled:bg-muted), come shadcn/ui scrive i suoi stati. Con group in cellClass, le classi group-data-filled: stilizzano ciò che si trova dentro una cella riempita, cioè la sua cifra. Le celle cambiano le loro classi, mai il loro contenuto, quindi un tasto ristilizza una cella e non ridisegna nient’altro.
#Accessibilità
- Gli screen reader trovano un solo campo di testo, identificato da
accessibilityLabel(indica a cosa serve il codice e la sua lunghezza: “Codice di verifica, 6 cifre”) con il codice come valore; le celle sono nascoste agli screen reader. Mentremaskedil campo è sicuro, quindi il codice non viene letto ad alta voce. - La tastiera è quella della piattaforma: il tastierino numerico sui telefoni, qualsiasi tastiera sui desktop (entrano solo cifre), e Backspace. Con Tab si raggiunge l’input come qualsiasi campo di testo.
- Mentre
disablednon riceve il focus, e gli screen reader lo annunciano come disattivato.
Vedi Accessibilità per etichette, ruoli e annunci in generale.
#Note sulle piattaforme
- iOS: il campo è un campo per codici (
textContentTypeoneTimeCode), quindi la barra QuickType propone un codice da Messaggi o Mail; compare il tastierino numerico. Un campomaskedricomincia da capo quando si digita di nuovo dopo aver perso il focus, come fanno i campi sicuri di iOS. - Android:
autofillHintssmsOTPCodepermette alla tastiera e al servizio di compilazione automatica di proporre il codice di un SMS; compare la tastiera numerica. - macOS, Windows, Linux e web: digitazione e incolla vanno al campo (nel browser, con
inputmode="numeric"per la tastiera numerica del telefono), e il puntatore a I appare sopra le celle.