"use client";
import * as React from "react";
import { motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface RatingInputProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> {
/** Controlled value (0 = no rating). */
value?: number;
/** Initial value when uncontrolled. */
defaultValue?: number;
/** Called with the new value (0 when cleared). */
onChange?: (value: number) => void;
/** Number of icons. */
max?: number;
/** Allow half steps (3.5). */
allowHalf?: boolean;
/** Choosing the current value again clears the rating; Delete/Backspace also clear. */
allowClear?: boolean;
/** Display only: renders an image with an accessible name and supports fractional values (4.3). */
readOnly?: boolean;
disabled?: boolean;
size?: "sm" | "md" | "lg";
/** Fill, focus ring and particle colour (any CSS colour). */
accent?: string;
/** Text per whole value, e.g. ["Poor", "Fair", "Good", "Great", "Excellent"]. */
labels?: string[];
/** Show the numeric value (and label) next to the icons. */
showValue?: boolean;
/** Custom glyph (use fill="currentColor"); defaults to a star. */
icon?: React.ReactNode;
/** Group label. */
label?: string;
/** Visually hide the group label (it stays available to assistive tech). */
hideLabel?: boolean;
/** Plural noun used in accessible names: "3 of 5 stars". */
unit?: string;
/** Name for the native radios, so the value submits with forms. */
name?: string;
}
const SIZES = {
sm: { icon: 20, gap: "gap-0.5", text: "text-[12px]" },
md: { icon: 28, gap: "gap-1", text: "text-[13px]" },
lg: { icon: 36, gap: "gap-1.5", text: "text-[15px]" },
};
const PARTICLES = 8;
function StarGlyph() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2.6c.3 0 .58.17.71.45l2.5 5.07 5.6.81c.65.1.9.89.43 1.35l-4.05 3.95.96 5.57c.11.64-.56 1.13-1.14.83L12 18.0l-5.01 2.63c-.58.3-1.25-.19-1.14-.83l.96-5.57L2.76 10.28c-.47-.46-.21-1.25.43-1.35l5.6-.81 2.5-5.07c.13-.28.41-.45.71-.45Z" />
</svg>
);
}
const formatValue = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(1).replace(/\.0$/, ""));
/**
* A rating control built from native radio inputs (one per step, two per icon with half
* precision) inside role="radiogroup": arrow keys, form submission and focus come from the
* platform. Hovering previews the value; selecting pops the icon and bursts a few particles.
*/
export function RatingInput({
value,
defaultValue = 0,
onChange,
max = 5,
allowHalf = false,
allowClear = true,
readOnly = false,
disabled = false,
size = "md",
accent = "#FF7448",
labels,
showValue = true,
icon,
label = "Rating",
hideLabel = false,
unit = "stars",
name,
className = "",
style,
...props
}: RatingInputProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const labelId = `${uid}-label`;
const groupName = name ?? `rating-${uid}`;
const count = Math.max(1, Math.min(20, Math.round(max)));
const step = allowHalf ? 0.5 : 1;
const [inner, setInner] = React.useState(defaultValue);
const raw = value ?? inner;
// Interactive values snap to the step; read-only keeps fractions for averages.
const current = readOnly ? Math.min(count, Math.max(0, raw)) : Math.min(count, Math.max(0, Math.round(raw / step) * step));
const [hover, setHover] = React.useState<number | null>(null);
const [burst, setBurst] = React.useState<{ index: number; n: number } | null>(null);
const [cleared, setCleared] = React.useState("");
const display = !readOnly && hover !== null ? hover : current;
const dims = SIZES[size];
const labelFor = (v: number) => (labels && v > 0 ? labels[Math.ceil(v) - 1] : undefined);
const nameFor = (v: number) => {
const text = labelFor(v);
return `${formatValue(v)} of ${count} ${unit}${text ? `, ${text}` : ""}`;
};
const commit = (next: number) => {
if (value === undefined) setInner(next);
onChange?.(next);
if (next > 0 && !reduceMotion) setBurst((b) => ({ index: Math.ceil(next), n: (b?.n ?? 0) + 1 }));
setCleared(next === 0 ? "Rating cleared" : "");
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (readOnly || disabled) return;
const onChecked = event.target instanceof HTMLInputElement && Number(event.target.value) === current;
// Space on the checked radio re-selects it (browsers don't fire a click for that), so it clears too.
if (allowClear && current > 0 && (event.key === "Delete" || event.key === "Backspace" || (event.key === " " && onChecked))) {
event.preventDefault();
commit(0);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
const target = event.key === "Home" ? step : count;
commit(target);
event.currentTarget.querySelector<HTMLInputElement>(`input[value="${target}"]`)?.focus();
}
};
const glyph = icon ?? <StarGlyph />;
const renderIcon = (i: number) => {
const fill = Math.min(1, Math.max(0, display - (i - 1)));
const popping = burst?.index === i;
return (
<>
<motion.span
key={popping ? `pop-${burst.n}` : "still"}
aria-hidden="true"
className="relative block size-full"
initial={popping ? { scale: 0.55 } : false}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 520, damping: 14, mass: 0.6 }}
>
<span className="absolute inset-0 text-zinc-900/[0.13] dark:text-white/[0.14] [&>svg]:size-full">{glyph}</span>
<span
className="absolute inset-0 text-[var(--ri-accent)] transition-[clip-path] duration-150 ease-out motion-reduce:transition-none [&>svg]:size-full"
style={{ clipPath: `inset(0 ${((1 - fill) * 100).toFixed(2)}% 0 0)` }}
>
{glyph}
</span>
</motion.span>
{popping && !reduceMotion && (
<span key={`burst-${burst.n}`} aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2">
{Array.from({ length: PARTICLES }, (_, p) => {
const angle = (p / PARTICLES) * Math.PI * 2 - Math.PI / 2;
const distance = dims.icon * (p % 2 ? 0.72 : 0.95);
return (
<motion.span
key={p}
className="absolute -ml-[2px] -mt-[2px] size-1 rounded-full bg-[var(--ri-accent)]"
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{ x: Math.cos(angle) * distance, y: Math.sin(angle) * distance, opacity: 0, scale: 0.4 }}
transition={{ duration: 0.55, ease: [0.15, 0.8, 0.3, 1] }}
/>
);
})}
</span>
)}
</>
);
};
const valueText = labelFor(display);
return (
<div
className={`inline-flex flex-col items-start ${className}`}
style={{ ["--ri-accent" as string]: accent, ...style }}
{...props}
>
<span
id={labelId}
className={hideLabel ? "sr-only" : "mb-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"}
>
{label}
</span>
<div className="flex items-center gap-3">
{readOnly ? (
<div
role="img"
aria-label={`${label}: ${formatValue(Math.round(current * 10) / 10)} out of ${count} ${unit}`}
className={`flex items-center ${dims.gap}`}
>
{Array.from({ length: count }, (_, k) => (
<span key={k} className="relative block" style={{ width: dims.icon, height: dims.icon }}>
{renderIcon(k + 1)}
</span>
))}
</div>
) : (
<div
role="radiogroup"
aria-labelledby={labelId}
aria-disabled={disabled || undefined}
onKeyDown={handleKeyDown}
onPointerLeave={() => setHover(null)}
className={`flex items-center ${dims.gap} ${disabled ? "opacity-50" : ""}`}
>
{Array.from({ length: count }, (_, k) => {
const i = k + 1;
const steps = allowHalf ? [i - 0.5, i] : [i];
return (
<span
key={i}
className="relative block rounded-md has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ri-accent)]"
style={{ width: dims.icon, height: dims.icon }}
>
{renderIcon(i)}
{steps.map((v, s) => (
<input
key={v}
type="radio"
name={groupName}
value={v}
checked={current === v}
disabled={disabled}
aria-label={nameFor(v)}
onChange={() => commit(v)}
onClick={() => {
// Re-selecting the checked value clears it (onChange doesn't fire then).
if (allowClear && current === v) {
commit(0);
setHover(null);
}
}}
onPointerEnter={(e) => {
if (e.pointerType === "mouse" && !disabled) setHover(v);
}}
className={`absolute top-0 m-0 h-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed ${
steps.length === 2 ? (s === 0 ? "left-0 w-1/2" : "right-0 w-1/2") : "inset-x-0 w-full"
}`}
/>
))}
</span>
);
})}
</div>
)}
{showValue && (
<span aria-hidden="true" className={`flex items-baseline gap-2 tabular-nums ${dims.text}`}>
<span className="font-medium text-zinc-950 dark:text-zinc-50">{display > 0 ? formatValue(Math.round(display * 10) / 10) : "–"}</span>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{valueText ?? `/ ${count}`}
</span>
</span>
)}
</div>
<span className="sr-only" aria-live="polite">
{cleared}
</span>
</div>
);
}