"use client"; import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useEffect, useId, useState } from "react"; import { cn } from "@/lib/utils"; const STAR = (() => { const pts: string[] = []; for (let i = 0; i < 10; i++) { const r = i % 2 === 0 ? 10 : 4.4; const a = -Math.PI / 2 + (i * Math.PI) / 5; pts.push(`${(12 + r * Math.cos(a)).toFixed(2)},${(12.6 + r * Math.sin(a)).toFixed(2)}`); } return pts.join(" "); })(); const STAR_WORDS = ["Not rated", "Awful", "Poor", "Okay", "Good", "Excellent"]; export interface StarRatingProps { value?: number; defaultValue?: number; onValueChange?: (value: number) => void; max?: number; half?: boolean; size?: number; readOnly?: boolean; words?: string[] | false; className?: string; "aria-label"?: string; } export function StarRating({ value: valueProp, defaultValue = 0, onValueChange, max = 5, half = true, size = 28, readOnly = false, words = STAR_WORDS, className, "aria-label": ariaLabel = "Rating", }: StarRatingProps) { const [inner, setInner] = useState(defaultValue); const value = valueProp ?? inner; const [hover, setHover] = useState(null); const [pop, setPop] = useState(0); const reduce = useReducedMotion(); const step = half ? 0.5 : 1; const shown = hover ?? value; const commit = (next: number) => { const v = next === value ? 0 : next; if (valueProp === undefined) setInner(v); onValueChange?.(v); if (v > 0) setPop((p) => p + 1); }; const valueAt = (event: PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const raw = ((event.clientX - rect.left) / rect.width) * max; return Math.min(max, Math.max(step, Math.ceil(raw / step) * step)); }; const onKeyDown = (event: KeyboardEvent) => { if (readOnly) return; const keys: Record = { ArrowRight: value + step, ArrowUp: value + step, ArrowLeft: value - step, ArrowDown: value - step, Home: 0, End: max, }; if (!(event.key in keys)) return; event.preventDefault(); const next = Math.min(max, Math.max(0, keys[event.key])); if (valueProp === undefined) setInner(next); onValueChange?.(next); if (next > value) setPop((p) => p + 1); }; const word = words ? words[Math.ceil(shown)] : null; return (
setHover(null)} onPointerMove={(event) => !readOnly && setHover(valueAt(event))} onPointerUp={(event) => !readOnly && commit(valueAt(event))} role="slider" tabIndex={readOnly ? -1 : 0} > {Array.from({ length: max }, (_, i) => { const fill = Math.min(Math.max(shown - i, 0), 1); const popping = !reduce && pop > 0 && hover === null && i < value; return ( ); })}
{word && ( {shown ? shown.toFixed(half ? 1 : 0) : "–"} · {word} )}
); } const MOODS = ["Awful", "Not great", "Okay", "Good", "Love it"]; const FACE = { smile: [-7, -3, 0, 5, 9], mouthW: [14, 13, 12, 16, 18], open: [0, 0, 0, 0, 5], eyeH: [2.6, 2.4, 2.6, 2.6, 0.9], eyeY: [24, 24, 24, 23.5, 24], brow: [16, 7, 0, -5, -9], browY: [16.5, 17.5, 17, 16, 15], hue: [28, 50, 80, 115, 145], browOn: [1, 0.85, 0, 0, 0], }; const lerp = (arr: number[], t: number) => { const i = Math.min(Math.max(Math.floor(t), 0), arr.length - 2); const f = Math.min(Math.max(t - i, 0), 1); return arr[i] + (arr[i + 1] - arr[i]) * f; }; export interface EmojiRatingProps { value?: number | null; defaultValue?: number | null; onValueChange?: (value: number) => void; labels?: string[]; className?: string; "aria-label"?: string; } export function EmojiRating({ value: valueProp, defaultValue = null, onValueChange, labels = MOODS, className, "aria-label": ariaLabel = "How did it go?", }: EmojiRatingProps) { const [inner, setInner] = useState(defaultValue); const value = valueProp === undefined ? inner : valueProp; const reduce = useReducedMotion(); const id = useId(); const target = useMotionValue(value ?? 2); const mood = useSpring(target, { stiffness: 260, damping: 22 }); useEffect(() => { const next = value ?? 2; if (reduce) { target.jump(next); mood.jump(next); } else target.set(next); }, [value, reduce, target, mood]); const mouth = useTransform(mood, (m) => { const w = lerp(FACE.mouthW, m); const s = lerp(FACE.smile, m); const o = lerp(FACE.open, m); const y = 33.5 - s * 0.25; return `M ${24 - w / 2} ${y} Q 24 ${y + s} ${24 + w / 2} ${y} Q 24 ${y + s + o * 1.6} ${24 - w / 2} ${y} Z`; }); const eyeRy = useTransform(mood, (m) => lerp(FACE.eyeH, m)); const eyeY = useTransform(mood, (m) => lerp(FACE.eyeY, m)); const browL = useTransform(mood, (m) => `rotate(${lerp(FACE.brow, m)} 17 ${lerp(FACE.browY, m)})`); const browR = useTransform(mood, (m) => `rotate(${-lerp(FACE.brow, m)} 31 ${lerp(FACE.browY, m)})`); const browY = useTransform(mood, (m) => lerp(FACE.browY, m)); const fill = useTransform(mood, (m) => `oklch(0.86 0.12 ${lerp(FACE.hue, m)})`); const cheeks = useTransform(mood, [2.6, 4], [0, 0.45]); const brows = useTransform(mood, (m) => lerp(FACE.browOn, m)); const set = (next: number) => { if (valueProp === undefined) setInner(next); onValueChange?.(next); }; return (
{labels.map((label, i) => { const checked = value === i; return ( ); })}
{value === null ? "Pick one" : labels[value]}
); }