Skip to content

feat(number_field): a real spinbutton for quantities, prices and percentages - #629

Draft
mplatts wants to merge 2 commits into
mainfrom
feat/number-field
Draft

feat(number_field): a real spinbutton for quantities, prices and percentages#629
mplatts wants to merge 2 commits into
mainfrom
feat/number-field

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #607

Summary

A net-new <.number_field>: a real spinbutton for quantities, prices and percentages, composed on the existing pc-input-group surface rather than standing up a new one. Three variants (stacked, split, plain), three sizes, clamping mirrored to ARIA, a full keyboard map, wheel-while-focused, and hold-to-repeat.

  • lib/petal_components/number_field.ex - the component. @moduledoc carries the docs (why not type="number", variants, keyboard table, a11y contract, the Intl.NumberFormat pattern for currency/percent); the public function's @doc points at it.
  • assets/js/petal_components.js - PetalNumberField hook plus numberFieldMath (parse / clamp / decimal-safe add / format), exported so the specs can pin the rules without a DOM.
  • assets/default.css - a pc-number-field section appended inside its own @layer components { } block, so it doesn't inherit the unlayered tail's habit of beating consumer utilities.
  • lib/petal_components/field.ex - a "number-field" type: label, help text and the error tone for free.
  • dev.exs - /c/number-field with live dials and the three scenarios from the issue.
  • Showcase module, registry entry, PetalComponents import, changelog.

The spinbutton vs native-input decision

<input type="text" inputmode="decimal"> carrying role="spinbutton", per the issue's sketch and the Base UI approach. Not type="number". The reasons, in order of weight:

  1. precision is impossible on type="number". Writing "24.50" back into a number input is a value the browser normalises to 24.5 on read and re-renders however it likes. Format-on-blur is the component's whole formatting story, and the native type fights it.
  2. A half-typed value survives. type="number" reports value === "" for "12." or "1e", so any hook reading the input mid-keystroke sees an empty field. That makes "clamp on blur, never mid-keystroke" unimplementable.
  3. Spinners. Unstyleable and different in Chrome, Firefox and Safari - the thing this component exists to replace.
  4. inputmode="decimal" still gets the numeric keypad on mobile, which is most of what type="number" was buying.

What it costs: native constraint validation. min/max no longer participate in :invalid or in the browser's own form validation. The moduledoc says so in as many words and tells you to validate the bound server-side. aria-valuemin / aria-valuemax / aria-valuenow carry the semantics to assistive tech, which is what the APG spinbutton pattern asks for, and the hook enforces the clamp.

Bound buttons take aria-disabled, not disabled - a disabled button leaves the a11y tree, and a screen reader user tabbing the value up to 99 should still hear that an increment exists and is spent. A disabled control uses the native attribute everywhere, since then it genuinely isn't operable.

Deviations from the issue's API sketch

  • :leading / :trailing slots added. The sketch had no slots, but two of its three required scenarios need a $ and a % addon. They render into the input-group's own addon rows alongside the buttons.
  • decrement_label / increment_label attrs added. The sketch hard-coded "Increase value" / "Decrease value". Those are the defaults; the cart scenario wants "Remove one seat".
  • <.field type="number-field"> variant rides number_variant, not variant. variant is already taken on <.field> by radio-card. This follows the existing combo_variant precedent rather than inventing a third convention.
  • No errors attr on number_field/1. The sketch mentioned it. The error ring is painted by .pc-form-field-wrapper--error .pc-input-group, which is the wrapper's job - so <.field> renders the messages and the standalone component stays a control, matching <.input>.
  • No identity is a raise, not a generated id. <.number_field /> with no field, id or name raises ArgumentError. The hook needs a stable element id and the label's for= needs to name the input; minting a constant would collide on the second instance. Same call combo_box already makes.
  • step defaults to 1 and big_step to step * 10, as sketched - but big_step also drives PageUp/PageDown, not only shift+arrow.
  • Wrapper id is "#{id}-field". The hook lives on the group, the input keeps the clean id so for= and any aria-describedby still work.

Tests

Suite Before After
mix test 913 tests, 0 failures, 1 skipped 943 tests, 0 failures, 1 skipped
npm test 157 passing 195 passing

30 new Elixir tests (anatomy per variant, sizes, hook data attrs, id derivation, ARIA, bounds, disabled/readonly, precision, %Phoenix.HTML.FormField{} integration, <.field type="number-field"> including errors and size mapping) and 38 new JS tests (the pure maths, the keyboard map, wheel focused vs not, blur clamping and formatting, button presses, hold-to-repeat with fake timers including every stop path, ARIA sync across a patch).

mix format --check-formatted clean, mix compile --force --warnings-as-errors clean, mix credo identical to main (14 refactoring opportunities, 31 readability issues on both).

Verified in the playground

Live on /c/number-field, light and dark: every dial, keyboard-only stepping (arrow, shift+arrow to 23, End to 99 with aria-valuenow following), the wheel, hold-to-repeat, precision turning 7.5 into 7.50 on tab-out, and the buttons still stepping after a variant switch rewires them mid-patch.

…entages

<.number_field> replaces bare <input type="number"> in product UI. The
native control has spinners you can't style that differ per browser, and
a value the browser sanitises out from under any display formatting.

- <input type="text" inputmode="decimal"> carrying role="spinbutton" on
  the existing pc-input-group surface, so the border, radius, focus ring
  and error tone all come from the form family it sits in
- three variants: stacked chevrons, split minus/plus with the value
  centred, and plain for keyboard only; sm/md/lg sizes
- min/max/step clamp and mirror to aria-valuemin/valuemax/valuenow;
  big_step (defaulting to ten steps) rides shift+arrow and page up/down;
  Home and End jump to the bounds
- precision rounds and pads on blur, raw text while typing, server-side
  render matching so the first paint doesn't jump. No formatting dep -
  the moduledoc documents the Intl.NumberFormat pattern instead
- :leading and :trailing slots for currency and unit addons

PetalNumberField hook owns stepping, clamping, wheel-while-focused, and
hold-to-repeat that accelerates and stops on pointerleave/pointercancel
as well as pointerup. Binding is idempotent and re-runs on updated(), so
a variant switch that swaps which buttons exist stays wired.

a11y: the input is the single tab stop, buttons are labelled at
tabindex="-1", and a button at its bound takes aria-disabled rather than
disabled so it keeps its name for a screen reader. Native disabled for
the whole control.

<.field type="number-field"> wires it into the label/help/error
machinery, resolving one id so the label's for= and the hook address the
same input. <.field type="number"> is untouched.

Showcase: six examples. Playground: /c/number-field with live dials
(variant, size, bounds preset, disabled) and the cart / price /
percentage scenarios.

30 Elixir tests, 38 JS tests. 943 Elixir / 195 JS, zero credo delta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.51%. Comparing base (871b2cf) to head (ce3f643).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/petal_components/number_field.ex 95.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #629      +/-   ##
==========================================
+ Coverage   92.40%   92.51%   +0.11%     
==========================================
  Files         119      121       +2     
  Lines        5066     5211     +145     
==========================================
+ Hits         4681     4821     +140     
- Misses        385      390       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Playground page at /c/number-field, 1280px wide, full page.

Light

Number field, light mode

Dark

Number field, dark mode

Verified independently

Re-ran every check on a clean worktree off the pushed branch:

Check Result
mix test 943 tests, 0 failures, 1 skipped (+30)
npm test 195 passing (+38)
mix format --check-formatted clean
mix compile --force --warnings-as-errors clean
mix credo zero new entries vs main (45 before, 45 after; the field.ex complexity finding just shifts line 249 → 255 as the new clause pushes it down)
new dependencies none — mix.exs and package.json untouched

The field.ex change is worth a careful look since that file is on every form in every consumer app. It is strictly additive: "number-field" joins the allowed type list, a number_variant attr is added, the :global include list gains big_step precision decrement_label increment_label inputmode, and a new def field(%{type: "number-field"}) clause is appended. No existing clause or type is modified.

The type="text" inputmode="decimal" + role="spinbutton" decision (rather than native type="number") is well argued in the PR body — native normalises "24.50" away, making precision unimplementable, and reports value === "" for half-typed input like "12.", making "clamp on blur, never mid-keystroke" unimplementable. The tradeoff is losing native constraint validation, which the moduledoc calls out.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

…A, single change event

Audit round. (1) Home/End called write() directly, bypassing step()'s
guard - and readonly inputs still receive keydown, so a readonly value
could be rewritten from the keyboard. The guard now lives at the top of
handleKeydown, and startRepeat mirrors it (a hold on a readonly control
was focusing the input and scheduling a repeat timer that no-op'd every
tick). (2) aria-valuenow and the at-bound checks were computed from the
RAW parse while precision rounds the display - value=5.5 precision=0
painted '6' but announced 5.5 and left the increment button live at
max. Both now derive from the rounded value, so first paint and screen
reader agree with the pixels. (3) commitTyped fired a synthetic change
even when the clamp changed nothing - stacking on the browser's native
blur change and doubling every handler; write() now reports whether it
mutated and the synthetic change fires only for real clamps. (4)
Moduledoc: standalone spinbuttons need aria-label (APG requires a
name); pass-through test-pinned. JS tests for all three behaviours.
945 Elixir + 198 JS green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component: Number Field

2 participants