Elementos
OtpInput
A one-time-code input toma un número fijo de dígitos (un código de verificación de un mensaje de texto, el código de un autenticador) y los muestra en celdas. Se encarga del comportamiento: un campo de texto nativo, transparente y colocado sobre las celdas, recibe el teclado, de modo que la escritura, los métodos de entrada, el pegado, el teclado numérico y el autocompletado de códigos del sistema (“From Messages” de iOS, las sugerencias de SMS de Android) son los propios de la plataforma, y los lectores de pantalla encuentran un solo campo de texto. Solo entran dígitos, hasta length; Retroceso elimina el último, y onComplete indica cuándo el código está completo. El aspecto es el de la app: un OtpInput no tiene colores, tamaños, bordes ni etiqueta propios. Sus partes son las celdas, los grupos en que se dividen y un cursor, y la app da estilo a cada una.
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 de nsh/base, el crate nsh-base de Rust. En Rust, otp_input() crea uno con un setter por prop (.length(..), .cell_style(..)), un método de listener por evento (.on_complete(..)) y los métodos de estilo tipados para el propio input.
#El código
- Controlado: pasa
valuey actualízalo enonChange. Las celdas muestran elvalueque pasa la app, tal cual; un valor que establece la app (por ejemplo, borrar el código tras uno incorrecto) nunca se notifica. - No controlado: pasa
defaultValue(o nada), y lee el código desdeonChangeyonComplete.
Lo que hace el usuario se lee desde el campo, con el cursor siempre al final:
| El usuario | El código |
|---|---|
| Escribe un dígito | Se añade al final mientras el código sea más corto que length; una vez completo, no cambia nada. |
Escribe un dígito de ancho completo (0–9, de un método de entrada de Asia oriental) |
Se añade el dígito ASCII. |
| Escribe cualquier otra cosa | No cambia nada; el campo lo descarta. |
| Pulsa Retroceso | Se elimina el último dígito. |
| Pega, o el sistema autocompleta un código | Sus dígitos se añaden de la misma manera; un texto con length dígitos o más (123-456, el código de un mensaje) reemplaza el código por sus primeros length dígitos. |
onChange notifica cada cambio con el nuevo código, y onComplete lo sigue cuando el cambio llenó la última celda, de modo que la app verifica el código en ese momento.
#Props
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
length |
.length(..) |
number |
obligatorio | Cuántos dígitos tiene el código: una celda por cada uno. |
value |
.value(..) |
string |
ninguno | El código, controlado por la app (se muestra tal cual). Sin él, el input mantiene el suyo. |
defaultValue |
.default_value(..) |
string |
ninguno | El código al principio, mientras value no está establecido. |
masked |
.masked(..) |
boolean |
false |
Las celdas llenas muestran un punto en lugar de su dígito, y el campo es seguro: los lectores de pantalla no leen el código y el sistema lo mantiene fuera de sus sugerencias. |
disabled |
.disabled(..) |
boolean |
false |
Sin foco ni edición; los estilos disabled: se aplican al input y a sus partes, y los lectores de pantalla indican que está atenuado. |
groups |
.groups(..) |
number |
1 |
En cuántos grupos se dividen las celdas, lo más uniformemente posible (3 + 3 para seis dígitos en dos); al menos uno, como máximo uno por celda. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla para el campo: para qué sirve el código y cuántos dígitos tiene. |
cellClass, cellStyle |
.cell_class(..), .cell_style(|s| ..) |
string, StyleProp<TextStyle> |
ninguno | Cada celda: su tamaño, borde y texto (color, fontSize, que su dígito hereda); focus: y data-filled: dan estilo a sus estados (ver Partes). |
groupClass, groupStyle |
.group_class(..), .group_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | Cada grupo de celdas (flex-row gap-2). |
caretClass, caretStyle |
.caret_class(..), .caret_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | El cursor: su ancho, alto y color. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El propio input: la fila (o columna) que rodea a sus grupos. |
#Eventos
| Evento | Rust | Payload | Cuándo |
|---|---|---|---|
onChange |
.on_change(|value: &SharedString| ..) |
(e: { value: string }) |
El usuario cambió el código: escribió, borró, pegó o autocompletó. |
onComplete |
.on_complete(|e: &OtpInputCompleteEvent| ..) |
{ value } |
El cambio del usuario llenó la última celda, después de su onChange. |
onFocus / onBlur |
.on_focus / .on_blur |
ninguno | El input recibió / perdió el foco del teclado. |
#Métodos de ref
| Método | Rust | Descripción |
|---|---|---|
focus() |
focus() (OtpInputApi) |
Toma el foco del teclado y muestra el teclado en pantalla, como lo hace una pulsación sobre el input. |
#Partes
| Parte | Dentro de | Se estiliza con |
|---|---|---|
| El input | class, style |
|
| Un grupo | el input, uno por grupo | groupClass, groupStyle; disabled: sigue al input |
| Una celda | su grupo, una por dígito | cellClass, cellStyle; focus: en la celda que recibe el siguiente dígito mientras el input tiene el foco, data-filled: en cada celda que contiene un dígito, disabled: sigue al input |
| El dígito | una celda llena | los estilos de texto de la celda (text-xl, font-semibold); • mientras masked |
| El cursor | la celda vacía que recibe el siguiente dígito, mientras el input tiene el foco | caretClass, caretStyle |
La celda que recibe el siguiente dígito es la que sigue al último dígito, o la última celda una vez que el código está completo. El cursor parpadea como el de un campo de texto (medio segundo encendido, medio apagado, y fijo durante un momento después de cada edición), mediante su opacidad, de forma nativa, y conserva su lugar en el layout; con movimiento reducido permanece quieto. Una pulsación en cualquier parte del input le da el foco: el campo está sobre las celdas.
Una celda que contiene un dígito, enmascarado o no, tiene el atributo data-filled, de modo que las clases data-filled: dan estilo a las celdas que el usuario ha llenado (un borde más marcado, un relleno: data-filled:border-foreground data-filled:bg-muted), tal como lo escribe shadcn/ui con sus estados. Con group en cellClass, las clases group-data-filled: dan estilo a lo que hay dentro de una celda llena, su dígito. Las celdas cambian sus clases, nunca su contenido, así que una tecla restaura el estilo de una sola celda y no vuelve a dibujar nada.
#Accesibilidad
- Los lectores de pantalla encuentran un solo campo de texto, nombrado por
accessibilityLabel(indica para qué sirve el código y su longitud: “Código de verificación, 6 dígitos”) con el código como su valor; las celdas quedan ocultas para ellos. Mientrasmaskedel campo es seguro, así que el código no se lee en voz alta. - El teclado es el de la plataforma: el teclado numérico en teléfonos, cualquier teclado en escritorio (solo entran dígitos) y Retroceso. Tab llega al input como a cualquier campo de texto.
- Mientras
disabledno recibe foco, y los lectores de pantalla indican que está atenuado.
Consulta Accesibilidad para etiquetas, roles y anuncios en general.
#Notas de plataforma
- iOS: el campo es un campo de código (
textContentTypeoneTimeCode), por lo que la barra QuickType ofrece un código de Messages o Mail; aparece el teclado numérico. Un campomaskedempieza de nuevo cuando se vuelve a escribir en él después de haber perdido el foco, como lo hacen los campos seguros de iOS. - Android:
autofillHintssmsOTPCodepermite que el teclado y el servicio de autocompletado ofrezcan el código de un mensaje de texto; aparece el teclado numérico. - macOS, Windows, Linux y la web: la escritura y el pegado van al campo (en el navegador, con
inputmode="numeric"para el teclado numérico de un teléfono), y el puntero en forma de I se muestra sobre las celdas.