1'use client';
2
3import * as React from 'react';
4import { cn } from '../lib/cn';
5import { useControllableState } from '../lib/use-controllable-state';
6
7type IconProps = React.SVGProps<SVGSVGElement> & {
8 size?: number | string;
9};
10
11function StarIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
12 return (
13 <svg
14 xmlns="http://www.w3.org/2000/svg"
15 width={size}
16 height={size}
17 viewBox="0 0 24 24"
18 fill="none"
19 stroke="currentColor"
20 strokeWidth={strokeWidth}
21 strokeLinecap="round"
22 strokeLinejoin="round"
23 aria-hidden="true"
24 {...props}
25 >
26 <path d="M10.9 3.6c.4-.9 1.8-.9 2.2 0l1.9 4.2 4.5.5c1 .1 1.4 1.3.7 2l-3.4 3.1.9 4.5c.2 1-.9 1.8-1.7 1.3L12 16.9l-4 2.3c-.8.5-1.9-.3-1.7-1.3l.9-4.5-3.4-3.1c-.7-.7-.3-1.9.7-2l4.5-.5 1.9-4.2Z" />
27 </svg>
28 );
29}
30
31type RatingSize = 'sm' | 'default' | 'lg';
32type RatingPrecision = 1 | 0.5;
33
34const RATING_SIZE_CLASSES: Record<RatingSize, string> = {
35 sm: 'size-4',
36 default: 'size-5',
37 lg: 'size-7',
38};
39
40const RATING_HIT_CLASSES: Record<RatingSize, string> = {
41 sm: 'pointer-coarse:p-3.5',
42 default: 'pointer-coarse:p-3',
43 lg: 'pointer-coarse:p-2',
44};
45
46interface RatingProps extends Omit<
47 React.HTMLAttributes<HTMLDivElement>,
48 'onChange' | 'defaultValue'
49> {
50 value?: number;
51 defaultValue?: number;
52 onValueChange?: (value: number) => void;
53 max?: number;
54 precision?: RatingPrecision;
55 size?: RatingSize;
56 readOnly?: boolean;
57 disabled?: boolean;
58 allowClear?: boolean;
59 name?: string;
60 label?: string;
61 icon?: React.ReactNode;
62 filledIcon?: React.ReactNode;
63 getLabel?: (value: number, max: number) => string;
64}
65
66function roundToPrecision(value: number, precision: RatingPrecision): number {
67 return Math.round(value / precision) * precision;
68}
69
70function defaultLabel(value: number, max: number): string {
71 return `${value} of ${max}`;
72}
73
74const RatingRoot = ({
75 value: controlledValue,
76 defaultValue = 0,
77 onValueChange,
78 max = 5,
79 precision = 1,
80 size = 'default',
81 readOnly = false,
82 disabled = false,
83 allowClear = false,
84 name,
85 label = 'Rating',
86 icon,
87 filledIcon,
88 getLabel = defaultLabel,
89 className,
90 children,
91 onKeyDown,
92 onPointerLeave,
93 ref,
94 ...props
95}: RatingProps & { ref?: React.Ref<HTMLDivElement> }) => {
96 const [value, setStoredValue] = useControllableState({
97 value: controlledValue,
98 defaultValue,
99 onChange: onValueChange,
100 });
101 const [hoverValue, setHoverValue] = React.useState<number | null>(null);
102 const interactive = !readOnly && !disabled;
103 const displayed = hoverValue ?? value;
104 const mode = readOnly ? 'img' : precision === 1 ? 'radiogroup' : 'slider';
105 const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
106
107 const setValue = React.useCallback(
108 (next: number) => setStoredValue(Math.min(Math.max(next, 0), max)),
109 [max, setStoredValue],
110 );
111
112 const valueFromPointer = (event: React.MouseEvent<HTMLElement>, index: number) => {
113 if (precision === 1) return index + 1;
114 const rect = event.currentTarget.getBoundingClientRect();
115 const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 1;
116 return index + (ratio <= 0.5 ? 0.5 : 1);
117 };
118
119 const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
120 onKeyDown?.(event);
121 if (!interactive || event.defaultPrevented) return;
122
123 let next: number | null = null;
124 if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = value + precision;
125 else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = value - precision;
126 else if (event.key === 'Home') next = precision;
127 else if (event.key === 'End') next = max;
128 else if (event.key === 'Backspace' || event.key === 'Delete') next = 0;
129
130 if (next === null) return;
131 event.preventDefault();
132 const rounded = Math.min(Math.max(roundToPrecision(next, precision), 0), max);
133 setValue(rounded);
134 if (mode === 'radiogroup') itemRefs.current[Math.max(Math.ceil(rounded) - 1, 0)]?.focus();
135 };
136
137 const focusIndex = Math.max(Math.ceil(value) - 1, 0);
138 const items = Array.from({ length: max }, (_, index) => index);
139 const valueLabel = getLabel(value, max);
140
141 const renderIcon = (fill: number) => {
142 const outline = icon ?? <StarIcon strokeWidth={1.5} />;
143 const filled = filledIcon ?? <StarIcon strokeWidth={1.5} fill="currentColor" />;
144
145 return (
146 <span
147 aria-hidden="true"
148 className={cn(
149 'text-muted-foreground/60 relative block [&_svg]:size-full',
150 RATING_SIZE_CLASSES[size],
151 )}
152 >
153 {outline}
154 <span
155 data-slot="rating-fill"
156 className="text-warning absolute inset-y-0 left-0 overflow-hidden"
157 style={{ width: `${Math.min(Math.max(fill, 0), 1) * 100}%` }}
158 >
159 <span className={cn('block', RATING_SIZE_CLASSES[size])}>{filled}</span>
160 </span>
161 </span>
162 );
163 };
164
165 return (
166 <div
167 ref={ref}
168 role={mode}
169 aria-label={readOnly ? valueLabel : label}
170 aria-disabled={disabled || undefined}
171 aria-valuemin={mode === 'slider' ? 0 : undefined}
172 aria-valuemax={mode === 'slider' ? max : undefined}
173 aria-valuenow={mode === 'slider' ? value : undefined}
174 aria-valuetext={mode === 'slider' ? valueLabel : undefined}
175 tabIndex={mode === 'slider' && interactive ? 0 : undefined}
176 data-slot="rating"
177 data-value={value}
178 data-disabled={disabled ? '' : undefined}
179 data-readonly={readOnly ? '' : undefined}
180 onKeyDown={handleKeyDown}
181 onPointerLeave={(event) => {
182 onPointerLeave?.(event);
183 setHoverValue(null);
184 }}
185 className={cn(
186 'inline-flex items-center gap-2',
187 mode === 'slider' &&
188 'focus-visible:ring-ring ring-offset-background rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
189 disabled && 'cursor-not-allowed opacity-40',
190 className,
191 )}
192 {...props}
193 >
194 <span className="inline-flex items-center gap-0.5" data-slot="rating-items">
195 {items.map((index) => {
196 const fill = Math.min(Math.max(displayed - index, 0), 1);
197 const itemValue = index + 1;
198
199 const handlePointerMove = (event: React.PointerEvent<HTMLElement>) => {
200 if (!interactive || event.pointerType === 'touch') return;
201 setHoverValue(valueFromPointer(event, index));
202 };
203
204 if (readOnly) {
205 return (
206 <span key={index} data-slot="rating-item" className="inline-flex">
207 {renderIcon(fill)}
208 </span>
209 );
210 }
211
212 if (mode === 'slider') {
213 return (
214 <span
215 key={index}
216 aria-hidden="true"
217 data-slot="rating-item"
218 data-active={fill > 0 ? '' : undefined}
219 onPointerMove={handlePointerMove}
220 onClick={(event) => {
221 if (!interactive) return;
222 const next = valueFromPointer(event, index);
223 setValue(allowClear && next === value ? 0 : next);
224 setHoverValue(null);
225 }}
226 className={cn(
227 'inline-flex cursor-pointer rounded-sm transition-transform',
228 RATING_HIT_CLASSES[size],
229 interactive && 'hover:scale-110',
230 disabled && 'pointer-events-none',
231 )}
232 >
233 {renderIcon(fill)}
234 </span>
235 );
236 }
237
238 return (
239 <button
240 key={index}
241 ref={(node) => {
242 itemRefs.current[index] = node;
243 }}
244 type="button"
245 role="radio"
246 aria-checked={Math.ceil(value) === itemValue}
247 aria-label={getLabel(itemValue, max)}
248 tabIndex={index === focusIndex ? 0 : -1}
249 disabled={disabled}
250 data-slot="rating-item"
251 data-active={fill > 0 ? '' : undefined}
252 onPointerMove={handlePointerMove}
253 onClick={(event) => {
254 if (!interactive) return;
255 const next = event.detail === 0 ? itemValue : valueFromPointer(event, index);
256 setValue(allowClear && next === value ? 0 : next);
257 setHoverValue(null);
258 }}
259 onKeyDown={(event) => {
260 if ((event.key === 'Enter' || event.key === ' ') && interactive) {
261 event.preventDefault();
262 setValue(allowClear && itemValue === value ? 0 : itemValue);
263 }
264 }}
265 className={cn(
266 'focus-visible:ring-ring ring-offset-background inline-flex cursor-pointer rounded-sm transition-transform outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
267 RATING_HIT_CLASSES[size],
268 interactive && 'hover:scale-110',
269 disabled && 'pointer-events-none',
270 )}
271 >
272 {renderIcon(fill)}
273 </button>
274 );
275 })}
276 </span>
277 {children}
278 {name && <input type="hidden" name={name} value={value} />}
279 </div>
280 );
281};
282RatingRoot.displayName = 'Rating';
283
284const Rating = RatingRoot;
285
286export { Rating };
287export type { RatingProps, RatingSize, RatingPrecision };