Skip to content

feat(NumberInput): a stepper you can actually hit - #694

Merged
mrholek merged 3 commits into
v6-devfrom
feat/number-input-v6
Aug 4, 2026
Merged

feat(NumberInput): a stepper you can actually hit#694
mrholek merged 3 commits into
v6-devfrom
feat/number-input-v6

Conversation

@mrholek

@mrholek mrholek commented Aug 4, 2026

Copy link
Copy Markdown
Member

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:

<input type="number" class="form-control" value="3" data-coreui-toggle="number-input">

An input already inside a .form-control-group is left where you put it, so a number field can share a frame with other adornments. Only a wrapper the component created is removed on dispose(). 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, max and step stay the browser's; stepping is stepUp() / stepDown().
  • The buttons are .form-control-action, so sizing, disabled and validation come from the group.
  • Icons are inline SVG on currentColor from util/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 min when 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", not type="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 sized input[type=text] — the spinner is the only thing that can differ, so "identical to text" is proof it is gone:

at rest on hover
Chromium, no rules no spinner spinner
Chromium, appearance: textfield does not hide it
Chromium, ::-webkit-*-spin-button hides it
Firefox, no rules spinner spinner
Firefox, appearance: textfield hides it hides it
Firefox, ::-webkit-*-spin-button does not hide it does not hide it

Note the trap: appearance: textfield alone — 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.

⚠️ WebKit not verified. Playwright's WebKit build fails to launch in this environment (Protocol error (Page.overrideSetting): Unknown setting: PushAPIEnabled), including after a forced reinstall. Safari implements the same ::-webkit-*-spin-button pseudo-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

  • 20 unit tests: stepping, fractional steps, empty-field start, bounds, disabled/readonly, events, config, data attributes, the frame (wrapping, size transfer, authored group, unwrapping only what it created), dispose and the data API.
  • 3 visual baselines: default, at maximum (the disabled button), small.
  • Full docs page in the shape of Password Input's, plus a sidebar entry.

Coverage 88.53%, bundlewatch budgets raised for the new component (CSS unchanged beyond the one spinner rule).

Not done, deliberately: the components.json entry in coreui-internal-links — that repo's test requires every URL to return 200, and the v6 page is not live. It belongs to the release.

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
@coveralls

coveralls commented Aug 4, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 30938594795

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.1%) to 92.941%

Details

  • Coverage decreased (-0.1%) from the base build.
  • Patch coverage: 17 uncovered changes across 1 file (100 of 117 lines covered, 85.47%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
js/src/number-input.ts 115 98 85.22%
Total (2 files) 117 100 85.47%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 8664
Covered Lines: 8265
Line Coverage: 95.39%
Relevant Branches: 4823
Covered Branches: 4270
Branch Coverage: 88.53%
Branches in Coverage %: Yes
Coverage Strength: 362.15 hits per line

💛 - 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
Comment thread js/tests/unit/number-input.spec.js Fixed
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.
@mrholek
mrholek merged commit d595be2 into v6-dev Aug 4, 2026
8 checks passed
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).
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.

3 participants