BuNumberField
A number field Bulma never documented, built entirely from parts it did: stepper buttons attached to an input, with stepping, bounds, formatting and validation supplied by Vuetify0.
Reference: Bulma has no number field. The layout is form addons on bulma.io↗︎.
Usage
v-model holds the value — a number, or null while the field is empty. min, max and step bound it, and each stepper goes inert the moment the value reaches its bound. Compose the three parts in order: decrement, input, increment.
Give the input the leftover width with expanded. Without it the input sizes to its content and the group collapses to the width of a short number.
Anatomy
<script setup lang="ts">
import {
BuNumberField,
BuNumberFieldDecrement,
BuNumberFieldIncrement,
BuNumberFieldInput,
} from '@paper/bulma'
</script>
<template>
<BuNumberField>
<BuNumberFieldDecrement />
<BuNumberFieldInput />
<BuNumberFieldIncrement />
</BuNumberField>
</template>Composed on v0
Composes v0’s NumberField. BuNumberField creates a NumberField.Root unless an ambient one already exists; Decrement, Control, and Increment live inside each part’s own .control.
v0 owns the spinbutton: the math, the bounds, formatting, validation, keyboard, hold-to-repeat. Bulma owns .field.has-addons radii — each part wraps .control because first- and last-child selectors are how the group gets its corners.
An ambient NumberField.Root swallows every behavioral prop on BuNumberField — v-model, bounds, format, validation. Only presentational modifiers still apply. Point BuLabel and BuHelp at namespace="v0:number-field:root" or they inject nothing.
The markup it composes
Bulma ships no number input. There is no upstream component page to copy from, so this one is composed out of things Bulma does document: the attached-controls layout↗︎ — .field.has-addons wrapping one .control per item — with button.button steppers on either side of an input.input. No class in it is invented, and the package ships no CSS.
That changes what conformance can promise here, and the change is declared rather than glossed. Every other component in the package is diffed against markup captured verbatim from bulma.io. This one is diffed against a fixture the package authored itself, whose provenance header traces each class back to another fixture block or to a bulma.css selector — so every atom is upstream-verified and only the arrangement is ours. The arrangement is pinned the moment that fixture is written, so drift still fails the suite loudly. What the suite cannot claim is that upstream would have arranged the atoms the same way.
<div class="field has-addons">
<div class="control">
<button class="button" type="button">−</button>
</div>
<div class="control is-expanded">
<input class="input" type="text" inputmode="decimal" role="spinbutton" />
</div>
<div class="control">
<button class="button" type="button">+</button>
</div>
</div><template>
<BuNumberField v-model="quantity" :max="10" :min="0">
<BuNumberFieldDecrement />
<BuNumberFieldInput expanded />
<BuNumberFieldIncrement />
</BuNumberField>
</template>Each part renders its own .control wrapper rather than leaving it to you, because that wrapper is structural: Bulma keys the group’s corner radii off the first and last .control, and squares off the ones between. A bare stepper dropped in as a sibling would get no radius treatment and visibly break the group, so the composition is not something userland has to get right.
Examples
Props
BuNumberField renders .field.has-addons and owns the value, the bounds and the modifiers. Everything else is a part.
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | number | null | null | Field value; null while empty |
addons | 'centered' | 'right' | — | has-addons-{value} — alignment of the group |
aria-labelledby | string | — | Id of the element naming the spinbutton |
clamp | boolean | true | Clamp a committed value to min/max |
color | 'primary' | 'link' | 'info' | 'success' | 'warning' | 'danger' | — | is-{color} on the steppers only |
commit-on | 'input' | 'change' | 'change' | When typed input writes into v-model |
disabled | boolean | false | Disables the input and both steppers |
error | boolean | false | Force the invalid state |
error-messages | string | string[] | — | Manual errors, merged with rule errors |
form | string | — | Id of the form to associate with |
format | Intl.NumberFormatOptions | — | Display format while blurred |
id | string | number | auto | Input id; generated when omitted |
label | string | — | Accessible name for the spinbutton |
leap | number | step * 10 | PageUp / PageDown increment |
locale | string | 'en-US' | BCP 47 tag used to format the display |
max | number | — | Upper bound |
min | number | — | Lower bound |
name | string | — | Form field name |
namespace | string | 'v0:number-field:root' | Context namespace the parts bind to |
readonly | boolean | false | Input stays focusable; steppers inert |
required | boolean | — | Marks the field required |
rounded | boolean | false | is-rounded on the input and both steppers |
rules | ValidationRule[] | [] | Validation rules |
size | 'small' | 'normal' | 'medium' | 'large' | — | is-{size} on the input and both steppers |
spin-delay | number | 400 | Delay in ms before spin-on-hold starts |
spin-rate | number | 60 | Interval in ms between repeats while held |
step | number | 1 | Stepper and arrow-key increment |
validate-on | ValidateOn | 'blur' | When validation runs |
wheel | boolean | false | Mouse wheel adjusts the value while focused |
wrap | boolean | false | Wrap around at the bounds |
| Part | Renders | Notes |
|---|---|---|
BuNumberFieldDecrement | div.control + button.button | First control — carries the group’s left radii; slot content defaults to the minus glyph |
BuNumberFieldInput | div.control + input.input | Middle control — square corners; expanded adds is-expanded; owns is-danger |
BuNumberFieldIncrement | div.control + button.button | Last control — carries the group’s right radii; slot content defaults to the plus glyph |
Each part takes a namespace prop as an escape hatch, but the parent’s namespace wins whenever they are composed inside a BuNumberField.
Accessibility
The input is the widget. It carries role="spinbutton" and inputmode="decimal", and the full aria-value* set follows the value: aria-valuenow and aria-valuetext while a value exists, plus aria-valuemin and aria-valuemax whenever the corresponding bound is finite. On an empty field aria-valuenow and aria-valuetext are omitted rather than faked — there is no number to report.
The steppers are deliberately not focusable. Both carry tabindex="-1" and a localized aria-label, which is the ARIA Authoring Practices spinbutton pattern: keyboard users operate the field through the input, and the buttons exist for pointer input. That leaves one tab stop per field instead of three.
| Key | Behavior |
|---|---|
| Up / Down | Step by step |
| Shift + Up / Down | Step by leap |
| PageUp / PageDown | Step by leap |
| Home / End | Jump to min / max |
| Enter | Commit the typed text |
Holding a stepper repeats it — after spin-delay, at spin-rate. The release is caught on the document, so dragging off the button before letting go still stops the spin.
Name the field. The spinbutton is a real input, so it needs an accessible name like any other: a sibling BuLabel pointed at its id, the label prop, or aria-labelledby. An unnamed field fails the axe label rule.
The steppers reach their bounds by way of the native disabled attribute, not aria-disabled — so a disabled stepper is inert to pointer and assistive technology alike, and Bulma’s existing [disabled] styling applies with nothing added.