Elements

NumberInput

A number input holds a number the user types or steps: a quantity, a price, a size. It owns its behavior: its field takes a number, or the text on the way to one (-, 1.), and refuses anything else, other keyboards’ digits and signs included; its buttons, Up and Down and a screen reader’s increment and decrement step the number by step, keeping its decimals, and stop at min and max; leaving the field holds a typed number to them. The look is the app’s: a NumberInput has no colors, sizes, borders, icons or strings of its own. It is made of the decrement button, the field between the app’s prefix and suffix, and the increment button (or the field between them and a column with both buttons, controlsRight), and the app styles each part and fills the 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 }) => (next: 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={(next) => disabled.set(next)}
                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 comes from nsh/base, the nsh-base crate in Rust. In Rust, number_input() makes one with a setter per prop (.value(..), .input_style(..)), a listener method per event (.on_change(..)), the typed style methods for the input itself, and .prefix(..) / .suffix(..) for what goes around the field. Its value is an Option<f64>.

#Value

The value is a number, or null for an empty field. The app owns it with value and sets the one onChange reports, as for every control of nsh/base; or it leaves the value to the input with defaultValue, and hears the changes all the same.

The field’s text and the value are kept apart where they must be, so the user can type freely:

  • onChange reports the number the text reads as whenever that number changes: null while the text reads as none (empty, -, .); typing 1. after 1, or 1.50 after 1.5, reports nothing.
  • The text stays as the user types it while it reads as the value the input holds, so a controlled input that passes each reported value back keeps 1. and 2.50. A value the app sets that the input did not report shows in the field, as the shortest text that reads as it (0.3, 12).
  • While typing, a number may be outside min and max (1 on the way to 15 with min={10}), and is reported as typed. When the field is left, or on Return / Enter, it is held to the range and that is reported; a text that reads as no number stays as it is.
  • A step (a button, a key, a screen reader’s action) asks for the next value with onChange; with a controlled value the field moves when the app passes it back, keeping the step’s decimals (99.5 up to max={100} shows 100.0), so an app that refuses a step keeps the field where it is. An edit the app refuses stays in the field while it edits, and goes when the field is left.
const price = signal<number | null>(19.95);

<NumberInput
  value={price()}
  onChange={(next) => price.set(next)}
  onSubmit={(next) => save(next)}
  min={0}
  step={0.05}
  placeholder="Price"
  accessibilityLabel="Price"
/>;

The field takes:

  • Digits, one decimal point and a sign first (+ or -); a letter, a second point or sign, or a sign elsewhere is refused, and the field goes back to its text.
  • Full-width digits, signs and point, the minus sign (U+2212) and the ideographic full stop (。), read as their ASCII forms, as Chinese and Japanese input methods type them.
  • A comma as the decimal point while the text has no point (3,25 is 3.25), as the decimal key of many languages’ keyboards types it.

#Props

PropRustTypeDefaultDescription
value.value(..)number | nullnoneThe value (controlled): a number, or null for an empty field. The app sets the one onChange reports.
defaultValue.default_value(..)number | nullnoneThe value at first while value is not set; the input then keeps its own.
min.min(..)numbernoneThe smallest value: steps stop at it, a typed value below it is held to it when the field is left.
max.max(..)numbernoneThe largest value, as min from above; one below min is min.
step.step(..)number1How far a step goes (Page Up / Page Down: ten steps); one that is not a positive number is 1.
disabled.disabled(..)booleanfalseNo typing, steps or focus; disabled: classes apply to the input and its parts.
readonly.readonly(..)booleanfalseThe text can be selected and copied but not changed, by typing or by steps; the buttons are disabled.
placeholder.placeholder(..)stringnoneShown while the field is empty.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say for the field, when no visible label names it.
controlsRight.controls_right(..)booleanfalseBoth buttons in a column on the right of the field, the increment over the decrement. Read when the input is made.
inputClass, inputStyle.input_style(|s| ..)string, StyleProp<TextStyle>noneThe field: its box and its text (color, fontSize, textAlign, placeholderColor).
decrementButtonClass, decrementButtonStyle.decrement_button_style(|s| ..)string, StyleProp<ViewStyle>noneThe decrement button.
incrementButtonClass, incrementButtonStyle.increment_button_style(|s| ..)string, StyleProp<ViewStyle>noneThe increment button.
decrementButton, incrementButton.decrement_button(..), .increment_button(..)JSX.ElementnoneThe content of the buttons: a sign, an icon.
controlsClass, controlsStyle.controls_style(|s| ..)string, StyleProp<ViewStyle>noneThe column that holds both buttons with controlsRight.
prefix.prefix(..)JSX.ElementnoneWhat goes before the field, after the button on its left: a currency sign, an icon.
suffix.suffix(..)JSX.ElementnoneWhat follows the field, before the buttons on its right: a unit, a currency.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe input itself: the row around the field and the buttons.

#Events

EventRustPayloadWhen
onChange.on_change(|value: &Option<f64>| ..)number | nullThe number the field reads as changed (typing, a step, a screen reader’s increment or decrement, the field left with a value out of range).
onSubmit.on_submit(|value: &Option<f64>| ..)number | nullReturn / Enter in the field: the value, held to min and max.
onFocus.on_focus(..)noneThe field took the keyboard focus.
onBlur.on_blur(..)noneThe field lost the keyboard focus (its value held to min and max first).

#Parts

PartInsideStyled with
The inputclass, style
The decrement buttonthe input, first (with controlsRight: the controls column, second)decrementButtonClass, decrementButtonStyle; its content decrementButton
The fieldthe inputinputClass, inputStyle
The prefixthe input, before the fieldits own elements (prefix)
The suffixthe input, after the fieldits own elements (suffix)
The increment buttonthe input, last (with controlsRight: the controls column, first)incrementButtonClass, incrementButtonStyle; its content incrementButton
The controls columnthe input, last, with controlsRightcontrolsClass, controlsStyle

The input is a row the app lays out: flex-row on class, and flex-1 on the field to give it the room the buttons leave. States the parts show:

  • The input: focus-within: while its field edits (a ring around the whole input), disabled:.
  • The field: focus: while it edits, disabled:.
  • A button: hover: under the pointer, active: while pressed, disabled: while the input is disabled or read-only and where a step would do nothing (the decrement at min, the increment at max). Put the hover behind enabled: (enabled:hover:bg-accent) so a disabled button does not light up.

Motion of the states is the app’s choice (transition-colors), and motion-reduce: keeps it still for people who ask for less motion.

#Keyboard

While the field edits, the input takes WAI-ARIA’s spin button keys, which repeat while held:

KeyDoes
UpOne step up.
DownOne step down.
Page Up, Page DownTen steps up or down.

Home and End stay the field’s: they move the caret in the text. The buttons are not stops of the keyboard focus: Tab goes from the field to what follows the input, and a press on a button leaves the focus in the field (on the desktops a press moves the focus to the field when it was elsewhere; on touch screens it leaves it, so no keyboard comes up).

#Accessibility

  • The field is a spin button ("spinbutton") with its label, its value (the number it reads as, between min and max) and its text.
  • A screen reader’s increment and decrement actions (VoiceOver’s swipe up and down) step it, as Up and Down do.
  • The buttons are hidden from screen readers, as a browser’s number field’s are: the field’s own increment and decrement step it.
  • While disabled, screen readers say the field is dimmed.

See Accessibility for labels, roles and actions in general.

#Platform notes

  • The field is the platform’s text field (Input): the keyboard, the caret, selection, copy and paste, input methods are the system’s. Phones show a decimal keyboard, with a sign when min is below 0 or unset.
  • Every platform draws what the app styles. Up / Down and Page Up / Page Down reach the input from a hardware keyboard on every platform.