É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.
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("+"))
}import { mount, signal, Text } from "nsh";
import { NumberInput } from "nsh/base";
function App() {
const tickets = signal<number | null>(2);
return (
<NumberInput
value={tickets()}
onChange={({ value }) => tickets.set(value)}
min={1}
max={10}
accessibilityLabel="Tickets"
class="h-8 flex-row overflow-hidden rounded-lg border border-input bg-field focus-within:outline-2 focus-within:outline-ring"
inputClass="flex-1 text-center text-foreground"
decrementButtonClass="w-8 items-center justify-center enabled:hover:bg-accent active:bg-muted disabled:opacity-40"
incrementButtonClass="w-8 items-center justify-center enabled:hover:bg-accent active:bg-muted disabled:opacity-40"
decrementButton={<Text>−</Text>}
incrementButton={<Text>+</Text>}
/>
);
}
mount(App);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 :
onChangesignale le nombre que le texte représente chaque fois que ce nombre change :nulltant que le texte ne représente aucun nombre (vide,-,.) ; taper1.après1, ou1.50après1.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.et2.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
minetmax(1 en route vers 15 avecmin={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.5augmenté jusqu’àmax={100}affiche100.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,25vaut 3.25), comme la touche décimale des claviers de nombreuses langues la saisit.
#Props
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
value | .value(..) | number | null | aucun | La valeur (contrôlée) : un nombre, ou null pour un champ vide. L’app définit celle que onChange signale. |
defaultValue | .default_value(..) | number | null | aucun | La valeur initiale tant que value n’est pas définie ; le champ numérique conserve alors sa propre valeur. |
min | .min(..) | number | aucun | La 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(..) | number | aucun | La plus grande valeur, comme min par le haut ; une valeur inférieure à min équivaut à min. |
step | .step(..) | number | 1 | L’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(..) | boolean | false | Ni saisie, ni pas, ni focus ; les classes disabled: s’appliquent au champ numérique et à ses parties. |
readonly | .readonly(..) | boolean | false | Le 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(..) | string | aucun | Affiché tant que le champ est vide. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que les lecteurs d’écran annoncent pour le champ, quand aucun libellé visible ne le nomme. |
controlsRight | .controls_right(..) | boolean | false | Les 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> | aucun | Le champ : sa boîte et son texte (color, fontSize, textAlign, placeholderColor, caretColor). |
decrementButtonClass, decrementButtonStyle | .decrement_button_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | Le bouton de décrémentation. |
incrementButtonClass, incrementButtonStyle | .increment_button_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | Le bouton d’incrémentation. |
decrementButton, incrementButton | .decrement_button(..), .increment_button(..) | JSX.Element | aucun | Le contenu des boutons : un signe, une icône. |
controlsClass, controlsStyle | .controls_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | La colonne qui contient les deux boutons avec controlsRight. |
prefix | .prefix(..) | JSX.Element | aucun | Ce qui précède le champ, après le bouton de gauche : un signe monétaire, une icône. |
suffix | .suffix(..) | JSX.Element | aucun | Ce qui suit le champ, avant les boutons de droite : une unité, une devise. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | Le champ numérique lui-même : la ligne qui entoure le champ et les boutons. |
#Événements
| Événement | Rust | Données | Quand |
|---|---|---|---|
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(..) | aucune | Le champ a reçu le focus clavier. |
onBlur | .on_blur(..) | aucune | Le champ a perdu le focus clavier (sa valeur d’abord ramenée dans min et max). |
#Parties
| Partie | Emplacement | Stylée avec |
|---|---|---|
| Le champ numérique | class, style | |
| Le bouton de décrémentation | dans le champ numérique, en premier (avec controlsRight : dans la colonne de contrôles, en second) | decrementButtonClass, decrementButtonStyle ; son contenu decrementButton |
| Le champ | dans le champ numérique | inputClass, inputStyle |
| Le préfixe | dans le champ numérique, avant le champ | ses propres éléments (prefix) |
| Le suffixe | dans le champ numérique, après le champ | ses propres éléments (suffix) |
| Le bouton d’incrémentation | dans 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ôles | dans le champ numérique, en dernier, avec controlsRight | controlsClass, 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èreenabled:(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 :
| Touche | Action |
|---|---|
| Haut | Un pas vers le haut. |
| Bas | Un pas vers le bas. |
| Page précédente, Page suivante | Dix 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, entreminetmax) 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
minest 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.