元素
NumberInput
數字輸入框用於承載用戶輸入或步進調整的數字,例如數量、價格、尺寸。它自行管理行為:欄位接受數字,或正在輸入成數字的文字(-、1.),其他內容一律拒絕,包括其他鍵盤的數字和符號;它的按鈕、上方向鍵和下方向鍵,以及螢幕閱讀器的遞增和遞減操作,會按 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,即 Rust 中的 nsh-base crate。在 Rust 中,number_input() 會建立一個數字輸入框,每個屬性有一個 setter(.value(..)、.input_style(..)),每個事件有一個監聽方法(.on_change(..)),輸入框本身有型別化的樣式方法,欄位周圍的內容則使用 .prefix(..) / .suffix(..)。它的值是 Option<f64>。
#值
值是一個數字,欄位為空時則為 null。應用程式以 value 持有該值,並設定 onChange 所回報的值,與 nsh/base 的每個控件一樣;或者以 defaultValue 把值交給輸入框管理,同樣可以接收到變更。
欄位的文字與值在必要時各自獨立,讓用戶可以自由輸入:
- 每當文字所讀出的數字改變時,
onChange就會回報該數字;當文字讀不出任何數字時(空白、-、.),回報null;在1之後輸入1.,或在1.5之後輸入1.50,則不會回報任何內容。 - 只要文字讀出的值與輸入框持有的值相同,文字就會保持用戶輸入的樣子,因此把每個回報的值傳回去的受控輸入框,會保留
1.和2.50。應用程式設定而輸入框沒有回報過的值,會以能讀出該值的最短文字顯示在欄位中(0.3、12)。 - 輸入期間,數字可以超出
min和max的範圍(例如min={10}時,1 是在輸入 15 的途中),並按輸入的內容回報。離開欄位或按 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: class 會套用於輸入框及其各部分。 |
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 |
輸入框是一行由應用程式排版的元素:在 class 上使用 flex-row,並在欄位上使用 flex-1,讓它佔用按鈕剩下的空間。各部分會顯示的狀態:
- 輸入框:欄位編輯期間為
focus-within:(圍繞整個輸入框的光環),以及disabled:。 - 欄位:編輯期間為
focus:,以及disabled:。 - 按鈕:指標位於其上時為
hover:,按下期間為active:,輸入框停用或唯讀,以及步進無效時為disabled:(在min時的遞減按鈕、在max時的遞增按鈕)。請把懸停樣式放在enabled:(enabled:hover:bg-accent)之後,這樣停用的按鈕就不會亮起。
狀態的動態效果由應用程式決定(transition-colors),而 motion-reduce: 會為要求減少動態效果的用戶讓它保持靜止。
#鍵盤
欄位編輯期間,輸入框接受 WAI-ARIA 的 spin button 按鍵,按住時會重複觸發:
| 按鍵 | 作用 |
|---|---|
| 上方向鍵 | 向上步進一次。 |
| 下方向鍵 | 向下步進一次。 |
| Page Up、Page Down | 向上或向下步進十次。 |
Home 和 End 仍屬於欄位:它們會在文字中移動插入點。按鈕不是鍵盤焦點的停靠點:Tab 會從欄位移到輸入框之後的內容,而按下按鈕會讓焦點留在欄位中(在桌面平台上,如果焦點原本在別處,按下按鈕會把焦點移到欄位;在觸控螢幕上則保持不變,因此不會彈出鍵盤)。
#無障礙功能
- 欄位是一個 spin button(
"spinbutton"),帶有它的標籤、值(它所讀出的數字,介於min和max之間)及其文字。 - 螢幕閱讀器的遞增和遞減操作(VoiceOver 的向上和向下滑動)會像上方向鍵和下方向鍵一樣步進數字。
- 按鈕對螢幕閱讀器隱藏,與瀏覽器的數字欄位相同:由欄位本身的遞增和遞減操作來步進。
- 當
disabled時,螢幕閱讀器會說明該欄位呈暗灰狀態。
有關標籤、角色和操作的一般說明,請參閱 無障礙功能。
#平台說明
- 欄位是平台的文字欄位(Input):鍵盤、插入點、選取、複製和貼上、輸入法均由系統提供。手機會顯示小數鍵盤,當
min小於 0 或未設定時,鍵盤會帶有正負號。 - 每個平台都會繪製應用程式所設定的樣式。在所有平台上,實體鍵盤的上方向鍵、下方向鍵以及 Page Up / Page Down 都能作用於輸入框。