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