# Number Field

A text input that holds a number, with stepping, bounds and locale formatting.

Number field holds a single number and renders it through `Intl.NumberFormat`.
The arrow keys step the value, a held stepper repeats, and every change is
clamped to the bounds. Useful when the value is a quantity somebody will nudge
as often as type; where the bounds matter more than the exact figure, use
[`Slider`](/components/slider) instead.

::component-preview{name="NumberFieldPreview"}
```vue
<template>
  <div class="flex w-[17rem] flex-col gap-6">
    <div class="flex flex-col gap-1.5">
      <span :class="caption">Quantity</span>

      <NumberField.Root
        v-model="quantity"
        class="group"
        :options="{ min: 0, max: 99, name: 'quantity' }"
      >
        <NumberField.Group :class="box">
          <NumberField.Decrement :class="stepper" aria-label="One fewer">
            −
          </NumberField.Decrement>
          <NumberField.Input :class="control" />
          <NumberField.Increment :class="stepper" aria-label="One more">
            +
          </NumberField.Increment>
        </NumberField.Group>
      </NumberField.Root>
    </div>

    <div class="flex flex-col gap-1.5">
      <span :class="caption">Budget</span>

      <NumberField.Root
        v-model="budget"
        class="group"
        :options="{
          min: 0,
          step: 50,
          name: 'budget',
          locale: 'de-DE',
          format: { style: 'currency', currency: 'EUR' },
        }"
      >
        <NumberField.Group :class="box">
          <NumberField.Decrement :class="stepper" aria-label="Fifty less">
            −
          </NumberField.Decrement>
          <NumberField.Input :class="control" />
          <NumberField.Increment :class="stepper" aria-label="Fifty more">
            +
          </NumberField.Increment>
        </NumberField.Group>
      </NumberField.Root>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { NumberField } from '@maas/mirror/vue'

const quantity = ref(1)
const budget = ref(1250)

const caption = 'type-component-2xs text-surface-muted'

const box = [
  'relative isolate flex h-12 w-full items-center justify-between',
  'rounded-component-lg border-surface border-2',
  'transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
  'outline-4 outline-transparent focus-within:focus-ring',
  'group-data-[disabled=true]:border-disabled-subtle',
].join(' ')

const stepper = [
  'flex h-full w-11 shrink-0 cursor-pointer items-center justify-center',
  'type-component-lg leading-none text-surface-muted select-none',
  'rounded-component-md bg-transparent outline-none',
  'hover:text-surface active:text-primary-solid',
  'data-[disabled=true]:text-disabled-muted data-[disabled=true]:cursor-not-allowed',
].join(' ')

const control = [
  'type-component-lg leading-[normal]! text-surface tabular-nums',
  'h-full w-full min-w-0 appearance-none bg-transparent text-center outline-none',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')
</script>
```
::

## Usage guidelines

- The value is a number or `null`, and the input shows the formatted text for
  it. Read `v-model`, never the input’s own `value`.
- The steppers sit outside the tab order on purpose, because the input already
  reaches the value from the keyboard. Give each one an `aria-label` all the
  same, since a touch screen reader still lands on them.
- Make sure the field has a label, through a [`Label`](/components/label) inside
  a [`Field`](/components/field) or an `aria-label` on `NumberField.Input`.
- `NumberField.ScrubArea` asks for a pointer lock and carries on without one, so
  it is safe to render wherever the API is missing.

## Anatomy

Assemble the parts. `NumberField.Group` is what ties the input and its steppers
together for a screen reader, and the scrub area is optional.

::component-anatomy
---
parts:
  - name: NumberField.Root
    required: true
    description: 'Renders a <div> plus a visually hidden <input> carrying the numeric value.'
    children:
      - name: NumberField.ScrubArea
        description: An area that changes the value by dragging.
        children:
          - name: NumberField.ScrubAreaCursor
            description: Stands in for the pointer while it is locked.
      - name: NumberField.Group
        description: Renders a <div role="group"> around the input and its steppers.
        children:
          - name: NumberField.Decrement
            description: A <button> that steps down.
          - name: NumberField.Input
            required: true
            description: Renders the <input> that holds the formatted value.
          - name: NumberField.Increment
            description: A <button> that steps up.
---
::

```vue
<script setup lang="ts">
import { Field, Label, NumberField } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'quantity' }">
    <Label>Quantity</Label>
    <NumberField.Root v-model="quantity" :options="{ min: 0, max: 99 }">
      <NumberField.Group>
        <NumberField.Decrement>−</NumberField.Decrement>
        <NumberField.Input />
        <NumberField.Increment>+</NumberField.Increment>
      </NumberField.Group>
    </NumberField.Root>
  </Field.Root>
</template>
```

## Examples

### Bounds and steps

`options.min` and `options.max` clamp the value, and a stepper disables itself
once the value has reached its bound.

```vue
<template>
  <NumberField.Root
    v-model="quantity"
    :options="{ min: 1, max: 10, step: 1 }"
  />
</template>
```

`options.step` is the granularity of one arrow press. `options.smallStep` and
`options.largeStep` are the same movement with a modifier held, so the user can
step finer or coarser without any change to the field.

```vue
<template>
  <NumberField.Root
    v-model="rate"
    :options="{ step: 0.5, smallStep: 0.1, largeStep: 5 }"
  />
</template>
```

### Snapping

By default the value goes wherever the arithmetic puts it, so a field starting
at `7` with a step of `5` lands on `12`. `options.snapOnStep` pulls every change
onto `min + n * step` instead, and does the same to a typed value on commit.

```vue
<template>
  <NumberField.Root
    v-model="quantity"
    :options="{ step: 5, snapOnStep: true }"
  />
</template>
```

### Formatting and locale

`options.format` is passed straight to `Intl.NumberFormat`, and `options.locale`
decides the separators. The same pair reads the value back, so a German field
accepts `1.234,50 €` and returns `1234.5`.

```vue
<template>
  <NumberField.Root
    v-model="budget"
    :options="{
      locale: 'de-DE',
      format: { style: 'currency', currency: 'EUR' },
    }"
  />
</template>
```

A percentage format holds the fraction and shows the percentage, the way
`Intl.NumberFormat` does everywhere else: `0.25` renders as `25%` and typing
`50%` gives you `0.5`.

```vue
<template>
  <NumberField.Root
    v-model="share"
    :options="{ format: { style: 'percent' } }"
  />
</template>
```

While somebody is typing, the text stays as they left it and the value follows
along unformatted. A blur, an Enter, a stepper press or a scrub commits the
change, and the display is written back from the value at that point.

### Scrubbing

`NumberField.ScrubArea` turns a drag into a value change. It locks the pointer
where the browser allows it, so the drag never runs out of screen, and
`NumberField.ScrubAreaCursor` stands in for the pointer that the lock hid.

The drag is configured on the root, under `options.scrub`, so it reads the same
wherever the area is rendered. `options.scrub.pixelSensitivity` is how many
pixels of movement one step is worth.

```vue
<template>
  <NumberField.Root
    v-model="opacity"
    :options="{ min: 0, max: 100, scrub: { pixelSensitivity: 3 } }"
  >
    <NumberField.ScrubArea>
      <span>Opacity</span>
      <NumberField.ScrubAreaCursor>↔</NumberField.ScrubAreaCursor>
    </NumberField.ScrubArea>
    <NumberField.Group>
      <NumberField.Input />
    </NumberField.Group>
  </NumberField.Root>
</template>
```

`options.scrub.direction` moves the reading to the vertical axis, where up is an
increase. `options.scrub.teleportDistance` bounds the box the cursor wraps
around, centred on the scrub area, which keeps a long drag from walking off to a
corner of the screen.

```vue
<template>
  <NumberField.Root
    v-model="opacity"
    :options="{ scrub: { direction: 'vertical', teleportDistance: 200 } }"
  />
</template>
```

### Scrubbing with the wheel

`options.allowWheelScrub` lets the wheel move the value, but only while the
input holds focus, so a page scroll never changes a number on the way past.

```vue
<template>
  <NumberField.Root v-model="quantity" :options="{ allowWheelScrub: true }" />
</template>
```

### Reacting to changes

`update:modelValue` fires on every change, a keystroke included, while
`valueCommit` fires once an interaction ends: a blur, an Enter, the release of a
stepper, the end of a scrub. Render from the first and persist from the second.

```vue
<template>
  <NumberField.Root
    v-model="quantity"
    @value-commit="save"
    @scrubbing="dragging = $event"
  />
</template>
```

### Inside a form

`NumberField.Root` renders a visually hidden input carrying `options.name` and
the numeric value, so [the form sees it](/components/form-integration#form-participation)
without the formatted text getting in the way. An empty field submits an empty
string.

```vue
<template>
  <form @submit.prevent="submit">
    <NumberField.Root
      v-model="quantity"
      :options="{ name: 'quantity', required: true }"
    />
  </form>
</template>
```

The input is the only tab stop, so it reports focus to a `Field` and blurring it
marks the field touched. Validation runs against the number, not the text.

### Reaching the field from anywhere

Give the root an `id` and `useMirrorNumberField(id)` reads and writes that field
from anywhere in the app.

```vue
<template>
  <Button @click="setValue(1)">Reset</Button>
</template>

<script setup lang="ts">
const { value, formatted, increment, decrement, setValue } =
  useMirrorNumberField(NumberFieldId.Quantity)
</script>
```

## API reference

**Module.** A bundled `options` object, and `useMirrorNumberField(id)` as the
programmatic API. Every part takes `id` to resolve a field it is not nested
inside.

### `NumberField.Root`

Renders a `<div>` plus a visually hidden `<input>` carrying the numeric value.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: '[The instance ID](#reaching-the-field-from-anywhere), and the DOM ID of the input.'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: modelValue
        description: The value. `null` is an empty field. `v-model`.
      - label: 'number | null | undefined'
      - label: undefined
  - items:
      - label: defaultValue
        description: 'Initial value when [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: 'number | null'
      - label: 'null'
  - items:
      - label: invalid
        description: Forces the invalid state, whatever validation decided.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: NumberFieldOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: min
        description: 'Lower [bound](#bounds-and-steps).'
      - label: number
      - label: undefined
  - items:
      - label: max
        description: 'Upper [bound](#bounds-and-steps).'
      - label: number
      - label: undefined
  - items:
      - label: step
        description: 'Granularity of [one step](#bounds-and-steps).'
      - label: number
      - label: '1'
  - items:
      - label: smallStep
        description: Granularity while `Alt` is held.
      - label: number
      - label: '0.1'
  - items:
      - label: largeStep
        description: Granularity while `Shift` is held.
      - label: number
      - label: '10'
  - items:
      - label: snapOnStep
        description: 'Pulls every change onto [`min + n * step`](#snapping).'
      - label: boolean
      - label: 'false'
  - items:
      - label: allowWheelScrub
        description: 'Lets [the wheel](#scrubbing-with-the-wheel) move the value.'
      - label: boolean
      - label: 'false'
  - items:
      - label: format
        description: 'Passed to [`Intl.NumberFormat`](#formatting-and-locale).'
      - label: Intl.NumberFormatOptions
      - label: undefined
  - items:
      - label: locale
        description: The locale the separators come from. Unset, the runtime’s own locale answers.
      - label: string
      - label: undefined
  - items:
      - label: name
        description: 'Form field name, carried by [the hidden input](#inside-a-form).'
      - label: string
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: disabled
        description: Blocks every change.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: readOnly
        description: Blocks every change, keeps focus.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: required
        description: Marks the input required.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: scrub.direction
        description: 'The axis [the drag is read from](#scrubbing).'
      - label: '''horizontal'' | ''vertical'''
      - label: '''horizontal'''
  - items:
      - label: scrub.pixelSensitivity
        description: How many pixels of movement one step is worth.
      - label: number
      - label: '2'
  - items:
      - label: scrub.teleportDistance
        description: The size of the box the cursor wraps around. Unset, the viewport.
      - label: number
      - label: undefined
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:modelValue
        description: 'The value [changes](#reacting-to-changes), a keystroke included.'
      - label: 'number | null'
  - items:
      - label: valueCommit
        description: 'An [interaction ends](#reacting-to-changes).'
      - label: 'number | null'
  - items:
      - label: scrubbing
        description: A scrub drag starts or ends.
      - label: boolean
---
::

#### Slot props

The [field state set](/components/styling#the-field-state-set), plus the three
below.

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: value
        description: The current value.
      - label: 'number | null'
  - items:
      - label: inputValue
        description: The text the input is showing.
      - label: string
  - items:
      - label: scrubbing
        description: Mirrors `data-scrubbing`.
      - label: boolean
---
::

### `NumberField.Group`

Renders a `<div role="group">` around the input and its steppers, so a screen
reader reads the three as one control.

#### Slot props

The field state set, plus `value`, `inputValue` and `scrubbing`.

### `NumberField.Input`

Renders an `<input type="text">` carrying the formatted value, with
`inputmode` set to `decimal` wherever the step or the format can produce a
fraction and `numeric` otherwise. It takes its DOM ID from the root, so a
`Label` reaches it.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the field this belongs to.
      - label: string
      - label: injected
        plaintext: true
---
::

### `NumberField.Increment`

Renders a `<button>` that steps the value up, out of the tab order and disabled
once the value has reached `max`. Holding it repeats after 400ms, then every
60ms until it is released.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the field this belongs to.
      - label: string
      - label: injected
        plaintext: true
  - items:
      - label: disabled
        description: Disables this stepper alone, whatever the field says.
      - label: boolean
      - label: undefined
---
::

#### Slot props

The field state set, where `disabled` also covers the bound this stepper has
reached.

### `NumberField.Decrement`

Renders a `<button>` that steps the value down, disabled once the value has
reached `min`. Same props and slot props as `NumberField.Increment`.

### `NumberField.ScrubArea`

Renders a `<span>` that changes the value by dragging. It requests a pointer
lock on pointerdown and carries on without one where the API is missing or the
browser refuses. How the drag reads comes from
[`options.scrub`](#scrubbing) on the root.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the field this belongs to.
      - label: string
      - label: injected
        plaintext: true
---
::

#### Slot props

The field state set, plus `direction`, `scrubbing` and `value`.

### `NumberField.ScrubAreaCursor`

Renders a `<span>` while a scrub is running and nothing the rest of the time. It
stands in for the pointer the lock hid, follows it through a component-local
variable, and is hidden from assistive technology.

#### Slot props

The field state set, plus `direction` and `scrubbing`.

### Composable

`useMirrorNumberField(id)` reaches a number field from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: value
        description: The current value.
      - label: 'ComputedRef<number | null>'
        escape: true
  - items:
      - label: inputValue
        description: The text the input is showing.
      - label: ComputedRef<string>
        escape: true
  - items:
      - label: formatted
        description: The value as the format and locale render it.
      - label: ComputedRef<string>
        escape: true
  - items:
      - label: scrubbing
        description: Mirrors `data-scrubbing`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: readOnly
        description: Mirrors `data-readonly`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: atMin
        description: The value has reached `min`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: atMax
        description: The value has reached `max`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: setValue
        description: Sets the value, clamped and snapped.
      - label: '(next: number | null) => void'
        escape: true
  - items:
      - label: increment
        description: Steps up, by `step` unless an amount is given.
      - label: '(amount?: number) => void'
        escape: true
  - items:
      - label: decrement
        description: Steps down, by `step` unless an amount is given.
      - label: '(amount?: number) => void'
        escape: true
  - items:
      - label: commit
        description: Commits the typed text and emits `valueCommit`.
      - label: '() => void'
        escape: true
  - items:
      - label: focusInput
        description: Focuses the input.
      - label: '() => void'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_number_field_context
        description: Any `NumberField` part rendered outside `NumberField.Root` with no `id` of its own.
  - items:
      - label: missing_number_field_scrub_area
        description: A `NumberField.ScrubAreaCursor` rendered outside `NumberField.ScrubArea`.
  - items:
      - label: invalid_number_field_range
        description: '`min` is greater than `max`.'
  - items:
      - label: invalid_number_field_step
        description: '`step`, `smallStep` or `largeStep` is not positive.'
---
::

## Accessibility

The input is a text input rather than a `spinbutton`, because the value it shows
is formatted text that a screen reader should read as written.
`aria-roledescription="Number field"` names it, and the steppers point at it with
`aria-controls`. The steppers carry `tabindex="-1"`: the keyboard reaches the
value through the input, and a touch screen reader still reaches the buttons.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: '`ArrowUp` / `ArrowDown`'
        plaintext: true
      - label: Increase or decrease by `step`.
        plaintext: true
  - items:
      - label: Shift and an arrow
        plaintext: true
      - label: Increase or decrease by `largeStep`.
        plaintext: true
  - items:
      - label: Alt and an arrow
        plaintext: true
      - label: Increase or decrease by `smallStep`.
        plaintext: true
  - items:
      - label: '`PageUp` / `PageDown`'
        plaintext: true
      - label: Increase or decrease by `largeStep`.
        plaintext: true
  - items:
      - label: '`Home` / `End`'
        plaintext: true
      - label: Jump to `min` or `max`. Inert while that bound is unset.
        plaintext: true
  - items:
      - label: Enter
      - label: Commit the typed value. A field inside a form still submits it.
        plaintext: true
  - items:
      - label: Wheel
      - label: Increase or decrease by `step`, while the input holds focus and `allowWheelScrub` is set.
        plaintext: true
---
::

Every key above works on `NumberField.Input`, and a read-only or disabled field
ignores all of them. A disabled field leaves the tab order altogether, where a
read-only one keeps its place in it.
