GrNumberInput

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда значение числовое.

Когда брать

  • значение числовое — количество, цена, процент: v-model даёт number, а не строку;
  • есть шаг и границыstep, min, max с кнопками ± и удержанием;
  • важна точностьprecision вместе с разделителем и группировкой разрядов по локали;
  • пустое значение допустимоnull означает «не заполнено», а не ноль.

Когда взять другое

НужноБерите
Значение задают перетаскиваниемGrSlider
Значение текстовоеGrInput
Число — оценка звёздамиGrRating
Выбор из фиксированного набора чиселGrSelect
Показать число, а не ввестиGrStatistic

Разница с GrInput type="number" не косметическая: нативное числовое поле отдаёт строку, теряет значение на неверном вводе и по-разному ведёт себя в браузерах. Здесь модель — number | null, и null отличается от нуля.

Значение — число, ввод — черновик

v-modelnumber | null, где null означает «пусто». Так же, как у GrSlider: числовой контрол отдаёт число, а не строку, которую потребителю приходится разбирать самому.

Возражение против числовой модели было такое: незавершённый ввод («-», «1,») числом не является, а превращать его в NaN на каждом нажатии нельзя. Оно снимается разделением ролей — незавершённый набор просто не обязан быть моделью:

  • черновик — строка ровно в том виде, в каком её набирают. Живёт внутри поля, пока набор не завершён, и именно он показывается на экране;
  • модель — число. Пока черновик не разбирается в число, модель говорит null, а не хранит полуфабрикат.

Коммит (change, потеря фокуса, шаг кнопкой или клавишей) снимает черновик: применяются min/max и precision, и показ снова считается от модели. Именно поэтому precision="2" не превращает «7» в «7.00» прямо под курсором — только по завершении ввода.

decimalSeparator перестаёт быть частью значения и остаётся тем, чем и был, — способом показа и ввода дробной части. В модели 1,25 при decimal-separator="," лежит как 1.25.

Кнопки ±

<GrNumberInput v-model="qty" controls :min="1" :max="10" />

controls показывает кнопки, controlsDirection ставит их столбиком справа или по бокам поля.

Кнопка гаснет на своей границе — на максимуме «+» недоступна, а не молча бездействует. В readonly гаснут обе: кнопка, которая заведомо получит отказ, вводит в заблуждение. Удержание кнопки шагает повторно (пауза 400 мс, затем каждые 60 мс) и останавливается на границе, при отпускании и при уходе курсора. Финальный клик такого удержания шага не даёт — это хвост жеста, а не новое намерение. Клавиатурная активация (Enter/Space) под это правило не подпадает никогда: она шагает всегда, даже если жест до неё оборвался без клика.

Фокус кнопка себе не забирает: нажав Enter на «+», клавиатурный пользователь остаётся на ней и может нажать снова. Поле фокусируется только тогда, когда шаг пришёл от него самого — стрелками /.

Шаг

step задаёт величину шага, precision — сколько знаков оставлять. Дробный шаг не копит двоичную погрешность и без precision: результат округляется до разрядности большего из операндов, поэтому три нажатия при step="0.1" дают 0.3, а не 0.30000000000000004.

PageUp/PageDown шагают крупно — по тому же правилу, что у GrSlider: десять шагов либо десятая часть диапазона, что крупнее. Без заданных min/max диапазона не существует, и остаются десять шагов.

Группировка и локаль

useGrouping показывает значение с разделителями разрядов, пока поле не в фокусе: при правке группировка мешала бы. Локаль берётся из пропа locale, а если он не задан — из i18n-адаптера пакета, так что в мультиязычном приложении её не нужно передавать каждому полю.

При группировке отформатированное значение уходит в aria-valuetext — иначе диктор читал бы сырое число. Без группировки атрибут не выставляется: aria-valuenow уже несёт то же самое.

Очистка и события

clearable добавляет крестик; он не показывается при пустом значении, в readonly и disabled. Очистка эмитит clear и возвращает фокус в поле.

Набор событий совпадает с GrInput: update:modelValue, change, focus, blur, clear.

Состояния

state (default | success | warning | danger) задаёт оттенок рамки, invalid форсирует красную. disabled гасит поле токенами --gr-disabled-*, а не прозрачностью: opacity разбавляет выверенные на AA цвета текста.

Readonly

readonly-поле ведёт себя как текст: стрелки, PageUp/PageDown и Home/End отдаются нативной каретке, значение не меняется ни клавишами, ни кнопками «±» (они в этом состоянии недоступны), ни автоповтором.

Playground 30

Загружается…

Код
<GrNumberInput />

Установка

npm i @feugene/granularity

Импорт

import { GrNumberInput } from '@feugene/granularity/components/GrNumberInput'

API

Props

PropTypeпо умолчаниюОписание
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseБыстрый флаг невалидности; эквивалент `state='danger'` + `aria-invalid`.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`). Складывается с `required` у `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedДоступное имя вне `GrFormField`.
clearableboolean | undefinedundefinedКнопка очистки значения.
clearLabelstring | undefinedundefinedA11y-подпись кнопки очистки.
namestring | undefinedundefined
prefixMinWidthstring | undefinedundefined
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalseФиксированная ширина у prefix/suffix: жёсткая ширина (из `*MaxWidth` → `*MinWidth` → дефолт) + обрезка контента по краю (prefix — справа, suffix — слева). По умолчанию аддоны растягиваются под контент.
suffixFixedboolean | undefinedfalse
maxnumber | undefinedundefined
idstring | undefinedundefined
localestring | undefinedundefinedBCP-47 локаль для отображения значения (группировка разрядов и разделители через `Intl.NumberFormat`). Работает вместе с `useGrouping`.
precisionnumber | undefinedundefined
state"default" | "success" | "warning" | "danger" | undefined"default"
autocompletestring | undefinedundefined
inputmode"search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefined"decimal"
textAlignGrNumberInputTextAlign | undefined"left"
decimalSeparatorstring | undefined"."
stepnumber | undefined1
minnumber | undefinedundefined
useGroupingboolean | undefinedfalseГруппировать разряды при отображении (когда поле не в фокусе). При фокусе показывается «сырое» значение для редактирования. По умолчанию выключено.
controlsboolean | undefinedfalseПоказывать кнопки +/-.
controlsDirectionGrNumberInputControlsDirection | undefined"vertical"
increaseLabelstring | undefinedundefinedi18n-friendly aria-label для кнопки "увеличить".
decreaseLabelstring | undefinedundefinedi18n-friendly aria-label для кнопки "уменьшить".
modelValueобязательныйnumber | nullЗначение поля. `null` — пусто. Незавершённый ввод («-», «1,») числом не является и в модель не попадает: пока он набирается, поле держит его во внутреннем черновике, а модель честно говорит «числа пока нет».

Slots

SlotTypeОписание
prefixanyАддон слева от поля: знак валюты, иконка.
suffixanyАддон справа от поля: единица измерения.

Events

EventTypeОписание
update:modelValue[value: number | null]
change[value: number | null]
clear[]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeОписание
focus() => void
blur() => void

Примеры 4

Кнопки столбиком и по бокам

Показываем базовый capability-scenario числового поля: инкремент/декремент, prefix/suffix slots и разную ориентацию controls.

Controls
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrNumberInput } from '@feugene/granularity'

const amount = ref<number | null>(128.5)
const quantity = ref<number | null>(3)
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <GrFormField label="Vertical controls">
      <GrNumberInput v-model="amount" controls clearable :precision="2" placeholder="0.00">
        <template #prefix>$</template>
      </GrNumberInput>
    </GrFormField>

    <GrFormField label="Horizontal controls">
      <GrNumberInput
        v-model="quantity"
        controls
        controls-direction="horizontal"
        :min="1"
        :max="10"
        placeholder="0"
      >
        <template #suffix>seats</template>
      </GrNumberInput>
    </GrFormField>
  </div>
</template>

Разделитель дробной части, точность и границы

Этот сценарий показывает локализованный ввод с запятой и одновременную работу min/max/step/precision.

Separator
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrNumberInput } from '@feugene/granularity'

const amountComma = ref<number | null>(1.25)
const percentage = ref<number | null>(42.5)
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <GrFormField label="Comma decimal separator">
      <GrNumberInput
        v-model="amountComma"
        decimal-separator=","
        :precision="2"
        :step="0.25"
        placeholder="0,00"
      >
        <template #suffix>kg</template>
      </GrNumberInput>
    </GrFormField>

    <GrFormField label="Range guards">
      <GrNumberInput
        v-model="percentage"
        decimal-separator=","
        :min="0"
        :max="100"
        :step="0.5"
        :precision="1"
        placeholder="0,0"
      >
        <template #suffix>%</template>
      </GrNumberInput>
    </GrFormField>
  </div>
</template>

Выравнивание текста при длинных аддонах

Карточка подчёркивает ещё один важный сценарий: числовое поле в финансовых формах с правым выравниванием и длинными suffix-элементами.

`textAlign` помогает согласовать числовые поля с табличными layout и формами с денежными значениями.

Alignment
<script setup lang="ts">
import { ref } from 'vue'

import { GrNumberInput, GrRadioGroup } from '@feugene/granularity'

const alignment = ref<'left' | 'center' | 'right'>('right')
const alignmentOptions = [
  { label: 'Left', value: 'left' },
  { label: 'Center', value: 'center' },
  { label: 'Right', value: 'right' },
]
const budget = ref<number | null>(240000)
</script>

<template>
  <div class="grid gap-4">
    <GrRadioGroup
      v-model="alignment"
      :options="alignmentOptions"
      variant="button"
      size="sm"
    />

    <div class="grid items-start gap-3 lg:grid-cols-2">
      <GrNumberInput
        v-model="budget"
        :text-align="alignment"
        suffix-min-width="3rem"
        suffix-max-width="8rem"
        placeholder="0"
      >
        <template #prefix>Budget</template>
        <template #suffix>Monthly recurring revenue</template>
      </GrNumberInput>

      <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] bg-[var(--gr-muted)]/40 p-4 text-sm text-[var(--gr-muted-fg)]">
        `textAlign` помогает согласовать числовые поля с табличными layout и формами с денежными значениями.
      </div>
    </div>
  </div>
</template>

Группировка разрядов по локали

С useGrouping поле показывает сгруппированное значение (тысячные разделители через Intl.NumberFormat) в состоянии blur и сырое — при фокусе для редактирования. Групповой разделитель берётся из locale, а десятичный уважает decimalSeparator.

Grouping
<script setup lang="ts">
import { ref } from 'vue'

import { GrFormField, GrNumberInput } from '@feugene/granularity'

const usAmount = ref<number | null>(1234567)
const euAmount = ref<number | null>(1234567.89)
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <GrFormField label="Grouped thousands (en-US)">
      <GrNumberInput
        v-model="usAmount"
        use-grouping
        locale="en-US"
        placeholder="0"
      >
        <template #prefix>$</template>
      </GrNumberInput>
    </GrFormField>

    <GrFormField label="Locale grouping (de-DE, comma decimals)">
      <GrNumberInput
        v-model="euAmount"
        use-grouping
        locale="de-DE"
        decimal-separator=","
        :precision="2"
        placeholder="0,00"
      >
        <template #suffix>EUR</template>
      </GrNumberInput>
    </GrFormField>
  </div>
</template>

Доступность

Паттерн APG
spinbutton
Клавиши
/ — шаг, PageUp/PageDown — крупный шаг (десять шагов либо десятая часть диапазона, что крупнее — правило GrSlider), Home/End — к min/max; при readonly клавиши отдаются нативной каретке, значение не меняется

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты