From 13cea65c5ffb1ae82e0bf4bd609a1d2fdbb3f001 Mon Sep 17 00:00:00 2001 From: Albert Prieto Date: Fri, 21 Aug 2026 10:25:40 +0200 Subject: [PATCH] fix(F0NumberInput): make the stepper keyboard operable 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. --- .../__tests__/F0NumberInput.test.tsx | 49 +++++++++++++++++++ .../F0NumberInput/components/Arrows.tsx | 18 ++++++- .../src/components/F0NumberInput/internal.tsx | 19 +++++++ .../providers/i18n/i18n-provider-defaults.ts | 2 + 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/F0NumberInput/__tests__/F0NumberInput.test.tsx b/packages/react/src/components/F0NumberInput/__tests__/F0NumberInput.test.tsx index 823225101a..ba9e81a5e2 100644 --- a/packages/react/src/components/F0NumberInput/__tests__/F0NumberInput.test.tsx +++ b/packages/react/src/components/F0NumberInput/__tests__/F0NumberInput.test.tsx @@ -143,6 +143,55 @@ describe("F0NumberInput", () => { expect(input).toHaveValue("1") }) + + // The arrow buttons are mouse-only on purpose, so the keyboard route to + // the stepper is ArrowUp/ArrowDown on the input (WCAG 2.1.1). + describe("from the keyboard", () => { + test("ArrowUp increases and ArrowDown decreases the value", async () => { + render() + + const input = screen.getByRole("textbox") + await userEvent.click(input) + + await userEvent.keyboard("{ArrowUp}") + await waitFor(() => expect(input).toHaveValue("2")) + + await userEvent.keyboard("{ArrowDown}") + await waitFor(() => expect(input).toHaveValue("1")) + }) + + test("ArrowUp does not increase the value above the max", async () => { + render() + + const input = screen.getByRole("textbox") + await userEvent.click(input) + await userEvent.keyboard("{ArrowUp}") + + expect(input).toHaveValue("5") + }) + + test("ArrowDown does not decrease the value below the min", async () => { + render() + + const input = screen.getByRole("textbox") + await userEvent.click(input) + await userEvent.keyboard("{ArrowDown}") + + expect(input).toHaveValue("1") + }) + + test("leaves the value alone when no step is set", async () => { + render( + + ) + + const input = screen.getByRole("textbox") + await userEvent.click(input) + await userEvent.keyboard("{ArrowUp}") + + expect(input).toHaveValue("5") + }) + }) }) describe("inline extraContent mode", () => { diff --git a/packages/react/src/components/F0NumberInput/components/Arrows.tsx b/packages/react/src/components/F0NumberInput/components/Arrows.tsx index 97a0a695af..35f2806226 100644 --- a/packages/react/src/components/F0NumberInput/components/Arrows.tsx +++ b/packages/react/src/components/F0NumberInput/components/Arrows.tsx @@ -2,6 +2,7 @@ import { ChevronDown } from "lucide-react" import { F0Icon } from "@/components/F0Icon/F0Icon" import { ChevronUp } from "@/icons/app" +import { useI18n } from "@/lib/providers/i18n" type ArrowsProps = { step?: number @@ -9,7 +10,20 @@ type ArrowsProps = { onClickArrow: (type: "increase" | "decrease") => () => void } +/** + * Mouse affordance for the stepper. Deliberately kept out of the tab order: + * the keyboard route to the same behaviour is ArrowUp/ArrowDown on the input + * itself (see the `onKeyDown` in ../internal.tsx), which is what a native + * number input does and what a keyboard user reaches for first. + * + * Do not add `tabIndex` here without resizing the targets. These are 16x12 CSS + * px stacked ~12px apart, so making them focusable brings them into the scope + * of axe's `target-size` rule (WCAG 2.5.8), which they fail on both the size + * and the offset sub-check. + */ export const Arrows = ({ onClickArrow, step, disabled }: ArrowsProps) => { + const i18n = useI18n() + if (!step || disabled) return null return ( @@ -21,7 +35,7 @@ export const Arrows = ({ onClickArrow, step, disabled }: ArrowsProps) => { onClick={onClickArrow("increase")} className="h-3 cursor-pointer" role="button" - aria-label="Increase" + aria-label={i18n.t("numberInput.increase")} > @@ -29,7 +43,7 @@ export const Arrows = ({ onClickArrow, step, disabled }: ArrowsProps) => { onClick={onClickArrow("decrease")} className="h-3 cursor-pointer" role="button" - aria-label="Decrease" + aria-label={i18n.t("numberInput.decrease")} > diff --git a/packages/react/src/components/F0NumberInput/internal.tsx b/packages/react/src/components/F0NumberInput/internal.tsx index 3750a084a0..91e4104565 100644 --- a/packages/react/src/components/F0NumberInput/internal.tsx +++ b/packages/react/src/components/F0NumberInput/internal.tsx @@ -2,6 +2,7 @@ import { useControllableState } from "@radix-ui/react-use-controllable-state" import { CSSProperties, ComponentProps, + type KeyboardEvent, type ReactNode, forwardRef, useCallback, @@ -350,6 +351,23 @@ export const NumberInputInternal = forwardRef< handleChange(formatValue(newValue, locale, maxDecimals)) } + /** + * Keyboard route to the stepper. The arrow buttons are mouse-only by design + * (see components/Arrows.tsx), so without this the stepper is unreachable + * for a keyboard user: WCAG 2.1.1. Mirrors a native number input, which + * steps on ArrowUp/ArrowDown while the field has focus. + * + * Gated on `step` by handleStep, so fields with no stepper are unaffected. + */ + const handleKeyDown = (event: KeyboardEvent) => { + if (!step || disabled || readonly) return + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return + + // Stop the caret jumping to the start/end of the field as it steps. + event.preventDefault() + handleStep(event.key === "ArrowUp" ? "increase" : "decrease")() + } + useEffect(() => { // With grouping, the resting (blurred) display shows thousands separators // and the focused display drops them for editing. This branch also drives @@ -401,6 +419,7 @@ export const NumberInputInternal = forwardRef< onBlur?.() }} onBeforeInput={handleBeforeInput} + onKeyDown={handleKeyDown} appendTag={units} append={ step ? ( diff --git a/packages/react/src/lib/providers/i18n/i18n-provider-defaults.ts b/packages/react/src/lib/providers/i18n/i18n-provider-defaults.ts index 4c55e9e80b..9ee8bae351 100644 --- a/packages/react/src/lib/providers/i18n/i18n-provider-defaults.ts +++ b/packages/react/src/lib/providers/i18n/i18n-provider-defaults.ts @@ -711,6 +711,8 @@ export const defaultTranslations = { between: "It should be between {{min}} and {{max}}", greaterThan: "It should be greater than {{min}}", lessThan: "It should be less than {{max}}", + increase: "Increase", + decrease: "Decrease", }, phoneInput: { country: "Country",