GrNumberInput
Берут, когда значение числовое.
Когда брать
- значение числовое — количество, цена, процент:
v-modelдаётnumber, а не строку; - есть шаг и границы —
step,min,maxс кнопками ± и удержанием; - важна точность —
precisionвместе с разделителем и группировкой разрядов по локали; - пустое значение допустимо —
nullозначает «не заполнено», а не ноль.
Когда взять другое
| Нужно | Берите |
|---|---|
| Значение задают перетаскиванием | GrSlider |
| Значение текстовое | GrInput |
| Число — оценка звёздами | GrRating |
| Выбор из фиксированного набора чисел | GrSelect |
| Показать число, а не ввести | GrStatistic |
Разница с GrInput type="number" не косметическая: нативное числовое поле
отдаёт строку, теряет значение на неверном вводе и по-разному ведёт себя в
браузерах. Здесь модель — number | null, и null отличается от нуля.
Значение — число, ввод — черновик
v-model — number | 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно и уходит в форму, но не редактируется. |
invalid | boolean | undefined | false | Быстрый флаг невалидности; эквивалент `state='danger'` + `aria-invalid`. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). Складывается с `required` у `GrFormField`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Доступное имя вне `GrFormField`. |
clearable | boolean | undefined | undefined | Кнопка очистки значения. |
clearLabel | string | undefined | undefined | A11y-подпись кнопки очистки. |
name | string | undefined | undefined | — |
prefixMinWidth | string | undefined | undefined | — |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | Фиксированная ширина у prefix/suffix: жёсткая ширина (из `*MaxWidth` → `*MinWidth` → дефолт) + обрезка контента по краю (prefix — справа, suffix — слева). По умолчанию аддоны растягиваются под контент. |
suffixFixed | boolean | undefined | false | — |
max | number | undefined | undefined | — |
id | string | undefined | undefined | — |
locale | string | undefined | undefined | BCP-47 локаль для отображения значения (группировка разрядов и разделители через `Intl.NumberFormat`). Работает вместе с `useGrouping`. |
precision | number | undefined | undefined | — |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | — |
autocomplete | string | undefined | undefined | — |
inputmode | "search" | "none" | "text" | "email" | "tel" | "url" | "numeric" | "decimal" | undefined | "decimal" | — |
textAlign | GrNumberInputTextAlign | undefined | "left" | — |
decimalSeparator | string | undefined | "." | — |
step | number | undefined | 1 | — |
min | number | undefined | undefined | — |
useGrouping | boolean | undefined | false | Группировать разряды при отображении (когда поле не в фокусе). При фокусе показывается «сырое» значение для редактирования. По умолчанию выключено. |
controls | boolean | undefined | false | Показывать кнопки +/-. |
controlsDirection | GrNumberInputControlsDirection | undefined | "vertical" | — |
increaseLabel | string | undefined | undefined | i18n-friendly aria-label для кнопки "увеличить". |
decreaseLabel | string | undefined | undefined | i18n-friendly aria-label для кнопки "уменьшить". |
modelValueобязательный | number | null | — | Значение поля. `null` — пусто. Незавершённый ввод («-», «1,») числом не является и в модель не попадает: пока он набирается, поле держит его во внутреннем черновике, а модель честно говорит «числа пока нет». |
Slots
| Slot | Type | Описание |
|---|---|---|
prefix | any | Аддон слева от поля: знак валюты, иконка. |
suffix | any | Аддон справа от поля: единица измерения. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: number | null] | — |
change | [value: number | null] | — |
clear | [] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Примеры 4
Кнопки столбиком и по бокам
Показываем базовый capability-scenario числового поля: инкремент/декремент, prefix/suffix slots и разную ориентацию 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.
<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-элементами.
<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.
<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клавиши отдаются нативной каретке, значение не меняется