"use client"; import { useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from "react"; import { motion, stagger, useAnimate, useReducedMotion } from "motion/react"; const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5-5.8-3.1-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z"; type RatingStarsProps = { /** Current rating. Leave out to let the stars keep their own. */ value?: number; /** Rating at the start when `value` is left out. */ defaultValue?: number; /** Called with the new rating. 0 means cleared. */ onChange?: (value: number) => void; /** Number of stars. */ max?: number; /** 0.5 lets people pick half stars. */ step?: 0.5 | 1; /** Only shows the rating. Takes any fraction, like 4.3. */ readOnly?: boolean; /** One star in pixels. */ size?: number; /** What the rating is for, read by screen readers. */ label?: string; /** Filled star colour. */ accent?: string; /** Empty star colour, as a text colour class. */ className?: string; }; /* RatingStars: stars fill under the pointer as a preview, and a pick runs a small pop along them with a ring off the last one. */ export function RatingStars({ value, defaultValue = 0, onChange, max = 5, step = 1, readOnly = false, size = 36, label = "Rating", accent = "#fbbf24", className = "text-white/20", }: RatingStarsProps) { const reduce = useReducedMotion(); const [scope, run] = useAnimate(); const [own, setOwn] = useState(defaultValue); const [hover, setHover] = useState(null); const [ring, setRing] = useState({ star: 0, key: 0 }); const current = value ?? own; const shown = hover ?? current; const spoken = current ? `${current} out of ${max}` : "No rating"; const commit = (next: number) => { setOwn(next); onChange?.(next); if (reduce || !next) return; const star = Math.ceil(next); setRing((now) => ({ star, key: now.key + 1 })); run(`[data-star]:nth-child(-n+${star})`, { scale: [1, 1.28, 1] }, { duration: 0.32, delay: stagger(0.035) }); }; const under = (e: MouseEvent) => { const box = e.currentTarget.getBoundingClientRect(); return Math.min(max, Math.max(step, Math.ceil(((e.clientX - box.left) / box.width) * (max / step)) * step)); }; const onKey = (e: KeyboardEvent) => { const moves: Record = { ArrowRight: current + step, ArrowUp: current + step, ArrowLeft: current - step, ArrowDown: current - step, Home: 0, End: max }; if (!(e.key in moves)) return; e.preventDefault(); const next = Math.min(max, Math.max(0, moves[e.key])); if (next !== current) commit(next); }; const pick = (e: MouseEvent) => { const next = under(e); commit(next === current ? 0 : next); }; const input = readOnly ? { role: "img", "aria-label": `${label}: ${spoken}` } : { role: "slider", tabIndex: 0, "aria-label": label, "aria-valuemin": 0, "aria-valuemax": max, "aria-valuenow": current, "aria-valuetext": spoken, onKeyDown: onKey, onPointerMove: (e: MouseEvent) => setHover(under(e)), onPointerLeave: () => setHover(null), onPointerCancel: () => setHover(null), onClick: pick, }; return (
{Array.from({ length: max }, (_, i) => { const fill = Math.min(1, Math.max(0, shown - i)); return ( ); })} {ring.key > 0 && !reduce && ( )}
); }