Skip to content

fix(F0NumberInput): make the stepper keyboard operable - #5196

Open
albertpmz wants to merge 1 commit into
chore/number-input-stable-dodfrom
fix/number-input-stepper-keyboard
Open

fix(F0NumberInput): make the stepper keyboard operable#5196
albertpmz wants to merge 1 commit into
chore/number-input-stable-dodfrom
fix/number-input-stepper-keyboard

Conversation

@albertpmz

Copy link
Copy Markdown
Contributor

Description

The F0NumberInput stepper is mouse-only, which is a WCAG 2.1.1 Keyboard failure
(level A) in a component tagged stable. This adds ArrowUp/ArrowDown on the input.

Stacked on #5192. Base is chore/number-input-stable-dod, not main, so this
PR's CI inherits the a11y: { test: "error" } that #5192 adds. Based on main it
would run with axe unenforced and would not guard this change. Merge #5192 first;
this will need a rebase if that one is squashed.

Type of change

  • Bug fix

Implementation details

The defect

components/Arrows.tsx renders the increase/decrease controls as role="button"
divs carrying an onClick and nothing else: no tabIndex, no key handler.
internal.tsx had no onKeyDown, so the input did not step on arrow keys either.

The shape of the failure is worse than plain "no keyboard support". The arrows are
revealed by group-focus-within, so tabbing into the field makes them appear, and
then they cannot be reached or activated. Tab moves straight past to the next
field. The affordance shows up precisely for the user who cannot operate it, and
anyone who cannot use a mouse has no way to reach the stepper.

axe cannot catch this class of defect. The rule that flags a role="button"
with no focusability is focus-order-semantics, which is best-practice and
outside the enforced WCAG tag set. #5192 turned on axe enforcement for this file
and went green on all 16 stories with the defect sitting there untouched.

The fix

onKeyDown on the input routes ArrowUp/ArrowDown into the existing handleStep,
which already clamps to min/max and seeds from step when the value is null.
This mirrors a native number input and is what a keyboard user reaches for first.

Details worth reviewing:

  • preventDefault() on the two handled keys, otherwise the caret jumps to the
    start/end of the field while stepping.
  • Guarded on step, disabled and readonly. Fields with no stepper are
    unaffected, so this changes behaviour only where a stepper was opted into.
  • No handler composition needed: NumberInputInternalProps is a Pick that does
    not include onKeyDown, so consumers cannot pass one today.
  • Attaches for real: src/ui/input.tsx destructures onKeyDown and forwards it to
    the native <input>, so it is not swallowed by the wrapper.

Why the arrows keep no tabIndex

Adding tabIndex to the arrow divs is the obvious-looking fix and it is the wrong
one. Un-focusable controls are outside the scope of axe's target-size rule
(WCAG 2.5.8), because widget-not-inline-matches requires _isFocusable. Making
them focusable brings two 16x12 CSS px targets, stacked roughly 12px apart, into
scope, where they fail the size sub-check and the 24px offset sub-check alike. That
would break the axe enforcement this file gains from #5192. Keying the input avoids
the problem entirely.

The reasoning is recorded as a comment above Arrows so the next person does not
undo it.

Also in here

The hardcoded English "Increase" / "Decrease" labels now read through
useI18n(), joining the existing numberInput namespace in
i18n-provider-defaults.ts alongside between / greaterThan / lessThan. The
component already used useI18n() for its range hints, so these two were the
outliers.

Verification

Run from packages/react:

  • pnpm tsc clean
  • oxlint on the touched trees, 0 warnings / 0 errors
  • pnpm vitest --project=unit run src/components/F0NumberInput 60 pass (was 56)
  • pnpm vitest --project=unit run src/lib/providers/i18n 4 pass, 1 skipped
  • oxfmt --check clean
  • axe probe over all 16 stories at CI's rule scope and scan root: still 16/16
    clean, so the fix exposed no new targets
  • pnpm test-storybook --url … F0NumberInput 15/15 pass, with axe enforced via the
    base branch

Red-green

check-bugfix-red-green against the stacked base passes:

✔ Red-green verified: the changed unit tests fail on latest main
  and pass with this PR's changes.

One honest note on the four new tests: only
ArrowUp increases and ArrowDown decreases the value is red on the base. The other
three assert that the value does not move (above max, below min, and with no
step set), and those pass trivially on a branch where arrow keys do nothing. They
are regression guards for the clamping and the step gate rather than reproductions
of this bug.

Follow-up, deliberately not here

The arrow divs stay role="button" divs. Converting them to real <button>
elements is the tidier long-term shape, but it needs the 24px hit-area work first
for the target-size reason above, which is a visual density change that wants
design sign-off and moves the Chromatic baseline.

The stepper was mouse-only. Arrows.tsx renders the increase/decrease
controls as `role="button"` divs with an `onClick` and nothing else: no
tabIndex, no key handler. internal.tsx had no onKeyDown either, so the
input did not step on arrow keys.

The failure mode was worse than "no keyboard support": the arrows are
revealed by `group-focus-within`, so tabbing into the field makes them
appear, and then they cannot be reached or activated. The affordance shows
up precisely for the user who cannot use it. WCAG 2.1.1 Keyboard, level A.

Fixed on the input, not on the arrows. ArrowUp/ArrowDown now route to the
existing handleStep, which already clamps to min/max and seeds from `step`
when the value is null. This is what a native number input does, and it is
what a keyboard user tries first.

Deliberately not adding tabIndex to the arrow divs. Un-focusable controls
are outside the scope of axe's target-size rule, because
widget-not-inline-matches requires _isFocusable. Making them focusable
brings two 16x12 CSS px targets sitting ~12px apart into scope, failing
both the size and the offset sub-check, which would break the axe
enforcement this file just gained.

Also moves the hardcoded English "Increase"/"Decrease" labels onto
useI18n(), joining the existing numberInput namespace.

axe cannot see this class of defect: focus-order-semantics is
best-practice and outside the enforced WCAG tag set.
@albertpmz
albertpmz requested a review from a team as a code owner August 21, 2026 08:29
Copilot AI lite review requested due to automatic review settings August 21, 2026 08:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added fix react Changes affect packages/react labels Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Review policy: Code change

Default rule: any other change needs one approval from f0-devs (rule 4).

Required approvals

Team Why Status
@factorialco/f0-devs Every code change needs a dev approval ⏳ pending
How this was decided
  • PRs touching only sds/ modules require their owners and nothing else.
  • Otherwise, docs-only changes (*.md, *.mdx, *.stories.tsx, anything in __stories__/) → one f0-general approval.
  • Otherwise, feat: titles → one f0-devs and one f0-designers approval. Not a feature? Fix the title prefix.
  • Anything else → one f0-devs approval.
  • Add the needs-design-review label to also request a design approval on any PR.
  • Creating a new sds/ module (new package.yml) additionally requires an f0-general approval.

Policy source: ownership/review-policy.ts · Team members: ownership/teams.yml

@github-actions

Copy link
Copy Markdown
Contributor

📦 Alpha Package Version Published

Use pnpm i github:factorialco/f0#npm/alpha-pr-5196 to install the package

Use pnpm i github:factorialco/f0#fc450dd36122c5f2f005bbab8989f7879b09a5af to install this specific commit

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Visual review for your branch is published 🔍

Here are the links to:

@github-actions

Copy link
Copy Markdown
Contributor

♿ Accessibility (axe) — components changed in this PR

✅ No a11y issues in the stories this PR changed.

Scope: only stories in the files/component folders this PR changed. It can't yet flag downstream ripple from shared-code/token changes, or diff against main (planned: base-vs-head delta).

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for packages/react

Status Category Percentage Covered / Total
🔵 Lines 67.06% 27525 / 41045
🔵 Statements 66.07% 29106 / 44052
🔵 Functions 59.78% 6539 / 10937
🔵 Branches 59.97% 20511 / 34200
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/react/src/components/F0NumberInput/internal.tsx 83.59% 82.42% 95.23% 84.74% 69-72, 240, 243, 260, 282-296, 305, 332-335, 340, 342-343, 364
packages/react/src/components/F0NumberInput/components/Arrows.tsx 83.33% 75% 100% 100% 27
packages/react/src/lib/providers/i18n/i18n-provider-defaults.ts 100% 100% 100% 100%
Generated in workflow #17322 for commit 13cea65 by the Vitest Coverage Report Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix react Changes affect packages/react

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants