Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

NumberInput

Un campo numerico contiene un numero che l’utente digita o modifica a passi: una quantità, un prezzo, una dimensione. Gestisce da sé il proprio comportamento: il suo campo accetta un numero, o il testo che sta per diventarlo (-, 1.), e rifiuta tutto il resto, comprese le cifre e i segni di altre tastiere; i suoi pulsanti, i tasti Su e Giù e le azioni di incremento e decremento di uno screen reader fanno avanzare il numero di step, mantenendone i decimali, e si fermano a min e max; uscendo dal campo, un numero digitato viene riportato entro tali limiti. L’aspetto spetta all’app: un NumberInput non ha colori, dimensioni, bordi, icone o stringhe proprie. È composto dal pulsante di decremento, dal campo tra prefix e suffix dell’app, e dal pulsante di incremento (oppure dal campo tra questi e una colonna con entrambi i pulsanti, controlsRight); l’app applica lo stile a ogni parte e riempie i pulsanti.

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 proviene da nsh/base, il crate nsh-base in Rust. In Rust, number_input() ne crea uno con un setter per ogni prop (.value(..), .input_style(..)), un metodo listener per ogni evento (.on_change(..)), i metodi di stile tipizzati per il campo numerico stesso, e .prefix(..) / .suffix(..) per ciò che sta attorno al campo. Il suo valore è un Option<f64>.

#Valore

Il valore è un numero, oppure null per un campo vuoto. L’app lo gestisce con value e imposta quello riportato da onChange, come per ogni controllo di nsh/base; oppure lascia il valore al campo numerico con defaultValue, e riceve comunque le modifiche.

Il testo del campo e il valore sono tenuti separati dove serve, in modo che l’utente possa digitare liberamente:

  • onChange riporta il numero che il testo rappresenta ogni volta che tale numero cambia: null quando il testo non rappresenta alcun numero (vuoto, -, .); digitare 1. dopo 1, o 1.50 dopo 1.5, non riporta nulla.
  • Il testo resta come l’utente lo digita finché rappresenta il valore che il campo numerico contiene, quindi un campo controllato che restituisce ogni valore riportato mantiene 1. e 2.50. Un valore impostato dall’app che il campo non ha riportato compare nel campo come il testo più breve che lo rappresenta (0.3, 12).
  • Durante la digitazione un numero può essere fuori da min e max (1 sulla strada verso 15 con min={10}) e viene riportato così com’è digitato. Quando si esce dal campo, o con Invio, il valore viene riportato entro l’intervallo e ciò viene segnalato; un testo che non rappresenta alcun numero resta invariato.
  • Un passo (un pulsante, un tasto, un’azione di uno screen reader) richiede il valore successivo con onChange; con un valore controllato il campo si aggiorna quando l’app lo restituisce, mantenendo i decimali del passo (99.5 incrementato di max={100} mostra 100.0), quindi un’app che rifiuta un passo lascia il campo dov’è. Una modifica rifiutata dall’app resta nel campo finché lo si modifica, e scompare quando si esce dal campo.
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"
/>;

Il campo accetta:

  • Cifre, un solo punto decimale e un segno all’inizio (+ o -); una lettera, un secondo punto o segno, o un segno in altra posizione vengono rifiutati e il campo torna al proprio testo.
  • Cifre, segni e punto a larghezza piena, il segno meno (U+2212) e il punto fermo ideografico (。), letti come le rispettive forme ASCII, come li digitano i metodi di input cinesi e giapponesi.
  • Una virgola come punto decimale finché il testo non contiene un punto (3,25 è 3.25), come la digita il tasto decimale delle tastiere di molte lingue.

#Props

PropRustTipoPredefinitoDescrizione
value.value(..)number | nullnessunoIl valore (controllato): un numero, oppure null per un campo vuoto. L’app imposta quello riportato da onChange.
defaultValue.default_value(..)number | nullnessunoIl valore iniziale finché value non è impostato; il campo numerico conserva poi un valore proprio.
min.min(..)numbernessunoIl valore minimo: i passi si fermano qui, un valore digitato inferiore viene riportato a questo limite quando si esce dal campo.
max.max(..)numbernessunoIl valore massimo, come min ma dall’alto; uno inferiore a min equivale a min.
step.step(..)number1Di quanto avanza un passo (Pag Su / Pag Giù: dieci passi); uno che non sia un numero positivo equivale a 1.
disabled.disabled(..)booleanfalseNessuna digitazione, passo o focus; le classi disabled: si applicano al campo numerico e alle sue parti.
readonly.readonly(..)booleanfalseIl testo può essere selezionato e copiato ma non modificato, né digitando né con i passi; i pulsanti sono disabilitati.
placeholder.placeholder(..)stringnessunoMostrato quando il campo è vuoto.
accessibilityLabel.accessibility_label(..)stringnessunoCiò che gli screen reader leggono per il campo, quando nessuna etichetta visibile lo nomina.
controlsRight.controls_right(..)booleanfalseEntrambi i pulsanti in colonna a destra del campo, l’incremento sopra il decremento. Letto quando il campo numerico viene creato.
inputClass, inputStyle.input_style(|s| ..)string, StyleProp<InputStyle>nessunoIl campo: il suo riquadro e il suo testo (color, fontSize, textAlign, placeholderColor, caretColor).
decrementButtonClass, decrementButtonStyle.decrement_button_style(|s| ..)string, StyleProp<ViewStyle>nessunoIl pulsante di decremento.
incrementButtonClass, incrementButtonStyle.increment_button_style(|s| ..)string, StyleProp<ViewStyle>nessunoIl pulsante di incremento.
decrementButton, incrementButton.decrement_button(..), .increment_button(..)JSX.ElementnessunoIl contenuto dei pulsanti: un segno, un’icona.
controlsClass, controlsStyle.controls_style(|s| ..)string, StyleProp<ViewStyle>nessunoLa colonna che contiene entrambi i pulsanti con controlsRight.
prefix.prefix(..)JSX.ElementnessunoCiò che precede il campo, dopo il pulsante alla sua sinistra: un simbolo di valuta, un’icona.
suffix.suffix(..)JSX.ElementnessunoCiò che segue il campo, prima dei pulsanti alla sua destra: un’unità, una valuta.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoIl campo numerico stesso: la riga attorno al campo e ai pulsanti.

#Eventi

EventoRustPayloadQuando
onChange.on_change(|value: &Option<f64>| ..){ value: number | null }Il numero che il campo rappresenta è cambiato (digitazione, un passo, un incremento o decremento di uno screen reader, uscita dal campo con un valore fuori intervallo).
onSubmit.on_submit(|value: &Option<f64>| ..){ value: number | null }Invio nel campo: il valore, riportato entro min e max.
onFocus.on_focus(..)nessunoIl campo ha ricevuto il focus della tastiera.
onBlur.on_blur(..)nessunoIl campo ha perso il focus della tastiera (prima il suo valore viene riportato entro min e max).

#Parti

PartePosizioneStile applicato con
Il campo numericoclass, style
Il pulsante di decrementonel campo numerico, per primo (con controlsRight: la colonna dei controlli, secondo)decrementButtonClass, decrementButtonStyle; il suo contenuto decrementButton
Il camponel campo numericoinputClass, inputStyle
Il prefissonel campo numerico, prima del campopropri elementi (prefix)
Il suffissonel campo numerico, dopo il campopropri elementi (suffix)
Il pulsante di incrementonel campo numerico, per ultimo (con controlsRight: la colonna dei controlli, primo)incrementButtonClass, incrementButtonStyle; il suo contenuto incrementButton
La colonna dei controllinel campo numerico, per ultima, con controlsRightcontrolsClass, controlsStyle

Il campo numerico è una riga che l’app dispone: flex-row su class, e flex-1 sul campo per dargli lo spazio che i pulsanti lasciano. Gli stati che le parti mostrano:

  • Il campo numerico: focus-within: mentre il suo campo è in modifica (un anello attorno all’intero campo numerico), disabled:.
  • Il campo: focus: mentre è in modifica, disabled:.
  • Un pulsante: hover: sotto il puntatore, active: mentre è premuto, disabled: quando il campo numerico è disabilitato o di sola lettura e dove un passo non avrebbe effetto (il decremento a min, l’incremento a max). Metti l’hover dietro enabled: (enabled:hover:bg-accent) così che un pulsante disabilitato non si illumini.

Il movimento degli stati è una scelta dell’app (transition-colors), e motion-reduce: lo mantiene fermo per chi chiede meno movimento.

#Tastiera

Mentre il campo è in modifica, il campo numerico accetta i tasti dello spin button di WAI-ARIA, che si ripetono se tenuti premuti:

TastoAzione
SuUn passo in su.
GiùUn passo in giù.
Pag Su, Pag GiùDieci passi in su o in giù.

Home e Fine restano del campo: spostano il cursore nel testo. I pulsanti non sono tappe del focus della tastiera: Tab porta dal campo a ciò che segue il campo numerico, e la pressione di un pulsante lascia il focus nel campo (sui desktop la pressione sposta il focus sul campo se era altrove; sui touch screen lo lascia dov’è, così non compare alcuna tastiera).

#Accessibilità

  • Il campo è uno spin button ("spinbutton") con la sua etichetta, il suo valore (il numero che rappresenta, tra min e max) e il suo testo.
  • Le azioni di incremento e decremento di uno screen reader (lo scorrimento verso l’alto e verso il basso di VoiceOver) lo fanno avanzare a passi, come Su e Giù.
  • I pulsanti sono nascosti agli screen reader, come quelli del campo numerico di un browser: l’incremento e il decremento propri del campo lo fanno avanzare a passi.
  • Quando disabled, gli screen reader indicano che il campo è disattivato.

Vedi Accessibilità per etichette, ruoli e azioni in generale.

#Note sulle piattaforme

  • Il campo è il campo di testo della piattaforma (Input): la tastiera, il cursore, la selezione, copia e incolla e i metodi di input sono quelli del sistema. I telefoni mostrano una tastiera decimale, con un segno quando min è inferiore a 0 o non è impostato.
  • Ogni piattaforma disegna ciò a cui l’app applica lo stile. Su / Giù e Pag Su / Pag Giù raggiungono il campo numerico da una tastiera fisica su ogni piattaforma.