元素
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 }) => (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("+"))
}import { mount, signal, Text } from "nsh";
import { NumberInput } from "nsh/base";
function App() {
const tickets = signal<number | null>(2);
return (
<NumberInput
value={tickets()}
onChange={(next) => tickets.set(next)}
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,在 Rust 中是 nsh-base crate。在 Rust 中,number_input() 创建一个数字输入框,每个属性一个
setter(.value(..)、.input_style(..)),每个事件一个监听方法(.on_change(..)),用类型化的样式方法设置输入框本身,用 .prefix(..) / .suffix(..) 添加输入框前后的内容。它的值是
Option<f64>。
#值
值是一个数字,或表示空输入框的 null。和 nsh/base 的所有控件一样,应用用 value 持有它,并把 onChange 报告的值设回去;也可以用 defaultValue 把值交给输入框自己保存,
变化照样会报告。
输入框的文本和值在需要时分开保存,让用户可以自由输入:
- 输入框读出的数字变化时,
onChange报告这个数字:文本读不出数字时(空、-、.)报告null;在1后面输入成1.,或在1.5后面输入成1.50,不会报告。 - 只要文本读出的是输入框当前持有的值,文本就保持用户输入的样子,所以把每个报告的值设回去的受控输入框会保留
1.和2.50。应用设置的、输入框没有报告过的值会显示在输入框里, 写成能读回这个值的最短文本(0.3、12)。 - 输入过程中,数字可以超出
min和max(min={10}时,输入 15 要先经过 1),并按输入的样子报告。离开输入框或按 Return / Enter 时,它被限制到这个范围内并报告; 读不出数字的文本保持原样。 - 一次步进(按钮、按键、屏幕阅读器的操作)通过
onChange请求下一个值;受控时,应用把值设回去后输入框才变化,并保留步进的小数位(max={100}时99.5向上步进显示100.0),所以拒绝这次步进的应用会让输入框保持不变。应用拒绝的输入在编辑期间留在输入框里,离开输入框时消失。
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"
/>;
输入框接受:
- 数字、一个小数点和开头的一个正负号(
+或-);字母、第二个小数点或正负号、出现在其他位置的正负号会被拒绝,输入框回到原来的文本。 - 全角数字、正负号和小数点,减号(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<TextStyle> | 无 | 输入框:它的盒子和文字(color、fontSize、textAlign、placeholderColor)。 |
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>| ..) | number | null | 输入框读出的数字变化了(输入、步进、屏幕阅读器的增加或减少、离开输入框时值超出范围)。 |
onSubmit | .on_submit(|value: &Option<f64>| ..) | 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 |
数字输入框是一行,由应用布局:在 class 上用 flex-row,在输入框上用 flex-1 让它占满按钮留下的空间。各部件显示的状态:
- 数字输入框:输入框编辑时的
focus-within:(围住整个数字输入框的焦点环)、disabled:。 - 输入框:编辑时的
focus:、disabled:。 - 按钮:指针悬停时的
hover:、按下时的active:,数字输入框禁用或只读时、以及步进不起作用时(减少按钮在min、增加按钮在max)的disabled:。把悬停放在enabled:后面(enabled:hover:bg-accent),禁用的按钮就不会亮起。
状态的动效由应用决定(transition-colors),motion-reduce: 让希望减少动效的用户看到静止的状态。
#键盘
输入框编辑时,数字输入框接收 WAI-ARIA 数值调节框(spin button)的按键,按住时会重复:
| 按键 | 作用 |
|---|---|
| Up | 向上一步。 |
| Down | 向下一步。 |
| Page Up、Page Down | 向上或向下十步。 |
Home 和 End 仍归输入框:它们在文本中移动光标。按钮不是键盘焦点的一站:Tab 从输入框直接移到数字输入框后面的内容,按下按钮时焦点留在输入框里(在桌面平台上,焦点在别处时按下按钮会把焦点移到输入框; 在触摸屏上不会移动焦点,所以不会弹出键盘)。
#无障碍
- 输入框是一个数值调节框(
"spinbutton"),带有它的标签、值(读出的数字,在min和max之间)和文本。 - 屏幕阅读器的增加和减少操作(VoiceOver 的上下轻扫)会步进它,和 Up、Down 一样。
- 和浏览器的数字输入框一样,按钮对屏幕阅读器隐藏:输入框自己的增加和减少操作就能步进它。
disabled时,屏幕阅读器会说明输入框已变暗。
标签、角色和操作的一般用法见无障碍。
#平台说明
- 输入框是平台自己的文本框(Input):键盘、光标、选择、复制粘贴和输入法都由系统提供。手机上显示小数键盘,
min小于 0 或未设置时带正负号。 - 每个平台都绘制应用设置的样式。硬件键盘的 Up / Down 和 Page Up / Page Down 在所有平台上都能到达数字输入框。