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

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.

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 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:

  • onChange meldet die Zahl, als die der Text gelesen wird, sobald sich diese Zahl ändert: null, solange der Text als keine Zahl gelesen wird (leer, -, .). Tippt man 1. nach 1 oder 1.50 nach 1.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. und 2.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 min und max liegen (1 auf dem Weg zu 15 mit min={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 onChange nach 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.5 hoch bis max={100} zeigt 100.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,25 ist 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 bei min, das Erhöhen bei max). Setzen Sie das Hover hinter enabled: (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, zwischen min und max) 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 disabled gilt, 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 min kleiner 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.