Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Elementos

NumberInput

Um input numérico guarda um número que o usuário digita ou incrementa: uma quantidade, um preço, um tamanho. Ele é dono do seu comportamento: seu campo aceita um número, ou o texto a caminho de um (-, 1.), e recusa todo o resto, inclusive dígitos e sinais de outros teclados; seus botões, as teclas Seta para cima e Seta para baixo e as ações de incremento e decremento de um leitor de tela mudam o número em step, mantendo suas casas decimais, e param em min e max; ao sair do campo, um número digitado é limitado a eles. A aparência é do app: um NumberInput não tem cores, tamanhos, bordas, ícones nem textos próprios. Ele é feito do botão de decremento, do campo entre o prefix e o suffix do app, e do botão de incremento (ou do campo entre eles e uma coluna com os dois botões, controlsRight), e o app estiliza cada parte e preenche os botões.

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 vem de nsh/base, o crate nsh-base em Rust. Em Rust, number_input() cria um com um setter por prop (.value(..), .input_style(..)), um método de listener por evento (.on_change(..)), os métodos de estilo tipados para o próprio input, e .prefix(..) / .suffix(..) para o que fica ao redor do campo. Seu valor é um Option<f64>.

#Valor

O valor é um número, ou null para um campo vazio. O app é dono dele com value e define o que onChange informa, como em todo controle de nsh/base; ou deixa o valor com o input usando defaultValue, e ouve as mudanças do mesmo jeito.

O texto do campo e o valor são mantidos separados onde precisam ser, para que o usuário possa digitar livremente:

  • onChange informa o número que o texto representa sempre que esse número muda: null enquanto o texto não representa nenhum (vazio, -, .); digitar 1. depois de 1, ou 1.50 depois de 1.5, não informa nada.
  • O texto permanece como o usuário o digita enquanto representa o valor que o input mantém, de modo que um input controlado que devolve cada valor informado mantém 1. e 2.50. Um valor definido pelo app que o input não informou aparece no campo, como o texto mais curto que o representa (0.3, 12).
  • Ao digitar, um número pode ficar fora de min e max (1 a caminho de 15 com min={10}), e é informado como digitado. Quando o campo é deixado, ou ao pressionar Return / Enter, ele é limitado ao intervalo e isso é informado; um texto que não representa nenhum número permanece como está.
  • Um passo (um botão, uma tecla, uma ação de leitor de tela) pede o próximo valor com onChange; com um valor controlado, o campo se move quando o app o devolve, mantendo as casas decimais do passo (99.5 até max={100} mostra 100.0), de modo que um app que recusa um passo mantém o campo onde está. Uma edição que o app recusa permanece no campo enquanto ele está em edição, e some quando o campo é deixado.
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"
/>;

O campo aceita:

  • Dígitos, um ponto decimal e um sinal no início (+ ou -); uma letra, um segundo ponto ou sinal, ou um sinal em outra posição é recusado, e o campo volta ao seu texto.
  • Dígitos, sinais e ponto de largura total, o sinal de menos (U+2212) e o ponto final ideográfico (。), lidos como suas formas ASCII, como os métodos de entrada de chinês e japonês os digitam.
  • Uma vírgula como ponto decimal enquanto o texto não tem ponto (3,25 é 3.25), como a tecla decimal dos teclados de muitos idiomas a digita.

#Props

Prop Rust Tipo Padrão Descrição
value .value(..) number | null nenhum O valor (controlado): um número, ou null para um campo vazio. O app define o que onChange informa.
defaultValue .default_value(..) number | null nenhum O valor inicial enquanto value não está definido; o input então mantém o seu próprio.
min .min(..) number nenhum O menor valor: os passos param nele, e um valor digitado abaixo dele é limitado a ele quando o campo é deixado.
max .max(..) number nenhum O maior valor, como min por cima; um valor abaixo de min é min.
step .step(..) number 1 Quanto um passo avança (Page Up / Page Down: dez passos); um valor que não seja um número positivo é 1.
disabled .disabled(..) boolean false Sem digitação, passos nem foco; as classes disabled: se aplicam ao input e às suas partes.
readonly .readonly(..) boolean false O texto pode ser selecionado e copiado, mas não alterado, nem por digitação nem por passos; os botões ficam desabilitados.
placeholder .placeholder(..) string nenhum Exibido enquanto o campo está vazio.
accessibilityLabel .accessibility_label(..) string nenhum O que os leitores de tela dizem para o campo, quando nenhum rótulo visível o nomeia.
controlsRight .controls_right(..) boolean false Os dois botões em uma coluna à direita do campo, o de incremento acima do de decremento. Lido quando o input é criado.
inputClass, inputStyle .input_style(|s| ..) string, StyleProp<InputStyle> nenhum O campo: sua caixa e seu texto (color, fontSize, textAlign, placeholderColor, caretColor).
decrementButtonClass, decrementButtonStyle .decrement_button_style(|s| ..) string, StyleProp<ViewStyle> nenhum O botão de decremento.
incrementButtonClass, incrementButtonStyle .increment_button_style(|s| ..) string, StyleProp<ViewStyle> nenhum O botão de incremento.
decrementButton, incrementButton .decrement_button(..), .increment_button(..) JSX.Element nenhum O conteúdo dos botões: um sinal, um ícone.
controlsClass, controlsStyle .controls_style(|s| ..) string, StyleProp<ViewStyle> nenhum A coluna que contém os dois botões com controlsRight.
prefix .prefix(..) JSX.Element nenhum O que vem antes do campo, depois do botão à sua esquerda: um símbolo de moeda, um ícone.
suffix .suffix(..) JSX.Element nenhum O que vem depois do campo, antes dos botões à direita: uma unidade, uma moeda.
class, style métodos de estilo string, StyleProp<ViewStyle> nenhum O próprio input: a linha ao redor do campo e dos botões.

#Eventos

Evento Rust Payload Quando
onChange .on_change(|value: &Option<f64>| ..) { value: number | null } O número que o campo representa mudou (digitação, um passo, o incremento ou decremento de um leitor de tela, o campo deixado com um valor fora do intervalo).
onSubmit .on_submit(|value: &Option<f64>| ..) { value: number | null } Return / Enter no campo: o valor, limitado a min e max.
onFocus .on_focus(..) nenhum O campo recebeu o foco do teclado.
onBlur .on_blur(..) nenhum O campo perdeu o foco do teclado (antes, seu valor é limitado a min e max).

#Partes

Parte Dentro de Estilizada com
O input class, style
O botão de decremento o input, primeiro (com controlsRight: a coluna de controles, em segundo) decrementButtonClass, decrementButtonStyle; seu conteúdo decrementButton
O campo o input inputClass, inputStyle
O prefixo o input, antes do campo seus próprios elementos (prefix)
O sufixo o input, depois do campo seus próprios elementos (suffix)
O botão de incremento o input, por último (com controlsRight: a coluna de controles, em primeiro) incrementButtonClass, incrementButtonStyle; seu conteúdo incrementButton
A coluna de controles o input, por último, com controlsRight controlsClass, controlsStyle

O input é uma linha que o app organiza: flex-row em class, e flex-1 no campo para dar a ele o espaço que os botões deixam. Estados que as partes mostram:

  • O input: focus-within: enquanto seu campo está em edição (um anel ao redor de todo o input), disabled:.
  • O campo: focus: enquanto está em edição, disabled:.
  • Um botão: hover: sob o ponteiro, active: enquanto pressionado, disabled: enquanto o input está desabilitado ou somente leitura e onde um passo não faria nada (o decremento em min, o incremento em max). Coloque o hover atrás de enabled: (enabled:hover:bg-accent) para que um botão desabilitado não se acenda.

A animação dos estados é escolha do app (transition-colors), e motion-reduce: a mantém parada para quem pede menos movimento.

#Teclado

Enquanto o campo está em edição, o input aceita as teclas de spin button do WAI-ARIA, que se repetem enquanto pressionadas:

Tecla Ação
Seta para cima Um passo para cima.
Seta para baixo Um passo para baixo.
Page Up, Page Down Dez passos para cima ou para baixo.

Home e End continuam sendo do campo: movem o cursor no texto. Os botões não são paradas do foco do teclado: Tab vai do campo para o que vem depois do input, e um toque em um botão deixa o foco no campo (nos desktops, um toque move o foco para o campo quando ele estava em outro lugar; em telas sensíveis ao toque, o foco é deixado onde está, para que nenhum teclado apareça).

#Acessibilidade

  • O campo é um spin button ("spinbutton") com seu rótulo, seu valor (o número que o texto representa, entre min e max) e seu texto.
  • As ações de incremento e decremento de um leitor de tela (o deslizar para cima e para baixo do VoiceOver) fazem o passo, como Seta para cima e Seta para baixo.
  • Os botões ficam ocultos para os leitores de tela, como os de um campo numérico de navegador: as ações de incremento e decremento do próprio campo fazem o passo.
  • Enquanto disabled, os leitores de tela dizem que o campo está esmaecido.

Veja Acessibilidade para rótulos, papéis e ações em geral.

#Notas de plataforma

  • O campo é o campo de texto da plataforma (Input): o teclado, o cursor, a seleção, copiar e colar e os métodos de entrada são do sistema. Celulares mostram um teclado decimal, com um sinal quando min é menor que 0 ou não está definido.
  • Toda plataforma desenha o que o app estiliza. Seta para cima / Seta para baixo e Page Up / Page Down chegam ao input a partir de um teclado físico em todas as plataformas.