import React from 'react'; import { useTranslation } from 'react-i18next'; export default function StarRating({ value, onChange, disabled = false, maxStars = 5, maxSelectable: maxSelectableProp, labelKey = 'albumDetail.ratingLabel', ariaLabel, className = '', }: { value: number; onChange: (rating: number) => void; disabled?: boolean; /** Number of star buttons (1…maxStars). Default 5. */ maxStars?: number; /** Highest selectable star (inclusive); higher stars are shown but disabled. */ maxSelectable?: number; labelKey?: string; /** Overrides `t(labelKey)` for the radiogroup `aria-label` when set. */ ariaLabel?: string; className?: string; }) { const { t } = useTranslation(); const stars = React.useMemo( () => Array.from({ length: Math.max(1, Math.min(5, maxStars)) }, (_, i) => i + 1), [maxStars] ); const selectCap = Math.min(maxSelectableProp ?? stars.length, stars.length); const [hover, setHover] = React.useState(0); const [pulseStar, setPulseStar] = React.useState(null); const [clearShrinkStar, setClearShrinkStar] = React.useState(null); /** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */ const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false); const prevValueRef = React.useRef(value); const internalClickRef = React.useRef(false); const cappedValue = Math.min(Math.max(0, value), selectCap); React.useEffect(() => { if (value > 0) setSuppressHoverPreview(false); }, [value]); // Keep keyboard-driven changes visually in sync with mouse click effects. React.useEffect(() => { const prev = prevValueRef.current; const next = value; prevValueRef.current = value; if (internalClickRef.current) { internalClickRef.current = false; return; } if (prev === next) return; setPulseStar(null); setClearShrinkStar(null); if (next > prev) { const star = Math.max(1, Math.min(selectCap, next)); requestAnimationFrame(() => { requestAnimationFrame(() => setPulseStar(star)); }); return; } if (next < prev) { const star = Math.max(1, Math.min(selectCap, prev)); if (next === 0) setSuppressHoverPreview(true); requestAnimationFrame(() => { requestAnimationFrame(() => setClearShrinkStar(star)); }); } }, [value, selectCap]); const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap); const filled = (n: number) => (effectiveHover || cappedValue) >= n; const handleStarClick = (n: number) => { if (disabled || n > selectCap) return; setSuppressHoverPreview(false); const next = cappedValue === n ? 0 : n; internalClickRef.current = true; onChange(next); setHover(0); setPulseStar(null); setClearShrinkStar(null); if (next === 0) { setSuppressHoverPreview(true); requestAnimationFrame(() => { requestAnimationFrame(() => setClearShrinkStar(n)); }); } else { requestAnimationFrame(() => { requestAnimationFrame(() => setPulseStar(n)); }); } }; const handleContainerLeave = () => { setHover(0); setSuppressHoverPreview(false); }; return (
{stars.map(n => { const locked = n > selectCap; return ( ); })}
); }