Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

OtpInput

Un champ de code à usage unique accepte un nombre fixe de chiffres (un code de vérification reçu par SMS, le code d’un authentificateur) et les affiche dans des cellules. Il gère le comportement : un seul champ de texte natif, transparent et placé au-dessus des cellules, reçoit le clavier. La saisie, les méthodes de saisie, le collage, le pavé numérique et le remplissage automatique de code du système (« Depuis Messages » sur iOS, les suggestions de SMS sur Android) sont donc ceux de la plateforme, et les lecteurs d’écran ne trouvent qu’un seul champ de texte. Seuls les chiffres sont acceptés, jusqu’à length ; Retour arrière supprime le dernier, et onComplete indique quand le code est complet. L’apparence est celle de l’app : un OtpInput n’a ni couleurs, ni tailles, ni bordures, ni libellé propres. Ses parties sont les cellules, les groupes dans lesquels elles sont réparties et un curseur, et l’app style chacune d’elles.

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 provient de nsh/base, le crate nsh-base en Rust. En Rust, otp_input() en crée un avec un setter par prop (.length(..), .cell_style(..)), une méthode d’écoute par événement (.on_complete(..)) et les méthodes de style typées pour le champ lui-même.

#Le code

  • Contrôlé : passez value et mettez-le à jour dans onChange. Les cellules affichent le value transmis par l’app, tel quel ; une valeur définie par l’app (effacer le code après une erreur) n’est jamais signalée.
  • Non contrôlé : passez defaultValue (ou rien), et lisez le code depuis onChange et onComplete.

Ce que fait l’utilisateur est lu depuis le champ, le curseur étant toujours à la fin :

L’utilisateurLe code
Saisit un chiffreIl est ajouté à la fin tant que le code est plus court que length ; une fois le code complet, rien ne change.
Saisit un chiffre pleine chasse (0–9, celui d’une méthode de saisie d’Asie de l’Est)Le chiffre ASCII est ajouté.
Saisit autre choseRien ne change ; le champ l’ignore.
Appuie sur Retour arrièreLe dernier chiffre est supprimé.
Colle un texte, ou le système remplit automatiquement un codeSes chiffres sont ajoutés de la même manière ; un texte contenant length chiffres ou plus (123-456, le code d’un message) remplace le code par ses length premiers chiffres.

onChange signale chaque changement avec le nouveau code, et onComplete le suit lorsque le changement a rempli la dernière cellule ; c’est là que l’app vérifie le code.

#Props

PropRustTypeDéfautDescription
length.length(..)numberrequisLe nombre de chiffres du code : une cellule chacun.
value.value(..)stringaucunLe code, contrôlé par l’app (affiché tel quel). Sans cette prop, le champ conserve son propre code.
defaultValue.default_value(..)stringaucunLe code initial, tant que value n’est pas défini.
masked.masked(..)booleanfalseLes cellules remplies affichent une puce au lieu de leur chiffre, et le champ est un champ sécurisé : les lecteurs d’écran ne lisent pas le code, et le système l’exclut de ses suggestions.
disabled.disabled(..)booleanfalseNi focus ni édition ; les styles disabled: s’appliquent au champ et à ses parties, et les lecteurs d’écran indiquent qu’il est grisé.
groups.groups(..)number1Le nombre de groupes en lesquels les cellules sont réparties, aussi équitablement que possible (3 + 3 pour six chiffres en deux) ; au moins un, au plus un par cellule.
accessibilityLabel.accessibility_label(..)stringaucunCe que les lecteurs d’écran annoncent pour le champ : à quoi sert le code et combien de chiffres il comporte.
cellClass, cellStyle.cell_class(..), .cell_style(|s| ..)string, StyleProp<TextStyle>aucunChaque cellule : sa taille, sa bordure et son texte (color, fontSize, que son chiffre hérite) ; focus: et data-filled: stylent ses états (voir Parties).
groupClass, groupStyle.group_class(..), .group_style(|s| ..)string, StyleProp<ViewStyle>aucunChaque groupe de cellules (flex-row gap-2).
caretClass, caretStyle.caret_class(..), .caret_style(|s| ..)string, StyleProp<ViewStyle>aucunLe curseur : sa largeur, sa hauteur et sa couleur.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLe champ lui-même : la ligne (ou la colonne) autour de ses groupes.

#Événements

ÉvénementRustDonnéesQuand
onChange.on_change(|value: &SharedString| ..)(e: { value: string })L’utilisateur a modifié le code : saisie, suppression, collage ou remplissage automatique.
onComplete.on_complete(|e: &OtpInputCompleteEvent| ..){ value }La modification de l’utilisateur a rempli la dernière cellule, après son onChange.
onFocus / onBlur.on_focus / .on_bluraucuneLe champ a pris / perdu le focus du clavier.

#Méthodes de ref

MéthodeRustDescription
focus()focus() (OtpInputApi)Prend le focus du clavier et affiche le clavier logiciel, comme un appui sur le champ.

#Parties

PartieContenue dansStylée avec
Le champclass, style
Un groupele champ, un par groupegroupClass, groupStyle ; disabled: suit le champ
Une celluleson groupe, une par chiffrecellClass, cellStyle ; focus: sur la cellule qui recevra le prochain chiffre tant que le champ a le focus, data-filled: sur chaque cellule qui contient un chiffre, disabled: suit le champ
Le chiffreune cellule remplieles styles de texte de la cellule (text-xl, font-semibold) ; • tant que masked
Le curseurla cellule vide qui recevra le prochain chiffre, tant que le champ a le focuscaretClass, caretStyle

La cellule qui reçoit le prochain chiffre est celle qui suit le dernier chiffre, ou la dernière cellule une fois le code complet. Le curseur clignote comme celui d’un champ de texte (une demi-seconde allumé, une demi-seconde éteint, et fixe un instant après chaque modification), par son opacité, de façon native, tout en gardant sa place dans la mise en page ; avec la réduction des animations, il reste immobile. Un appui n’importe où sur le champ lui donne le focus : le champ de texte est placé au-dessus des cellules.

Une cellule qui contient un chiffre, masqué ou non, porte l’attribut data-filled, de sorte que les classes data-filled: stylent les cellules que l’utilisateur a remplies (une bordure plus marquée, un fond : data-filled:border-foreground data-filled:bg-muted), à la manière dont shadcn/ui écrit ses états. Avec group dans cellClass, les classes group-data-filled: stylent l’intérieur d’une cellule remplie, c’est-à-dire son chiffre. Les cellules changent de classes, jamais de contenu, de sorte qu’une touche restyle une seule cellule et ne redessine rien d’autre.

#Accessibilité

  • Les lecteurs d’écran trouvent un seul champ de texte, nommé par accessibilityLabel (indiquez à quoi sert le code et sa longueur : « Code de vérification, 6 chiffres ») avec le code comme valeur ; les cellules leur sont masquées. Tant que masked, le champ est sécurisé, donc le code n’est pas lu à voix haute.
  • Le clavier est celui de la plateforme : le pavé numérique sur téléphone, n’importe quel clavier sur ordinateur (seuls les chiffres sont acceptés), et Retour arrière. La touche Tab atteint le champ comme n’importe quel champ de texte.
  • Tant que disabled, il ne prend pas le focus, et les lecteurs d’écran indiquent qu’il est grisé.

Voir Accessibilité pour les libellés, les rôles et les annonces en général.

#Notes de plateforme

  • iOS : le champ est un champ de code (textContentType oneTimeCode), de sorte que la barre QuickType propose un code provenant de Messages ou de Mail ; le pavé numérique s’affiche. Un champ masked repart de zéro lorsqu’on y saisit à nouveau du texte après qu’il a perdu le focus, comme les champs sécurisés d’iOS.
  • Android : autofillHints smsOTPCode permet au clavier et au service de remplissage automatique de proposer le code d’un SMS ; le clavier numérique s’affiche.
  • macOS, Windows, Linux et le web : la saisie et le collage vont au champ (dans le navigateur, avec inputmode="numeric" pour le clavier numérique d’un téléphone), et le pointeur en I s’affiche au-dessus des cellules.