feat(NumberInput): a stepper you can actually hit - #694
Merged
Conversation
The browser's own spinner is a few pixels wide, appears only on hover in Chromium, and does not exist at all on iOS. This replaces it with two buttons sized like every other adornment in a form-control-group. The component is thin because the frame and the native input do the work: min, max and step stay the browser's, stepping is stepUp()/stepDown(), and the buttons are .form-control-action, so sizing, disabled and validation come from the group. What is left is the boundary state - a button that cannot move the value is disabled rather than silently inert - repeat on hold, and starting from min when the field is empty, because stepUp() throws on a value the browser considers invalid. The buttons stay out of the tab order: a number field already steps with the arrow keys, so adding them would cost two tab stops per field for an affordance the keyboard already has. They keep their labels. Hiding the native spinner needs both mechanisms, measured rather than assumed: Chromium answers only to ::-webkit-*-spin-button (and paints the spinner on hover, not at rest), Firefox has no such pseudo-element and answers only to appearance: textfield. Neither rule covers the other engine.
| describe('constructor', () => { | ||
| it('should add the stepper buttons to the group', () => { | ||
| const input = markup('value="1"') | ||
| const numberInput = new NumberInput(input) // eslint-disable-line no-unused-vars |
| }) | ||
|
|
||
| it('should keep the buttons out of the tab order', () => { | ||
| const numberInput = new NumberInput(markup('value="1"')) // eslint-disable-line no-unused-vars |
|
|
||
| it('should follow a value typed into the input', () => { | ||
| const input = markup('value="1" max="3"') | ||
| const numberInput = new NumberInput(input) // eslint-disable-line no-unused-vars |
|
|
||
| describe('config', () => { | ||
| it('should take its icons from the options', () => { | ||
| const numberInput = new NumberInput(markup('value="1"'), { // eslint-disable-line no-unused-vars |
| }) | ||
|
|
||
| it('should read options from data attributes', () => { | ||
| const numberInput = new NumberInput(markup('value="1" data-coreui-aria-increment-label="More"')) // eslint-disable-line no-unused-vars |
| }) | ||
|
|
||
| it('should leave the icon alone when sanitize is off', () => { | ||
| const numberInput = new NumberInput(markup('value="1"'), { // eslint-disable-line no-unused-vars |
Coverage Report for CI Build 30938594795Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage decreased (-0.1%) to 92.941%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats💛 - Coveralls |
The buttons are the component's, so the wrapper that lays them out can be as well. An input on its own gets one, which makes the markup a plain form control: <input type="number" class="form-control" data-coreui-toggle="number-input"> A size written on the input moves onto that wrapper, since sizing belongs to the frame. An input already inside a group is left where the author put it - the group may hold more than this field - and only a wrapper the component created is removed on dispose. Drops the split layout that briefly landed here: on a full-width field it spreads the value between two distant buttons, and the width is the author's, not ours. The group is a flex row, so anyone who wants that writes their own buttons in the order they want.
| it('should wrap a bare input in a group', () => { | ||
| fixtureEl.innerHTML = '<input type="number" class="form-control" value="1">' | ||
| const input = fixtureEl.querySelector('input') | ||
| const numberInput = new NumberInput(input) // eslint-disable-line no-unused-vars |
| it('should use a group the author already wrote', () => { | ||
| const input = markup('value="1"') | ||
| const group = input.closest('.form-control-group') | ||
| const numberInput = new NumberInput(input) // eslint-disable-line no-unused-vars |
… the size A class written on the input describes the field, and once the component wraps that input the field is the wrapper. Leaving mb-3 on the control put the margin inside the border, which the sizing example showed plainly: two fields with no space between them. So everything except .form-control itself moves - size, spacing, width, whatever the author wrote - and moves back on dispose. .form-control stays because that is the class the group neutralises.
| it('should move every class but form-control onto the group', () => { | ||
| fixtureEl.innerHTML = '<input type="number" class="form-control form-control-lg mb-3 w-50" value="1">' | ||
| const input = fixtureEl.querySelector('input') | ||
| const numberInput = new NumberInput(input) // eslint-disable-line no-unused-vars |
mrholek
added a commit
that referenced
this pull request
Aug 7, 2026
An audit of every v6-dev commit against the guide found five user-visible changes with no entry, and one entry that had gone stale. Missing: - **The `*.rtl.css` builds are gone** (#682). The biggest gap: 20 files left `dist/css` and nothing said so. Anyone linking `coreui.rtl.min.css` gets a 404 with no explanation of what to do instead. - **Button always exposes `aria-pressed`** (#706). - **Number Input** (#694) was mentioned only in passing, inside the entry about the frame it shares with Password Input, never announced as new. - **Multi Select option checkboxes are the form-check surface now** (#660, #661), which retires the per-indicator look variables. - **Markers are hidden with `list-style-type: ""`** (#664). Stale: the select-indicator entry still said "the RTL build flips the right/left keyword at compile time" — there is no RTL build. And the popup primitive entry claimed "the anchored presentation is unchanged", which stopped being true when the panel started fading in (#698).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The browser's own number spinner is a few pixels wide, appears only on hover in Chromium, and does not exist at all on iOS. This replaces it with two buttons sized like every other adornment in a
form-control-group.The markup is a plain form control — the component builds the frame and the buttons:
An input already inside a
.form-control-groupis left where you put it, so a number field can share a frame with other adornments. Only a wrapper the component created is removed ondispose(). A size written on the input (.form-control-sm) moves onto the frame, because that is where sizing belongs.Why it is small
The frame and the native input do most of the work, which is the point of having built the primitive first:
min,maxandstepstay the browser's; stepping isstepUp()/stepDown()..form-control-action, so sizing, disabled and validation come from the group.currentColorfromutil/icons.ts, sanitized and swappable through options — the pattern Password Input established.What is genuinely the component's: the boundary state (a button that cannot move the value is disabled rather than silently inert), repeat-on-hold, and starting from
minwhen the field is empty —stepUp()throws on a value the browser considers invalid, which an empty field is.Decisions worth flagging
The buttons are out of the tab order. A number field already steps with ↑/↓, so putting them in costs two tab stops per field for an affordance the keyboard already has. They keep their
aria-labels.type="number", nottype="text"+inputmode. The native type is what makes the plugin thin. The cost is that it accepts the dot as decimal separator regardless of locale, and reports an empty string for anything it rejects — so the field cannot tell you what was typed. That is a formatted-input problem, not a stepper one, and the docs say so rather than pretending otherwise.No split (
− value +) layout. It briefly landed and came out: on a full-width field it strands the value between two distant buttons, and the width belongs to the author. The group is a flex row, so anyone wanting that writes their own buttons in the order they want.Hiding the native spinner — measured, not assumed
Both mechanisms are needed and neither covers the other engine. I probed each rule in each engine by screenshotting
input[type=number]against an identically sizedinput[type=text]— the spinner is the only thing that can differ, so "identical to text" is proof it is gone:appearance: textfield::-webkit-*-spin-buttonappearance: textfield::-webkit-*-spin-buttonNote the trap:
appearance: textfieldalone — the "modern, prefix-free" advice you find everywhere — does nothing in Chromium. And Firefox paints the spinner at rest, so without the rule its users would see the native control beside ours permanently.Protocol error (Page.overrideSetting): Unknown setting: PushAPIEnabled), including after a forced reinstall. Safari implements the same::-webkit-*-spin-buttonpseudo-element Chromium inherited from it, so the rule set should cover it — but that is inference, not measurement, and worth a look on a real Safari before release.Tests and docs
Coverage 88.53%, bundlewatch budgets raised for the new component (CSS unchanged beyond the one spinner rule).
Not done, deliberately: the
components.jsonentry incoreui-internal-links— that repo's test requires every URL to return 200, and the v6 page is not live. It belongs to the release.