@zentauri-ui/zentauri-components 1.7.7 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/cli/registry.json +1 -0
- package/dist/design-system/index.d.ts +1 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/design-system/rating.d.ts +43 -0
- package/dist/design-system/rating.d.ts.map +1 -0
- package/dist/ui/rating/index.d.ts +4 -0
- package/dist/ui/rating/index.d.ts.map +1 -0
- package/dist/ui/rating/rating.d.ts +6 -0
- package/dist/ui/rating/rating.d.ts.map +1 -0
- package/dist/ui/rating/types.d.ts +25 -0
- package/dist/ui/rating/types.d.ts.map +1 -0
- package/dist/ui/rating/variants.d.ts +7 -0
- package/dist/ui/rating/variants.d.ts.map +1 -0
- package/dist/ui/rating.js +319 -0
- package/dist/ui/rating.js.map +1 -0
- package/dist/ui/rating.mjs +315 -0
- package/dist/ui/rating.mjs.map +1 -0
- package/package.json +1 -1
- package/src/design-system/index.ts +1 -0
- package/src/design-system/rating.ts +81 -0
- package/src/ui/rating/index.ts +10 -0
- package/src/ui/rating/rating.test.tsx +139 -0
- package/src/ui/rating/rating.tsx +305 -0
- package/src/ui/rating/types.ts +33 -0
- package/src/ui/rating/variants.ts +26 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
useCallback,
|
|
7
|
+
useId,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { FaFire, FaHeart, FaStar, FaThumbsUp } from "react-icons/fa";
|
|
11
|
+
import type { IconType } from "react-icons";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
zuiRatingControlBase,
|
|
15
|
+
zuiRatingErrorBase,
|
|
16
|
+
zuiRatingGroupBase,
|
|
17
|
+
zuiRatingHintBase,
|
|
18
|
+
zuiRatingLabelBase,
|
|
19
|
+
zuiRatingRootBase,
|
|
20
|
+
} from "../../design-system/rating";
|
|
21
|
+
import { cn } from "../../lib/utils";
|
|
22
|
+
|
|
23
|
+
import type { RatingPresetIcon, RatingProps } from "./types";
|
|
24
|
+
import { ratingIconVariants, ratingItemVariants } from "./variants";
|
|
25
|
+
|
|
26
|
+
const PRESET_ICONS: Record<RatingPresetIcon, IconType> = {
|
|
27
|
+
star: FaStar,
|
|
28
|
+
heart: FaHeart,
|
|
29
|
+
flame: FaFire,
|
|
30
|
+
thumb: FaThumbsUp,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function clamp(value: number, min: number, max: number): number {
|
|
34
|
+
return Math.min(Math.max(value, min), max);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeMax(max: number | undefined): number {
|
|
38
|
+
const resolved = Number.isFinite(max) ? Number(max) : 5;
|
|
39
|
+
return Math.max(1, Math.min(10, Math.floor(resolved)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeValue(
|
|
43
|
+
value: number | undefined,
|
|
44
|
+
max: number,
|
|
45
|
+
allowHalf: boolean,
|
|
46
|
+
): number {
|
|
47
|
+
const resolved = Number.isFinite(value) ? Number(value) : 0;
|
|
48
|
+
const step = allowHalf ? 0.5 : 1;
|
|
49
|
+
return clamp(Math.round(resolved / step) * step, 0, max);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultGetLabel(value: number, max: number): string {
|
|
53
|
+
return `${value} of ${max}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveIcon(icon: RatingProps["icon"]): IconType {
|
|
57
|
+
if (!icon) {
|
|
58
|
+
return PRESET_ICONS.star;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof icon === "string") {
|
|
62
|
+
return PRESET_ICONS[icon as RatingPresetIcon] ?? PRESET_ICONS.star;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return icon;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Rating(props: RatingProps) {
|
|
69
|
+
const {
|
|
70
|
+
allowClear = false,
|
|
71
|
+
allowHalf = false,
|
|
72
|
+
appearance,
|
|
73
|
+
className,
|
|
74
|
+
defaultValue = 0,
|
|
75
|
+
disabled,
|
|
76
|
+
errorMessage,
|
|
77
|
+
getLabel = defaultGetLabel,
|
|
78
|
+
hint,
|
|
79
|
+
icon,
|
|
80
|
+
iconClassName,
|
|
81
|
+
id,
|
|
82
|
+
label,
|
|
83
|
+
max = 5,
|
|
84
|
+
name,
|
|
85
|
+
onValueChange,
|
|
86
|
+
readOnly,
|
|
87
|
+
ref,
|
|
88
|
+
size,
|
|
89
|
+
value,
|
|
90
|
+
...rest
|
|
91
|
+
} = props;
|
|
92
|
+
|
|
93
|
+
const generatedId = useId();
|
|
94
|
+
const rootId = id ?? generatedId;
|
|
95
|
+
const resolvedMax = normalizeMax(max);
|
|
96
|
+
const isControlled = value !== undefined;
|
|
97
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
|
|
98
|
+
normalizeValue(defaultValue, resolvedMax, allowHalf),
|
|
99
|
+
);
|
|
100
|
+
const [hoverValue, setHoverValue] = useState<number | undefined>();
|
|
101
|
+
const resolvedValue = normalizeValue(
|
|
102
|
+
isControlled ? value : uncontrolledValue,
|
|
103
|
+
resolvedMax,
|
|
104
|
+
allowHalf,
|
|
105
|
+
);
|
|
106
|
+
const displayValue = hoverValue ?? resolvedValue;
|
|
107
|
+
const Icon = resolveIcon(icon);
|
|
108
|
+
const interactive = !disabled && !readOnly;
|
|
109
|
+
const controlsDisabled = disabled || readOnly;
|
|
110
|
+
const step = allowHalf ? 0.5 : 1;
|
|
111
|
+
const labelId = `${rootId}-label`;
|
|
112
|
+
const hintId = `${rootId}-hint`;
|
|
113
|
+
const errorId = `${rootId}-error`;
|
|
114
|
+
const describedBy = [
|
|
115
|
+
hint !== undefined ? hintId : undefined,
|
|
116
|
+
errorMessage !== undefined ? errorId : undefined,
|
|
117
|
+
]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(" ");
|
|
120
|
+
|
|
121
|
+
const commitValue = useCallback(
|
|
122
|
+
(nextValue: number) => {
|
|
123
|
+
const normalized = normalizeValue(nextValue, resolvedMax, allowHalf);
|
|
124
|
+
const next = allowClear && normalized === resolvedValue ? 0 : normalized;
|
|
125
|
+
|
|
126
|
+
if (!isControlled) {
|
|
127
|
+
setUncontrolledValue(next);
|
|
128
|
+
}
|
|
129
|
+
onValueChange?.(next);
|
|
130
|
+
},
|
|
131
|
+
[
|
|
132
|
+
allowClear,
|
|
133
|
+
allowHalf,
|
|
134
|
+
isControlled,
|
|
135
|
+
onValueChange,
|
|
136
|
+
resolvedMax,
|
|
137
|
+
resolvedValue,
|
|
138
|
+
],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const handleKeyDown = useCallback(
|
|
142
|
+
(event: KeyboardEvent<HTMLButtonElement>) => {
|
|
143
|
+
if (!interactive) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const root = event.currentTarget.closest('[data-slot="rating"]');
|
|
148
|
+
const focusValue = (nextValue: number) => {
|
|
149
|
+
const control = root?.querySelector(
|
|
150
|
+
`button[data-value="${nextValue}"]`,
|
|
151
|
+
) as HTMLButtonElement | null;
|
|
152
|
+
control?.focus();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
const nextValue = clamp(resolvedValue + step, step, resolvedMax);
|
|
158
|
+
commitValue(nextValue);
|
|
159
|
+
focusValue(nextValue);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
const nextValue = clamp(resolvedValue - step, 0, resolvedMax);
|
|
166
|
+
commitValue(nextValue);
|
|
167
|
+
focusValue(nextValue === 0 ? step : nextValue);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (event.key === "Home") {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
commitValue(step);
|
|
174
|
+
focusValue(step);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (event.key === "End") {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
commitValue(resolvedMax);
|
|
181
|
+
focusValue(resolvedMax);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
[commitValue, interactive, resolvedMax, resolvedValue, step],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
ref={ref}
|
|
190
|
+
id={rootId}
|
|
191
|
+
className={cn(zuiRatingRootBase, className)}
|
|
192
|
+
data-disabled={disabled ? "true" : undefined}
|
|
193
|
+
data-readonly={readOnly ? "true" : undefined}
|
|
194
|
+
data-slot="rating"
|
|
195
|
+
{...rest}
|
|
196
|
+
>
|
|
197
|
+
{label !== undefined && (
|
|
198
|
+
<p id={labelId} className={zuiRatingLabelBase}>
|
|
199
|
+
{label}
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
{hint !== undefined && (
|
|
203
|
+
<p id={hintId} className={zuiRatingHintBase}>
|
|
204
|
+
{hint}
|
|
205
|
+
</p>
|
|
206
|
+
)}
|
|
207
|
+
<div
|
|
208
|
+
aria-describedby={describedBy || undefined}
|
|
209
|
+
aria-invalid={errorMessage !== undefined ? true : undefined}
|
|
210
|
+
aria-labelledby={label !== undefined ? labelId : undefined}
|
|
211
|
+
aria-label={label === undefined ? "Rating" : undefined}
|
|
212
|
+
className={zuiRatingGroupBase}
|
|
213
|
+
data-slot="rating-group"
|
|
214
|
+
onPointerLeave={() => setHoverValue(undefined)}
|
|
215
|
+
role="radiogroup"
|
|
216
|
+
>
|
|
217
|
+
{Array.from({ length: resolvedMax }, (_, index) => {
|
|
218
|
+
const fullValue = index + 1;
|
|
219
|
+
const fillAmount = clamp(displayValue - index, 0, 1);
|
|
220
|
+
const clipStyle = {
|
|
221
|
+
clipPath: `inset(0 ${100 - fillAmount * 100}% 0 0)`,
|
|
222
|
+
} satisfies CSSProperties;
|
|
223
|
+
const itemOptions = allowHalf
|
|
224
|
+
? [fullValue - 0.5, fullValue]
|
|
225
|
+
: [fullValue];
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<span
|
|
229
|
+
key={fullValue}
|
|
230
|
+
className={ratingItemVariants({ size })}
|
|
231
|
+
data-interactive={interactive ? "true" : undefined}
|
|
232
|
+
data-slot="rating-item"
|
|
233
|
+
>
|
|
234
|
+
<Icon
|
|
235
|
+
aria-hidden="true"
|
|
236
|
+
className={cn("col-start-1 row-start-1", iconClassName)}
|
|
237
|
+
/>
|
|
238
|
+
<Icon
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
className={cn(
|
|
241
|
+
ratingIconVariants({ appearance }),
|
|
242
|
+
iconClassName,
|
|
243
|
+
)}
|
|
244
|
+
data-slot="rating-icon-fill"
|
|
245
|
+
style={clipStyle}
|
|
246
|
+
/>
|
|
247
|
+
{itemOptions.map((optionValue, optionIndex) => (
|
|
248
|
+
<button
|
|
249
|
+
key={optionValue}
|
|
250
|
+
type="button"
|
|
251
|
+
aria-checked={resolvedValue === optionValue}
|
|
252
|
+
aria-label={getLabel(optionValue, resolvedMax)}
|
|
253
|
+
className={cn(
|
|
254
|
+
zuiRatingControlBase,
|
|
255
|
+
allowHalf
|
|
256
|
+
? optionIndex === 0
|
|
257
|
+
? "left-0 w-1/2"
|
|
258
|
+
: "right-0 w-1/2"
|
|
259
|
+
: "inset-x-0",
|
|
260
|
+
)}
|
|
261
|
+
data-slot="rating-control"
|
|
262
|
+
data-value={optionValue}
|
|
263
|
+
disabled={controlsDisabled}
|
|
264
|
+
onClick={() => {
|
|
265
|
+
if (interactive) {
|
|
266
|
+
commitValue(optionValue);
|
|
267
|
+
}
|
|
268
|
+
}}
|
|
269
|
+
onKeyDown={handleKeyDown}
|
|
270
|
+
onPointerEnter={() => {
|
|
271
|
+
if (interactive) {
|
|
272
|
+
setHoverValue(optionValue);
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
role="radio"
|
|
276
|
+
tabIndex={
|
|
277
|
+
resolvedValue === optionValue ||
|
|
278
|
+
(resolvedValue === 0 && optionValue === step)
|
|
279
|
+
? 0
|
|
280
|
+
: -1
|
|
281
|
+
}
|
|
282
|
+
/>
|
|
283
|
+
))}
|
|
284
|
+
</span>
|
|
285
|
+
);
|
|
286
|
+
})}
|
|
287
|
+
</div>
|
|
288
|
+
{name !== undefined && (
|
|
289
|
+
<input
|
|
290
|
+
type="hidden"
|
|
291
|
+
name={name}
|
|
292
|
+
value={resolvedValue}
|
|
293
|
+
disabled={disabled}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
{errorMessage !== undefined && (
|
|
297
|
+
<p id={errorId} className={zuiRatingErrorBase}>
|
|
298
|
+
{errorMessage}
|
|
299
|
+
</p>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
Rating.displayName = "Rating";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { VariantProps } from "class-variance-authority";
|
|
2
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
3
|
+
import type { IconType } from "react-icons";
|
|
4
|
+
|
|
5
|
+
import type { ratingIconVariants, ratingItemVariants } from "./variants";
|
|
6
|
+
|
|
7
|
+
export type RatingPresetIcon = "star" | "heart" | "flame" | "thumb";
|
|
8
|
+
|
|
9
|
+
export type RatingItemVariantProps = VariantProps<typeof ratingItemVariants>;
|
|
10
|
+
export type RatingIconVariantProps = VariantProps<typeof ratingIconVariants>;
|
|
11
|
+
|
|
12
|
+
export type RatingProps = RatingItemVariantProps &
|
|
13
|
+
RatingIconVariantProps &
|
|
14
|
+
Omit<
|
|
15
|
+
ComponentPropsWithRef<"div">,
|
|
16
|
+
"children" | "defaultValue" | "onChange"
|
|
17
|
+
> & {
|
|
18
|
+
allowClear?: boolean;
|
|
19
|
+
allowHalf?: boolean;
|
|
20
|
+
defaultValue?: number;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
errorMessage?: ReactNode;
|
|
23
|
+
getLabel?: (value: number, max: number) => string;
|
|
24
|
+
hint?: ReactNode;
|
|
25
|
+
icon?: RatingPresetIcon | IconType;
|
|
26
|
+
iconClassName?: string;
|
|
27
|
+
label?: ReactNode;
|
|
28
|
+
max?: number;
|
|
29
|
+
name?: string;
|
|
30
|
+
onValueChange?: (value: number) => void;
|
|
31
|
+
readOnly?: boolean;
|
|
32
|
+
value?: number;
|
|
33
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
zuiRatingAppearances,
|
|
5
|
+
zuiRatingIconBase,
|
|
6
|
+
zuiRatingItemBase,
|
|
7
|
+
zuiRatingSizes,
|
|
8
|
+
} from "../../design-system/rating";
|
|
9
|
+
|
|
10
|
+
export const ratingItemVariants = cva(zuiRatingItemBase, {
|
|
11
|
+
variants: {
|
|
12
|
+
size: zuiRatingSizes,
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
size: "md",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const ratingIconVariants = cva(zuiRatingIconBase, {
|
|
20
|
+
variants: {
|
|
21
|
+
appearance: zuiRatingAppearances,
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
appearance: "amber",
|
|
25
|
+
},
|
|
26
|
+
});
|