PetalComponents.NumberField (petal_components v4.15.4)

Copy Markdown View Source

A numeric spinbutton: one real input on the input-group surface, with decrement and increment buttons, clamping, and a full keyboard map.

Reach for it wherever a quantity, a price or a percentage is typed. It replaces <input type="number">, whose spinners are unstyleable and inconsistent across browsers, and whose value sanitising fights any kind of display formatting.

Why not type="number"

The control renders <input type="text" inputmode="decimal"> carrying role="spinbutton", the WAI-ARIA pattern for exactly this widget. That buys:

  • spinners we draw ourselves, identical in every browser
  • precision formatting on blur, which type="number" silently discards
  • a value that stays readable while editing, since the browser never "sanitises" a half-typed number to the empty string
  • the numeric keypad on mobile, via inputmode

What it costs: native constraint validation. min and max are enforced by the hook and mirrored to aria-valuemin / aria-valuemax, not by the browser, so validate the bound on the server too - the same as you would for any user-supplied number.

Examples

<.number_field name="quantity" value="1" min={1} max={99} />

<.number_field field={@form[:quantity]} min={1} max={99} />

<.number_field field={@form[:price]} variant="split" precision={2} step={0.5}>
  <:leading>$</:leading>
</.number_field>

<.number_field field={@form[:share]} min={0} max={100} step={5} big_step={25}>
  <:trailing>%</:trailing>
</.number_field>

Inside <.field> it gets a label, help text and error styling for free:

<.field type="number-field" field={@form[:quantity]} label="Quantity" min={1} />

Variants

  • stacked (default) - chevron up/down stacked at the inline end
  • split - minus at the inline start, plus at the inline end, value centred
  • plain - no buttons; typing, arrows and wheel only

Keyboard and pointer

InputEffect
ArrowUp / ArrowDownstep by step
Shift + arrowstep by big_step (defaults to step * 10)
PageUp / PageDownstep by big_step
Home / Endjump to min / max when set
Wheelsteps while the input is focused, and only then
Press and hold a buttonone step, then repeat, accelerating

Typed text is never clamped mid-keystroke - it is clamped and formatted on blur, so 1 on the way to 15 does not snap to the maximum under your fingers.

Accessibility

The input is the single tab stop: the buttons are tabindex="-1" with aria-labels, the way the APG spinbutton pattern prescribes. aria-valuenow tracks the value on every change, and a button at its bound gets aria-disabled rather than disabled, so it stays discoverable to a screen reader. A disabled control uses the native attribute throughout.

Inside <.field> the label wires up automatically. Standalone, the input has NO accessible name - the APG pattern requires one, so pass aria-label (it rides through the global attrs) or reference a visible label with aria-labelledby.

Formatting beyond precision

precision rounds and pads to a fixed number of decimals on blur. That is the whole built-in formatting story - no locale engine, no masking dependency.

For currency or percent display, format the visible text on blur and keep the raw number in the posted value, using the platform's own Intl.NumberFormat:

// in your app's JS, alongside the petal hooks
export const PriceField = {
  mounted() {
    const input = this.el.querySelector("[data-pc-number-input]");
    const fmt = new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD"
    });

    input.addEventListener("focus", () => {
      input.value = input.dataset.raw ?? input.value;
    });

    input.addEventListener("blur", () => {
      const n = parseFloat(input.value);
      if (Number.isNaN(n)) return;
      input.dataset.raw = String(n);
      input.value = fmt.format(n);
    });
  }
};

Post the raw number in a hidden input if the formatted text would confuse your changeset.

Setup

Needs the PetalNumberField hook from the petal_components JS bundle:

import PetalComponents from "../../deps/petal_components/assets/js/petal_components.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...PetalComponents }
})

Without the hook it degrades to a plain text input that still posts its value.

Summary

Functions

Renders a numeric spinbutton. Full documentation, including the keyboard map and the Intl.NumberFormat pattern, lives on PetalComponents.NumberField.

The input id this component would render for a given id and name.

Functions

number_field(assigns)

Renders a numeric spinbutton. Full documentation, including the keyboard map and the Intl.NumberFormat pattern, lives on PetalComponents.NumberField.

Attributes

  • id (:any) - input id; generated from the field or name if not passed. Defaults to nil.
  • name (:any) - input name; generated from the field if not passed.
  • value (:any) - current value; generated from the field if not passed.
  • field (Phoenix.HTML.FormField) - a form field struct, e.g. @form[:quantity]; sets id, name and value like other inputs.
  • min (:any) - lower bound; values are clamped and mirrored to aria-valuemin. Defaults to nil.
  • max (:any) - upper bound; clamped and mirrored to aria-valuemax. Defaults to nil.
  • step (:any) - increment for the buttons, arrow keys and wheel. Defaults to 1.
  • big_step (:any) - increment for shift+arrow and page up/down; defaults to step * 10. Defaults to nil.
  • precision (:integer) - decimal places shown on blur; the raw text stands while editing. nil means no formatting. Defaults to nil.
  • variant (:string) - stacked: both buttons at the inline end; split: minus at the start, plus at the end; plain: no buttons. Defaults to "stacked". Must be one of "stacked", "split", or "plain".
  • size (:string) - input height and text size. Defaults to "md". Must be one of "sm", "md", or "lg".
  • disabled (:boolean) - disables the input and both buttons natively. Defaults to false.
  • decrement_label (:string) - accessible name for the decrement button. Defaults to "Decrease value".
  • increment_label (:string) - accessible name for the increment button. Defaults to "Increase value".
  • class (:any) - extra classes for the field surface. Defaults to nil.
  • Global attributes are accepted. all other attributes land on the input. Supports all globals plus: ["autocomplete", "form", "placeholder", "readonly", "required", "inputmode", "aria-describedby"].

Slots

  • leading - addon before the input, e.g. a currency symbol.
  • trailing - addon after the input, e.g. a unit.

resolve_id(id, name)

@spec resolve_id(String.t() | nil, String.t() | nil) :: String.t()

The input id this component would render for a given id and name.

<.field type="number-field"> calls it so the label's for= names the same control the hook addresses. One identity, no way for the two to drift.