Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

OtpInput

Ein One-Time-Code-Input nimmt eine feste Anzahl von Ziffern entgegen (einen Bestätigungscode aus einer SMS oder den Code einer Authenticator-App) und zeigt sie in Zellen an. Er übernimmt das Verhalten: Ein natives Textfeld, transparent und über den Zellen liegend, erhält die Tastatur. Eingabe, Eingabemethoden, Einfügen, der Ziffernblock und das Code-Autofill des Systems (iOS’ „Von Nachrichten”, Androids SMS-Vorschläge) stammen daher von der Plattform selbst, und Screenreader finden ein einziges Textfeld. Es werden nur Ziffern angenommen, bis zu length. Die Rücktaste entfernt die jeweils letzte Ziffer, und onComplete meldet, wenn der Code vollständig ist. Das Aussehen liegt bei der App: Ein OtpInput hat keine eigenen Farben, Größen, Rahmen oder Beschriftungen. Seine Bestandteile sind die Zellen, die Gruppen, in die sie aufgeteilt sind, und ein Caret. Die App gestaltet jeden davon.

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 stammt aus nsh/base, dem nsh-base-Crate in Rust. In Rust erzeugt otp_input() eine Instanz mit einem Setter pro Prop (.length(..), .cell_style(..)), einer Listener-Methode pro Event (.on_complete(..)) und den typisierten Style-Methoden für den Input selbst.

#Der Code

  • Kontrolliert: value übergeben und in onChange setzen. Die Zellen zeigen den value, den die App übergibt, unverändert an. Ein von der App gesetzter Wert (etwa das Leeren des Codes nach einem falschen) wird nie gemeldet.
  • Unkontrolliert: defaultValue übergeben (oder nichts) und den Code aus onChange und onComplete lesen.

Was der Nutzer tut, wird aus dem Feld gelesen, wobei der Caret immer am Ende steht:

Der Nutzer Der Code
Tippt eine Ziffer Sie wird am Ende angehängt, solange der Code kürzer als length ist. Ist er voll, ändert sich nichts.
Tippt eine Ziffer in Vollbreite (0–9, etwa von der Eingabemethode für ostasiatische Sprachen) Die ASCII-Ziffer wird angehängt.
Tippt etwas anderes Nichts ändert sich; das Feld verwirft die Eingabe.
Drückt die Rücktaste Die letzte Ziffer wird entfernt.
Fügt ein, oder das System füllt einen Code automatisch aus Dessen Ziffern werden auf dieselbe Weise angehängt. Text mit length oder mehr Ziffern (123-456, der Code einer Nachricht) ersetzt den Code durch seine ersten length Ziffern.

onChange meldet jede Änderung mit dem neuen Code, und onComplete folgt darauf, wenn die Änderung die letzte Zelle gefüllt hat. Dort prüft die App den Code.

#Props

Prop Rust Typ Standard Beschreibung
length .length(..) number erforderlich Wie viele Ziffern der Code hat: eine Zelle pro Ziffer.
value .value(..) string keiner Der von der App kontrollierte Code (wird unverändert angezeigt). Ohne ihn verwaltet der Input seinen eigenen.
defaultValue .default_value(..) string keiner Der anfängliche Code, solange value nicht gesetzt ist.
masked .masked(..) boolean false Gefüllte Zellen zeigen einen Punkt statt ihrer Ziffer, und das Feld ist ein sicheres Feld: Screenreader lesen den Code nicht vor, und das System hält ihn aus seinen Vorschlägen heraus.
disabled .disabled(..) boolean false Kein Fokus und keine Bearbeitung. disabled:-Styles gelten für den Input und seine Bestandteile, und Screenreader melden ihn als abgeblendet.
groups .groups(..) number 1 In wie viele Gruppen die Zellen aufgeteilt werden, so gleichmäßig wie möglich (3 + 3 bei sechs Ziffern in zwei Gruppen). Mindestens eine, höchstens eine pro Zelle.
accessibilityLabel .accessibility_label(..) string keiner Was Screenreader für das Feld ansagen: wofür der Code dient und wie viele Ziffern er hat.
cellClass, cellStyle .cell_class(..), .cell_style(|s| ..) string, StyleProp<TextStyle> keiner Jede Zelle: ihre Größe, ihr Rahmen und ihr Text (color, fontSize, die ihre Ziffer erbt). focus: und data-filled: gestalten ihre Zustände (siehe Bestandteile).
groupClass, groupStyle .group_class(..), .group_style(|s| ..) string, StyleProp<ViewStyle> keiner Jede Gruppe von Zellen (flex-row gap-2).
caretClass, caretStyle .caret_class(..), .caret_style(|s| ..) string, StyleProp<ViewStyle> keiner Der Caret: seine Breite, Höhe und Farbe.
class, style Style-Methoden string, StyleProp<ViewStyle> keiner Der Input selbst: die Zeile (oder Spalte) um seine Gruppen.

#Events

Event Rust Payload Wann
onChange .on_change(|value: &SharedString| ..) (e: { value: string }) Der Nutzer hat den Code geändert: getippt, gelöscht, eingefügt oder automatisch ausgefüllt.
onComplete .on_complete(|e: &OtpInputCompleteEvent| ..) { value } Die Änderung des Nutzers hat die letzte Zelle gefüllt, nach dem zugehörigen onChange.
onFocus / onBlur .on_focus / .on_blur keine Der Input hat den Tastaturfokus erhalten / verloren.

#Ref-Methoden

Methode Rust Beschreibung
focus() focus() (OtpInputApi) Erhält den Tastaturfokus und zeigt die Software-Tastatur an, wie bei einem Tippen auf den Input.

#Bestandteile

Bestandteil Enthalten in Gestaltet mit
Der Input class, style
Eine Gruppe dem Input, eine pro Gruppe groupClass, groupStyle; disabled: folgt dem Input
Eine Zelle ihrer Gruppe, eine pro Ziffer cellClass, cellStyle; focus: auf der Zelle, die die nächste Ziffer aufnimmt, solange der Input den Fokus hat, data-filled: auf jeder Zelle, die eine Ziffer enthält, disabled: folgt dem Input
Die Ziffer einer gefüllten Zelle den Textstyles der Zelle (text-xl, font-semibold); • solange masked
Der Caret der leeren Zelle, die die nächste Ziffer aufnimmt, solange der Input den Fokus hat caretClass, caretStyle

Die Zelle, die die nächste Ziffer aufnimmt, ist die nach der letzten Ziffer, oder die letzte Zelle, sobald der Code vollständig ist. Der Caret blinkt wie bei einem Textfeld (eine halbe Sekunde an, eine halbe Sekunde aus, und nach jeder Bearbeitung kurz dauerhaft sichtbar), und zwar über seine Opacity, nativ, während er seinen Platz im Layout behält. Bei reduzierter Bewegung bleibt er ruhig. Ein Tippen irgendwo auf den Input fokussiert ihn: Das Feld liegt über den Zellen.

Eine Zelle, die eine Ziffer enthält, ob maskiert oder nicht, hat das Attribut data-filled. So gestalten data-filled:-Klassen die Zellen, die der Nutzer gefüllt hat (ein stärkerer Rahmen, eine Füllung: data-filled:border-foreground data-filled:bg-muted), wie es shadcn/ui bei seinen Zuständen schreibt. Mit group in cellClass gestalten group-data-filled:-Klassen, was sich innerhalb einer gefüllten Zelle befindet, nämlich ihre Ziffer. Die Zellen ändern ihre Klassen, nie ihren Inhalt, sodass ein Tastendruck eine einzelne Zelle neu gestaltet und nichts neu zeichnet.

#Barrierefreiheit

  • Screenreader finden ein einziges Textfeld, benannt durch accessibilityLabel (sagen Sie, wofür der Code dient und wie lang er ist: „Bestätigungscode, 6 Ziffern”), mit dem Code als Wert. Die Zellen sind für sie verborgen. Solange masked, ist das Feld ein sicheres Feld, sodass der Code nicht vorgelesen wird.
  • Die Tastatur ist die der Plattform: der Ziffernblock auf Smartphones, auf Desktops jede Tastatur (es werden nur Ziffern angenommen) und die Rücktaste. Mit Tab erreicht man den Input wie jedes Textfeld.
  • Solange disabled, erhält er keinen Fokus, und Screenreader melden ihn als abgeblendet.

Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.

#Hinweise zu Plattformen

  • iOS: Das Feld ist ein Code-Feld (textContentType oneTimeCode), sodass die QuickType-Leiste einen Code aus Nachrichten oder Mail anbietet. Der Ziffernblock erscheint. Ein masked-Feld beginnt von vorn, wenn erneut hineingetippt wird, nachdem es den Fokus verloren hat, wie es bei den sicheren Feldern von iOS der Fall ist.
  • Android: autofillHints smsOTPCode lässt die Tastatur und den Autofill-Dienst den Code einer SMS anbieten. Die Zifferntastatur erscheint.
  • macOS, Windows, Linux und das Web: Tippen und Einfügen gehen an das Feld (im Browser mit inputmode="numeric" für die Zifferntastatur eines Smartphones), und über den Zellen wird der I-Beam-Zeiger angezeigt.