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

Éléments

NumberInput

Un champ numérique contient un nombre que l’utilisateur saisit ou fait varier par pas : une quantité, un prix, une taille. Il gère lui-même son comportement : son champ accepte un nombre, ou le texte en cours de saisie d’un nombre (-, 1.), et refuse tout le reste, y compris les chiffres et les signes d’autres claviers ; ses boutons, les touches Haut et Bas, ainsi que l’incrémentation et la décrémentation d’un lecteur d’écran, font varier le nombre de step en conservant ses décimales, et s’arrêtent à min et max ; quand on quitte le champ, un nombre saisi est ramené dans ces bornes. L’apparence appartient à l’app : un NumberInput n’a ni couleurs, ni tailles, ni bordures, ni icônes, ni chaînes de texte qui lui soient propres. Il se compose du bouton de décrémentation, du champ placé entre le prefix et le suffix de l’app, et du bouton d’incrémentation (ou du champ entre eux et d’une colonne portant les deux boutons, controlsRight) ; l’app stylise chaque partie et remplit les boutons.

el-number-input.tsx
el-number-input.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { NumberInput, Switch } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";

// The parts: geometry and states in classes, colors from the theme's
// tokens. The input is one recessed well (a stronger border under the
// pointer, the focus ring while its field edits: `focus-within:`); the
// buttons light up under the pointer and while pressed (a transition the
// reduced-motion setting stills) and fade where a step would do nothing (at
// `min` / `max`) or while the input is disabled, without their hover. A
// pointer gets the compact sizes. The price stacks its buttons on the right
// with its currency beside the field.
const frame = [
  desktop ? "h-8 rounded-lg" : "h-11 rounded-xl",
  "flex-row items-stretch overflow-hidden border border-input bg-field",
  "enabled:hover:border-faint focus-within:outline-2 focus-within:outline-info disabled:opacity-50",
].join(" ");
const stepButton = [
  desktop ? "w-8" : "w-11",
  "items-center justify-center transition-colors duration-100 motion-reduce:transition-none",
  "enabled:hover:bg-accent active:bg-track disabled:opacity-35",
].join(" ");
const stackedButton = "flex-1 items-center justify-center transition-colors duration-100 motion-reduce:transition-none enabled:hover:bg-accent active:bg-track disabled:opacity-35";
const field = () => ({
  flex: 1,
  minWidth: 0,
  paddingHorizontal: desktop ? 8 : 10,
  fontSize: desktop ? 14 : 16,
  color: ui().text,
  placeholderColor: ui().faint,
  fontVariant: ["tabular-nums" as const],
});

/** A minus or a plus drawn with two bars in the text color. */
function Glyph(props: { plus?: boolean; small?: boolean }) {
  const length = props.small ? 8 : desktop ? 10 : 12;
  const bar = () => ({ position: "absolute" as const, borderRadius: 1, backgroundColor: ui().text });
  return (
    <View style={{ pointerEvents: "none", width: length, height: length }}>
      <View style={{ ...bar(), left: 0, right: 0, top: length / 2 - 0.75, height: 1.5 }} />
      {props.plus ? <View style={{ ...bar(), top: 0, bottom: 0, left: length / 2 - 0.75, width: 1.5 }} /> : null}
    </View>
  );
}

function Row(props: { label: string; detail: string; children: JSX.Element }) {
  return (
    <View class={`flex-row items-center gap-3 ${desktop ? "px-3 py-2.5" : "px-4 py-3"}`}>
      <View style={{ flex: 1, gap: 2 }}>
        <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
      </View>
      <View style={{ width: desktop ? 148 : 168 }}>{props.children}</View>
    </View>
  );
}

const show = (v: number | null) => (v === null ? "null" : `${v}`);

function App() {
  const tickets = signal<number | null>(2);
  const price = signal<number | null>(19.95);
  const temperature = signal<number | null>(null);
  const disabled = signal(false);
  const events = signal<string[]>([]);
  const change = (name: string, value: { set: (v: number | null) => void }) => ({ value: next }: { value: number | null }) => {
    value.set(next);
    events.set([`${name} → ${show(next)}`, ...events()].slice(0, 5));
  };
  const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
  const sides = () => ({
    disabled: disabled(),
    class: frame,
    inputStyle: { ...field(), textAlign: "center" as const },
    decrementButtonClass: `${stepButton} border-r border-line`,
    incrementButtonClass: `${stepButton} border-l border-line`,
    decrementButton: <Glyph />,
    incrementButton: <Glyph plus />,
  });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>number-input</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Type a number, click − and +, or press ↑ ↓ (Page Up / Down: ten steps) in the field" : "Type a number, or tap − and +"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value · disabled">
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                tickets {show(tickets())} · price {show(price())} · °C {show(temperature())}
                {disabled() ? " (disabled)" : ""}
              </Text>
              <Switch
                checked={disabled()}
                onChange={({ checked }) => disabled.set(checked)}
                class={`-mx-2 flex-row-reverse items-center justify-between gap-3 rounded-lg px-2 ${desktop ? "py-1" : "py-2"} active:opacity-70`}
                hoverStyle={hoverFill()}
                focusStyle={focusRing()}
                trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
                trackStyle={disabled() ? ui().accentFill : ui().inset}
                thumbClass={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"}
                thumbStyle={disabled() ? { backgroundColor: ui().onAccent } : ui().raised}
              >
                <Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Disabled</Text>
              </Switch>
            </Panel>
            <Log title="onChange({ value })" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Row label="Tickets" detail="1 to 10">
            <NumberInput
              value={tickets()}
              onChange={change("tickets", tickets)}
              min={1}
              max={10}
              accessibilityLabel="Tickets"
              {...sides()}
            />
          </Row>
          {line()}
          <Row label="Price" detail="Steps of 0.05">
            <NumberInput
              value={price()}
              onChange={change("price", price)}
              min={0}
              step={0.05}
              accessibilityLabel="Price"
              controlsRight
              disabled={disabled()}
              class={frame}
              inputStyle={field()}
              controlsClass={`${desktop ? "w-6" : "w-9"} border-l border-line`}
              incrementButtonClass={`${stackedButton} border-b border-line`}
              decrementButtonClass={stackedButton}
              incrementButton={<Glyph plus small />}
              decrementButton={<Glyph small />}
              suffix={<Text style={{ alignSelf: "center", paddingRight: 8, fontSize: size.detail, color: ui().muted }}>USD</Text>}
            />
          </Row>
          {line()}
          <Row label="Temperature" detail="−20 to 40 °C, halves">
            <NumberInput
              value={temperature()}
              onChange={change("temperature", temperature)}
              min={-20}
              max={40}
              step={0.5}
              placeholder="Not set"
              accessibilityLabel="Temperature"
              {...sides()}
            />
          </Row>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{number_input, theme};
use nsh_core::prelude::*;

fn tickets(count: Signal<Option<f64>>) -> impl IntoElement {
    number_input()
        .value(move || count.get())
        .min(1.0)
        .max(10.0)
        .accessibility_label("Tickets")
        .on_change(move |next: &Option<f64>| count.set(*next))
        .h(px(32.0))
        .flex_row()
        .rounded(px(8.0))
        .border_1()
        .border_color(move || theme().input)
        .input_style(|s| s.flex_1().text_center().text_color(move || theme().foreground))
        .decrement_button_style(|s| s.w(px(32.0)).items_center().justify_center())
        .increment_button_style(|s| s.w(px(32.0)).items_center().justify_center())
        .decrement_button(text("−"))
        .increment_button(text("+"))
}

NumberInput provient de nsh/base, le crate nsh-base en Rust. En Rust, number_input() en crée un avec un setter par prop (.value(..), .input_style(..)), une méthode d’écoute par événement (.on_change(..)), les méthodes de style typées pour le champ numérique lui-même, et .prefix(..) / .suffix(..) pour ce qui entoure le champ. Sa valeur est un Option<f64>.

#Valeur

La valeur est un nombre, ou null pour un champ vide. L’app la possède avec value et définit celle que onChange signale, comme pour tout contrôle de nsh/base ; ou bien elle laisse la valeur au champ numérique avec defaultValue, et reçoit quand même les changements.

Le texte du champ et la valeur sont distingués là où il le faut, afin que l’utilisateur puisse taper librement :

  • onChange signale le nombre que le texte représente chaque fois que ce nombre change : null tant que le texte ne représente aucun nombre (vide, -, .) ; taper 1. après 1, ou 1.50 après 1.5, ne signale rien.
  • Le texte reste tel que l’utilisateur le saisit tant qu’il représente la valeur détenue par le champ numérique, de sorte qu’un champ contrôlé qui renvoie chaque valeur signalée conserve 1. et 2.50. Une valeur définie par l’app et que le champ numérique n’a pas signalée s’affiche dans le champ sous la forme du texte le plus court qui la représente (0.3, 12).
  • Pendant la saisie, un nombre peut être en dehors de min et max (1 en route vers 15 avec min={10}), et il est signalé tel que saisi. Quand on quitte le champ, ou avec Retour / Entrée, il est ramené dans la plage et cette valeur est signalée ; un texte qui ne représente aucun nombre reste tel quel.
  • Un pas (un bouton, une touche, une action de lecteur d’écran) demande la valeur suivante avec onChange ; avec une valeur contrôlée, le champ ne bouge que lorsque l’app la renvoie, en conservant les décimales du pas (99.5 augmenté jusqu’à max={100} affiche 100.0), de sorte qu’une app qui refuse un pas laisse le champ où il est. Une modification refusée par l’app reste dans le champ pendant qu’il est en cours d’édition, et disparaît quand on le quitte.
const price = signal<number | null>(19.95);

<NumberInput
  value={price()}
  onChange={({ value }) => price.set(value)}
  onSubmit={({ value }) => save(value)}
  min={0}
  step={0.05}
  placeholder="Price"
  accessibilityLabel="Price"
/>;

Le champ accepte :

  • Des chiffres, un seul point décimal et un signe en premier (+ ou -) ; une lettre, un deuxième point ou signe, ou un signe ailleurs est refusé, et le champ revient à son texte précédent.
  • Les chiffres, signes et point pleine largeur, le signe moins (U+2212) et le point final idéographique (。), lus comme leurs formes ASCII, tels que les saisissent les méthodes de saisie chinoise et japonaise.
  • Une virgule comme point décimal tant que le texte n’a pas de point (3,25 vaut 3.25), comme la touche décimale des claviers de nombreuses langues la saisit.

#Props

PropRustTypeDéfautDescription
value.value(..)number | nullaucunLa valeur (contrôlée) : un nombre, ou null pour un champ vide. L’app définit celle que onChange signale.
defaultValue.default_value(..)number | nullaucunLa valeur initiale tant que value n’est pas définie ; le champ numérique conserve alors sa propre valeur.
min.min(..)numberaucunLa plus petite valeur : les pas s’arrêtent à elle, une valeur saisie en dessous est ramenée à elle quand on quitte le champ.
max.max(..)numberaucunLa plus grande valeur, comme min par le haut ; une valeur inférieure à min équivaut à min.
step.step(..)number1L’amplitude d’un pas (Page précédente / Page suivante : dix pas) ; une valeur qui n’est pas un nombre positif équivaut à 1.
disabled.disabled(..)booleanfalseNi saisie, ni pas, ni focus ; les classes disabled: s’appliquent au champ numérique et à ses parties.
readonly.readonly(..)booleanfalseLe texte peut être sélectionné et copié mais pas modifié, ni par la saisie ni par les pas ; les boutons sont désactivés.
placeholder.placeholder(..)stringaucunAffiché tant que le champ est vide.
accessibilityLabel.accessibility_label(..)stringaucunCe que les lecteurs d’écran annoncent pour le champ, quand aucun libellé visible ne le nomme.
controlsRight.controls_right(..)booleanfalseLes deux boutons dans une colonne à droite du champ, l’incrémentation au-dessus de la décrémentation. Lu à la création du champ numérique.
inputClass, inputStyle.input_style(|s| ..)string, StyleProp<InputStyle>aucunLe champ : sa boîte et son texte (color, fontSize, textAlign, placeholderColor, caretColor).
decrementButtonClass, decrementButtonStyle.decrement_button_style(|s| ..)string, StyleProp<ViewStyle>aucunLe bouton de décrémentation.
incrementButtonClass, incrementButtonStyle.increment_button_style(|s| ..)string, StyleProp<ViewStyle>aucunLe bouton d’incrémentation.
decrementButton, incrementButton.decrement_button(..), .increment_button(..)JSX.ElementaucunLe contenu des boutons : un signe, une icône.
controlsClass, controlsStyle.controls_style(|s| ..)string, StyleProp<ViewStyle>aucunLa colonne qui contient les deux boutons avec controlsRight.
prefix.prefix(..)JSX.ElementaucunCe qui précède le champ, après le bouton de gauche : un signe monétaire, une icône.
suffix.suffix(..)JSX.ElementaucunCe qui suit le champ, avant les boutons de droite : une unité, une devise.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLe champ numérique lui-même : la ligne qui entoure le champ et les boutons.

#Événements

ÉvénementRustDonnéesQuand
onChange.on_change(|value: &Option<f64>| ..){ value: number | null }Le nombre que représente le champ a changé (saisie, pas, incrémentation ou décrémentation d’un lecteur d’écran, champ quitté avec une valeur hors plage).
onSubmit.on_submit(|value: &Option<f64>| ..){ value: number | null }Retour / Entrée dans le champ : la valeur, ramenée dans min et max.
onFocus.on_focus(..)aucuneLe champ a reçu le focus clavier.
onBlur.on_blur(..)aucuneLe champ a perdu le focus clavier (sa valeur d’abord ramenée dans min et max).

#Parties

PartieEmplacementStylée avec
Le champ numériqueclass, style
Le bouton de décrémentationdans le champ numérique, en premier (avec controlsRight : dans la colonne de contrôles, en second)decrementButtonClass, decrementButtonStyle ; son contenu decrementButton
Le champdans le champ numériqueinputClass, inputStyle
Le préfixedans le champ numérique, avant le champses propres éléments (prefix)
Le suffixedans le champ numérique, après le champses propres éléments (suffix)
Le bouton d’incrémentationdans le champ numérique, en dernier (avec controlsRight : dans la colonne de contrôles, en premier)incrementButtonClass, incrementButtonStyle ; son contenu incrementButton
La colonne de contrôlesdans le champ numérique, en dernier, avec controlsRightcontrolsClass, controlsStyle

Le champ numérique est une ligne dont l’app gère la mise en page : flex-row sur class, et flex-1 sur le champ pour lui donner la place que laissent les boutons. États que montrent les parties :

  • Le champ numérique : focus-within: pendant que son champ est en cours d’édition (un anneau autour de l’ensemble), disabled:.
  • Le champ : focus: pendant son édition, disabled:.
  • Un bouton : hover: sous le pointeur, active: pendant l’appui, disabled: lorsque le champ numérique est désactivé ou en lecture seule et là où un pas ne ferait rien (la décrémentation à min, l’incrémentation à max). Placez le survol derrière enabled: (enabled:hover:bg-accent) pour qu’un bouton désactivé ne s’illumine pas.

Le mouvement des états est au choix de l’app (transition-colors), et motion-reduce: le fige pour les personnes qui demandent moins de mouvement.

#Clavier

Pendant l’édition du champ, le champ numérique prend en charge les touches du spin button de WAI-ARIA, qui se répètent lorsqu’on les maintient :

ToucheAction
HautUn pas vers le haut.
BasUn pas vers le bas.
Page précédente, Page suivanteDix pas vers le haut ou vers le bas.

Début et Fin restent ceux du champ : ils déplacent le curseur dans le texte. Les boutons ne sont pas des arrêts du focus clavier : Tab passe du champ à ce qui suit le champ numérique, et un appui sur un bouton laisse le focus dans le champ (sur les ordinateurs, un appui déplace le focus vers le champ s’il était ailleurs ; sur les écrans tactiles, il le laisse en place, afin qu’aucun clavier n’apparaisse).

#Accessibilité

  • Le champ est un spin button ("spinbutton") avec son libellé, sa valeur (le nombre que représente le texte, entre min et max) et son texte.
  • Les actions d’incrémentation et de décrémentation d’un lecteur d’écran (le balayage vers le haut et vers le bas de VoiceOver) le font varier par pas, comme le font Haut et Bas.
  • Les boutons sont masqués aux lecteurs d’écran, comme ceux du champ numérique d’un navigateur : l’incrémentation et la décrémentation propres au champ le font varier par pas.
  • Lorsque disabled, les lecteurs d’écran indiquent que le champ est grisé.

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

#Notes par plateforme

  • Le champ est le champ de texte de la plateforme (Input) : le clavier, le curseur, la sélection, le copier-coller et les méthodes de saisie sont ceux du système. Les téléphones affichent un clavier décimal, avec un signe lorsque min est inférieur à 0 ou non défini.
  • Chaque plateforme dessine ce que l’app stylise. Haut / Bas et Page précédente / Page suivante parviennent au champ numérique depuis un clavier physique sur toutes les plateformes.