Number Fields
Number fields are a common field in many forms. They include built-in validation to reject non-numerical input. Additionally, the browser may provide stepper arrows to let the user increase and decrease the value with a configurable step amount.
The Number field is usually used for number values rather than numeric values. For example while a credit card number is numeric, you should not use a number field for it. Instead you should use it for values that are meant to be consumed as a number like units, prices and percentages.
The native HTML number input and most other implementations do not offer a good experience. Here are a couple of common issues:
- Lack of proper internationalization: Mainly, the lack of support for other numeral systems like the Arabic numerals
(٠١٢٣٤٥٦٧٨٩)
are a pain to work with. Users often have to switch keyboard languages/layout to enter numbers and even then it is not perfect. Keyboards don’t always have all the necessary characters for decimals and thousands separators. This means the number input is not accessible for the global audience. - No formatting support: this includes grouping
,
and displaying units and currencies and other simple masking features.
Formwerk tries to address all these issues and more by utilizing the Intl.NumberFormat
API. It provides a solid foundation for building number fields that are accessible and easy to use for users all over the world.
Features
- Supports using the
input
element as a base withtype="text"
(don’t worry, we add theinputmode
automatically for accessability and mobile keyboards). - Labeling, descriptions, error message displays are automatically linked to input and label elements with
aria-*
attributes. - Formatting and parsing numbers with the
Intl.NumberFormat
API depending on the site locale or the user’s preferred language. - Support for multiple numeral systems (Latin, Arabic, and Chinese).
- Support for
Intl.NumberFormat
units and currencies. - Support for the Spinbutton ARIA pattern for increment/decrement buttons.
- Support for
min
,max
andstep
attributes. - Validation support with native HTML constraint validation or Standard Schema validation.
- Rejects non-numerical input characters and any incoming key presses that would make the number invalid.
- Support for
v-model
binding. - Supported Keyboard features:
Key | Description |
---|---|
↑Arrow Up | increment the value by the step amount. |
↓Arrow Down | decrement the value by the step amount. |
⇞Page Up | increment the value by larger multiple of the step amount. |
⇟Page Down | decrement the value by larger multiple of the step amount. |
Home | set the value to the min value if provided, otherwise has no effect. |
End | set the value to the max value if provided, otherwise has no effect. |
Wheel up | increment the value by the step amount. |
Wheel down | decrement the value by the step amount. |
Anatomy
Building a Number Field Component
You can start by importing the useNumberField
composable and using it in your number field component.
The useNumberField
composable returns binding objects for the elements shown in the anatomy, you will use v-bind
to bind them to the corresponding DOM elements.
<script setup lang="ts">import { type NumberFieldProps, useNumberField } from '@formwerk/core';
const props = defineProps<NumberFieldProps>();
const { inputProps, labelProps, errorMessage, descriptionProps, errorMessageProps, incrementButtonProps, decrementButtonProps, isTouched,} = useNumberField(props);</script>
<template> <div class="field" :class="{ 'is-touched': isTouched }"> <label v-bind="labelProps">{{ label }}</label> <div class="input-wrapper"> <input v-bind="inputProps" />
<div class="spinbutton"> <button type="button" v-bind="incrementButtonProps">+</button> <button type="button" v-bind="decrementButtonProps">-</button> </div> </div>
<div v-if="description" v-bind="descriptionProps" class="hint"> {{ description }} </div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error"> {{ errorMessage }} </div> </div></template>
114 collapsed lines
<style scoped>.field { --color-primary: #10b981; --color-text: #333; --color-hint: #666; --color-border: #d4d4d8; --color-error: #f00; --color-hover: #eee; --color-focus: var(--color-primary);
label { color: var(--color-text); display: block; margin-bottom: 0.25rem; font-size: 14px; font-weight: 500; }
.hint { color: var(--color-hint); font-size: 13px; opacity: 0; transition: opacity 0.3s ease; }
.hint, .error { margin-top: 0.25rem; }
.input-wrapper { color: var(--color-text); display: flex; align-items: stretch; font-size: 13px; width: max-content; border: 1px solid var(--color-border); border-radius: 6px; transition: border-color 0.3s ease; overflow: hidden; background-color: #fff;
&:focus-within { outline: none; border-color: var(--color-focus); box-shadow: 0 0 0 1px var(--color-focus); }
input { appearance: none; border: none; outline: none; padding: 0.5rem 0.6rem; background-color: transparent; font-size: 14px; } }
.spinbutton { margin-left: auto; display: flex; flex-direction: column; align-items: starlight; justify-content: center; border-inline-start: 1px solid var(--color-border); width: 2rem;
button { background-color: transparent; border: none; outline: none; padding: 2px 0.25rem; margin: 0; color: var(--color-text); font-size: 15px; cursor: pointer; &:hover { background-color: var(--color-hover); } }
button + button { border-top: 1px solid var(--color-border); } }
.error { color: var(--color-error); display: none; font-size: 13px; }
&:has(:focus) { .hint { opacity: 1; } }
&.is-touched { &:has(:invalid) { --color-border: var(--color-error); --color-focus: var(--color-error);
.error { display: block; }
.hint { display: none; } } }}</style>
Notice that we imported the NumberFieldProps
in the previous example. This is recommended to use as your component prop types. Not only you get type safety for your component out of it but also it handles the reactivity aspects of the props so you don’t have to. You are free to extend it with your own props or omit the ones you don’t need.
Validation
HTML Constraints
You can use the following native HTML validation properties to validate the number field:
Name | Type | Description |
---|---|---|
max | number | The maximum value for the number field. |
min | number | The minimum value for the number field. |
required | boolean | Whether the number field is required. |
step | number | The step amount for the number field. |
Here is an example of how to use the max
and min
properties to limit the number field value between 0 and 100.
Assuming you have a NumberField
component like the one shown above, you can use it like this:
<script setup lang="ts">import { ref } from 'vue';import NumberField from './NumberField.vue';
const value = ref(26);</script>
<template> <NumberField v-model="value" label="Amount" max="50" min="0" step="2" required /></template>
Standard Schema
useNumberField
also supports Standard Schema validation through the schema
prop. This includes multiple providers like Zod, Valibot, Arktype, and more.
<script setup lang="ts">import { z } from 'zod';import NumberField from './NumberField.vue';
const schema = z .number('Required') .min(1, 'Must be greater than 0') .max(14, 'Must be less than 14');</script>
<template> <NumberField label="Number" :schema="schema" /></template>
Mixed Validation
While it is unlikely that you need both HTML constraints and Standard Schemas to validate a number field, Formwerk supports mixed validation, which means you can use both HTML constraints and Standard Schemas to validate the field and define step amount and min/max values and they work seamlessly together.
Note that HTML constraints are validated first, so any errors from the HTML constraints will be displayed first. Then once all HTML constraints are satisfied, the Standard Schema is validated.
<script setup lang="ts">import { z } from 'zod';import NumberField from './NumberField.vue';
const schema = z.number().min(1).max(10);</script>
<template> <NumberField label="Number" :schema="schema" max="50" min="0" step="2" /></template>
Usage
Disabled
Use disabled to mark fields as non-interactive, disabled fields are not validated and are not submitted.
If you need to prevent the user from interacting with the field while still allowing it to submit, consider using readonly
instead.
<script setup lang="ts">import NumberField from './NumberField.vue';</script>
<template> <NumberField label="Disabled" disabled /></template>
Readonly
Readonly fields are validated and submitted, but they do not accept user input. The field is still focusable and the value is copyable. For more info, check the MDN.
<script setup lang="ts">import NumberField from './NumberField.vue';</script>
<template> <NumberField label="Readonly" value="47" readonly /></template>
Formatting and Units
You can use the formatOptions
prop to format the number field value. It accepts all options that are supported by the Intl.NumberFormat
API.
<script setup lang="ts">import { ref } from 'vue';import NumberField from './NumberField.vue';
const value = ref(1000);</script>
<template> <NumberField label="Amount" v-model="value" step="100" :formatOptions="{ style: 'currency', currency: 'USD' }" /></template>
i18n
Aside from formatting you can also use any numeral system supported by the Intl.NumberFormat
API. Like Arabic, and Chinese.
The number field also accepts a locale
prop to change the locale of the number field. Usually you should not want to pass it manually but for demonstration purposes it is shown below.
Actually, here are 3 fields each with a different numeral system bound to the same value, and you get the parsed value with either of them.
<script setup lang="ts">import { ref } from 'vue';import NumberField from './NumberField.vue';
const value = ref(10);</script>
<template> <NumberField label="Latin" v-model="value" /> <NumberField label="Arabic" v-model="value" locale="ar-EG" /> <NumberField label="Chinese" v-model="value" locale="zh-cn-u-nu-hanidec" />
value is: {{ value }}</template>
RTL
The number field doesn’t really need much for RTL support, however the dir
prop can be used to set the direction of the field for convenience.
<script setup lang="ts">import NumberField from './NumberField.vue';</script>
<template> <NumberField label="Amount" value="10" dir="rtl" /></template>
Examples
These are some examples of number fields built with Formwerk.
API
Props
These are the properties that can be passed to the useNumberField
composable.
Name | Type | Description |
---|---|---|
decrementLabel | The label text for the decrement button. | |
description | The description text for the number field. | |
disabled | Whether the number field is disabled. | |
disableHtmlValidation | Whether to disable HTML5 form validation. | |
disableWheel | Whether to disable mouse wheel input. | |
formatOptions | Options for number formatting. | |
incrementLabel | The label text for the increment button. | |
label | The label text for the number field. | |
locale | The locale to use for number formatting. | |
max | The maximum allowed value. | |
min | The minimum allowed value. | |
modelValue | The v-model value of the number field. | |
name | The name attribute for the number field input. | |
placeholder | Placeholder text shown when the number field is empty. | |
readonly | Whether the number field is readonly. | |
required | Whether the number field is required. | |
schema | Schema for number field validation. | |
step | The amount to increment/decrement by. | |
value | The value attribute of the number field input. |
Returns
These are the properties in the object returned by the useNumberField
composable.
Name | Type | Description |
---|---|---|
decrement | Decrements the number field value. | |
decrementButtonProps | Ref<{ 'aria-label': string; tabindex: string; role: string; "aria-disabled": true; type?: undefined; disabled?: undefined; onMousedown: (e: MouseEvent) => | Props for the decrement button. |
descriptionProps | Props for the description element. | |
displayError | Display the error message for the field. | |
errorMessage | The error message for the field. | |
errorMessageProps | Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }> | Props for the error message element. |
errors | The errors for the field. | |
fieldValue | The value of the field. | |
formattedText | The formatted text of the number field. | |
increment | Increments the number field value. | |
incrementButtonProps | Ref<{ 'aria-label': string; tabindex: string; role: string; "aria-disabled": true; type?: undefined; disabled?: undefined; onMousedown: (e: MouseEvent) => | Props for the increment button. |
inputEl | Reference to the input element. | |
inputProps | Props for the input element. | |
isDirty | Whether the field is dirty. | |
isDisabled | Whether the field is disabled. | |
isTouched | Whether the field is touched. | |
isValid | Whether the field is valid. | |
labelProps | Props for the label element. | |
setErrors | (messages: Arrayable<string>) => void | Sets the errors for the field. |
setTouched | Sets the touched state for the field. | |
setValue | Sets the value for the field. | |
submitErrorMessage | The error message for the field from the last submit attempt. | |
submitErrors | The errors for the field from the last submit attempt. | |
validityDetails | Validity details for the number field. |