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.
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 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:
onChangeinforma o número que o texto representa sempre que esse número muda:nullenquanto o texto não representa nenhum (vazio,-,.); digitar1.depois de1, ou1.50depois de1.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.e2.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
minemax(1 a caminho de 15 commin={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.5atémax={100}mostra100.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 emmin, o incremento emmax). Coloque o hover atrás deenabled:(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, entreminemax) 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.