"use client"; import { useId, useRef, useState, type KeyboardEvent, type PointerEvent, } from "react"; import { Star } from "lucide-react"; import type { FormControlA11yProps } from "@/components/ui/form"; import { RATING_MAX, RATING_MIN } from "@/lib/validations/dive"; import { cn } from "@/lib/utils"; import { countNoun } from "@/lib/format"; const STEPS = Array.from( { length: RATING_MAX - RATING_MIN + 1 }, (_, index) => RATING_MIN + index, ); /** One step's name, which is what a screen reader reads for it. */ function ratingStepLabel(step: number): string { return countNoun(step, "star"); } /** A stored rating as a sentence, for the places that show one. */ function ratingSummary(rating: number): string { return `${rating} of ${RATING_MAX} stars`; } export interface RatingInputProps extends FormControlA11yProps { /** The step chosen, or `null` for unrated. */ value: number | null; onChange: (value: number | null) => void; /** The visible label's id - a group is named by reference, not by `for`. */ "aria-labelledby"?: string; disabled?: boolean; } /** * The diver's rating of a dive as five stars, any one of which may be picked, * and none. * * **Native radios, hidden behind the stars.** A radio group is what a set of * mutually exclusive steps is, and the browser supplies its keyboard for free: * the group is one tab stop, the arrow keys move the step and pick it as they go, * and a screen reader announces the group's name, the step's and its position - * "4 stars, 4 of 5". Hand-rolled buttons with a roving tab index would rebuild all * of that and then have to be kept in step with it. * * **A radio cannot be unchecked, so clearing is two other things.** Backspace or * Delete on a focused star clears it, and a Clear button after the stars does for a * pointer, while there is a rating to clear. It sits beside the group rather than in * it, which may only hold radios. Unrated is a real answer - `null`, not zero, and * what every dive logged before this field existed holds. */ export function RatingInput({ value, onChange, id, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-labelledby": ariaLabelledBy, disabled, }: RatingInputProps) { const groupRef = useRef(null); // One group per control: a second rating on the page must not share its // radios' name, or the browser would treat the ten as one set. const name = useId(); // The step under a mouse pointer, drawn as if picked so the diver sees what a // click would give. Touch has no hover, and a tap would leave one stuck. const [hovered, setHovered] = useState(null); const shown = hovered ?? value; const hover = (step: number | null) => (event: PointerEvent) => { if (event.pointerType !== "touch" && !disabled) setHovered(step); }; const clearOnKey = (event: KeyboardEvent) => { if (event.key !== "Backspace" && event.key !== "Delete") return; event.preventDefault(); onChange(null); }; const clear = () => { onChange(null); // The button goes with the rating it cleared, so the focus it held lands on // the stars rather than on the page. groupRef.current?.querySelector("input")?.focus(); }; return ( // An input's height, 44px on touch as well, so the field sits on its grid row // like the boxes beside it rather than a few pixels short of them.
{STEPS.map((step) => ( ))}
{value !== null && !disabled && ( )}
); } /** * A stored rating drawn as stars, for reading, each the size of the text it * sits in. One image with one name rather than five, so a screen reader says * "4 of 5 stars" once. */ export function RatingStars({ rating, className, }: { rating: number; className?: string; }) { return ( {STEPS.map((step) => ( ))} ); }