Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

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.

el-otp-input.tsx
el-otp-input.tsxLive
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))
}

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 value y actualízalo en onChange. Las celdas muestran el value que 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 desde onChange y onComplete.

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. Mientras masked el 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 disabled no 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 (textContentType oneTimeCode), por lo que la barra QuickType ofrece un código de Messages o Mail; aparece el teclado numérico. Un campo masked empieza 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: autofillHints smsOTPCode permite 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.