Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

NumberInput

Числовой ввод хранит число, которое пользователь набирает или меняет шагами: количество, цену, размер. Поведение он берёт на себя: его поле принимает число или текст на пути к числу (-, 1.) и отклоняет всё остальное, включая цифры и знаки других клавиатур. Его кнопки, клавиши Up и Down, а также увеличение и уменьшение у скринридера меняют число на step, сохраняя его десятичные знаки, и останавливаются на min и max. При уходе из поля набранное число приводится к этим границам. Внешний вид задаёт приложение: у NumberInput нет собственных цветов, размеров, границ, значков или строк. Он состоит из кнопки уменьшения, поля между prefix и suffix и кнопки увеличения (или поля между ними и колонки с обеими кнопками, controlsRight). Приложение стилизует каждую часть и наполняет кнопки.

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, крейта 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(..)number1На сколько меняет значение шаг (Page Up / Page Down: десять шагов); значение, не являющееся положительным числом, считается 1.
disabled.disabled(..)booleanfalseБез набора, шагов и фокуса; классы disabled: применяются к вводу и его частям.
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>нетСам ввод: строка вокруг поля и кнопок.

#События

Событие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
Колонка кнопоквнутри ввода, последняя, при controlsRightcontrolsClass, 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 доходят до ввода с аппаратной клавиатуры на любой платформе.