elmeg-demo/frontend/components/ui/star-rating.tsx
fullsizemalt 835299fab5 feat: Support decimal ratings (e.g., 9.2)
- Rating/Review models now use float instead of int
- Star rating component shows partial fills
- Numeric value displayed while rating
- Supports precision: 0.1 increments
2025-12-21 17:53:56 -08:00

125 lines
4 KiB
TypeScript

"use client"
import { useState, useRef } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingProps {
value: number
onChange?: (value: number) => void
readonly?: boolean
className?: string
size?: "sm" | "md" | "lg"
precision?: "full" | "half" | "decimal"
}
export function StarRating({
value,
onChange,
readonly = false,
className,
size = "md",
precision = "decimal"
}: RatingProps) {
const [hoverValue, setHoverValue] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const stars = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const starSize = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5"
}[size]
const handleMouseMove = (e: React.MouseEvent, starIndex: number) => {
if (readonly) return
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const width = rect.width
const fraction = x / width
let newValue: number
if (precision === "decimal") {
// Allow 0.1 precision (e.g., 9.2)
newValue = starIndex + Math.round(fraction * 10) / 10
newValue = Math.max(starIndex, Math.min(starIndex + 1, newValue))
} else if (precision === "half") {
// Half-star precision
newValue = fraction < 0.5 ? starIndex + 0.5 : starIndex + 1
} else {
// Full star only
newValue = starIndex + 1
}
setHoverValue(newValue)
}
const handleClick = () => {
if (!readonly && hoverValue !== null && onChange) {
// Round to 1 decimal place
onChange(Math.round(hoverValue * 10) / 10)
}
}
const displayValue = hoverValue !== null ? hoverValue : value
// Calculate fill percentage for each star
const getStarFill = (starIndex: number): number => {
const starStart = starIndex
const starEnd = starIndex + 1
if (displayValue >= starEnd) return 100
if (displayValue <= starStart) return 0
return (displayValue - starStart) * 100
}
return (
<div
ref={containerRef}
className={cn("flex gap-0.5 items-center", className)}
onMouseLeave={() => !readonly && setHoverValue(null)}
>
{stars.map((star, index) => {
const fillPercent = getStarFill(index)
return (
<button
key={star}
type="button"
disabled={readonly}
className={cn(
"p-0.5 transition-transform relative",
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
)}
onMouseMove={(e) => handleMouseMove(e, index)}
onClick={handleClick}
>
{/* Background star (empty) */}
<Star
className={cn(starSize, "fill-muted text-muted-foreground")}
/>
{/* Foreground star (filled) - uses clip-path for partial fill */}
<div
className="absolute inset-0 p-0.5 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - fillPercent}% 0 0)`
}}
>
<Star
className={cn(starSize, "fill-primary text-primary")}
/>
</div>
</button>
)
})}
{/* Display numeric value */}
{!readonly && (
<span className="ml-2 text-sm font-mono text-muted-foreground min-w-[2.5rem]">
{displayValue > 0 ? displayValue.toFixed(1) : "—"}
</span>
)}
</div>
)
}