"use client"
import * as React from "react"
import { StarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
const STAR_SIZES = { default: "size-5", sm: "size-4" } as const
const FILLED = "text-warning fill-current"
const EMPTY = "text-muted-foreground/40"
const starLabel = (value: number) => `${value} star${value === 1 ? "" : "s"}`
export type RatingProps = Omit<React.ComponentProps<"div">, "onChange"> & {
value: number
/** Leave it out for a display-only score: with no way to change the rating it renders as a static image. */
onValueChange?: (value: number) => void
max?: number
/** Renders the score as a single labelled image even when onValueChange is set. */
readOnly?: boolean
/**
* Draws the fraction of a score as part of a star — 4.8 is four whole stars
* and 80% of the fifth — where the default draws whole stars only. A score
* nothing can change only: an interactive rating is picked a star at a time.
*/
fractional?: boolean
size?: "sm" | "default"
/** Names the group, e.g. "Support quality"; falls back to "Rating". */
label?: string
}
/** How much of a star a score covers, from 0 to 1: all of it below the score, the remainder in the star it ends in. */
function shareOf(star: number, score: number): number {
return Math.min(Math.max(score - (star - 1), 0), 1)
}
/** A star score, read-only or picked with the pointer and the arrow keys. */
function Rating({
className,
value,
onValueChange,
max = 5,
readOnly = false,
fractional = false,
size = "default",
label,
...props
}: RatingProps) {
const [preview, setPreview] = React.useState<number | null>(null)
const starRefs = React.useRef(new Map<number, HTMLButtonElement | null>())
const stars = Array.from({ length: max }, (_, index) => index + 1)
const shown = preview ?? value
// A rating nothing can change is a picture of a number, however it got that
// way — asked for with readOnly, or left without a handler.
const interactive = !readOnly && onValueChange !== undefined
// Whichever star holds the tab stop, even when the score is zero, fractional,
// or out of range.
const tabStop = Math.min(Math.max(Math.round(value) || 1, 1), max)
const spokenScore = `${value} out of ${max} stars`
function select(next: number, moveFocus = false) {
if (next < 1 || next > max) return
onValueChange?.(next)
// Focus follows selection in a radiogroup, and the group is controlled, so
// the tab stop only moves once the caller applies the new value.
if (moveFocus) starRefs.current.get(next)?.focus()
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault()
select(value + 1, true)
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault()
select(value - 1, true)
}
}
return (
<div
data-slot="rating"
data-size={size}
data-value={value}
data-preview={preview ?? undefined}
data-readonly={!interactive || undefined}
data-fractional={(fractional && !interactive) || undefined}
// One name reads the score out in full, instead of five radios nobody can
// set.
role={interactive ? "radiogroup" : "img"}
aria-label={
interactive ? (label ?? "Rating") : label ? `${label}: ${spokenScore}` : spokenScore
}
className={cn("inline-flex w-fit items-center gap-0.5", className)}
onMouseLeave={interactive ? () => setPreview(null) : undefined}
{...props}
>
{stars.map((star) => {
const filled = star <= shown
const icon = <StarIcon className={cn(STAR_SIZES[size], filled ? FILLED : EMPTY)} />
if (!interactive) {
// In fractional mode the star the score ends in is drawn in part: the
// filled star laid over the empty one and cut at the score's share,
// from the inline start, so a right-to-left row fills from the right.
const share = fractional ? shareOf(star, value) : 0
const part = share > 0 && share < 1 ? Math.round(share * 100) : null
return (
<span
key={star}
data-slot="rating-star"
data-filled={filled || undefined}
data-fill={part === null ? undefined : part / 100}
className={part === null ? undefined : "relative"}
>
{icon}
{part === null ? null : (
<span
data-slot="rating-star-fill"
className="absolute inset-y-0 start-0 overflow-hidden"
style={{ width: `${part}%` }}
>
<StarIcon className={cn(STAR_SIZES[size], FILLED, "max-w-none")} />
</span>
)}
</span>
)
}
return (
<button
key={star}
ref={(node) => {
starRefs.current.set(star, node)
}}
type="button"
role="radio"
aria-checked={star === value}
aria-label={starLabel(star)}
data-slot="rating-star"
data-filled={filled || undefined}
tabIndex={star === tabStop ? 0 : -1}
onClick={() => select(star)}
onKeyDown={handleKeyDown}
onMouseEnter={() => setPreview(star)}
onFocus={() => setPreview(star)}
onBlur={() => setPreview(null)}
// The kit's focus outline on the star itself. A page restyles the
// stars from the root, through [data-slot=rating-star].
className="rounded-sm p-0.5 transition-colors focus-ring"
>
{icon}
</button>
)
})}
</div>
)
}
export { Rating }