本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

NumberInput

數字輸入框保存使用者輸入或步進調整的數字,例如數量、價格、尺寸。它自行掌管行為:欄位接受數字,或正在輸入成數字的文字(-、1.),其他一律拒絕,包括其他鍵盤的數字與符號;它的按鈕、上下鍵,以及螢幕閱讀器的遞增與遞減,會以 step 為單位步進調整數字並保留其小數位數,且停在 min 與 max;離開欄位時,輸入的數字會被限制在這個範圍內。外觀由 App 決定:NumberInput 本身沒有任何顏色、尺寸、邊框、圖示或字串。它由遞減按鈕、位於 App 的 prefix 與 suffix 之間的欄位,以及遞增按鈕組成(或是欄位,加上位於其右側、包含兩個按鈕的一欄,controlsRight),App 為每個部分設定樣式並填入按鈕內容。

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 }) => ({ 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("+"))
}

NumberInput 來自 nsh/base,也就是 Rust 中的 nsh-base crate。在 Rust 中,number_input() 會建立一個數字輸入框,每個 prop 有一個 setter(.value(..)、.input_style(..)),每個事件有一個監聽方法(.on_change(..)),輸入框本身有型別化的樣式方法,欄位周圍的內容則使用 .prefix(..) / .suffix(..)。它的值是 Option<f64>。

#值

值是一個數字,欄位為空時則為 null。App 以 value 掌管值,並設定 onChange 回報的值,與 nsh/base 的每個控制項相同;或者以 defaultValue 把值交給輸入框自行處理,同樣能收到變更通知。

欄位的文字與值在必要之處分開處理,讓使用者能自由輸入:

  • 只要文字解讀出的數字有變,onChange 就會回報該數字;當文字解讀不出數字(空白、-、.)時回報 null;在 1 之後輸入 1.,或在 1.5 之後輸入 1.50,則不會回報任何內容。
  • 只要文字解讀出的值與輸入框持有的值相同,文字就維持使用者輸入的樣子,因此把每個回報的值傳回去的受控輸入框會保留 1. 與 2.50。App 設定、但輸入框並未回報的值,會以能解讀為該值的最短文字顯示在欄位中(0.3、12)。
  • 輸入過程中,數字可以超出 min 與 max 的範圍(在 min={10} 的情況下,要輸入 15 時先輸入的 1),並照輸入的內容回報。離開欄位或按下 Return / Enter 時,數值會被限制在範圍內並回報;解讀不出數字的文字則維持原樣。
  • 步進(按鈕、按鍵、螢幕閱讀器的動作)會以 onChange 請求下一個值;在受控值的情況下,欄位會在 App 把值傳回後才變動,並保留步進的小數位數(99.5 上調到 max={100} 會顯示 100.0),因此拒絕步進的 App 會讓欄位維持原狀。App 拒絕的編輯在編輯期間會留在欄位中,離開欄位時才消失。
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),如同許多語言鍵盤上的小數鍵所輸入的內容。

#Props

PropRust型別預設值說明
value.value(..)number | null無值(受控):一個數字,欄位為空時則為 null。App 設定 onChange 回報的值。
defaultValue.default_value(..)number | null無未設定 value 時的初始值;此時輸入框自行保存值。
min.min(..)number無最小值:步進會停在此值,輸入低於此值的數字會在離開欄位時被限制為此值。
max.max(..)number無最大值,與 min 從上方限制的方式相同;低於 min 的值視為 min。
step.step(..)number1每次步進的幅度(Page Up / Page Down:十個步進);不是正數的值視為 1。
disabled.disabled(..)booleanfalse無法輸入、步進或取得焦點;disabled: class 會套用到輸入框及其各部分。
readonly.readonly(..)booleanfalse文字可以選取與複製,但無法以輸入或步進變更;按鈕會停用。
placeholder.placeholder(..)string無欄位為空時顯示。
accessibilityLabel.accessibility_label(..)string無在沒有可見標籤為欄位命名時,螢幕閱讀器為欄位朗讀的內容。
controlsRight.controls_right(..)booleanfalse兩個按鈕在欄位右側垂直排成一欄,遞增在上、遞減在下。在建立輸入框時讀取。
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>無輸入框本身:圍繞欄位與按鈕的那一列。

#事件

事件RustPayload時機
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
控制欄在輸入框內,位於最後,搭配 controlsRightcontrolsClass、controlsStyle

輸入框是由 App 負責排版的一列:在 class 上使用 flex-row,並在欄位上使用 flex-1,讓欄位取得按鈕之外剩餘的空間。各部分會呈現的狀態如下:

  • 輸入框:欄位編輯期間為 focus-within:(整個輸入框外圍的光環)、disabled:。
  • 欄位:編輯期間為 focus:、disabled:。
  • 按鈕:指標位於其上時為 hover:,按下期間為 active:,輸入框停用或唯讀,以及步進不會有任何作用時為 disabled:(在 min 的遞減按鈕、在 max 的遞增按鈕)。請把 hover 放在 enabled:(enabled:hover:bg-accent)之後,這樣停用的按鈕就不會亮起。

狀態的動態效果由 App 自行選擇(transition-colors),而 motion-reduce: 會讓要求減少動態效果的使用者看到靜止的畫面。

#鍵盤

欄位編輯期間,輸入框接受 WAI-ARIA 的 spin button 按鍵,按住時會重複觸發:

按鍵作用
Up向上一個步進。
Down向下一個步進。
Page Up、Page Down向上或向下十個步進。

Home 與 End 仍屬於欄位:它們會在文字中移動插入點。按鈕不是鍵盤焦點的停駐點:Tab 會從欄位移到輸入框之後的內容,而按下按鈕會讓焦點留在欄位中(在桌面平台上,若焦點原本在別處,按下按鈕會把焦點移到欄位;在觸控螢幕上則維持不動,因此不會跳出鍵盤)。

#無障礙

  • 欄位是一個 spin button("spinbutton"),具有標籤、值(它解讀出的數字,介於 min 與 max 之間)以及文字。
  • 螢幕閱讀器的遞增與遞減動作(VoiceOver 的上下滑動)會像 Up 與 Down 一樣對欄位進行步進。
  • 按鈕對螢幕閱讀器隱藏,與瀏覽器的數字欄位相同:欄位本身的遞增與遞減就能進行步進。
  • 當 disabled 時,螢幕閱讀器會說明欄位呈暗淡狀態。

關於標籤、角色與動作的一般說明,請參閱 Accessibility。

#平台說明

  • 欄位是平台的文字欄位(Input):鍵盤、插入點、選取、複製與貼上、輸入法皆由系統提供。當 min 小於 0 或未設定時,手機會顯示帶有正負號的小數鍵盤。
  • 每個平台都會繪製 App 設定的樣式。在每個平台上,Up / Down 與 Page Up / Page Down 都能從實體鍵盤傳達到輸入框。