feat(number_field): a real spinbutton for quantities, prices and percentages - #629
feat(number_field): a real spinbutton for quantities, prices and percentages#629mplatts wants to merge 2 commits into
Conversation
…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 Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
ScreenshotsPlayground page at Light Dark Verified independentlyRe-ran every check on a clean worktree off the pushed branch:
The The Images live on the |
…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.


Closes #607
Summary
A net-new
<.number_field>: a real spinbutton for quantities, prices and percentages, composed on the existingpc-input-groupsurface 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.@moduledoccarries the docs (why nottype="number", variants, keyboard table, a11y contract, theIntl.NumberFormatpattern for currency/percent); the public function's@docpoints at it.assets/js/petal_components.js-PetalNumberFieldhook plusnumberFieldMath(parse / clamp / decimal-safe add / format), exported so the specs can pin the rules without a DOM.assets/default.css- apc-number-fieldsection 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-fieldwith live dials and the three scenarios from the issue.PetalComponentsimport, changelog.The spinbutton vs native-input decision
<input type="text" inputmode="decimal">carryingrole="spinbutton", per the issue's sketch and the Base UI approach. Nottype="number". The reasons, in order of weight:precisionis impossible ontype="number". Writing"24.50"back into a number input is a value the browser normalises to24.5on read and re-renders however it likes. Format-on-blur is the component's whole formatting story, and the native type fights it.type="number"reportsvalue === ""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.inputmode="decimal"still gets the numeric keypad on mobile, which is most of whattype="number"was buying.What it costs: native constraint validation.
min/maxno longer participate in:invalidor 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-valuenowcarry 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, notdisabled- 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. Adisabledcontrol uses the native attribute everywhere, since then it genuinely isn't operable.Deviations from the issue's API sketch
:leading/:trailingslots 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_labelattrs 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 ridesnumber_variant, notvariant.variantis already taken on<.field>by radio-card. This follows the existingcombo_variantprecedent rather than inventing a third convention.errorsattr onnumber_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>.<.number_field />with nofield,idornameraisesArgumentError. The hook needs a stable element id and the label'sfor=needs to name the input; minting a constant would collide on the second instance. Same callcombo_boxalready makes.stepdefaults to1andbig_steptostep * 10, as sketched - butbig_stepalso drivesPageUp/PageDown, not only shift+arrow."#{id}-field". The hook lives on the group, the input keeps the clean id sofor=and anyaria-describedbystill work.Tests
mix testnpm test30 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-formattedclean,mix compile --force --warnings-as-errorsclean,mix credoidentical tomain(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 witharia-valuenowfollowing), the wheel, hold-to-repeat,precisionturning7.5into7.50on tab-out, and the buttons still stepping after a variant switch rewires them mid-patch.