Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

NumberInput

Un number input contiene un número que el usuario escribe o incrementa: una cantidad, un precio, un tamaño. Es dueño de su comportamiento: su campo admite un número, o el texto en camino a serlo (-, 1.), y rechaza cualquier otra cosa, incluidos los dígitos y signos de otros teclados; sus botones, las teclas Flecha arriba y Flecha abajo y el incremento y decremento de un lector de pantalla cambian el número en step, conservando sus decimales, y se detienen en min y max; al salir del campo, un número escrito se ajusta a ellos. El aspecto es el de la app: un NumberInput no tiene colores, tamaños, bordes, iconos ni cadenas propios. Se compone del botón de decremento, el campo entre el prefix y el suffix de la app, y el botón de incremento (o el campo entre ellos y una columna con ambos botones, controlsRight), y la app da estilo a cada parte y rellena los botones.

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 de nsh/base, el crate nsh-base de Rust. En Rust, number_input() crea uno con un setter por prop (.value(..), .input_style(..)), un método de escucha por evento (.on_change(..)), los métodos de estilo tipados para el propio input, y .prefix(..) / .suffix(..) para lo que va alrededor del campo. Su valor es un Option<f64>.

#Valor

El valor es un número, o null si el campo está vacío. La app lo controla con value y establece el que informa onChange, como en cualquier control de nsh/base; o deja el valor al input con defaultValue, y aun así se entera de los cambios.

El texto del campo y el valor se mantienen separados donde es necesario, para que el usuario pueda escribir libremente:

  • onChange informa del número que el texto representa cada vez que ese número cambia: null mientras el texto no representa ninguno (vacío, -, .); escribir 1. después de 1, o 1.50 después de 1.5, no informa de nada.
  • El texto se queda como el usuario lo escribe mientras represente el valor que tiene el input, de modo que un input controlado que devuelve cada valor informado conserva 1. y 2.50. Un valor que establece la app y que el input no informó se muestra en el campo como el texto más corto que lo representa (0.3, 12).
  • Al escribir, un número puede quedar fuera de min y max (1 de camino a 15 con min={10}), y se informa tal como se escribió. Al salir del campo, o con Return / Enter, se ajusta al rango y se informa de ello; un texto que no representa ningún número se queda como está.
  • Un paso (un botón, una tecla, una acción de un lector de pantalla) pide el siguiente valor con onChange; con un valor controlado, el campo se mueve cuando la app lo devuelve, conservando los decimales del paso (99.5 subiendo hasta max={100} muestra 100.0), de modo que una app que rechaza un paso mantiene el campo donde está. Una edición que la app rechaza permanece en el campo mientras este se edita, y desaparece al salir del 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"
/>;

El campo admite:

  • Dígitos, un punto decimal y un signo al principio (+ o -); una letra, un segundo punto o signo, o un signo en cualquier otro lugar se rechaza, y el campo vuelve a su texto.
  • Dígitos, signos y punto de ancho completo, el signo menos (U+2212) y el punto ideográfico (。), que se leen como sus formas ASCII, tal como los escriben los métodos de entrada chinos y japoneses.
  • Una coma como punto decimal mientras el texto no tenga punto (3,25 es 3.25), como la escribe la tecla decimal de los teclados de muchos idiomas.

#Props

Prop Rust Tipo Por defecto Descripción
value .value(..) number | null ninguno El valor (controlado): un número, o null si el campo está vacío. La app establece el que informa onChange.
defaultValue .default_value(..) number | null ninguno El valor inicial mientras value no esté establecido; entonces el input conserva el suyo.
min .min(..) number ninguno El valor más pequeño: los pasos se detienen en él, y un valor escrito por debajo se ajusta a él al salir del campo.
max .max(..) number ninguno El valor más grande, como min pero desde arriba; uno por debajo de min es min.
step .step(..) number 1 Hasta dónde llega un paso (Page Up / Page Down: diez pasos); uno que no sea un número positivo es 1.
disabled .disabled(..) boolean false Sin escritura, pasos ni foco; las clases disabled: se aplican al input y a sus partes.
readonly .readonly(..) boolean false El texto se puede seleccionar y copiar pero no cambiar, ni escribiendo ni con pasos; los botones están deshabilitados.
placeholder .placeholder(..) string ninguno Se muestra mientras el campo está vacío.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla para el campo, cuando ninguna etiqueta visible lo nombra.
controlsRight .controls_right(..) boolean false Ambos botones en una columna a la derecha del campo, el de incremento sobre el de decremento. Se lee cuando se crea el input.
inputClass, inputStyle .input_style(|s| ..) string, StyleProp<InputStyle> ninguno El campo: su caja y su texto (color, fontSize, textAlign, placeholderColor, caretColor).
decrementButtonClass, decrementButtonStyle .decrement_button_style(|s| ..) string, StyleProp<ViewStyle> ninguno El botón de decremento.
incrementButtonClass, incrementButtonStyle .increment_button_style(|s| ..) string, StyleProp<ViewStyle> ninguno El botón de incremento.
decrementButton, incrementButton .decrement_button(..), .increment_button(..) JSX.Element ninguno El contenido de los botones: un signo, un icono.
controlsClass, controlsStyle .controls_style(|s| ..) string, StyleProp<ViewStyle> ninguno La columna que contiene ambos botones con controlsRight.
prefix .prefix(..) JSX.Element ninguno Lo que va antes del campo, después del botón de su izquierda: un signo de moneda, un icono.
suffix .suffix(..) JSX.Element ninguno Lo que sigue al campo, antes de los botones de su derecha: una unidad, una moneda.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El propio input: la fila que rodea al campo y a los botones.

#Eventos

Evento Rust Payload Cuándo
onChange .on_change(|value: &Option<f64>| ..) { value: number | null } Cambió el número que representa el campo (escritura, un paso, un incremento o decremento de un lector de pantalla, o salida del campo con un valor fuera de rango).
onSubmit .on_submit(|value: &Option<f64>| ..) { value: number | null } Return / Enter en el campo: el valor, ajustado a min y max.
onFocus .on_focus(..) ninguno El campo recibió el foco del teclado.
onBlur .on_blur(..) ninguno El campo perdió el foco del teclado (antes se ajusta su valor a min y max).

#Partes

Parte Dentro de Se estiliza con
El input class, style
El botón de decremento el input, primero (con controlsRight: la columna de controles, segundo) decrementButtonClass, decrementButtonStyle; su contenido decrementButton
El campo el input inputClass, inputStyle
El prefijo el input, antes del campo sus propios elementos (prefix)
El sufijo el input, después del campo sus propios elementos (suffix)
El botón de incremento el input, último (con controlsRight: la columna de controles, primero) incrementButtonClass, incrementButtonStyle; su contenido incrementButton
La columna de controles el input, último, con controlsRight controlsClass, controlsStyle

El input es una fila que dispone la app: flex-row en class, y flex-1 en el campo para darle el espacio que dejan los botones. Estados que muestran las partes:

  • El input: focus-within: mientras su campo se edita (un anillo alrededor de todo el input), disabled:.
  • El campo: focus: mientras se edita, disabled:.
  • Un botón: hover: bajo el puntero, active: mientras se presiona, disabled: mientras el input está deshabilitado o es de solo lectura y donde un paso no haría nada (el decremento en min, el incremento en max). Pon el hover detrás de enabled: (enabled:hover:bg-accent) para que un botón deshabilitado no se ilumine.

El movimiento de los estados lo decide la app (transition-colors), y motion-reduce: lo detiene para las personas que piden menos movimiento.

#Teclado

Mientras el campo se edita, el input admite las teclas del spin button de WAI-ARIA, que se repiten al mantenerlas pulsadas:

Tecla Acción
Flecha arriba Un paso hacia arriba.
Flecha abajo Un paso hacia abajo.
Page Up, Page Down Diez pasos hacia arriba o hacia abajo.

Home y End siguen siendo del campo: mueven el cursor en el texto. Los botones no son paradas del foco del teclado: Tab pasa del campo a lo que sigue al input, y pulsar un botón deja el foco en el campo (en los equipos de escritorio, pulsar mueve el foco al campo si estaba en otro lugar; en las pantallas táctiles lo deja donde está, para que no aparezca el teclado).

#Accesibilidad

  • El campo es un spin button ("spinbutton") con su etiqueta, su valor (el número que representa, entre min y max) y su texto.
  • Las acciones de incremento y decremento de un lector de pantalla (el deslizamiento hacia arriba y hacia abajo de VoiceOver) lo cambian por pasos, igual que Flecha arriba y Flecha abajo.
  • Los botones están ocultos para los lectores de pantalla, como los de un campo numérico de un navegador: el incremento y el decremento del propio campo lo cambian por pasos.
  • Mientras disabled, los lectores de pantalla dicen que el campo está atenuado.

Consulta Accesibilidad para etiquetas, roles y acciones en general.

#Notas de plataforma

  • El campo es el campo de texto de la plataforma (Input): el teclado, el cursor, la selección, copiar y pegar y los métodos de entrada son los del sistema. Los teléfonos muestran un teclado decimal, con un signo cuando min es menor que 0 o no está establecido.
  • Cada plataforma dibuja lo que la app estiliza. Flecha arriba / Flecha abajo y Page Up / Page Down llegan al input desde un teclado físico en todas las plataformas.