Number Input
Numeric stepper input with − / + controls, formatting, and no native spinners.
Installation
bin/rails g wabi:add number_inputExample
render Components::UI::NumberInput.new(name: "quantity", value: 1, min: 0, aria_label: "Quantity", class: "w-44")
Currency
render Components::UI::NumberInput.new(
name: "price", value: 19.99, step: 0.01, precision: 2,
format: :currency, currency: "USD", aria_label: "Price", class: "w-44"
)
Percent
With format: :percent the value is a ratio (Intl semantics): value: 0.25 displays as 25%.
render Components::UI::NumberInput.new(
name: "discount", value: 0.25, min: 0, max: 1, step: 0.05,
format: :percent, aria_label: "Discount", class: "w-44"
)
Min / max / step
render Components::UI::NumberInput.new(name: "rating", value: 3, min: 0, max: 5, step: 1, aria_label: "Rating", class: "w-44")
Invalid
render Components::UI::NumberInput.new(name: "qty", value: 2, invalid: true, aria_label: "Quantity", class: "w-44")
Sizes
render Components::UI::NumberInput.new(name: "a", value: 1, size: :sm, aria_label: "Small quantity", class: "w-40")
render Components::UI::NumberInput.new(name: "b", value: 1, size: :md, aria_label: "Medium quantity", class: "w-40")
render Components::UI::NumberInput.new(name: "c", value: 1, size: :lg, aria_label: "Large quantity", class: "w-40")
Source
app/components/ui/number_input.rb
# frozen_string_literal: true
require "json"
module Components
module UI
class NumberInput < Wabi::Base
variants do
base "flex items-center rounded-md border border-input bg-background " \
"ring-offset-background overflow-hidden " \
"focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 " \
"data-[invalid]:border-destructive data-[invalid]:focus-within:ring-destructive " \
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
variant :size, {
sm: "h-9",
md: "h-10",
lg: "h-11"
}, default: :md
end
def initialize(name: nil, value: nil, min: nil, max: nil, step: 1,
precision: nil, format: :decimal, currency: "USD",
size: :md, allow_mouse_wheel: false, invalid: false,
disabled: false, aria_label: nil, **attrs)
@name = name
@value = value
@min = min
@max = max
@step = step
@precision = precision
@format = format
@currency = currency
@size = size
@allow_mouse_wheel = allow_mouse_wheel
@invalid = invalid
@disabled = disabled
# Optional accessible name forwarded to the Zag machine's aria-label option
# so screen readers announce a meaningful spinbutton label instead of the
# generic Zag default ("Enter number"). Pass aria_label: "Quantity" (for example).
@aria_label = aria_label
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
# Anatomy: the root is just the Zag root/wrapper (intrinsic width). The
# bordered, focus-ring, data-[invalid]/[disabled] styling lives on the
# inner `control` div via tokens(size:) (base + size) — that's where Zag
# emits data-invalid/data-disabled through getControlProps.
div(
**@attrs,
data: {
controller: "wabi--number-input",
"wabi--number-input-name-value": @name,
"wabi--number-input-value-value": @value.nil? ? "" : @value.to_s,
"wabi--number-input-min-value": @min.nil? ? "" : @min.to_s,
"wabi--number-input-max-value": @max.nil? ? "" : @max.to_s,
"wabi--number-input-step-value": @step.to_s,
"wabi--number-input-format-options-value": format_options.to_json,
"wabi--number-input-allow-mouse-wheel-value": @allow_mouse_wheel.to_s,
"wabi--number-input-invalid-value": @invalid.to_s,
"wabi--number-input-disabled-value": @disabled.to_s,
"wabi--number-input-aria-label-value": @aria_label.nil? ? "" : @aria_label.to_s,
},
class: merge_class("inline-flex w-fit", user_class)
) do
div(data: { "wabi--number-input-target": "control" }, class: tokens(size: @size)) do
button(type: "button",
data: { "wabi--number-input-target": "decrement" },
class: trigger_class) { "−" }
input(type: "text",
inputmode: "decimal",
name: @name,
data: { "wabi--number-input-target": "input" },
class: input_class)
button(type: "button",
data: { "wabi--number-input-target": "increment" },
class: trigger_class) { "+" }
end
end
end
private
def format_options
opts =
case @format
when :currency then { style: "currency", currency: @currency }
when :percent then { style: "percent" }
else { style: "decimal" }
end
if @precision
opts[:minimumFractionDigits] = @precision
opts[:maximumFractionDigits] = @precision
end
opts
end
def trigger_class
width = { sm: "w-8", md: "w-10", lg: "w-11" }.fetch(@size, "w-10")
"#{width} h-full shrink-0 inline-flex items-center justify-center select-none " \
"text-lg leading-none text-muted-foreground transition-colors motion-reduce:transition-none " \
"hover:bg-accent hover:text-accent-foreground active:bg-accent/80 " \
"disabled:pointer-events-none disabled:opacity-50"
end
def input_class
text = @size == :lg ? "text-base" : "text-sm"
"flex-1 w-full min-w-0 border-x border-input bg-transparent text-center px-2 #{text} " \
"placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed " \
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none " \
"[&::-webkit-inner-spin-button]:appearance-none"
end
end
end
end
app/javascript/controllers/wabi/number_input_controller.js
import { Controller } from "@hotwired/stimulus"
import * as numberInput from "@zag-js/number-input"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"
export default class extends Controller {
static targets = ["control", "input", "decrement", "increment"]
static values = {
name: String,
value: String,
min: String,
max: String,
step: { type: Number, default: 1 },
formatOptions: Object,
allowMouseWheel: { type: Boolean, default: false },
invalid: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
ariaLabel: String,
}
connect() {
this.machine = new VanillaMachine(numberInput.machine, {
id: this.element.id || crypto.randomUUID(),
name: this.nameValue || undefined,
// defaultValue (not value) keeps the machine uncontrolled — it owns state.
defaultValue: this.valueValue !== "" ? this.valueValue : undefined,
// Stimulus String values default to ""; coerce to Number only when set,
// otherwise pass undefined so Zag applies no bound (Number("") is 0, not NaN).
min: this.minValue !== "" ? Number(this.minValue) : undefined,
max: this.maxValue !== "" ? Number(this.maxValue) : undefined,
step: this.stepValue,
// Stimulus Object values default to {}, which is truthy — Zag would then
// activate Intl formatting and drop the input pattern. Pass undefined when
// empty so a controller mounted without a format-options attr stays plain.
formatOptions: Object.keys(this.formatOptionsValue).length > 0 ? this.formatOptionsValue : undefined,
allowMouseWheel: this.allowMouseWheelValue,
invalid: this.invalidValue,
disabled: this.disabledValue,
onValueChange: ({ value, valueAsNumber }) => {
this.dispatch("change", { detail: { value, valueAsNumber } })
},
})
this.unsubscribe = this.machine.subscribe(() => this.render())
this.machine.start()
this.render()
}
disconnect() {
this.unsubscribe?.()
this.machine?.stop()
}
render() {
const api = numberInput.connect(this.machine.service, normalizeProps)
spreadProps(this.element, api.getRootProps())
if (this.hasControlTarget) spreadProps(this.controlTarget, api.getControlProps())
if (this.hasInputTarget) {
spreadProps(this.inputTarget, api.getInputProps())
// Zag's getInputProps() does not put aria-label on the spinbutton input
// (only the +/- buttons get one), so apply the caller-supplied name directly.
if (this.ariaLabelValue) this.inputTarget.setAttribute("aria-label", this.ariaLabelValue)
else this.inputTarget.removeAttribute("aria-label")
}
if (this.hasDecrementTarget) spreadProps(this.decrementTarget, api.getDecrementTriggerProps())
if (this.hasIncrementTarget) spreadProps(this.incrementTarget, api.getIncrementTriggerProps())
}
}
Accessibility
- The inner input is a role=spinbutton with aria-valuemin/max/now managed by Zag.
- Increment and decrement are real <button>s with accessible labels.
- Keyboard: ↑/↓ adjust by step, PageUp/PageDown by a larger step, Home/End jump to min/max.
- For form labeling, wrap the field in a <label>, or pass a stable id: — Zag composes the input id as number-input:<id>:input.