@cntyclub/ui-react 0.1.0

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 (124) hide show
  1. package/dist/chunk-HDGMSYQS.js +26461 -0
  2. package/dist/chunk-HDGMSYQS.js.map +1 -0
  3. package/dist/chunk-PR4QN5HX.js +39 -0
  4. package/dist/chunk-PR4QN5HX.js.map +1 -0
  5. package/dist/form.d.ts +175 -0
  6. package/dist/form.js +5207 -0
  7. package/dist/form.js.map +1 -0
  8. package/dist/index.d.ts +1462 -0
  9. package/dist/index.js +81862 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/input-CZvh825j.d.ts +24 -0
  12. package/dist/qr-code-styling-3Y6LZH6V.js +1123 -0
  13. package/dist/qr-code-styling-3Y6LZH6V.js.map +1 -0
  14. package/package.json +79 -0
  15. package/src/components/form/checkbox-group-field.tsx +101 -0
  16. package/src/components/form/date-field.tsx +79 -0
  17. package/src/components/form/date-range-field.tsx +106 -0
  18. package/src/components/form/form-context.ts +10 -0
  19. package/src/components/form/form.tsx +54 -0
  20. package/src/components/form/number-field.tsx +69 -0
  21. package/src/components/form/select-field.tsx +76 -0
  22. package/src/components/form/submit-button.tsx +28 -0
  23. package/src/components/form/text-field.tsx +107 -0
  24. package/src/components/layout/dashboard-header.tsx +54 -0
  25. package/src/components/layout/dashboard-panel.tsx +34 -0
  26. package/src/components/theme-provider.tsx +403 -0
  27. package/src/components/ui/accordion.tsx +69 -0
  28. package/src/components/ui/alert-dialog.tsx +169 -0
  29. package/src/components/ui/alert.tsx +80 -0
  30. package/src/components/ui/animated-theme-toggler.tsx +265 -0
  31. package/src/components/ui/app-store-buttons.tsx +182 -0
  32. package/src/components/ui/aspect-ratio.tsx +23 -0
  33. package/src/components/ui/autocomplete.tsx +296 -0
  34. package/src/components/ui/avatar-group.tsx +95 -0
  35. package/src/components/ui/avatar.tsx +285 -0
  36. package/src/components/ui/badge-group.tsx +160 -0
  37. package/src/components/ui/badge.tsx +172 -0
  38. package/src/components/ui/breadcrumb.tsx +112 -0
  39. package/src/components/ui/button.tsx +77 -0
  40. package/src/components/ui/calendar.tsx +137 -0
  41. package/src/components/ui/card.tsx +244 -0
  42. package/src/components/ui/carousel.tsx +258 -0
  43. package/src/components/ui/chart.tsx +379 -0
  44. package/src/components/ui/checkbox-group.tsx +16 -0
  45. package/src/components/ui/checkbox.tsx +82 -0
  46. package/src/components/ui/collapsible.tsx +45 -0
  47. package/src/components/ui/combobox.tsx +411 -0
  48. package/src/components/ui/command.tsx +264 -0
  49. package/src/components/ui/context-menu.tsx +271 -0
  50. package/src/components/ui/credit-card.tsx +214 -0
  51. package/src/components/ui/dialog.tsx +196 -0
  52. package/src/components/ui/drawer.tsx +135 -0
  53. package/src/components/ui/empty.tsx +127 -0
  54. package/src/components/ui/featured-icon.tsx +149 -0
  55. package/src/components/ui/field.tsx +88 -0
  56. package/src/components/ui/fieldset.tsx +29 -0
  57. package/src/components/ui/form.tsx +17 -0
  58. package/src/components/ui/frame.tsx +82 -0
  59. package/src/components/ui/generic-empty.tsx +142 -0
  60. package/src/components/ui/group.tsx +97 -0
  61. package/src/components/ui/horizontal-scroll-fader.tsx +228 -0
  62. package/src/components/ui/input-group.tsx +102 -0
  63. package/src/components/ui/input-otp.tsx +96 -0
  64. package/src/components/ui/input.tsx +66 -0
  65. package/src/components/ui/item.tsx +198 -0
  66. package/src/components/ui/kbd.tsx +30 -0
  67. package/src/components/ui/label.tsx +28 -0
  68. package/src/components/ui/menu.tsx +312 -0
  69. package/src/components/ui/menubar.tsx +93 -0
  70. package/src/components/ui/meter.tsx +67 -0
  71. package/src/components/ui/multi-select.tsx +308 -0
  72. package/src/components/ui/navigation-menu.tsx +143 -0
  73. package/src/components/ui/number-field.tsx +160 -0
  74. package/src/components/ui/pagination-controls.tsx +74 -0
  75. package/src/components/ui/pagination.tsx +149 -0
  76. package/src/components/ui/popover.tsx +119 -0
  77. package/src/components/ui/preview-card.tsx +55 -0
  78. package/src/components/ui/progress.tsx +289 -0
  79. package/src/components/ui/qr-code.tsx +150 -0
  80. package/src/components/ui/radio-group.tsx +103 -0
  81. package/src/components/ui/resizable.tsx +56 -0
  82. package/src/components/ui/scroll-area.tsx +90 -0
  83. package/src/components/ui/scroller.tsx +38 -0
  84. package/src/components/ui/section-header.tsx +118 -0
  85. package/src/components/ui/select.tsx +181 -0
  86. package/src/components/ui/separator.tsx +23 -0
  87. package/src/components/ui/sheet.tsx +224 -0
  88. package/src/components/ui/sidebar.tsx +744 -0
  89. package/src/components/ui/skeleton.tsx +16 -0
  90. package/src/components/ui/slider.tsx +108 -0
  91. package/src/components/ui/smooth-scroll.tsx +143 -0
  92. package/src/components/ui/social-button.tsx +247 -0
  93. package/src/components/ui/spinner-on-demand.tsx +32 -0
  94. package/src/components/ui/spinner.tsx +18 -0
  95. package/src/components/ui/stat.tsx +187 -0
  96. package/src/components/ui/stepper.tsx +167 -0
  97. package/src/components/ui/switch.tsx +56 -0
  98. package/src/components/ui/table.tsx +126 -0
  99. package/src/components/ui/tabs.tsx +90 -0
  100. package/src/components/ui/tag.tsx +229 -0
  101. package/src/components/ui/target-countdown.tsx +46 -0
  102. package/src/components/ui/text-editor.tsx +313 -0
  103. package/src/components/ui/textarea.tsx +51 -0
  104. package/src/components/ui/timeline.tsx +116 -0
  105. package/src/components/ui/toast.tsx +268 -0
  106. package/src/components/ui/toggle-group.tsx +101 -0
  107. package/src/components/ui/toggle.tsx +45 -0
  108. package/src/components/ui/toolbar.tsx +89 -0
  109. package/src/components/ui/tooltip.tsx +102 -0
  110. package/src/components/ui/vertical-scroll-fader.tsx +250 -0
  111. package/src/components/ui/video-player.tsx +275 -0
  112. package/src/components/upload/avatar-upload-base.tsx +131 -0
  113. package/src/components/upload/image-upload-base.tsx +112 -0
  114. package/src/form.ts +17 -0
  115. package/src/index.ts +125 -0
  116. package/src/lib/hooks/use-callback-ref.ts +15 -0
  117. package/src/lib/hooks/use-first-render.ts +11 -0
  118. package/src/lib/hooks/use-hover.ts +53 -0
  119. package/src/lib/hooks/use-is-tab-active.ts +17 -0
  120. package/src/lib/hooks/use-media-query.ts +164 -0
  121. package/src/lib/utils/css.ts +6 -0
  122. package/src/styles.css +300 -0
  123. package/src/types/helpers.ts +24 -0
  124. package/src/types/react.d.ts +7 -0
@@ -0,0 +1,23 @@
1
+ import type * as React from "react";
2
+
3
+ import { cn } from "../../lib/utils/css";
4
+
5
+ function AspectRatio({
6
+ ratio = 1,
7
+ className,
8
+ style,
9
+ ...props
10
+ }: React.ComponentProps<"div"> & {
11
+ ratio?: number;
12
+ }) {
13
+ return (
14
+ <div
15
+ className={cn("relative w-full overflow-hidden", className)}
16
+ data-slot="aspect-ratio"
17
+ style={{ aspectRatio: ratio, ...style }}
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ export { AspectRatio };
@@ -0,0 +1,296 @@
1
+ "use client";
2
+
3
+ import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete";
4
+ import { ChevronsUpDownIcon, XIcon } from "lucide-react";
5
+ import { Input } from "./input";
6
+ import { ScrollArea } from "./scroll-area";
7
+ import { cn } from "../../lib/utils/css";
8
+
9
+ const Autocomplete = AutocompletePrimitive.Root;
10
+
11
+ function AutocompleteInput({
12
+ className,
13
+ showTrigger = false,
14
+ showClear = false,
15
+ startAddon,
16
+ size,
17
+ ...props
18
+ }: Omit<AutocompletePrimitive.Input.Props, "size"> & {
19
+ showTrigger?: boolean;
20
+ showClear?: boolean;
21
+ startAddon?: React.ReactNode;
22
+ size?: "sm" | "default" | "lg" | number;
23
+ ref?: React.Ref<HTMLInputElement>;
24
+ }) {
25
+ const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
26
+
27
+ return (
28
+ <div className="relative not-has-[>*.w-full]:w-fit w-full text-foreground has-disabled:opacity-64">
29
+ {startAddon && (
30
+ <div
31
+ aria-hidden="true"
32
+ className="[&_svg]:-mx-0.5 pointer-events-none absolute inset-y-0 start-px z-10 flex items-center ps-[calc(--spacing(3)-1px)] opacity-80 has-[+[data-size=sm]]:ps-[calc(--spacing(2.5)-1px)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4"
33
+ data-slot="autocomplete-start-addon"
34
+ >
35
+ {startAddon}
36
+ </div>
37
+ )}
38
+ <AutocompletePrimitive.Input
39
+ className={cn(
40
+ startAddon &&
41
+ "data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7.5)-1px)] *:data-[slot=autocomplete-input]:ps-[calc(--spacing(8.5)-1px)] sm:data-[size=sm]:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(7)-1px)] sm:*:data-[slot=autocomplete-input]:ps-[calc(--spacing(8)-1px)]",
42
+ sizeValue === "sm"
43
+ ? "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-6.5"
44
+ : "has-[+[data-slot=autocomplete-trigger],+[data-slot=autocomplete-clear]]:*:data-[slot=autocomplete-input]:pe-7",
45
+ className,
46
+ )}
47
+ data-slot="autocomplete-input"
48
+ render={<Input nativeInput size={sizeValue} />}
49
+ {...props}
50
+ />
51
+ {showTrigger && (
52
+ <AutocompleteTrigger
53
+ className={cn(
54
+ "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
55
+ sizeValue === "sm" ? "end-0" : "end-0.5",
56
+ )}
57
+ >
58
+ <ChevronsUpDownIcon />
59
+ </AutocompleteTrigger>
60
+ )}
61
+ {showClear && (
62
+ <AutocompleteClear
63
+ className={cn(
64
+ "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-colors pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=autocomplete-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
65
+ sizeValue === "sm" ? "end-0" : "end-0.5",
66
+ )}
67
+ >
68
+ <XIcon />
69
+ </AutocompleteClear>
70
+ )}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ function AutocompletePopup({
76
+ className,
77
+ children,
78
+ sideOffset = 4,
79
+ ...props
80
+ }: AutocompletePrimitive.Popup.Props & {
81
+ sideOffset?: number;
82
+ }) {
83
+ return (
84
+ <AutocompletePrimitive.Portal>
85
+ <AutocompletePrimitive.Positioner
86
+ className="z-50 select-none"
87
+ data-slot="autocomplete-positioner"
88
+ sideOffset={sideOffset}
89
+ >
90
+ <AutocompletePrimitive.Popup
91
+ className={cn(
92
+ "relative flex max-h-[min(var(--available-height),23rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) flex-col rounded-lg border bg-popover not-dark:bg-clip-padding text-foreground shadow-lg/5 transition-opacity duration-150 ease-out before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
93
+ className,
94
+ )}
95
+ data-slot="autocomplete-popup"
96
+ {...props}
97
+ >
98
+ {children}
99
+ </AutocompletePrimitive.Popup>
100
+ </AutocompletePrimitive.Positioner>
101
+ </AutocompletePrimitive.Portal>
102
+ );
103
+ }
104
+
105
+ function AutocompleteItem({
106
+ className,
107
+ children,
108
+ ...props
109
+ }: AutocompletePrimitive.Item.Props) {
110
+ return (
111
+ <AutocompletePrimitive.Item
112
+ className={cn(
113
+ "flex min-h-8 cursor-default select-none items-center rounded-sm px-2 py-1 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm",
114
+ className,
115
+ )}
116
+ data-slot="autocomplete-item"
117
+ {...props}
118
+ >
119
+ {children}
120
+ </AutocompletePrimitive.Item>
121
+ );
122
+ }
123
+
124
+ function AutocompleteSeparator({
125
+ className,
126
+ ...props
127
+ }: AutocompletePrimitive.Separator.Props) {
128
+ return (
129
+ <AutocompletePrimitive.Separator
130
+ className={cn("mx-2 my-1 h-px bg-border last:hidden", className)}
131
+ data-slot="autocomplete-separator"
132
+ {...props}
133
+ />
134
+ );
135
+ }
136
+
137
+ function AutocompleteGroup({
138
+ className,
139
+ ...props
140
+ }: AutocompletePrimitive.Group.Props) {
141
+ return (
142
+ <AutocompletePrimitive.Group
143
+ className={cn("[[role=group]+&]:mt-1.5", className)}
144
+ data-slot="autocomplete-group"
145
+ {...props}
146
+ />
147
+ );
148
+ }
149
+
150
+ function AutocompleteGroupLabel({
151
+ className,
152
+ ...props
153
+ }: AutocompletePrimitive.GroupLabel.Props) {
154
+ return (
155
+ <AutocompletePrimitive.GroupLabel
156
+ className={cn(
157
+ "px-2 py-1.5 font-medium text-muted-foreground text-xs",
158
+ className,
159
+ )}
160
+ data-slot="autocomplete-group-label"
161
+ {...props}
162
+ />
163
+ );
164
+ }
165
+
166
+ function AutocompleteEmpty({
167
+ className,
168
+ ...props
169
+ }: AutocompletePrimitive.Empty.Props) {
170
+ return (
171
+ <AutocompletePrimitive.Empty
172
+ className={cn(
173
+ "not-empty:p-2 text-center text-base text-muted-foreground sm:text-sm",
174
+ className,
175
+ )}
176
+ data-slot="autocomplete-empty"
177
+ {...props}
178
+ />
179
+ );
180
+ }
181
+
182
+ function AutocompleteRow({
183
+ className,
184
+ ...props
185
+ }: AutocompletePrimitive.Row.Props) {
186
+ return (
187
+ <AutocompletePrimitive.Row
188
+ className={className}
189
+ data-slot="autocomplete-row"
190
+ {...props}
191
+ />
192
+ );
193
+ }
194
+
195
+ function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
196
+ return (
197
+ <AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
198
+ );
199
+ }
200
+
201
+ function AutocompleteList({
202
+ className,
203
+ ...props
204
+ }: AutocompletePrimitive.List.Props) {
205
+ return (
206
+ <ScrollArea scrollbarGutter scrollFade className="grow">
207
+ <AutocompletePrimitive.List
208
+ className={cn(
209
+ "not-empty:scroll-py-1 not-empty:p-1 in-data-has-overflow-y:pe-3",
210
+ className,
211
+ )}
212
+ data-slot="autocomplete-list"
213
+ {...props}
214
+ />
215
+ </ScrollArea>
216
+ );
217
+ }
218
+
219
+ function AutocompleteClear({
220
+ className,
221
+ ...props
222
+ }: AutocompletePrimitive.Clear.Props) {
223
+ return (
224
+ <AutocompletePrimitive.Clear
225
+ className={cn(
226
+ "-translate-y-1/2 absolute end-0.5 top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-[color,background-color,box-shadow,opacity] pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
227
+ className,
228
+ )}
229
+ data-slot="autocomplete-clear"
230
+ {...props}
231
+ >
232
+ <XIcon />
233
+ </AutocompletePrimitive.Clear>
234
+ );
235
+ }
236
+
237
+ function AutocompleteStatus({
238
+ className,
239
+ ...props
240
+ }: AutocompletePrimitive.Status.Props) {
241
+ return (
242
+ <AutocompletePrimitive.Status
243
+ className={cn(
244
+ "px-3 py-2 font-medium text-muted-foreground text-xs empty:m-0 empty:p-0",
245
+ className,
246
+ )}
247
+ data-slot="autocomplete-status"
248
+ {...props}
249
+ />
250
+ );
251
+ }
252
+
253
+ function AutocompleteCollection({
254
+ ...props
255
+ }: AutocompletePrimitive.Collection.Props) {
256
+ return (
257
+ <AutocompletePrimitive.Collection
258
+ data-slot="autocomplete-collection"
259
+ {...props}
260
+ />
261
+ );
262
+ }
263
+
264
+ function AutocompleteTrigger({
265
+ className,
266
+ ...props
267
+ }: AutocompletePrimitive.Trigger.Props) {
268
+ return (
269
+ <AutocompletePrimitive.Trigger
270
+ className={className}
271
+ data-slot="autocomplete-trigger"
272
+ {...props}
273
+ />
274
+ );
275
+ }
276
+
277
+ const useAutocompleteFilter = AutocompletePrimitive.useFilter;
278
+
279
+ export {
280
+ Autocomplete,
281
+ AutocompleteInput,
282
+ AutocompleteTrigger,
283
+ AutocompletePopup,
284
+ AutocompleteItem,
285
+ AutocompleteSeparator,
286
+ AutocompleteGroup,
287
+ AutocompleteGroupLabel,
288
+ AutocompleteEmpty,
289
+ AutocompleteValue,
290
+ AutocompleteList,
291
+ AutocompleteClear,
292
+ AutocompleteStatus,
293
+ AutocompleteRow,
294
+ AutocompleteCollection,
295
+ useAutocompleteFilter,
296
+ };
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import type * as React from "react";
5
+
6
+ import { cn } from "../../lib/utils/css";
7
+ import { Avatar, avatarVariants } from "./avatar";
8
+
9
+ type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>;
10
+
11
+ const avatarGroupVariants = cva(
12
+ // Each child gets a ring in the page color so overlapping avatars stay
13
+ // visually separated; hover lifts one to the front.
14
+ "isolate flex items-center *:relative *:rounded-full *:ring-2 *:ring-background *:transition-transform hover:*:z-10",
15
+ {
16
+ defaultVariants: { overlap: "md" },
17
+ variants: {
18
+ overlap: {
19
+ sm: "*:not-first:-ml-1.5",
20
+ md: "*:not-first:-ml-2.5",
21
+ lg: "*:not-first:-ml-3.5",
22
+ },
23
+ },
24
+ },
25
+ );
26
+
27
+ interface AvatarGroupItem {
28
+ src?: string;
29
+ /** Initials shown when there is no image. */
30
+ initials?: React.ReactNode;
31
+ alt?: string;
32
+ }
33
+
34
+ interface AvatarGroupProps
35
+ extends React.ComponentProps<"div">,
36
+ VariantProps<typeof avatarGroupVariants> {
37
+ /** Data-driven avatars. Omit to compose `<Avatar>` children yourself. */
38
+ items?: AvatarGroupItem[];
39
+ /** Cap the visible avatars; the rest collapse into a "+N" chip. */
40
+ max?: number;
41
+ size?: AvatarSize;
42
+ }
43
+
44
+ /**
45
+ * A row of overlapping avatars with an overflow "+N" chip. Pass `items` for the
46
+ * data-driven path, or compose `<Avatar>` children directly for full control.
47
+ */
48
+ function AvatarGroup({
49
+ className,
50
+ items,
51
+ max,
52
+ size = "sm",
53
+ overlap,
54
+ children,
55
+ ...props
56
+ }: AvatarGroupProps) {
57
+ const shown = items && max ? items.slice(0, max) : items;
58
+ const overflow = items && max ? items.length - max : 0;
59
+
60
+ return (
61
+ <div
62
+ className={cn(avatarGroupVariants({ overlap }), className)}
63
+ data-slot="avatar-group"
64
+ role="group"
65
+ {...props}
66
+ >
67
+ {items
68
+ ? shown?.map((item, i) => (
69
+ <Avatar
70
+ alt={item.alt}
71
+ // biome-ignore lint/suspicious/noArrayIndexKey: order is stable
72
+ key={i}
73
+ initials={item.initials}
74
+ size={size}
75
+ src={item.src}
76
+ />
77
+ ))
78
+ : children}
79
+ {overflow > 0 ? (
80
+ <span
81
+ aria-label={`${overflow} more`}
82
+ className={cn(
83
+ avatarVariants({ size }),
84
+ "bg-muted font-medium text-muted-foreground",
85
+ )}
86
+ data-slot="avatar-group-overflow"
87
+ >
88
+ +{overflow}
89
+ </span>
90
+ ) : null}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export { AvatarGroup, avatarGroupVariants };
@@ -0,0 +1,285 @@
1
+ "use client";
2
+
3
+ import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { CheckIcon, PlusIcon, UserRoundIcon } from "lucide-react";
6
+ import type * as React from "react";
7
+
8
+ import { cn } from "../../lib/utils/css";
9
+
10
+ const avatarVariants = cva(
11
+ "relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-muted align-middle font-medium",
12
+ {
13
+ defaultVariants: { size: "sm" },
14
+ variants: {
15
+ size: {
16
+ xs: "size-6 text-[0.625rem]",
17
+ sm: "size-8 text-xs",
18
+ md: "size-10 text-sm",
19
+ lg: "size-12 text-base",
20
+ xl: "size-14 text-lg",
21
+ "2xl": "size-16 text-xl",
22
+ },
23
+ },
24
+ },
25
+ );
26
+
27
+ type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>;
28
+
29
+ interface AvatarProps
30
+ extends AvatarPrimitive.Root.Props,
31
+ VariantProps<typeof avatarVariants> {
32
+ /** Image URL. When omitted or it fails to load, the fallback is shown. */
33
+ src?: string;
34
+ alt?: string;
35
+ /** Initials shown when there is no image. */
36
+ initials?: React.ReactNode;
37
+ /** Custom fallback node (overrides initials and the placeholder icon). */
38
+ placeholder?: React.ReactNode;
39
+ /** Icon component used as the fallback when nothing else is provided. */
40
+ placeholderIcon?: React.ElementType;
41
+ /** A presence dot in the bottom-right corner. */
42
+ status?: "online" | "offline";
43
+ /** Shows a verified tick in the bottom-right corner. */
44
+ verified?: boolean;
45
+ /** A node (e.g. <AvatarCompanyIcon />) shown in the top-right corner. */
46
+ badge?: React.ReactNode;
47
+ }
48
+
49
+ function Avatar({
50
+ className,
51
+ size,
52
+ src,
53
+ alt,
54
+ initials,
55
+ placeholder,
56
+ placeholderIcon: PlaceholderIcon = UserRoundIcon,
57
+ status,
58
+ verified,
59
+ badge,
60
+ children,
61
+ ...props
62
+ }: AvatarProps) {
63
+ const fallback = initials ? (
64
+ initials
65
+ ) : (
66
+ (placeholder ?? <PlaceholderIcon className="size-[55%] text-muted-foreground" />)
67
+ );
68
+
69
+ const root = (
70
+ <AvatarPrimitive.Root
71
+ className={cn(avatarVariants({ size }), className)}
72
+ data-slot="avatar"
73
+ {...props}
74
+ >
75
+ {children ?? (
76
+ <>
77
+ {src ? <AvatarImage alt={alt} src={src} /> : null}
78
+ <AvatarFallback>{fallback}</AvatarFallback>
79
+ </>
80
+ )}
81
+ </AvatarPrimitive.Root>
82
+ );
83
+
84
+ if (!(status || verified || badge)) return root;
85
+
86
+ // Overlays live in a non-clipping wrapper so the rounded avatar's
87
+ // overflow-hidden doesn't cut them off at the corners.
88
+ return (
89
+ <span
90
+ className="relative inline-flex shrink-0 align-middle"
91
+ data-slot="avatar-root"
92
+ >
93
+ {root}
94
+ {badge ? (
95
+ <span className="absolute end-0 top-0 flex" data-slot="avatar-badge">
96
+ {badge}
97
+ </span>
98
+ ) : null}
99
+ {status ? (
100
+ <span
101
+ className="absolute end-[6%] bottom-[6%] h-1/4 w-1/4 rounded-full ring-2 ring-background data-[status=offline]:bg-muted-foreground/40 data-[status=online]:bg-success"
102
+ data-slot="avatar-status"
103
+ data-status={status}
104
+ />
105
+ ) : null}
106
+ {verified ? (
107
+ <span
108
+ className="absolute end-0 bottom-0 flex h-1/3 w-1/3 items-center justify-center rounded-full bg-brand text-background ring-2 ring-background"
109
+ data-slot="avatar-verified"
110
+ >
111
+ <CheckIcon className="size-2/3" strokeWidth={3} />
112
+ </span>
113
+ ) : null}
114
+ </span>
115
+ );
116
+ }
117
+
118
+ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
119
+ return (
120
+ <AvatarPrimitive.Image
121
+ className={cn("size-full object-cover", className)}
122
+ data-slot="avatar-image"
123
+ {...props}
124
+ />
125
+ );
126
+ }
127
+
128
+ function AvatarFallback({
129
+ className,
130
+ ...props
131
+ }: AvatarPrimitive.Fallback.Props) {
132
+ return (
133
+ <AvatarPrimitive.Fallback
134
+ className={cn(
135
+ "flex size-full items-center justify-center rounded-[inherit] bg-muted text-muted-foreground",
136
+ className,
137
+ )}
138
+ data-slot="avatar-fallback"
139
+ {...props}
140
+ />
141
+ );
142
+ }
143
+
144
+ /** A small company/brand logo badge, meant for the Avatar `badge` prop. */
145
+ function AvatarCompanyIcon({
146
+ className,
147
+ size = "md",
148
+ alt,
149
+ ...props
150
+ }: React.ComponentProps<"img"> & { size?: "sm" | "md" | "lg" }) {
151
+ const sizes = { lg: "size-5", md: "size-4", sm: "size-3.5" } as const;
152
+ return (
153
+ // biome-ignore lint/a11y/useAltText: alt forwarded via props
154
+ <img
155
+ alt={alt}
156
+ className={cn(
157
+ "rounded-[4px] object-cover ring-2 ring-background",
158
+ sizes[size],
159
+ className,
160
+ )}
161
+ data-slot="avatar-company-icon"
162
+ {...props}
163
+ />
164
+ );
165
+ }
166
+
167
+ /** A dashed "add" button sized to match avatars — for avatar groups. */
168
+ function AvatarAddButton({
169
+ className,
170
+ size = "sm",
171
+ ...props
172
+ }: React.ComponentProps<"button"> & { size?: AvatarSize }) {
173
+ return (
174
+ <button
175
+ className={cn(
176
+ avatarVariants({ size }),
177
+ "border border-input border-dashed bg-transparent text-muted-foreground outline-none transition-colors hover:border-transparent hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
178
+ className,
179
+ )}
180
+ data-slot="avatar-add-button"
181
+ type="button"
182
+ {...props}
183
+ >
184
+ <PlusIcon className="size-1/2" />
185
+ </button>
186
+ );
187
+ }
188
+
189
+ const labelGroupText = {
190
+ lg: { subtitle: "text-sm", title: "text-base" },
191
+ md: { subtitle: "text-sm", title: "text-sm" },
192
+ sm: { subtitle: "text-xs", title: "text-sm" },
193
+ } as const;
194
+
195
+ /** An avatar paired with a title and subtitle. */
196
+ function AvatarLabelGroup({
197
+ className,
198
+ size = "md",
199
+ src,
200
+ alt,
201
+ initials,
202
+ status,
203
+ verified,
204
+ title,
205
+ subtitle,
206
+ ...props
207
+ }: React.ComponentProps<"div"> & {
208
+ size?: "sm" | "md" | "lg";
209
+ src?: string;
210
+ alt?: string;
211
+ initials?: string;
212
+ status?: "online" | "offline";
213
+ verified?: boolean;
214
+ title: React.ReactNode;
215
+ subtitle?: React.ReactNode;
216
+ }) {
217
+ return (
218
+ <div
219
+ className={cn("flex items-center gap-3", className)}
220
+ data-slot="avatar-label-group"
221
+ {...props}
222
+ >
223
+ <Avatar
224
+ alt={alt}
225
+ initials={initials}
226
+ size={size}
227
+ src={src}
228
+ status={status}
229
+ verified={verified}
230
+ />
231
+ <div className="flex min-w-0 flex-col">
232
+ <span
233
+ className={cn(
234
+ "truncate font-medium text-foreground leading-tight",
235
+ labelGroupText[size].title,
236
+ )}
237
+ >
238
+ {title}
239
+ </span>
240
+ {subtitle ? (
241
+ <span
242
+ className={cn(
243
+ "truncate text-muted-foreground leading-tight",
244
+ labelGroupText[size].subtitle,
245
+ )}
246
+ >
247
+ {subtitle}
248
+ </span>
249
+ ) : null}
250
+ </div>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ const profilePhotoSizes = { lg: "size-20", md: "size-16", sm: "size-14" } as const;
256
+
257
+ /** A larger, framed profile photo with optional verified tick. */
258
+ function AvatarProfilePhoto({
259
+ className,
260
+ size = "md",
261
+ ...props
262
+ }: Omit<AvatarProps, "size"> & { size?: "sm" | "md" | "lg" }) {
263
+ return (
264
+ <Avatar
265
+ className={cn(
266
+ "ring-1 ring-black/8 ring-inset dark:ring-white/12",
267
+ profilePhotoSizes[size],
268
+ className,
269
+ )}
270
+ data-slot="avatar-profile-photo"
271
+ {...props}
272
+ />
273
+ );
274
+ }
275
+
276
+ export {
277
+ Avatar,
278
+ AvatarImage,
279
+ AvatarFallback,
280
+ AvatarCompanyIcon,
281
+ AvatarAddButton,
282
+ AvatarLabelGroup,
283
+ AvatarProfilePhoto,
284
+ avatarVariants,
285
+ };