GrNumberInput
A number field with stepper controls and range constraints.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the value is numeric — a quantity, a price, a percentage:
v-modelgives anumberrather than a string; - there is a step and there are bounds —
step,min,maxwith ± buttons and hold-to-repeat; - precision matters —
precisiontogether with a separator and digit grouping by locale; - an empty value is allowed —
nullmeans “not filled in” rather than zero.
When to take something else
| Need | Take |
|---|---|
| The value is set by dragging | GrSlider |
| The value is text | GrInput |
| The number is a rating in stars | GrRating |
| A choice from a fixed set of numbers | GrSelect |
| Show a number rather than enter one | GrStatistic |
The difference from GrInput type="number" is not cosmetic: a native numeric field gives away a
string, loses the value on invalid input and behaves differently across browsers. Here the model
is number | null, and null differs from zero.
The value is a number, the input is a draft
v-model is number | null, where null means “empty”. The same as in
GrSlider: a numeric control gives away a number rather than a string the
consumer has to parse themselves.
The objection to a numeric model went like this: an unfinished input (”-”, “1,”) is not a number,
and turning it into NaN on every keystroke is not allowed. It is answered by a division of
roles — an unfinished entry simply is not obliged to be the model:
- the draft is a string exactly as it is being typed. It lives inside the field until the entry is finished, and it is exactly what is shown on the screen;
- the model is a number. While the draft does not parse into a number, the model says
nullrather than storing a half-finished thing.
A commit (change, the loss of focus, a step by a button or a key) removes the draft: min/max
and precision are applied, and the display is computed from the model again. That is exactly why
precision="2" does not turn “7” into “7.00” right under the cursor — only when the entry is
finished.
decimalSeparator stops being part of the value and remains what it was — a way of displaying
and entering the fractional part. In the model 1,25 with decimal-separator="," lies as
1.25.
The ± buttons
<GrNumberInput v-model="qty" controls :min="1" :max="10" />
controls shows the buttons, and controlsDirection puts them in a column on the right or on
both sides of the field.
A button dims at its own bound — at the maximum the ”+” is unavailable rather than silently
idle. In readonly both dim: a button that is known to be refused is misleading. Holding a button
steps repeatedly (a pause of 400 ms, then every 60 ms) and stops at a bound, on release and when
the cursor leaves. The final click of such a hold gives no step — that is the tail of a gesture
rather than a new intent. A keyboard activation (Enter/Space) never falls under that rule: it
always steps, even if the gesture before it was interrupted without a click.
The button does not take the focus for itself: having pressed Enter on the ”+”, a keyboard user
stays on it and can press again. The field is focused only when the step came from it — from the
↑/↓ arrows.
The step
step sets the size of a step, and precision how many digits to keep. A fractional step does
not accumulate binary error even without precision: the result is rounded to the number of
digits of the larger operand, so three presses of ↑ with step="0.1" give 0.3 rather than
0.30000000000000004.
PageUp/PageDown step coarsely — by the same rule as in GrSlider: ten steps or a tenth of
the range, whichever is larger. Without min/max set there is no range, and ten steps remain.
Grouping and the locale
useGrouping shows the value with digit separators while the field is not focused: while
editing, the grouping would get in the way. The locale comes from the locale prop, and if it is
not set, from the i18n adapter of the package, so in a multilingual application it does not have
to be passed to every field.
With grouping, the formatted value goes into aria-valuetext — otherwise a screen reader would
read the raw number. Without grouping the attribute is not set: aria-valuenow already carries
the same thing.
Clearing and the events
clearable adds a cross; it is not shown when the value is empty, in readonly and in
disabled. Clearing emits clear and returns the focus to the field.
The set of events matches GrInput: update:modelValue, change, focus,
blur, clear.
The states
state (default | success | warning | danger) sets the hue of the border, and invalid forces
the red one. disabled dims the field with the --gr-disabled-* tokens rather than with
transparency: opacity dilutes text colours tuned to AA.
Readonly
A readonly field behaves like text: the arrows, PageUp/PageDown and Home/End are given
to the native caret, and the value is changed neither by keys, nor by the ”±” buttons (they are
unavailable in this state), nor by the auto-repeat.
Playground 30
Loading…
<GrNumberInput />Install
npm i @feugene/granularityImport
import { GrNumberInput } from '@feugene/granularity/components/GrNumberInput'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read-only: the value is visible and leaves with the form, but is not edited. |
invalid | boolean | undefined | false | A quick invalidity flag; the equivalent of `state='danger'` plus `aria-invalid`. |
required | boolean | undefined | false | A required field (`aria-required`). It adds to the `required` of `GrFormField`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The accessible name outside `GrFormField`. |
clearable | boolean | undefined | undefined | A button that clears the value. |
clearLabel | string | undefined | undefined | The a11y label of the clear button. |
name | string | undefined | undefined | — |
prefixMinWidth | string | undefined | undefined | — |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | A fixed width for the prefix/suffix: a rigid width (from `*MaxWidth` → `*MinWidth` → the default) plus clipping of the content at the edge (the prefix on the right, the suffix on the left). By default the addons stretch to the content. |
suffixFixed | boolean | undefined | false | — |
max | number | undefined | undefined | — |
id | string | undefined | undefined | — |
locale | string | undefined | undefined | The BCP-47 locale for displaying the value (digit grouping and separators through `Intl.NumberFormat`). It works together with `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 | Group the digits when displaying (while the field is not focused). On focus the "raw" value is shown for editing. Off by default. |
controls | boolean | undefined | false | Whether to show the +/- buttons. |
controlsDirection | GrNumberInputControlsDirection | undefined | "vertical" | — |
increaseLabel | string | undefined | undefined | An i18n-friendly aria-label for the "increase" button. |
decreaseLabel | string | undefined | undefined | An i18n-friendly aria-label for the "decrease" button. |
modelValuerequired | number | null | — | The value of the field. `null` means empty. An unfinished entry ("-", "1,") is not a number and does not reach the model: while it is being typed the field keeps it in an internal draft, and the model honestly says "there is no number yet". |
Slots
| Slot | Type | Description |
|---|---|---|
prefix | any | An addon to the left of the field: a currency sign, an icon. |
suffix | any | An addon to the right of the field: a unit of measurement. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: number | null] | — |
change | [value: number | null] | — |
clear | [] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 4
Vertical and horizontal 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>Decimal separator, precision and range guards
<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>Text alignment with long add-ons
<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>Locale-aware thousands 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>Accessibility
- APG pattern
spinbutton