@voila.dev/ui 1.1.9

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.
Files changed (99) hide show
  1. package/package.json +78 -0
  2. package/src/components/ui/accordion.tsx +79 -0
  3. package/src/components/ui/alert-dialog.tsx +198 -0
  4. package/src/components/ui/alert.tsx +105 -0
  5. package/src/components/ui/aspect-ratio.tsx +30 -0
  6. package/src/components/ui/avatar.tsx +125 -0
  7. package/src/components/ui/badge-variants.ts +166 -0
  8. package/src/components/ui/badge.tsx +45 -0
  9. package/src/components/ui/banner.tsx +84 -0
  10. package/src/components/ui/breadcrumb.tsx +127 -0
  11. package/src/components/ui/button-group.tsx +99 -0
  12. package/src/components/ui/button-variants.ts +108 -0
  13. package/src/components/ui/button.tsx +54 -0
  14. package/src/components/ui/calendar.tsx +266 -0
  15. package/src/components/ui/card.tsx +155 -0
  16. package/src/components/ui/carousel.tsx +319 -0
  17. package/src/components/ui/chat.tsx +896 -0
  18. package/src/components/ui/checkbox-group.tsx +44 -0
  19. package/src/components/ui/checkbox.tsx +34 -0
  20. package/src/components/ui/chip.tsx +60 -0
  21. package/src/components/ui/collapsible.tsx +44 -0
  22. package/src/components/ui/color-picker.tsx +191 -0
  23. package/src/components/ui/combobox.tsx +317 -0
  24. package/src/components/ui/command.tsx +187 -0
  25. package/src/components/ui/confirm-dialog.tsx +125 -0
  26. package/src/components/ui/context-menu.tsx +254 -0
  27. package/src/components/ui/copyable-text.tsx +65 -0
  28. package/src/components/ui/date-picker.tsx +316 -0
  29. package/src/components/ui/date-time-picker.tsx +1071 -0
  30. package/src/components/ui/dialog.tsx +176 -0
  31. package/src/components/ui/direction.tsx +15 -0
  32. package/src/components/ui/drawer.tsx +178 -0
  33. package/src/components/ui/dropdown-menu.tsx +247 -0
  34. package/src/components/ui/empty.tsx +131 -0
  35. package/src/components/ui/field.tsx +257 -0
  36. package/src/components/ui/formatted-input.tsx +219 -0
  37. package/src/components/ui/gallery.tsx +161 -0
  38. package/src/components/ui/hover-card.tsx +77 -0
  39. package/src/components/ui/icon-picker.tsx +193 -0
  40. package/src/components/ui/image-cropper.tsx +787 -0
  41. package/src/components/ui/image-upload-field.tsx +344 -0
  42. package/src/components/ui/input-group.tsx +175 -0
  43. package/src/components/ui/input-otp.tsx +83 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/item.tsx +212 -0
  46. package/src/components/ui/kbd.tsx +62 -0
  47. package/src/components/ui/label.tsx +19 -0
  48. package/src/components/ui/list.tsx +46 -0
  49. package/src/components/ui/menu-variants.ts +71 -0
  50. package/src/components/ui/menubar.tsx +226 -0
  51. package/src/components/ui/money-input.tsx +96 -0
  52. package/src/components/ui/native-date-picker.tsx +120 -0
  53. package/src/components/ui/native-select.tsx +74 -0
  54. package/src/components/ui/navigation-menu.tsx +183 -0
  55. package/src/components/ui/pagination.tsx +150 -0
  56. package/src/components/ui/popover.tsx +104 -0
  57. package/src/components/ui/profile-header.tsx +196 -0
  58. package/src/components/ui/progress.tsx +71 -0
  59. package/src/components/ui/radio-group.tsx +115 -0
  60. package/src/components/ui/rating.tsx +200 -0
  61. package/src/components/ui/resizable.tsx +61 -0
  62. package/src/components/ui/responsive-dialog.tsx +278 -0
  63. package/src/components/ui/responsive-select.tsx +289 -0
  64. package/src/components/ui/responsive-sheet.tsx +225 -0
  65. package/src/components/ui/scroll-area.tsx +51 -0
  66. package/src/components/ui/section.tsx +106 -0
  67. package/src/components/ui/segmented-control.tsx +148 -0
  68. package/src/components/ui/select.tsx +216 -0
  69. package/src/components/ui/separator.tsx +69 -0
  70. package/src/components/ui/sheet.tsx +154 -0
  71. package/src/components/ui/shortcut.tsx +41 -0
  72. package/src/components/ui/sidebar.tsx +739 -0
  73. package/src/components/ui/skeleton.tsx +23 -0
  74. package/src/components/ui/slider.tsx +74 -0
  75. package/src/components/ui/sonner.tsx +64 -0
  76. package/src/components/ui/spinner.tsx +19 -0
  77. package/src/components/ui/stat-card.tsx +138 -0
  78. package/src/components/ui/stepper.tsx +181 -0
  79. package/src/components/ui/sticky-action-bar.tsx +42 -0
  80. package/src/components/ui/switch.tsx +30 -0
  81. package/src/components/ui/table.tsx +132 -0
  82. package/src/components/ui/tabs.tsx +77 -0
  83. package/src/components/ui/textarea.tsx +24 -0
  84. package/src/components/ui/time-picker.tsx +221 -0
  85. package/src/components/ui/toggle-group.tsx +86 -0
  86. package/src/components/ui/toggle.tsx +43 -0
  87. package/src/components/ui/tooltip.tsx +79 -0
  88. package/src/components/ui/translation-input.tsx +126 -0
  89. package/src/components/ui/user-avatar.tsx +74 -0
  90. package/src/css-modules.d.ts +4 -0
  91. package/src/hooks/use-command-palette.ts +28 -0
  92. package/src/hooks/use-mobile.ts +21 -0
  93. package/src/hooks/use-pagination.ts +81 -0
  94. package/src/hooks/use-picker-state.ts +44 -0
  95. package/src/lib/cva.ts +18 -0
  96. package/src/lib/time-math.ts +62 -0
  97. package/src/lib/utils.ts +14 -0
  98. package/src/styles/globals.css +36 -0
  99. package/src/styles.css +10 -0
@@ -0,0 +1,104 @@
1
+ import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
2
+ import type * as React from "react";
3
+
4
+ import { cn } from "#/lib/utils.ts";
5
+
6
+ function Popover({ ...props }: PopoverPrimitive.Root.Props) {
7
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />;
8
+ }
9
+
10
+ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
11
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
12
+ }
13
+
14
+ function PopoverClose({ ...props }: PopoverPrimitive.Close.Props) {
15
+ return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
16
+ }
17
+
18
+ function PopoverContent({
19
+ className,
20
+ align = "center",
21
+ alignOffset = 0,
22
+ side = "bottom",
23
+ sideOffset = 4,
24
+ positionerProps,
25
+ ...props
26
+ }: PopoverPrimitive.Popup.Props &
27
+ Pick<
28
+ PopoverPrimitive.Positioner.Props,
29
+ "align" | "alignOffset" | "side" | "sideOffset"
30
+ > & {
31
+ // Escape hatch for the Positioner props not surfaced as dedicated props
32
+ // (collisionPadding, sticky, anchor, positionMethod, arrowPadding…).
33
+ positionerProps?: Omit<
34
+ PopoverPrimitive.Positioner.Props,
35
+ "align" | "alignOffset" | "side" | "sideOffset" | "children"
36
+ >;
37
+ }) {
38
+ const { className: positionerClassName, ...restPositionerProps } =
39
+ positionerProps ?? {};
40
+ return (
41
+ <PopoverPrimitive.Portal>
42
+ <PopoverPrimitive.Positioner
43
+ align={align}
44
+ alignOffset={alignOffset}
45
+ side={side}
46
+ sideOffset={sideOffset}
47
+ className={cn("isolate z-50", positionerClassName)}
48
+ {...restPositionerProps}
49
+ >
50
+ <PopoverPrimitive.Popup
51
+ data-slot="popover-content"
52
+ className={cn(
53
+ "z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:animate-none",
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ </PopoverPrimitive.Positioner>
59
+ </PopoverPrimitive.Portal>
60
+ );
61
+ }
62
+
63
+ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
64
+ return (
65
+ <div
66
+ data-slot="popover-header"
67
+ className={cn("flex flex-col gap-0.5", className)}
68
+ {...props}
69
+ />
70
+ );
71
+ }
72
+
73
+ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
74
+ return (
75
+ <PopoverPrimitive.Title
76
+ data-slot="popover-title"
77
+ className={cn("font-medium", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ function PopoverDescription({
84
+ className,
85
+ ...props
86
+ }: PopoverPrimitive.Description.Props) {
87
+ return (
88
+ <PopoverPrimitive.Description
89
+ data-slot="popover-description"
90
+ className={cn("text-muted-foreground", className)}
91
+ {...props}
92
+ />
93
+ );
94
+ }
95
+
96
+ export {
97
+ Popover,
98
+ PopoverClose,
99
+ PopoverContent,
100
+ PopoverDescription,
101
+ PopoverHeader,
102
+ PopoverTitle,
103
+ PopoverTrigger,
104
+ };
@@ -0,0 +1,196 @@
1
+ import type * as React from "react";
2
+ import { AspectRatio } from "#/components/ui/aspect-ratio.tsx";
3
+ import {
4
+ Avatar,
5
+ AvatarFallback,
6
+ AvatarImage,
7
+ } from "#/components/ui/avatar.tsx";
8
+ import { getInitials } from "#/components/ui/user-avatar.tsx";
9
+ import { cn } from "#/lib/utils.ts";
10
+
11
+ type ProfileTheme = "provider" | "organization";
12
+
13
+ // `theme` coordinates the themed children (cover gradient, avatar ring) off
14
+ // the `data-theme` attribute their group root emits.
15
+
16
+ /** Themed cover gradient used when no `coverImage` is supplied. */
17
+ const coverGradientClasses =
18
+ "bg-gradient-to-br group-data-[theme=organization]/profile-header-cover:from-organization group-data-[theme=organization]/profile-header-cover:via-organization/80 group-data-[theme=organization]/profile-header-cover:to-organization/40 group-data-[theme=provider]/profile-header-cover:from-provider group-data-[theme=provider]/profile-header-cover:via-provider/80 group-data-[theme=provider]/profile-header-cover:to-provider/40";
19
+
20
+ /** Ring color that frames the overlapping avatar, matched to the theme. */
21
+ const avatarRingClasses =
22
+ "group-data-[theme=organization]/profile-header:ring-organization/20 group-data-[theme=provider]/profile-header:ring-provider/20";
23
+
24
+ type ProfileHeaderAvatar = { src?: string; name?: string };
25
+
26
+ function isAvatarDescriptor(
27
+ avatar: React.ReactNode | ProfileHeaderAvatar | undefined,
28
+ ): avatar is ProfileHeaderAvatar {
29
+ return (
30
+ typeof avatar === "object" &&
31
+ avatar !== null &&
32
+ !("$$typeof" in (avatar as object))
33
+ );
34
+ }
35
+
36
+ /** Cover band — renders a `coverImage` (node or src) or a themed gradient. */
37
+ function ProfileHeaderCover({
38
+ coverImage,
39
+ theme = "provider",
40
+ className,
41
+ children,
42
+ ...props
43
+ }: React.ComponentProps<"div"> & {
44
+ coverImage?: React.ReactNode | string;
45
+ theme?: ProfileTheme;
46
+ }) {
47
+ return (
48
+ <div
49
+ data-slot="profile-header-cover"
50
+ data-theme={theme}
51
+ className={cn(
52
+ "group/profile-header-cover relative overflow-hidden",
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ <AspectRatio ratio={3}>
58
+ {typeof coverImage === "string" ? (
59
+ <img
60
+ src={coverImage}
61
+ alt=""
62
+ className="size-full object-cover"
63
+ data-slot="profile-header-cover-image"
64
+ />
65
+ ) : coverImage ? (
66
+ coverImage
67
+ ) : (
68
+ <div
69
+ aria-hidden
70
+ data-slot="profile-header-cover-fallback"
71
+ className={cn("size-full", coverGradientClasses)}
72
+ />
73
+ )}
74
+ </AspectRatio>
75
+ {children}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Hero band for a provider or organization profile: a themed cover, an
82
+ * overlapping circular avatar, name + optional headline, a trust-badge row
83
+ * and a right-aligned actions slot. Composable — the cover/body sub-parts are
84
+ * exported so consumers can rebuild a custom hero when needed.
85
+ */
86
+ function ProfileHeader({
87
+ name,
88
+ headline,
89
+ coverImage,
90
+ avatar,
91
+ theme = "provider",
92
+ badges,
93
+ actions,
94
+ className,
95
+ children,
96
+ ...props
97
+ }: Omit<React.ComponentProps<"div">, "title"> & {
98
+ name: string;
99
+ headline?: React.ReactNode;
100
+ coverImage?: React.ReactNode | string;
101
+ avatar?: React.ReactNode | ProfileHeaderAvatar;
102
+ theme?: ProfileTheme;
103
+ badges?: React.ReactNode;
104
+ actions?: React.ReactNode;
105
+ }) {
106
+ const avatarNode = isAvatarDescriptor(avatar) ? (
107
+ <Avatar
108
+ size="lg"
109
+ className={cn("size-16 ring-4 sm:size-24", avatarRingClasses)}
110
+ >
111
+ {avatar.src ? (
112
+ <AvatarImage src={avatar.src} alt={avatar.name ?? name} />
113
+ ) : null}
114
+ <AvatarFallback className="text-lg sm:text-2xl">
115
+ {getInitials(avatar.name ?? name)}
116
+ </AvatarFallback>
117
+ </Avatar>
118
+ ) : avatar !== undefined ? (
119
+ avatar
120
+ ) : (
121
+ <Avatar
122
+ size="lg"
123
+ className={cn("size-16 ring-4 sm:size-24", avatarRingClasses)}
124
+ >
125
+ <AvatarFallback className="text-lg sm:text-2xl">
126
+ {getInitials(name)}
127
+ </AvatarFallback>
128
+ </Avatar>
129
+ );
130
+
131
+ return (
132
+ <div
133
+ data-slot="profile-header"
134
+ data-theme={theme}
135
+ className={cn("group/profile-header flex flex-col", className)}
136
+ {...props}
137
+ >
138
+ <ProfileHeaderCover coverImage={coverImage} theme={theme} />
139
+ <div
140
+ data-slot="profile-header-body"
141
+ className="flex flex-col gap-3 px-4 pb-4 sm:flex-row sm:items-end sm:gap-4"
142
+ >
143
+ <div
144
+ data-slot="profile-header-avatar"
145
+ className="-mt-8 shrink-0 sm:-mt-12"
146
+ >
147
+ {avatarNode}
148
+ </div>
149
+ <div
150
+ data-slot="profile-header-identity"
151
+ className="flex min-w-0 flex-1 flex-col gap-2 sm:pb-1"
152
+ >
153
+ <div className="flex min-w-0 flex-col gap-0.5">
154
+ <h1
155
+ data-slot="profile-header-name"
156
+ className="truncate text-xl font-semibold tracking-tight sm:text-2xl"
157
+ >
158
+ {name}
159
+ </h1>
160
+ {headline ? (
161
+ <p
162
+ data-slot="profile-header-headline"
163
+ className="truncate text-sm text-muted-foreground"
164
+ >
165
+ {headline}
166
+ </p>
167
+ ) : null}
168
+ </div>
169
+ {badges ? (
170
+ <div
171
+ data-slot="profile-header-badges"
172
+ className="flex flex-wrap items-center gap-1.5"
173
+ >
174
+ {badges}
175
+ </div>
176
+ ) : null}
177
+ </div>
178
+ {actions ? (
179
+ <div
180
+ data-slot="profile-header-actions"
181
+ className="flex shrink-0 items-center gap-2 sm:pb-1"
182
+ >
183
+ {actions}
184
+ </div>
185
+ ) : null}
186
+ </div>
187
+ {children}
188
+ </div>
189
+ );
190
+ }
191
+
192
+ export {
193
+ ProfileHeader,
194
+ ProfileHeaderCover,
195
+ type ProfileTheme as ProfileHeaderTheme,
196
+ };
@@ -0,0 +1,71 @@
1
+ import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
2
+
3
+ import { cn } from "#/lib/utils.ts";
4
+
5
+ type ProgressProps = ProgressPrimitive.Root.Props & {
6
+ trackClassName?: string;
7
+ indicatorClassName?: string;
8
+ };
9
+
10
+ /**
11
+ * Children render before the track in a wrapping flex row, so a
12
+ * ProgressLabel + ProgressValue pair forms a header row and the full-width
13
+ * track wraps below it. Pass `value={null}` for an indeterminate bar.
14
+ */
15
+ function Progress({
16
+ className,
17
+ children,
18
+ trackClassName,
19
+ indicatorClassName,
20
+ ...props
21
+ }: ProgressProps) {
22
+ return (
23
+ <ProgressPrimitive.Root
24
+ data-slot="progress"
25
+ className={cn("flex flex-wrap gap-3", className)}
26
+ {...props}
27
+ >
28
+ {children}
29
+ <ProgressPrimitive.Track
30
+ data-slot="progress-track"
31
+ className={cn(
32
+ "relative flex h-1 w-full items-center overflow-hidden rounded-full bg-muted",
33
+ trackClassName,
34
+ )}
35
+ >
36
+ <ProgressPrimitive.Indicator
37
+ data-slot="progress-indicator"
38
+ className={cn(
39
+ "h-full bg-primary transition-[width] motion-reduce:transition-none data-indeterminate:w-1/2 data-indeterminate:animate-progress-indeterminate data-indeterminate:motion-reduce:animate-[progress-indeterminate_4s_linear_infinite]",
40
+ indicatorClassName,
41
+ )}
42
+ />
43
+ </ProgressPrimitive.Track>
44
+ </ProgressPrimitive.Root>
45
+ );
46
+ }
47
+
48
+ function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
49
+ return (
50
+ <ProgressPrimitive.Label
51
+ data-slot="progress-label"
52
+ className={cn("text-sm font-medium", className)}
53
+ {...props}
54
+ />
55
+ );
56
+ }
57
+
58
+ function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
59
+ return (
60
+ <ProgressPrimitive.Value
61
+ data-slot="progress-value"
62
+ className={cn(
63
+ "ml-auto text-sm text-muted-foreground tabular-nums",
64
+ className,
65
+ )}
66
+ {...props}
67
+ />
68
+ );
69
+ }
70
+
71
+ export { Progress, ProgressLabel, ProgressValue };
@@ -0,0 +1,115 @@
1
+ import { Radio as RadioPrimitive } from "@base-ui/react/radio";
2
+ import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
3
+ import { CheckCircleIcon } from "@phosphor-icons/react";
4
+
5
+ import { cn } from "#/lib/utils.ts";
6
+
7
+ type RadioGroupProps = RadioGroupPrimitive.Props & {
8
+ /**
9
+ * Layout direction of the items. `vertical` stacks them (the default);
10
+ * `horizontal` lays them out in a wrapping row - saves consumers from
11
+ * rebuilding a `flex-row` wrapper for short option sets.
12
+ */
13
+ orientation?: "vertical" | "horizontal";
14
+ };
15
+
16
+ function RadioGroup({
17
+ className,
18
+ orientation = "vertical",
19
+ ...props
20
+ }: RadioGroupProps) {
21
+ return (
22
+ <RadioGroupPrimitive
23
+ data-slot="radio-group"
24
+ data-orientation={orientation}
25
+ className={cn(
26
+ orientation === "horizontal"
27
+ ? "flex flex-wrap items-center gap-x-4 gap-y-2"
28
+ : "grid gap-2",
29
+ className,
30
+ )}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
37
+ return (
38
+ <RadioPrimitive.Root
39
+ data-slot="radio-group-item"
40
+ className={cn(
41
+ // `transition-colors` keeps the border/background from snapping when an
42
+ // item becomes (un)checked. The trailing `dark:data-checked:bg-primary`
43
+ // duplicates `data-checked:bg-primary` on purpose: in dark mode the
44
+ // unchecked `dark:bg-input/30` fill would otherwise win the
45
+ // tailwind-merge order once checked, so the dark override must come
46
+ // last. (Same idiom as checkbox.tsx.)
47
+ "group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none transition-colors after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-disabled:cursor-not-allowed data-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
48
+ className,
49
+ )}
50
+ {...props}
51
+ >
52
+ <RadioPrimitive.Indicator
53
+ data-slot="radio-group-indicator"
54
+ // The indicator unmounts when unchecked, so it animates in on selection;
55
+ // `data-checked` is present whenever it is mounted.
56
+ className="flex size-4 items-center justify-center data-checked:animate-in data-checked:zoom-in-50 motion-reduce:animate-none"
57
+ >
58
+ <span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
59
+ </RadioPrimitive.Indicator>
60
+ </RadioPrimitive.Root>
61
+ );
62
+ }
63
+
64
+ type RadioGroupCardProps = RadioPrimitive.Root.Props & {
65
+ /** Hide the checked-corner icon when the card's own content marks selection. */
66
+ showIndicator?: boolean;
67
+ };
68
+
69
+ /**
70
+ * "Choice card" alternative to `RadioGroupItem` - a bordered selectable card
71
+ * for plan/role pickers. Children are free-form (title, description, price,
72
+ * ...); the checked state reads as a primary border plus a corner icon. Base
73
+ * UI renders the card as a `<button>`, so don't nest interactive elements.
74
+ */
75
+ function RadioGroupCard({
76
+ className,
77
+ children,
78
+ showIndicator = true,
79
+ ...props
80
+ }: RadioGroupCardProps) {
81
+ return (
82
+ <RadioPrimitive.Root
83
+ data-slot="radio-group-card"
84
+ className={cn(
85
+ // `transition-colors` for the same no-snap reason as RadioGroupItem;
86
+ // the checked border doubles as a ring so the 1px border change does
87
+ // not shift the layout.
88
+ "relative flex flex-col items-start gap-1 rounded-lg border border-input p-4 text-start text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-disabled:cursor-not-allowed data-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-checked:border-primary data-checked:ring-1 data-checked:ring-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
89
+ showIndicator && "pr-9",
90
+ className,
91
+ )}
92
+ {...props}
93
+ >
94
+ {children}
95
+ {showIndicator && (
96
+ <RadioPrimitive.Indicator
97
+ data-slot="radio-group-card-indicator"
98
+ // Unmounts when unchecked, so it animates in on selection (same
99
+ // idiom as RadioGroupItem's dot).
100
+ className="absolute top-3 right-3 flex data-checked:animate-in data-checked:zoom-in-50 motion-reduce:animate-none"
101
+ >
102
+ <CheckCircleIcon weight="fill" className="size-4.5 text-primary" />
103
+ </RadioPrimitive.Indicator>
104
+ )}
105
+ </RadioPrimitive.Root>
106
+ );
107
+ }
108
+
109
+ export {
110
+ RadioGroup,
111
+ RadioGroupCard,
112
+ type RadioGroupCardProps,
113
+ RadioGroupItem,
114
+ type RadioGroupProps,
115
+ };
@@ -0,0 +1,200 @@
1
+ import { StarIcon } from "@phosphor-icons/react";
2
+ import * as React from "react";
3
+ import { UserAvatar } from "#/components/ui/user-avatar.tsx";
4
+ import { cn } from "#/lib/utils.ts";
5
+
6
+ const MAX_STARS = 5;
7
+
8
+ type RatingSize = "sm" | "default" | "lg";
9
+
10
+ // `size` coordinates the star and count children off the root's `data-size`.
11
+ const starSizeClasses =
12
+ "size-4 group-data-[size=lg]/rating:size-5 group-data-[size=sm]/rating:size-3.5";
13
+
14
+ /**
15
+ * Read-only star display. `value` (0–5) controls how many stars fill with the
16
+ * amber `--warning` token; the rest stay muted. Pass `count` to append the
17
+ * number of reviews (e.g. "(128)").
18
+ */
19
+ function Rating({
20
+ value,
21
+ count,
22
+ size = "default",
23
+ max = MAX_STARS,
24
+ className,
25
+ ...props
26
+ }: Omit<React.ComponentProps<"div">, "children"> & {
27
+ value: number;
28
+ count?: number;
29
+ size?: RatingSize;
30
+ max?: number;
31
+ }) {
32
+ const clamped = Math.max(0, Math.min(max, value));
33
+ const rounded = Math.round(clamped);
34
+
35
+ return (
36
+ <div
37
+ data-slot="rating"
38
+ data-size={size}
39
+ role="img"
40
+ aria-label={`${clamped} out of ${max} stars`}
41
+ className={cn("group/rating inline-flex items-center gap-1", className)}
42
+ {...props}
43
+ >
44
+ <span className="inline-flex items-center" aria-hidden>
45
+ {Array.from({ length: max }, (_unused, index) => {
46
+ const filled = index < rounded;
47
+ return (
48
+ <StarIcon
49
+ key={index}
50
+ weight={filled ? "fill" : "regular"}
51
+ data-slot="rating-star"
52
+ data-filled={filled || undefined}
53
+ className={cn(
54
+ starSizeClasses,
55
+ filled ? "text-warning" : "text-muted-foreground/40",
56
+ )}
57
+ />
58
+ );
59
+ })}
60
+ </span>
61
+ {count !== undefined ? (
62
+ <span
63
+ data-slot="rating-count"
64
+ className="text-sm text-muted-foreground tabular-nums group-data-[size=lg]/rating:text-base group-data-[size=sm]/rating:text-xs"
65
+ >
66
+ ({count})
67
+ </span>
68
+ ) : null}
69
+ </div>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Interactive star picker. Controlled via `value` / `onChange`; hovering
75
+ * previews the would-be selection. Keyboard accessible through a radiogroup of
76
+ * star buttons.
77
+ */
78
+ function RatingInput({
79
+ value,
80
+ onChange,
81
+ size = "default",
82
+ max = MAX_STARS,
83
+ disabled = false,
84
+ name,
85
+ className,
86
+ ...props
87
+ }: Omit<React.ComponentProps<"div">, "onChange"> & {
88
+ value: number;
89
+ onChange: (value: number) => void;
90
+ size?: RatingSize;
91
+ max?: number;
92
+ disabled?: boolean;
93
+ name?: string;
94
+ }) {
95
+ const [hovered, setHovered] = React.useState<number | null>(null);
96
+ const active = hovered ?? value;
97
+
98
+ return (
99
+ <div
100
+ data-slot="rating-input"
101
+ data-size={size}
102
+ role="radiogroup"
103
+ aria-label="Rating"
104
+ className={cn(
105
+ "group/rating inline-flex items-center gap-0.5",
106
+ disabled && "pointer-events-none opacity-50",
107
+ className,
108
+ )}
109
+ onMouseLeave={() => setHovered(null)}
110
+ {...props}
111
+ >
112
+ {Array.from({ length: max }, (_unused, index) => {
113
+ const starValue = index + 1;
114
+ const filled = starValue <= active;
115
+ return (
116
+ <button
117
+ key={index}
118
+ type="button"
119
+ role="radio"
120
+ aria-checked={starValue === value}
121
+ aria-label={`${starValue} star${starValue === 1 ? "" : "s"}`}
122
+ disabled={disabled}
123
+ data-slot="rating-input-star"
124
+ data-filled={filled || undefined}
125
+ className="touch-manipulation rounded-sm p-0.5 outline-none transition-transform focus-visible:ring-2 focus-visible:ring-ring/50 not-disabled:hover:scale-110"
126
+ onMouseEnter={() => setHovered(starValue)}
127
+ onFocus={() => setHovered(starValue)}
128
+ onBlur={() => setHovered(null)}
129
+ onClick={() => onChange(starValue)}
130
+ >
131
+ <StarIcon
132
+ weight={filled ? "fill" : "regular"}
133
+ className={cn(
134
+ starSizeClasses,
135
+ filled ? "text-warning" : "text-muted-foreground/40",
136
+ )}
137
+ />
138
+ </button>
139
+ );
140
+ })}
141
+ {name !== undefined ? (
142
+ <input type="hidden" name={name} value={value} />
143
+ ) : null}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * One review entry: the reviewer's avatar + name, their star rating, the date
150
+ * and the review body. Composes `UserAvatar` and `Rating`.
151
+ */
152
+ function ReviewItem({
153
+ authorName,
154
+ authorAvatarSrc,
155
+ rating,
156
+ date,
157
+ children,
158
+ className,
159
+ ...props
160
+ }: React.ComponentProps<"div"> & {
161
+ authorName: string;
162
+ authorAvatarSrc?: string;
163
+ rating: number;
164
+ /** Pre-formatted date string (the kit stays locale-agnostic). */
165
+ date?: React.ReactNode;
166
+ }) {
167
+ return (
168
+ <div
169
+ data-slot="review-item"
170
+ className={cn("flex flex-col gap-2", className)}
171
+ {...props}
172
+ >
173
+ <div className="flex items-start justify-between gap-3">
174
+ <UserAvatar
175
+ name={authorName}
176
+ src={authorAvatarSrc}
177
+ description={<Rating value={rating} size="sm" />}
178
+ />
179
+ {date ? (
180
+ <span
181
+ data-slot="review-item-date"
182
+ className="shrink-0 text-xs text-muted-foreground"
183
+ >
184
+ {date}
185
+ </span>
186
+ ) : null}
187
+ </div>
188
+ {children ? (
189
+ <p
190
+ data-slot="review-item-body"
191
+ className="text-sm/relaxed text-foreground"
192
+ >
193
+ {children}
194
+ </p>
195
+ ) : null}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ export { Rating, RatingInput, type RatingSize, ReviewItem };