GrNumberInput

Package: @feugene/granularitycoreGroup: forms

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-model gives a number rather than a string;
  • there is a step and there are boundsstep, min, max with ± buttons and hold-to-repeat;
  • precision mattersprecision together with a separator and digit grouping by locale;
  • an empty value is allowednull means “not filled in” rather than zero.

When to take something else

NeedTake
The value is set by draggingGrSlider
The value is textGrInput
The number is a rating in starsGrRating
A choice from a fixed set of numbersGrSelect
Show a number rather than enter oneGrStatistic

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 null rather 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…

Code
<GrNumberInput />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead-only: the value is visible and leaves with the form, but is not edited.
invalidboolean | undefinedfalseA quick invalidity flag; the equivalent of `state='danger'` plus `aria-invalid`.
requiredboolean | undefinedfalseA required field (`aria-required`). It adds to the `required` of `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedThe accessible name outside `GrFormField`.
clearableboolean | undefinedundefinedA button that clears the value.
clearLabelstring | undefinedundefinedThe a11y label of the clear button.
namestring | undefinedundefined
prefixMinWidthstring | undefinedundefined
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalseA 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.
suffixFixedboolean | undefinedfalse
maxnumber | undefinedundefined
idstring | undefinedundefined
localestring | undefinedundefinedThe BCP-47 locale for displaying the value (digit grouping and separators through `Intl.NumberFormat`). It works together with `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 | undefinedfalseGroup the digits when displaying (while the field is not focused). On focus the "raw" value is shown for editing. Off by default.
controlsboolean | undefinedfalseWhether to show the +/- buttons.
controlsDirectionGrNumberInputControlsDirection | undefined"vertical"
increaseLabelstring | undefinedundefinedAn i18n-friendly aria-label for the "increase" button.
decreaseLabelstring | undefinedundefinedAn i18n-friendly aria-label for the "decrease" button.
modelValuerequirednumber | nullThe 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

SlotTypeDescription
prefixanyAn addon to the left of the field: a currency sign, an icon.
suffixanyAn addon to the right of the field: a unit of measurement.

Events

EventTypeDescription
update:modelValue[value: number | null]
change[value: number | null]
clear[]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void
blur() => void

Examples 4

Vertical and horizontal 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>

Decimal separator, precision and range guards

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>

Text alignment with long add-ons

`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>

Locale-aware thousands grouping

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

Full keyboard contract of the package

Component documentationAll components