Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

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.

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 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 value e aggiornalo in onChange. Le celle mostrano il value passato 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 da onChange e onComplete.

Ciò che fa l’utente viene letto dal campo, con il cursore sempre alla fine:

L’utenteIl codice
Digita una cifraViene 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 carattereNon cambia nulla; il campo lo scarta.
Preme BackspaceL’ultima cifra viene rimossa.
Incolla, o il sistema compila automaticamente un codiceLe 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

PropRustTipoPredefinitoDescrizione
length.length(..)numberobbligatorioQuante cifre ha il codice: una cella ciascuna.
value.value(..)stringnessunoIl codice, controllato dall’app (mostrato così com’è). Senza di esso l’input mantiene un proprio codice.
defaultValue.default_value(..)stringnessunoIl codice iniziale, finché value non è impostato.
masked.masked(..)booleanfalseLe 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(..)booleanfalseNessun 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(..)number1In 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(..)stringnessunoCiò 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>nessunoOgni 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>nessunoOgni gruppo di celle (flex-row gap-2).
caretClass, caretStyle.caret_class(..), .caret_style(|s| ..)string, StyleProp<ViewStyle>nessunoIl cursore: larghezza, altezza e colore.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoL’input stesso: la riga (o colonna) attorno ai suoi gruppi.

#Eventi

EventoRustPayloadQuando
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_blurnessunoL’input ha ricevuto / perso il focus della tastiera.

#Metodi del ref

MetodoRustDescrizione
focus()focus() (OtpInputApi)Riceve il focus della tastiera e mostra la tastiera virtuale, come fa una pressione sull’input.

#Parti

ParteDentroStilizzata con
L’inputclass, style
Un gruppol’input, uno per gruppogroupClass, groupStyle; disabled: segue l’input
Una cellail suo gruppo, una per cifracellClass, 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 cifrauna cella riempitagli stili di testo della cella (text-xl, font-semibold); • mentre masked
Il cursorela cella vuota che riceve la cifra successiva, mentre l’input ha il focuscaretClass, 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. Mentre masked il 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 disabled non 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 (textContentType oneTimeCode), quindi la barra QuickType propone un codice da Messaggi o Mail; compare il tastierino numerico. Un campo masked ricomincia da capo quando si digita di nuovo dopo aver perso il focus, come fanno i campi sicuri di iOS.
  • Android: autofillHints smsOTPCode permette 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.