要素
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 は Rust の nsh-base クレートである nsh/base から提供されます。Rust では、number_input() により、プロパティごとのセッター(.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}で 15 を入力する途中の 1 など)、入力されたまま通知されます。フィールドを離れたとき、または 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"
/>;
フィールドが受け付けるもの:
- 数字、1 つの小数点、先頭の符号(
+または-)。文字、2 つ目の小数点や符号、先頭以外の符号は拒否され、フィールドは元のテキストに戻ります。 - 全角の数字、符号、小数点、マイナス記号(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 | 1 回のステップの幅(Page Up / Page Down では 10 ステップ分)。正の数でない値は 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 の場合: コントロール列の 2 番目) | decrementButtonClass, decrementButtonStyle。中身は decrementButton |
| フィールド | 入力の内側 | inputClass, inputStyle |
| プレフィックス | 入力の内側、フィールドの前 | 独自の要素(prefix) |
| サフィックス | 入力の内側、フィールドの後 | 独自の要素(suffix) |
| 増加ボタン | 入力の最後(controlsRight の場合: コントロール列の 1 番目) | 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 のスピンボタンのキーを受け付け、押し続けると繰り返されます。
| キー | 動作 |
|---|---|
| Up | 1 ステップ増やす。 |
| Down | 1 ステップ減らす。 |
| Page Up, Page Down | 10 ステップ増やす、または減らす。 |
Home と End はフィールド本来の動作のままで、テキスト内のキャレットを移動します。ボタンはキーボードフォーカスの停止位置ではありません。Tab はフィールドから入力の次の要素へ移動し、ボタンを押してもフォーカスはフィールドに残ります(デスクトップでは、フォーカスが別の場所にあった場合、押すとフォーカスがフィールドに移ります。タッチスクリーンではフォーカスを移さないため、キーボードは表示されません)。
#アクセシビリティ
- フィールドはスピンボタン(
"spinbutton")で、ラベル、値(minとmaxの間で、読み取られる数値)、およびテキストを持ちます。 - スクリーンリーダーの増加・減少アクション(VoiceOver の上下スワイプ)は、Up や Down と同様にステップ操作を行います。
- ボタンは、ブラウザーの数値フィールドのボタンと同様に、スクリーンリーダーからは隠されます。フィールド自身の増加・減少アクションがステップ操作を行います。
disabledの間、スクリーンリーダーはフィールドが淡色表示(無効)であると伝えます。
ラベル、ロール、アクションに関する一般的な説明は、アクセシビリティを参照してください。
#プラットフォームに関する注意
- フィールドはプラットフォームのテキストフィールド(Input)です。キーボード、キャレット、選択、コピーと貼り付け、入力メソッドはシステムのものです。スマートフォンでは小数点付きのキーボードが表示され、
minが 0 未満の場合や未設定の場合は符号も表示されます。 - どのプラットフォームでも、アプリがスタイルを設定したものがそのまま描画されます。Up / Down と Page Up / Page Down は、どのプラットフォームでもハードウェアキーボードから入力に届きます。