Перейти к содержимому
v3.0.0
English
Русский
Документация

NumberField

NumberField — составной компонент для ввода чисел с необязательными кнопками шага, границами, маской и изменением значения колесом или клавиатурой.
number-field.tsx · 16 строк
import {
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';

<div>
  <Label htmlFor='preview-guests'>Guests</Label>
  <NumberField min={1} max={10} defaultValue={2}>
    <NumberFieldDecrementButton aria-label='Remove guest' />
    <NumberFieldInput id='preview-guests' />
    <NumberFieldIncrementButton aria-label='Add guest' />
  </NumberField>
</div>
Поддержка браузеров
Недавно доступно, 2024
  • Chrome 125
  • Edge 125
  • Firefox 128
  • Safari 18
  • Safari на iOS 18
Размер в бандле
История версий
Добавлен в v1.0.0, последнее изменение в v3.0.0

Кратко

NumberField — составной компонент для ввода чисел с необязательными кнопками шага, границами, маской и изменением значения колесом или клавиатурой. Он предназначен для точных числовых значений, а не для любого текста, в котором есть цифры.

Импорты

import {
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  type NumberFieldDecrementProps,
  type NumberFieldIncrementProps,
  type NumberFieldInputProps,
  type NumberFieldProps,
  type NumberFieldSize,
} from '@vega-ui/react';

Экспортируемые типы

  • NumberFieldProps
  • NumberFieldInputProps
  • NumberFieldIncrementProps
  • NumberFieldDecrementProps
  • NumberFieldSize

Минимальная композиция

seat-count.tsx · 26 строк
import {
  HelperText,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';
import { useId } from 'react';

export default function SeatCount() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 240 }}>
      <Label htmlFor={id}>Seats</Label>
      <NumberField min={1} max={50} defaultValue={5}>
        <NumberFieldDecrementButton aria-label='Remove a seat' />
        <NumberFieldInput id={id} name='seats' aria-describedby={hintId} />
        <NumberFieldIncrementButton aria-label='Add a seat' />
      </NumberField>
      <HelperText id={hintId}>From 1 to 50 seats on the Team plan.</HelperText>
    </div>
  );
}

Обязательные части

  • NumberField: корневой контейнер состояния
  • NumberFieldInput: числовое текстовое поле

Необязательные части

  • NumberFieldIncrementButton: кнопка увеличения на шаг
  • NumberFieldDecrementButton: кнопка уменьшения на шаг

Порядок композиции

  1. NumberField
  2. NumberFieldDecrementButton
  3. NumberFieldInput
  4. NumberFieldIncrementButton

Варианты

  • Размер: sm, md, lg
  • Модель значения: контролируемая или неконтролируемая
  • Взаимодействие: ввод с клавиатуры, кнопки шага, стрелки, необязательное колесо мыши

Пограничные случаи

  • Во время редактирования введённое значение может быть временно пустым или незавершённым.
  • Кнопки шага, ввод и логика границ должны оставаться синхронизированными.
  • Изменение колесом мыши может быть неожиданным в плотных формах, если включить его необдуманно.
  • Ввод, шаг, ограничение и валидация должны подчиняться одной числовой политике, а не вести себя каждый по-своему.

Частые ошибки

  • Считать каждое промежуточное введённое значение валидным итоговым числом.
  • Забывать про обработку границ, когда есть кнопки шага.
  • Использовать компонент там, где понятнее было бы поле свободного текста.

Примеры

Базовый

Базовый: число мест с границами

min, max и defaultValue задаются на корне; id, name и aria-describedby — на NumberFieldInput. У кнопок шага нет встроенного имени, поэтому каждой нужен aria-label; на границах они отключаются сами.

seat-count.tsx · 26 строк
import {
  HelperText,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';
import { useId } from 'react';

export default function SeatCount() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 240 }}>
      <Label htmlFor={id}>Seats</Label>
      <NumberField min={1} max={50} defaultValue={5}>
        <NumberFieldDecrementButton aria-label='Remove a seat' />
        <NumberFieldInput id={id} name='seats' aria-describedby={hintId} />
        <NumberFieldIncrementButton aria-label='Add a seat' />
      </NumberField>
      <HelperText id={hintId}>From 1 to 50 seats on the Team plan.</HelperText>
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: количество определяет сумму заказа

value и onChangeValue работают с числами, а не строками. Очищенное поле сообщает NaN, поэтому сумма проверяет Number.isNaN перед форматированием, а кнопки ниже задают значение снаружи, в том числе очищают его.

en-US
ru-RU
ar-EG
he-IL
order-total.tsx · 41 строка
import {
  Button,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  Text,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const UNIT_PRICE = 24.5;
export const locales = ['en-US', 'ru-RU', 'ar-EG', 'he-IL'];

export default function OrderTotal({ locale = 'en-US' }: { locale?: string }) {
  const currency = new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' });

  const id = useId();
  const [quantity, setQuantity] = useState(2);
  const empty = Number.isNaN(quantity);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%', maxWidth: 240 }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Notebooks, {currency.format(UNIT_PRICE)} each</Label>
        <NumberField min={0} max={99} value={quantity} onChangeValue={setQuantity}>
          <NumberFieldDecrementButton aria-label='Decrease quantity' />
          <NumberFieldInput id={id} />
          <NumberFieldIncrementButton aria-label='Increase quantity' />
        </NumberField>
      </div>
      <Text size={3} fontWeight={500} role='status' style={{ fontVariantNumeric: 'tabular-nums' }}>
        Total: {empty ? '—' : currency.format(quantity * UNIT_PRICE)}
      </Text>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setQuantity(10)}>Bulk: 10</Button>
        <Button size='sm' variant='secondary' appearance='transparent' onClick={() => setQuantity(NaN)}>Clear</Button>
      </div>
    </div>
  );
}

Форма/интеграция

Форма/интеграция: отправка и сброс в форме

Ставьте name на NumberFieldInput. FormData возвращает текст поля с разделителями тысяч, поэтому перед преобразованием в число уберите пробелы. Сброс возвращает defaultValue.

form-submit-and-reset.tsx · 44 строки
import {
  Button,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  Text,
} from '@vega-ui/react';
import { FormEvent, useId, useState } from 'react';

export default function FormSubmitAndReset() {
  const id = useId();
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const raw = String(new FormData(event.currentTarget).get('monthlyBudget'));
    const budget = Number(raw.replace(/\s/g, ''));
    setSubmitted(`Field text “${raw}” → number ${budget}`);
  };

  return (
    <form
      onSubmit={onSubmit}
      onReset={() => setSubmitted('')}
      style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 280 }}
    >
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Monthly budget, USD</Label>
        <NumberField min={0} max={1000000} step={500} defaultValue={2500} maximumFractionDigits={0}>
          <NumberFieldDecrementButton aria-label='Decrease budget by 500' />
          <NumberFieldInput id={id} name='monthlyBudget' required />
          <NumberFieldIncrementButton aria-label='Increase budget by 500' />
        </NumberField>
      </div>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        <Button type='submit'>Save</Button>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
      </div>
      <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{submitted}</Text>
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: количество в корзине

Компактные счётчики size='sm' в повторяющихся строках. У каждого инпута и кнопки есть aria-label с названием товара: просто «Количество» неоднозначно, когда строк несколько. Очищенные строки не входят в сумму.

cart-line-items.tsx · 76 строк
import {
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  Text,
} from '@vega-ui/react';
import { useState } from 'react';

interface LineItem {
  sku: string;
  name: string;
  price: number;
  quantity: number;
}

const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export default function CartLineItems() {
  const [items, setItems] = useState<LineItem[]>([
    { sku: 'KB-01', name: 'Mechanical keyboard', price: 129, quantity: 1 },
    { sku: 'MS-02', name: 'Wireless mouse', price: 49, quantity: 2 },
    { sku: 'HB-07', name: 'USB-C hub, 7 ports', price: 64.9, quantity: 1 },
  ]);

  const changeQuantity = (sku: string, quantity: number) => {
    setItems((current) => current.map((item) => (item.sku === sku ? { ...item, quantity } : item)));
  };

  const total = items.reduce((sum, item) => {
    if (Number.isNaN(item.quantity)) return sum;
    return sum + item.price * item.quantity;
  }, 0);

  return (
    <div
      style={{
        display: 'grid',
        gap: 'var(--spacing-8)',
        width: '100%',
        padding: 'var(--spacing-8)',
        boxSizing: 'border-box',
        borderRadius: 'var(--radius-5)',
        border: '1px solid var(--separator-opaque)',
        background: 'var(--surface-primary)',
      }}
    >
      {items.map((item) => (
        <div key={item.sku} style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 132px', alignItems: 'center', gap: 'var(--spacing-6)' }}>
          <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
            <Text size={3} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</Text>
            <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{currency.format(item.price)}</Text>
          </div>
          <NumberField size='sm' min={0} max={20} value={item.quantity} onChangeValue={(quantity) => changeQuantity(item.sku, quantity)}>
            <NumberFieldDecrementButton aria-label={`Remove one ${item.name}`} />
            <NumberFieldInput aria-label={`Quantity of ${item.name}`} />
            <NumberFieldIncrementButton aria-label={`Add one ${item.name}`} />
          </NumberField>
        </div>
      ))}
      <div
        role='status'
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'baseline',
          paddingBlockStart: 'var(--spacing-8)',
          borderBlockStart: '1px solid var(--separator-opaque)',
        }}
      >
        <Text size={3} style={{ color: 'var(--label-secondary)' }}>Subtotal</Text>
        <Text size={3} fontWeight={600} style={{ fontVariantNumeric: 'tabular-nums' }}>{currency.format(total)}</Text>
      </div>
    </div>
  );
}

Ошибка

Ошибка: меньше бизнес-минимума

Маска удерживает значение в пределах min/max, но правила вроде «мест не меньше, чем активных участников» — ваша забота. Ошибка вычисляется из значения, ставится через error на корне и aria-invalid на инпуте и исчезает, как только значение снова корректно.

seats-below-active-members.tsx · 39 строк
import {
  HelperText,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const ACTIVE_MEMBERS = 12;

const getSeatsError = (seats: number) => {
  if (Number.isNaN(seats)) return 'Enter the number of seats.';
  if (seats < ACTIVE_MEMBERS) return `You have ${ACTIVE_MEMBERS} active members. Remove members first or keep at least ${ACTIVE_MEMBERS} seats.`;
  return '';
};

export default function SeatsBelowActiveMembers() {
  const id = useId();
  const messageId = `${id}-message`;
  const [seats, setSeats] = useState(10);
  const error = getSeatsError(seats);
  const invalid = error !== '';

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Seats after renewal</Label>
      <NumberField min={1} max={200} value={seats} onChangeValue={setSeats} error={invalid}>
        <NumberFieldDecrementButton aria-label='Remove a seat' />
        <NumberFieldInput id={id} aria-invalid={invalid} aria-describedby={messageId} />
        <NumberFieldIncrementButton aria-label='Add a seat' />
      </NumberField>
      <HelperText id={messageId} error={invalid}>
        {invalid ? error : `Covers all ${ACTIVE_MEMBERS} active members.`}
      </HelperText>
    </div>
  );
}

Отключённый

Отключённый: заблокировано до явного переопределения

disabled на корне отключает инпут и обе кнопки. Чекбокс разблокирует поле, а подсказка объясняет, почему оно заблокировано.

locked-until-override.tsx · 41 строка
import {
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndicator,
  CheckboxInput,
  HelperText,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

export default function LockedUntilOverride() {
  const id = useId();
  const [override, setOverride] = useState(false);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%', maxWidth: 280 }}>
      <Checkbox style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
        <CheckboxInput checked={override} onChange={(event) => setOverride(event.currentTarget.checked)} />
        <CheckboxIndicator>
          <CheckboxCheckedIcon />
        </CheckboxIndicator>
        Override the default rate limit
      </Checkbox>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor={id}>Requests per minute</Label>
        <NumberField min={60} max={6000} step={60} defaultValue={600} disabled={!override}>
          <NumberFieldDecrementButton aria-label='Decrease by 60' />
          <NumberFieldInput id={id} name='rateLimit' aria-describedby={`${id}-hint`} />
          <NumberFieldIncrementButton aria-label='Increase by 60' />
        </NumberField>
        <HelperText id={`${id}-hint`}>
          {override ? 'From 60 to 6,000, in steps of 60.' : 'Uses the plan default of 600.'}
        </HelperText>
      </div>
    </div>
  );
}

Пограничный случай

Пограничный случай: дробный шаг и единица измерения

step={0.5} вместе с maximumFractionDigits={1} даёт шаг без погрешностей плавающей точки. Суффикс % стоит между инпутом и кнопкой увеличения и скрыт от скринридера; вычисленная цена ниже связана через aria-describedby.

en-US
ru-RU
ar-EG
he-IL
discount-percentage.tsx · 35 строк
import {
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
  Text,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const LIST_PRICE = 1290;
export const locales = ['en-US', 'ru-RU', 'ar-EG', 'he-IL'];

export default function DiscountPercentage({ locale = 'en-US' }: { locale?: string }) {
  const currency = new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' });

  const id = useId();
  const [discount, setDiscount] = useState(12.5);
  const price = Number.isNaN(discount) ? LIST_PRICE : LIST_PRICE * (1 - discount / 100);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260 }}>
      <Label htmlFor={id}>Discount</Label>
      <NumberField min={0} max={100} step={0.5} maximumFractionDigits={1} value={discount} onChangeValue={setDiscount}>
        <NumberFieldDecrementButton aria-label='Decrease discount by 0.5%' />
        <NumberFieldInput id={id} aria-describedby={`${id}-price`} />
        <Text size={3} aria-hidden style={{ color: 'var(--label-secondary)' }}>%</Text>
        <NumberFieldIncrementButton aria-label='Increase discount by 0.5%' />
      </NumberField>
      <Text id={`${id}-price`} size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>
        Annual plan: {currency.format(price)} instead of {currency.format(LIST_PRICE)}
      </Text>
    </div>
  );
}

Пограничный случай: большие суммы без колеса мыши

Большие значения получают разделители тысяч прямо при вводе. changeOnWheel={false} не даёт колесу мыши менять сумму в фокусе, когда пользователь прокручивает длинную форму.

large-amount-no-wheel.tsx · 38 строк
import {
  HelperText,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';
import { useId, useState } from 'react';

const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });

export default function LargeAmountNoWheel() {
  const id = useId();
  const [limit, setLimit] = useState(2500000);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%' }}>
      <Label htmlFor={id}>Annual spend limit, USD</Label>
      <NumberField
        min={0}
        max={50000000}
        step={10000}
        maximumFractionDigits={0}
        changeOnWheel={false}
        value={limit}
        onChangeValue={setLimit}
      >
        <NumberFieldDecrementButton aria-label='Decrease by 10,000' />
        <NumberFieldInput id={id} aria-describedby={`${id}-hint`} />
        <NumberFieldIncrementButton aria-label='Increase by 10,000' />
      </NumberField>
      <HelperText id={`${id}-hint`} style={{ fontVariantNumeric: 'tabular-nums' }}>
        {Number.isNaN(limit) ? 'No limit set.' : `About ${compact.format(limit)}. Max 50M.`}
      </HelperText>
    </div>
  );
}

Анатомия

Обзор

NumberField расширяет модель TextField числовой маской, границами, поведением шага и необязательными кнопками шага.

Обязательные части

NumberField

Обязательна. Владеет числовыми ограничениями, производным состоянием границ и поведением шага.

NumberFieldInput

Обязательна. Рендерит сам input и синхронизируется с числовым контекстом.

Необязательные части

NumberFieldIncrementButton

Необязательна. Увеличивает значение на шаг.

NumberFieldDecrementButton

Необязательна. Уменьшает значение на шаг.

Порядок композиции

  1. NumberField
  2. необязательная кнопка уменьшения
  3. NumberFieldInput
  4. необязательная кнопка увеличения

Допустимые варианты композиции

seat-count.tsx · 26 строк
import {
  HelperText,
  Label,
  NumberField,
  NumberFieldDecrementButton,
  NumberFieldIncrementButton,
  NumberFieldInput,
} from '@vega-ui/react';
import { useId } from 'react';

export default function SeatCount() {
  const id = useId();
  const hintId = `${id}-hint`;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 240 }}>
      <Label htmlFor={id}>Seats</Label>
      <NumberField min={1} max={50} defaultValue={5}>
        <NumberFieldDecrementButton aria-label='Remove a seat' />
        <NumberFieldInput id={id} name='seats' aria-describedby={hintId} />
        <NumberFieldIncrementButton aria-label='Add a seat' />
      </NumberField>
      <HelperText id={hintId}>From 1 to 50 seats on the Team plan.</HelperText>
    </div>
  );
}

Недопустимые варианты композиции

Кнопки без input

Кнопки шага есть, но редактируемого числового значения нет.

Внешняя числовая логика, конфликтующая с внутренней моделью границ

Противоречащие друг другу правила ограничения или разбора дают неожиданные значения.

Допущения о свободном тексте

Элемент управления в первую очередь числовой, и его не стоит рассматривать как обычное текстовое поле.