Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<WithStepStory />)

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(<WithStepStory value={5} />)

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(<WithStepStory value={1} />)

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(
<F0NumberInput locale="en-US" value={5} label="No stepper here" />
)

const input = screen.getByRole("textbox")
await userEvent.click(input)
await userEvent.keyboard("{ArrowUp}")

expect(input).toHaveValue("5")
})
})
})

describe("inline extraContent mode", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,28 @@ 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
disabled?: boolean
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 (
Expand All @@ -21,15 +35,15 @@ 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")}
>
<F0Icon size="sm" icon={ChevronUp} />
</div>
<div
onClick={onClickArrow("decrease")}
className="h-3 cursor-pointer"
role="button"
aria-label="Decrease"
aria-label={i18n.t("numberInput.decrease")}
>
<F0Icon size="sm" icon={ChevronDown} />
</div>
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/components/F0NumberInput/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useControllableState } from "@radix-ui/react-use-controllable-state"
import {
CSSProperties,
ComponentProps,
type KeyboardEvent,
type ReactNode,
forwardRef,
useCallback,
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
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
Expand Down Expand Up @@ -401,6 +419,7 @@ export const NumberInputInternal = forwardRef<
onBlur?.()
}}
onBeforeInput={handleBeforeInput}
onKeyDown={handleKeyDown}
appendTag={units}
append={
step ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading