Элементы
NumberInput
Числовой ввод хранит число, которое пользователь набирает или меняет шагами: количество, цену, размер. Поведение он берёт на себя: его поле принимает число или текст на пути к числу (-, 1.) и отклоняет всё остальное, включая цифры и знаки других клавиатур. Его кнопки, клавиши Up и Down, а также увеличение и уменьшение у скринридера меняют число на step, сохраняя его десятичные знаки, и останавливаются на min и max. При уходе из поля набранное число приводится к этим границам. Внешний вид задаёт приложение: у NumberInput нет собственных цветов, размеров, границ, значков или строк. Он состоит из кнопки уменьшения, поля между prefix и suffix и кнопки увеличения (или поля между ними и колонки с обеими кнопками, controlsRight). Приложение стилизует каждую часть и наполняет кнопки.
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 происходит из nsh/base, крейта nsh-base в Rust. В Rust number_input() создаёт его с сеттером на каждое свойство (.value(..), .input_style(..)), методом-слушателем на каждое событие (.on_change(..)), типизированными методами стиля для самого ввода и .prefix(..) / .suffix(..) для того, что находится вокруг поля. Его значение имеет тип Option<f64>.
#Значение
Значение — это число или null для пустого поля. Приложение владеет им через value и устанавливает то значение, о котором сообщает onChange, как и для любого элемента управления nsh/base. Либо оно оставляет значение самому вводу с помощью defaultValue и всё равно узнаёт об изменениях.
Текст поля и значение разделены там, где это необходимо, чтобы пользователь мог печатать свободно:
onChangeсообщает число, которое читается из текста, всякий раз, когда это число меняется:null, пока текст не читается как число (пусто,-,.). Набор1.после1или1.50после1.5ничего не сообщает.- Текст остаётся таким, как его набрал пользователь, пока он читается как значение, которое хранит ввод. Поэтому управляемый ввод, который возвращает каждое сообщённое значение, сохраняет
1.и2.50. Значение, которое задало приложение и о котором ввод не сообщал, показывается в поле как самый короткий текст, читающийся как это значение (0.3,12). - Во время набора число может выходить за
minиmax(1 на пути к 15 приmin={10}) и сообщается как набрано. Когда поле покидают или нажимают Return / Enter, число приводится к диапазону, и об этом сообщается. Текст, который не читается как число, остаётся как есть. - Шаг (кнопка, клавиша, действие скринридера) запрашивает следующее значение через
onChange. При управляемом значении поле меняется, когда приложение возвращает его, сохраняя десятичные знаки шага (99.5вверх доmax={100}показывает100.0). Поэтому приложение, отклонившее шаг, оставляет поле на месте. Отклонённая приложением правка остаётся в поле, пока оно редактируется, и исчезает, когда поле покидают.
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"
/>;
Поле принимает:
- Цифры, одну десятичную точку и знак в начале (
+или-). Буква, вторая точка или второй знак, а также знак в другом месте отклоняются, и поле возвращается к прежнему тексту. - Полноширинные цифры, знаки и точку, знак минус (U+2212) и идеографическую точку (
。), которые читаются как их ASCII-формы, как их набирают китайские и японские методы ввода. - Запятую как десятичную точку, пока в тексте нет точки (
3,25— это 3.25), как её набирает десятичная клавиша клавиатур многих языков.
#Свойства
| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
value | .value(..) | number | null | нет | Значение (управляемое): число или null для пустого поля. Приложение устанавливает то значение, о котором сообщает onChange. |
defaultValue | .default_value(..) | number | null | нет | Начальное значение, пока value не задано; тогда ввод хранит собственное значение. |
min | .min(..) | number | нет | Наименьшее значение: шаги останавливаются на нём, а набранное значение ниже него приводится к нему при уходе из поля. |
max | .max(..) | number | нет | Наибольшее значение, как min сверху; значение ниже min считается min. |
step | .step(..) | number | 1 | На сколько меняет значение шаг (Page Up / Page Down: десять шагов); значение, не являющееся положительным числом, считается 1. |
disabled | .disabled(..) | boolean | false | Без набора, шагов и фокуса; классы disabled: применяются к вводу и его частям. |
readonly | .readonly(..) | boolean | false | Текст можно выделять и копировать, но нельзя менять ни набором, ни шагами; кнопки отключены. |
placeholder | .placeholder(..) | string | нет | Показывается, пока поле пусто. |
accessibilityLabel | .accessibility_label(..) | string | нет | Что скринридеры произносят для поля, когда его не называет видимая метка. |
controlsRight | .controls_right(..) | boolean | false | Обе кнопки в колонке справа от поля, увеличение над уменьшением. Читается при создании ввода. |
inputClass, inputStyle | .input_style(|s| ..) | string, StyleProp<InputStyle> | нет | Поле: его рамка и текст (color, fontSize, textAlign, placeholderColor, caretColor). |
decrementButtonClass, decrementButtonStyle | .decrement_button_style(|s| ..) | string, StyleProp<ViewStyle> | нет | Кнопка уменьшения. |
incrementButtonClass, incrementButtonStyle | .increment_button_style(|s| ..) | string, StyleProp<ViewStyle> | нет | Кнопка увеличения. |
decrementButton, incrementButton | .decrement_button(..), .increment_button(..) | JSX.Element | нет | Содержимое кнопок: знак, значок. |
controlsClass, controlsStyle | .controls_style(|s| ..) | string, StyleProp<ViewStyle> | нет | Колонка, в которой лежат обе кнопки при controlsRight. |
prefix | .prefix(..) | JSX.Element | нет | Что стоит перед полем, после левой кнопки: знак валюты, значок. |
suffix | .suffix(..) | JSX.Element | нет | Что следует за полем, перед правыми кнопками: единица измерения, валюта. |
class, style | методы стиля | string, StyleProp<ViewStyle> | нет | Сам ввод: строка вокруг поля и кнопок. |
#События
| Событие | Rust | Данные | Когда |
|---|---|---|---|
onChange | .on_change(|value: &Option<f64>| ..) | { value: number | null } | Число, которое читается из поля, изменилось (набор, шаг, увеличение или уменьшение скринридера, уход из поля со значением вне диапазона). |
onSubmit | .on_submit(|value: &Option<f64>| ..) | { value: number | null } | Return / Enter в поле: значение, приведённое к min и max. |
onFocus | .on_focus(..) | нет | Поле получило фокус клавиатуры. |
onBlur | .on_blur(..) | нет | Поле потеряло фокус клавиатуры (сначала его значение приводится к min и max). |
#Части
| Часть | Где находится | Стилизуется через |
|---|---|---|
| Ввод | class, style | |
| Кнопка уменьшения | внутри ввода, первая (при controlsRight: в колонке кнопок, вторая) | decrementButtonClass, decrementButtonStyle; её содержимое decrementButton |
| Поле | внутри ввода | inputClass, inputStyle |
| Префикс | внутри ввода, перед полем | собственные элементы (prefix) |
| Суффикс | внутри ввода, после поля | собственные элементы (suffix) |
| Кнопка увеличения | внутри ввода, последняя (при controlsRight: в колонке кнопок, первая) | incrementButtonClass, incrementButtonStyle; её содержимое incrementButton |
| Колонка кнопок | внутри ввода, последняя, при controlsRight | controlsClass, controlsStyle |
Ввод — это строка, которую раскладывает приложение: flex-row на class и flex-1 на поле, чтобы оно заняло место, оставшееся от кнопок. Состояния, которые показывают части:
- Ввод:
focus-within:, пока его поле редактируется (кольцо вокруг всего ввода),disabled:. - Поле:
focus:, пока оно редактируется,disabled:. - Кнопка:
hover:под указателем,active:при нажатии,disabled:, пока ввод отключён или доступен только для чтения, и там, где шаг ничего не изменит (уменьшение наmin, увеличение наmax). Помещайте наведение заenabled:(enabled:hover:bg-accent), чтобы отключённая кнопка не подсвечивалась.
Анимацию состояний выбирает приложение (transition-colors), а motion-reduce: оставляет её неподвижной для тех, кто просит меньше движения.
#Клавиатура
Пока поле редактируется, ввод обрабатывает клавиши spin button из WAI-ARIA, которые повторяются при удержании:
| Клавиша | Действие |
|---|---|
| Up | Один шаг вверх. |
| Down | Один шаг вниз. |
| Page Up, Page Down | Десять шагов вверх или вниз. |
Home и End остаются за полем: они двигают курсор в тексте. Кнопки не являются остановками фокуса клавиатуры: Tab переходит от поля к тому, что следует за вводом, а нажатие на кнопку оставляет фокус в поле (на десктопах нажатие переносит фокус в поле, если он был в другом месте; на сенсорных экранах фокус остаётся где был, поэтому клавиатура не появляется).
#Доступность
- Поле является spin button (
"spinbutton") со своей меткой, значением (число, которое читается из текста, междуminиmax) и текстом. - Действия увеличения и уменьшения у скринридера (смахивание вверх и вниз в VoiceOver) меняют значение так же, как Up и Down.
- Кнопки скрыты от скринридеров, как и у числового поля браузера: меняют значение собственные действия увеличения и уменьшения поля.
- Пока
disabled, скринридеры сообщают, что поле неактивно.
О метках, ролях и действиях в целом см. Доступность.
#Заметки по платформам
- Поле — это текстовое поле платформы (Input): клавиатура, курсор, выделение, копирование и вставка, методы ввода — системные. Телефоны показывают десятичную клавиатуру со знаком, когда
minменьше 0 или не задан. - Каждая платформа рисует то, что стилизует приложение. Клавиши Up / Down и Page Up / Page Down доходят до ввода с аппаратной клавиатуры на любой платформе.