@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.
- package/package.json +78 -0
- package/src/components/ui/accordion.tsx +79 -0
- package/src/components/ui/alert-dialog.tsx +198 -0
- package/src/components/ui/alert.tsx +105 -0
- package/src/components/ui/aspect-ratio.tsx +30 -0
- package/src/components/ui/avatar.tsx +125 -0
- package/src/components/ui/badge-variants.ts +166 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/banner.tsx +84 -0
- package/src/components/ui/breadcrumb.tsx +127 -0
- package/src/components/ui/button-group.tsx +99 -0
- package/src/components/ui/button-variants.ts +108 -0
- package/src/components/ui/button.tsx +54 -0
- package/src/components/ui/calendar.tsx +266 -0
- package/src/components/ui/card.tsx +155 -0
- package/src/components/ui/carousel.tsx +319 -0
- package/src/components/ui/chat.tsx +896 -0
- package/src/components/ui/checkbox-group.tsx +44 -0
- package/src/components/ui/checkbox.tsx +34 -0
- package/src/components/ui/chip.tsx +60 -0
- package/src/components/ui/collapsible.tsx +44 -0
- package/src/components/ui/color-picker.tsx +191 -0
- package/src/components/ui/combobox.tsx +317 -0
- package/src/components/ui/command.tsx +187 -0
- package/src/components/ui/confirm-dialog.tsx +125 -0
- package/src/components/ui/context-menu.tsx +254 -0
- package/src/components/ui/copyable-text.tsx +65 -0
- package/src/components/ui/date-picker.tsx +316 -0
- package/src/components/ui/date-time-picker.tsx +1071 -0
- package/src/components/ui/dialog.tsx +176 -0
- package/src/components/ui/direction.tsx +15 -0
- package/src/components/ui/drawer.tsx +178 -0
- package/src/components/ui/dropdown-menu.tsx +247 -0
- package/src/components/ui/empty.tsx +131 -0
- package/src/components/ui/field.tsx +257 -0
- package/src/components/ui/formatted-input.tsx +219 -0
- package/src/components/ui/gallery.tsx +161 -0
- package/src/components/ui/hover-card.tsx +77 -0
- package/src/components/ui/icon-picker.tsx +193 -0
- package/src/components/ui/image-cropper.tsx +787 -0
- package/src/components/ui/image-upload-field.tsx +344 -0
- package/src/components/ui/input-group.tsx +175 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/item.tsx +212 -0
- package/src/components/ui/kbd.tsx +62 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/list.tsx +46 -0
- package/src/components/ui/menu-variants.ts +71 -0
- package/src/components/ui/menubar.tsx +226 -0
- package/src/components/ui/money-input.tsx +96 -0
- package/src/components/ui/native-date-picker.tsx +120 -0
- package/src/components/ui/native-select.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +183 -0
- package/src/components/ui/pagination.tsx +150 -0
- package/src/components/ui/popover.tsx +104 -0
- package/src/components/ui/profile-header.tsx +196 -0
- package/src/components/ui/progress.tsx +71 -0
- package/src/components/ui/radio-group.tsx +115 -0
- package/src/components/ui/rating.tsx +200 -0
- package/src/components/ui/resizable.tsx +61 -0
- package/src/components/ui/responsive-dialog.tsx +278 -0
- package/src/components/ui/responsive-select.tsx +289 -0
- package/src/components/ui/responsive-sheet.tsx +225 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/section.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +148 -0
- package/src/components/ui/select.tsx +216 -0
- package/src/components/ui/separator.tsx +69 -0
- package/src/components/ui/sheet.tsx +154 -0
- package/src/components/ui/shortcut.tsx +41 -0
- package/src/components/ui/sidebar.tsx +739 -0
- package/src/components/ui/skeleton.tsx +23 -0
- package/src/components/ui/slider.tsx +74 -0
- package/src/components/ui/sonner.tsx +64 -0
- package/src/components/ui/spinner.tsx +19 -0
- package/src/components/ui/stat-card.tsx +138 -0
- package/src/components/ui/stepper.tsx +181 -0
- package/src/components/ui/sticky-action-bar.tsx +42 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +132 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/time-picker.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +79 -0
- package/src/components/ui/translation-input.tsx +126 -0
- package/src/components/ui/user-avatar.tsx +74 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/use-command-palette.ts +28 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-pagination.ts +81 -0
- package/src/hooks/use-picker-state.ts +44 -0
- package/src/lib/cva.ts +18 -0
- package/src/lib/time-math.ts +62 -0
- package/src/lib/utils.ts +14 -0
- package/src/styles/globals.css +36 -0
- 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 };
|