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",