src/components/dives/rating-input.tsx
Rama main · réplica de lectura del repositorio original.
"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<HTMLDivElement>(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<number | null>(null);
const shown = hovered ?? value;
const hover = (step: number | null) => (event: PointerEvent) => {
if (event.pointerType !== "touch" && !disabled) setHovered(step);
};
const clearOnKey = (event: KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>("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.
<div className="flex h-10 items-center gap-2 touch:h-11">
<div
ref={groupRef}
id={id}
role="radiogroup"
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
className="flex items-center gap-1 touch:gap-0"
onPointerLeave={hover(null)}
>
{STEPS.map((step) => (
<label
key={step}
className={cn(
"relative flex h-8 w-8 items-center justify-center touch:h-11 touch:w-11",
disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer",
)}
onPointerEnter={hover(step)}
>
<input
type="radio"
name={name}
value={step}
checked={value === step}
disabled={disabled}
aria-label={ratingStepLabel(step)}
onChange={() => onChange(step)}
onKeyDown={clearOnKey}
className="peer sr-only"
/>
<Star
aria-hidden
className={cn(
"h-6 w-6 rounded-sm peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
shown !== null && step <= shown
? "fill-coral text-coral"
: "text-muted-foreground",
)}
/>
</label>
))}
</div>
{value !== null && !disabled && (
<button
// Inside the dive `<form>`, where a button's default type submits.
type="button"
onClick={clear}
// Contains the visible word, so a speech-input user can say it.
aria-label="Clear rating"
className="relative rounded px-1.5 py-0.5 text-xs leading-none text-muted-foreground touch:tap-target transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Clear
</button>
)}
</div>
);
}
/**
* 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 (
<span
role="img"
aria-label={ratingSummary(rating)}
className={cn("inline-flex items-center gap-[0.125em]", className)}
>
{STEPS.map((step) => (
<Star
key={step}
aria-hidden
className={cn(
"size-[1em]",
step <= rating ? "fill-coral text-coral" : "text-muted-foreground",
)}
/>
))}
</span>
);
}