Elemente
NumberInput
Ein Zahlenfeld hält eine Zahl, die der Nutzer eintippt oder schrittweise ändert: eine Menge, einen Preis, eine Größe. Es bringt sein Verhalten selbst mit: Sein Feld nimmt eine Zahl an, oder den Text auf dem Weg zu einer Zahl (-, 1.), und weist alles andere zurück, auch Ziffern und Vorzeichen anderer Tastaturen. Seine Buttons, Up und Down sowie das Erhöhen und Verringern eines Screenreaders ändern die Zahl um step, behalten dabei ihre Dezimalstellen bei und halten bei min und max an. Verlässt man das Feld, wird eine eingetippte Zahl auf diese Grenzen begrenzt. Das Aussehen bestimmt die App: Ein NumberInput hat keine eigenen Farben, Größen, Rahmen, Icons oder Texte. Es besteht aus dem Button zum Verringern, dem Feld zwischen dem prefix und dem suffix der App und dem Button zum Erhöhen (oder dem Feld zwischen diesen und einer Spalte mit beiden Buttons, controlsRight). Die App gestaltet jeden Teil und befüllt die Buttons.
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 stammt aus nsh/base, dem nsh-base-Crate in Rust. In Rust erzeugt number_input() ein Zahlenfeld mit einem Setter pro Prop (.value(..), .input_style(..)), einer Listener-Methode pro Ereignis (.on_change(..)), den typisierten Style-Methoden für das Zahlenfeld selbst und .prefix(..) / .suffix(..) für das, was das Feld umgibt. Sein Wert ist ein Option<f64>.
#Wert
Der Wert ist eine Zahl oder null für ein leeres Feld. Die App besitzt ihn mit value und setzt den Wert, den onChange meldet, wie bei jedem Steuerelement von nsh/base. Oder sie überlässt den Wert dem Zahlenfeld mit defaultValue und erfährt dennoch von den Änderungen.
Der Text des Feldes und der Wert werden dort getrennt gehalten, wo es nötig ist, damit der Nutzer frei tippen kann:
onChangemeldet die Zahl, als die der Text gelesen wird, sobald sich diese Zahl ändert:null, solange der Text als keine Zahl gelesen wird (leer,-,.). Tippt man1.nach1oder1.50nach1.5, wird nichts gemeldet.- Der Text bleibt so, wie der Nutzer ihn tippt, solange er als der Wert gelesen wird, den das Zahlenfeld hält. Ein kontrolliertes Zahlenfeld, das jeden gemeldeten Wert zurückgibt, behält also
1.und2.50. Ein Wert, den die App setzt und den das Zahlenfeld nicht gemeldet hat, erscheint im Feld als der kürzeste Text, der als dieser Wert gelesen wird (0.3,12). - Beim Tippen darf eine Zahl außerhalb von
minundmaxliegen (1 auf dem Weg zu 15 mitmin={10}) und wird wie getippt gemeldet. Wird das Feld verlassen oder Return / Enter gedrückt, wird sie auf den Bereich begrenzt und das wird gemeldet. Ein Text, der als keine Zahl gelesen wird, bleibt unverändert. - Ein Schritt (ein Button, eine Taste, die Aktion eines Screenreaders) fragt mit
onChangenach dem nächsten Wert. Bei einem kontrollierten Wert bewegt sich das Feld, sobald die App ihn zurückgibt, und behält dabei die Dezimalstellen des Schritts (99.5hoch bismax={100}zeigt100.0). Eine App, die einen Schritt ablehnt, belässt das Feld also, wo es ist. Eine Eingabe, die die App ablehnt, bleibt im Feld stehen, solange es bearbeitet wird, und verschwindet, wenn das Feld verlassen wird.
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"
/>;
Das Feld nimmt an:
- Ziffern, einen Dezimalpunkt und ein Vorzeichen am Anfang (
+oder-). Ein Buchstabe, ein zweiter Punkt oder ein zweites Vorzeichen oder ein Vorzeichen an anderer Stelle wird zurückgewiesen, und das Feld kehrt zu seinem Text zurück. - Vollbreite Ziffern, Vorzeichen und Punkte, das Minuszeichen (U+2212) und den ideografischen Punkt (
。), die als ihre ASCII-Formen gelesen werden, so wie chinesische und japanische Eingabemethoden sie tippen. - Ein Komma als Dezimalpunkt, solange der Text keinen Punkt enthält (
3,25ist 3.25), so wie es die Dezimaltaste der Tastaturen vieler Sprachen tippt.
#Props
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
value |
.value(..) |
number | null |
keiner | Der Wert (kontrolliert): eine Zahl oder null für ein leeres Feld. Die App setzt den Wert, den onChange meldet. |
defaultValue |
.default_value(..) |
number | null |
keiner | Der Anfangswert, solange value nicht gesetzt ist. Das Zahlenfeld verwaltet den Wert dann selbst. |
min |
.min(..) |
number |
keiner | Der kleinste Wert: Schritte halten bei ihm an, ein eingetippter Wert darunter wird beim Verlassen des Feldes auf ihn begrenzt. |
max |
.max(..) |
number |
keiner | Der größte Wert, wie min von oben. Ein Wert unter min ist min. |
step |
.step(..) |
number |
1 |
Wie weit ein Schritt geht (Page Up / Page Down: zehn Schritte). Ein Wert, der keine positive Zahl ist, ist 1. |
disabled |
.disabled(..) |
boolean |
false |
Kein Tippen, keine Schritte, kein Fokus. disabled:-Klassen gelten für das Zahlenfeld und seine Teile. |
readonly |
.readonly(..) |
boolean |
false |
Der Text kann markiert und kopiert, aber weder durch Tippen noch durch Schritte geändert werden. Die Buttons sind deaktiviert. |
placeholder |
.placeholder(..) |
string |
keiner | Wird angezeigt, solange das Feld leer ist. |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Was Screenreader für das Feld ansagen, wenn kein sichtbares Label es benennt. |
controlsRight |
.controls_right(..) |
boolean |
false |
Beide Buttons in einer Spalte rechts vom Feld, der Button zum Erhöhen über dem zum Verringern. Wird beim Erzeugen des Zahlenfeldes gelesen. |
inputClass, inputStyle |
.input_style(|s| ..) |
string, StyleProp<InputStyle> |
keiner | Das Feld: seine Box und sein Text (color, fontSize, textAlign, placeholderColor, caretColor). |
decrementButtonClass, decrementButtonStyle |
.decrement_button_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Der Button zum Verringern. |
incrementButtonClass, incrementButtonStyle |
.increment_button_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Der Button zum Erhöhen. |
decrementButton, incrementButton |
.decrement_button(..), .increment_button(..) |
JSX.Element |
keiner | Der Inhalt der Buttons: ein Zeichen, ein Icon. |
controlsClass, controlsStyle |
.controls_style(|s| ..) |
string, StyleProp<ViewStyle> |
keiner | Die Spalte, die beide Buttons mit controlsRight enthält. |
prefix |
.prefix(..) |
JSX.Element |
keiner | Was vor dem Feld steht, nach dem Button auf seiner linken Seite: ein Währungszeichen, ein Icon. |
suffix |
.suffix(..) |
JSX.Element |
keiner | Was auf das Feld folgt, vor den Buttons auf seiner rechten Seite: eine Einheit, eine Währung. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Das Zahlenfeld selbst: die Zeile um das Feld und die Buttons. |
#Ereignisse
| Ereignis | Rust | Payload | Wann |
|---|---|---|---|
onChange |
.on_change(|value: &Option<f64>| ..) |
{ value: number | null } |
Die Zahl, als die das Feld gelesen wird, hat sich geändert (Tippen, ein Schritt, Erhöhen oder Verringern durch einen Screenreader, das Feld wurde mit einem Wert außerhalb des Bereichs verlassen). |
onSubmit |
.on_submit(|value: &Option<f64>| ..) |
{ value: number | null } |
Return / Enter im Feld: der Wert, auf min und max begrenzt. |
onFocus |
.on_focus(..) |
keiner | Das Feld hat den Tastaturfokus erhalten. |
onBlur |
.on_blur(..) |
keiner | Das Feld hat den Tastaturfokus verloren (sein Wert wurde zuvor auf min und max begrenzt). |
#Teile
| Teil | Enthalten in | Gestaltet mit |
|---|---|---|
| Das Zahlenfeld | class, style |
|
| Der Button zum Verringern | dem Zahlenfeld, als Erstes (mit controlsRight: der Spalte mit den Buttons, als Zweites) |
decrementButtonClass, decrementButtonStyle; sein Inhalt decrementButton |
| Das Feld | dem Zahlenfeld | inputClass, inputStyle |
| Das Präfix | dem Zahlenfeld, vor dem Feld | eigenen Elementen (prefix) |
| Das Suffix | dem Zahlenfeld, nach dem Feld | eigenen Elementen (suffix) |
| Der Button zum Erhöhen | dem Zahlenfeld, als Letztes (mit controlsRight: der Spalte mit den Buttons, als Erstes) |
incrementButtonClass, incrementButtonStyle; sein Inhalt incrementButton |
| Die Spalte mit den Buttons | dem Zahlenfeld, als Letztes, mit controlsRight |
controlsClass, controlsStyle |
Das Zahlenfeld ist eine Zeile, die die App anordnet: flex-row auf class und flex-1 auf dem Feld, damit es den Platz erhält, den die Buttons übrig lassen. Zustände, die die Teile zeigen:
- Das Zahlenfeld:
focus-within:, solange sein Feld bearbeitet wird (ein Ring um das gesamte Zahlenfeld),disabled:. - Das Feld:
focus:, solange es bearbeitet wird,disabled:. - Ein Button:
hover:unter dem Zeiger,active:während er gedrückt wird,disabled:, solange das Zahlenfeld deaktiviert oder schreibgeschützt ist und wo ein Schritt nichts bewirken würde (das Verringern beimin, das Erhöhen beimax). Setzen Sie das Hover hinterenabled:(enabled:hover:bg-accent), damit ein deaktivierter Button nicht aufleuchtet.
Die Bewegung der Zustände ist Sache der App (transition-colors), und motion-reduce: hält sie für Menschen still, die weniger Bewegung wünschen.
#Tastatur
Solange das Feld bearbeitet wird, nimmt das Zahlenfeld die Tasten des WAI-ARIA-Spin-Buttons an, die sich bei gedrückter Taste wiederholen:
| Taste | Bewirkt |
|---|---|
| Up | Einen Schritt nach oben. |
| Down | Einen Schritt nach unten. |
| Page Up, Page Down | Zehn Schritte nach oben oder unten. |
Home und End bleiben die des Feldes: Sie bewegen den Cursor im Text. Die Buttons sind keine Haltepunkte des Tastaturfokus: Tab führt vom Feld zu dem, was auf das Zahlenfeld folgt, und ein Druck auf einen Button belässt den Fokus im Feld (auf Desktops verschiebt ein Druck den Fokus auf das Feld, wenn er woanders war; auf Touchscreens belässt er ihn, damit keine Tastatur erscheint).
#Barrierefreiheit
- Das Feld ist ein Spin-Button (
"spinbutton") mit seinem Label, seinem Wert (die Zahl, als die es gelesen wird, zwischenminundmax) und seinem Text. - Die Aktionen eines Screenreaders zum Erhöhen und Verringern (bei VoiceOver das Wischen nach oben und unten) ändern den Wert schrittweise, so wie Up und Down.
- Die Buttons sind vor Screenreadern verborgen, wie die eines Zahlenfeldes im Browser: Das Erhöhen und Verringern des Feldes selbst ändert den Wert schrittweise.
- Solange
disabledgilt, sagen Screenreader, dass das Feld abgeblendet ist.
Siehe Barrierefreiheit für Labels, Rollen und Aktionen im Allgemeinen.
#Hinweise zu Plattformen
- Das Feld ist das Textfeld der Plattform (Input): Tastatur, Cursor, Auswahl, Kopieren und Einfügen sowie Eingabemethoden sind die des Systems. Smartphones zeigen eine Dezimaltastatur, mit Vorzeichen, wenn
minkleiner als 0 oder nicht gesetzt ist. - Jede Plattform zeichnet, was die App gestaltet. Up / Down und Page Up / Page Down erreichen das Zahlenfeld auf jeder Plattform von einer Hardware-Tastatur.