import * as React from 'react'; import { Star } from 'lucide-react'; import { cn } from '../../shared/utils'; interface RatingProps extends Omit, 'onChange'> { value?: number; onChange?: (value: number) => void; max?: number; /** Prevents interaction but keeps visible. Does not grey-out (use `disabled` for that). */ readonly?: boolean; /** Disables interaction and applies opacity-50 styling. */ disabled?: boolean; size?: 'sm' | 'md' | 'lg'; showValue?: boolean; /** Enables half-star precision (0.5 increments). */ allowHalf?: boolean; /** Custom aria-label generator. Receives the star value and max. */ getAriaLabel?: (value: number, max: number) => string; } /** * Star-based rating input or display. * * @description * Allows users to rate items by clicking stars, or displays existing ratings * in read-only mode. Supports custom max stars, sizes (sm/md/lg), half-star * precision, and value display. * * @ai-rules * 1. Use `readonly={true}` to display an existing rating without allowing interaction. * 2. Use `disabled={true}` to show a greyed-out, non-interactive rating. * 3. Use `allowHalf={true}` for 0.5-step precision ratings. * 4. Use `onChange` to capture the selected rating value in form state. */ const Rating = React.forwardRef( ( { className, value = 0, onChange, max = 5, readonly = false, disabled = false, size = 'md', showValue = false, allowHalf = false, getAriaLabel, ...props }, ref ) => { const [hoverValue, setHoverValue] = React.useState(null); const isInteractive = !readonly && !disabled; const sizeStyles = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-6 w-6', }; const handleClick = (rating: number) => { if (isInteractive) onChange?.(rating); }; const handleMouseMove = (e: React.MouseEvent, rating: number) => { if (!isInteractive) return; if (allowHalf) { const rect = e.currentTarget.getBoundingClientRect(); setHoverValue(e.clientX - rect.left < rect.width / 2 ? rating - 0.5 : rating); } else { setHoverValue(rating); } }; const handleMouseLeave = () => { if (isInteractive) setHoverValue(null); }; const handleClickWithHalf = (e: React.MouseEvent, rating: number) => { if (!isInteractive) return; if (allowHalf) { const rect = e.currentTarget.getBoundingClientRect(); const half = e.clientX - rect.left < rect.width / 2 ? rating - 0.5 : rating; onChange?.(half); } else { handleClick(rating); } }; const displayValue = hoverValue ?? value; return (
{Array.from({ length: max }, (_, index) => { const rating = index + 1; const isFull = rating <= displayValue; const isHalf = allowHalf && !isFull && rating - 0.5 <= displayValue; return ( ); })}
{showValue && ( {value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)} )}
); } ); Rating.displayName = 'Rating'; export { Rating }; export type { RatingProps };