@momentumworld/ui-react 0.17.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 (138) hide show
  1. package/dist/chunk-2HQPCV2L.js +895 -0
  2. package/dist/chunk-2HQPCV2L.js.map +1 -0
  3. package/dist/form.d.ts +175 -0
  4. package/dist/form.js +356 -0
  5. package/dist/form.js.map +1 -0
  6. package/dist/index.d.ts +2162 -0
  7. package/dist/index.js +11640 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/input-JCZs61MO.d.ts +24 -0
  10. package/package.json +90 -0
  11. package/src/components/form/checkbox-group-field.tsx +101 -0
  12. package/src/components/form/date-field.tsx +79 -0
  13. package/src/components/form/date-range-field.tsx +106 -0
  14. package/src/components/form/form-context.ts +10 -0
  15. package/src/components/form/form.tsx +54 -0
  16. package/src/components/form/number-field.tsx +69 -0
  17. package/src/components/form/select-field.tsx +76 -0
  18. package/src/components/form/submit-button.tsx +28 -0
  19. package/src/components/form/text-field.tsx +107 -0
  20. package/src/components/layout/dashboard-header.tsx +54 -0
  21. package/src/components/layout/dashboard-panel.tsx +34 -0
  22. package/src/components/layout/dashboard-topbar.tsx +221 -0
  23. package/src/components/theme-provider.tsx +403 -0
  24. package/src/components/ui/accordion.tsx +69 -0
  25. package/src/components/ui/alert-dialog.tsx +169 -0
  26. package/src/components/ui/alert.tsx +80 -0
  27. package/src/components/ui/animated-counter.tsx +109 -0
  28. package/src/components/ui/animated-theme-toggler.tsx +265 -0
  29. package/src/components/ui/app-store-buttons.tsx +182 -0
  30. package/src/components/ui/aspect-ratio.tsx +23 -0
  31. package/src/components/ui/aurora-orb.tsx +163 -0
  32. package/src/components/ui/aurora-text.tsx +76 -0
  33. package/src/components/ui/autocomplete.tsx +296 -0
  34. package/src/components/ui/avatar-group.tsx +150 -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/chat-divider.tsx +67 -0
  45. package/src/components/ui/chat.tsx +494 -0
  46. package/src/components/ui/checkbox-group.tsx +16 -0
  47. package/src/components/ui/checkbox.tsx +82 -0
  48. package/src/components/ui/collapsible.tsx +45 -0
  49. package/src/components/ui/color-picker.tsx +389 -0
  50. package/src/components/ui/combobox.tsx +411 -0
  51. package/src/components/ui/command.tsx +264 -0
  52. package/src/components/ui/confetti.tsx +196 -0
  53. package/src/components/ui/context-menu.tsx +271 -0
  54. package/src/components/ui/context-meter.tsx +134 -0
  55. package/src/components/ui/credit-card.tsx +214 -0
  56. package/src/components/ui/data-table-paged.tsx +163 -0
  57. package/src/components/ui/date-picker.tsx +154 -0
  58. package/src/components/ui/dialog.tsx +196 -0
  59. package/src/components/ui/drawer.tsx +135 -0
  60. package/src/components/ui/empty.tsx +127 -0
  61. package/src/components/ui/fab.tsx +45 -0
  62. package/src/components/ui/featured-icon.tsx +149 -0
  63. package/src/components/ui/field.tsx +88 -0
  64. package/src/components/ui/fieldset.tsx +29 -0
  65. package/src/components/ui/form.tsx +17 -0
  66. package/src/components/ui/frame.tsx +82 -0
  67. package/src/components/ui/generic-empty.tsx +142 -0
  68. package/src/components/ui/group.tsx +97 -0
  69. package/src/components/ui/help-tip.tsx +94 -0
  70. package/src/components/ui/horizontal-scroll-fader.tsx +228 -0
  71. package/src/components/ui/input-group.tsx +102 -0
  72. package/src/components/ui/input-otp.tsx +96 -0
  73. package/src/components/ui/input.tsx +66 -0
  74. package/src/components/ui/item.tsx +198 -0
  75. package/src/components/ui/kbd.tsx +30 -0
  76. package/src/components/ui/label.tsx +28 -0
  77. package/src/components/ui/markdown.tsx +188 -0
  78. package/src/components/ui/menu.tsx +312 -0
  79. package/src/components/ui/menubar.tsx +93 -0
  80. package/src/components/ui/meter.tsx +67 -0
  81. package/src/components/ui/multi-select.tsx +308 -0
  82. package/src/components/ui/navigation-menu.tsx +143 -0
  83. package/src/components/ui/number-field.tsx +160 -0
  84. package/src/components/ui/pagination-controls.tsx +74 -0
  85. package/src/components/ui/pagination.tsx +149 -0
  86. package/src/components/ui/pipeline.tsx +102 -0
  87. package/src/components/ui/popover.tsx +119 -0
  88. package/src/components/ui/preview-card.tsx +55 -0
  89. package/src/components/ui/progress.tsx +289 -0
  90. package/src/components/ui/qr-code.tsx +150 -0
  91. package/src/components/ui/radio-group.tsx +103 -0
  92. package/src/components/ui/resizable.tsx +56 -0
  93. package/src/components/ui/scroll-area.tsx +90 -0
  94. package/src/components/ui/scroller.tsx +38 -0
  95. package/src/components/ui/section-header.tsx +118 -0
  96. package/src/components/ui/segmented-control.tsx +83 -0
  97. package/src/components/ui/select.tsx +181 -0
  98. package/src/components/ui/separator.tsx +23 -0
  99. package/src/components/ui/sheet.tsx +224 -0
  100. package/src/components/ui/sidebar.tsx +774 -0
  101. package/src/components/ui/skeleton.tsx +16 -0
  102. package/src/components/ui/slider.tsx +108 -0
  103. package/src/components/ui/smooth-scroll.tsx +206 -0
  104. package/src/components/ui/social-button.tsx +247 -0
  105. package/src/components/ui/spinner-on-demand.tsx +32 -0
  106. package/src/components/ui/spinner.tsx +18 -0
  107. package/src/components/ui/stat.tsx +187 -0
  108. package/src/components/ui/stepper.tsx +167 -0
  109. package/src/components/ui/switch.tsx +56 -0
  110. package/src/components/ui/table.tsx +126 -0
  111. package/src/components/ui/tabs.tsx +90 -0
  112. package/src/components/ui/tag.tsx +242 -0
  113. package/src/components/ui/target-countdown.tsx +46 -0
  114. package/src/components/ui/text-editor.tsx +313 -0
  115. package/src/components/ui/textarea.tsx +51 -0
  116. package/src/components/ui/thinking-orb.tsx +6 -0
  117. package/src/components/ui/time-picker.tsx +308 -0
  118. package/src/components/ui/timeline.tsx +116 -0
  119. package/src/components/ui/toast.tsx +268 -0
  120. package/src/components/ui/toggle-group.tsx +101 -0
  121. package/src/components/ui/toggle.tsx +45 -0
  122. package/src/components/ui/toolbar.tsx +89 -0
  123. package/src/components/ui/tooltip.tsx +102 -0
  124. package/src/components/ui/vertical-scroll-fader.tsx +250 -0
  125. package/src/components/ui/video-player.tsx +275 -0
  126. package/src/components/upload/avatar-upload-base.tsx +131 -0
  127. package/src/components/upload/image-upload-base.tsx +112 -0
  128. package/src/form.ts +17 -0
  129. package/src/index.ts +143 -0
  130. package/src/lib/hooks/use-callback-ref.ts +15 -0
  131. package/src/lib/hooks/use-first-render.ts +11 -0
  132. package/src/lib/hooks/use-hover.ts +53 -0
  133. package/src/lib/hooks/use-is-tab-active.ts +17 -0
  134. package/src/lib/hooks/use-media-query.ts +164 -0
  135. package/src/lib/utils/css.ts +6 -0
  136. package/src/styles.css +329 -0
  137. package/src/types/helpers.ts +24 -0
  138. package/src/types/react.d.ts +7 -0
@@ -0,0 +1,308 @@
1
+ "use client";
2
+
3
+ import { format } from "date-fns";
4
+ import { CalendarClockIcon, ClockIcon } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ import { Button } from "./button";
8
+ import { Calendar } from "./calendar";
9
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
10
+ import { cn } from "../../lib/utils/css";
11
+
12
+ /**
13
+ * A time of day is a STRING here, not a `Date`.
14
+ *
15
+ * "Nine in the morning, every Tuesday" has no date, and giving it one forces every
16
+ * caller to invent a day and then remember to ignore it — which is how a weekly
17
+ * opening hour ends up shifted by a timezone that was never part of the question.
18
+ * `TimePicker` therefore speaks `"HH:mm"`, zero-padded and 24-hour, which is also
19
+ * what a DRF `TimeField` parses. `DateTimePicker` below takes a real `Date`, because
20
+ * an instant genuinely is one.
21
+ */
22
+ type TimeValue = string;
23
+
24
+ /** `"09:30"` → 570. Anything unparseable is `null` — read as "no time yet". */
25
+ function toMinutes(value?: TimeValue | null): number | null {
26
+ if (!value) return null;
27
+ const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
28
+ if (!match) return null;
29
+ const hours = Number(match[1]);
30
+ const minutes = Number(match[2]);
31
+ if (hours > 23 || minutes > 59) return null;
32
+ return hours * 60 + minutes;
33
+ }
34
+
35
+ /** 570 → `"09:30"`. The value on the wire. */
36
+ function toValue(minutes: number): TimeValue {
37
+ return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
38
+ }
39
+
40
+ /** 570 → `"9:30 AM"`, or `"09:30"` on a 24-hour clock. What a person reads. */
41
+ function toLabel(minutes: number, hour12: boolean): string {
42
+ if (!hour12) return toValue(minutes);
43
+ const hours = Math.floor(minutes / 60);
44
+ const twelve = hours % 12 === 0 ? 12 : hours % 12;
45
+ return `${twelve}:${String(minutes % 60).padStart(2, "0")} ${hours < 12 ? "AM" : "PM"}`;
46
+ }
47
+
48
+ /** Every offered time, as minutes past midnight. */
49
+ function timeOptions(step: number, min?: TimeValue, max?: TimeValue): number[] {
50
+ const size = Math.max(1, Math.round(step));
51
+ const first = toMinutes(min) ?? 0;
52
+ const last = toMinutes(max) ?? 24 * 60 - 1;
53
+ const out: number[] = [];
54
+ for (let at = Math.ceil(first / size) * size; at <= last; at += size) out.push(at);
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * The list of times inside a popover.
60
+ *
61
+ * A plain `overflow-y-auto` rather than `ScrollArea`: this list opens scrolled to the
62
+ * current value, and setting `scrollTop` on an element we own is exact. The obvious
63
+ * alternative, `scrollIntoView`, walks up the tree and will scroll the dialog behind
64
+ * the popover along with the list inside it.
65
+ */
66
+ function TimeList({
67
+ hour12,
68
+ onSelect,
69
+ options,
70
+ selected,
71
+ }: {
72
+ hour12: boolean;
73
+ onSelect: (minutes: number) => void;
74
+ options: number[];
75
+ selected: number | null;
76
+ }) {
77
+ const listRef = React.useRef<HTMLDivElement>(null);
78
+ const activeRef = React.useRef<HTMLButtonElement>(null);
79
+
80
+ React.useEffect(() => {
81
+ const list = listRef.current;
82
+ const active = activeRef.current;
83
+ if (!list || !active) return;
84
+ list.scrollTop = active.offsetTop - list.clientHeight / 2 + active.clientHeight / 2;
85
+ }, []);
86
+
87
+ return (
88
+ <div
89
+ className="max-h-64 w-32 shrink-0 overflow-y-auto p-1"
90
+ data-slot="time-picker-list"
91
+ ref={listRef}
92
+ role="listbox"
93
+ >
94
+ {options.map((minutes) => {
95
+ const isSelected = minutes === selected;
96
+ return (
97
+ <button
98
+ aria-selected={isSelected}
99
+ className={cn(
100
+ "flex w-full items-center rounded-lg px-2.5 py-1.5 text-left text-base sm:text-sm text-foreground outline-none",
101
+ "hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-[3px]",
102
+ isSelected && "bg-primary text-primary-foreground hover:bg-primary",
103
+ )}
104
+ key={minutes}
105
+ onClick={() => onSelect(minutes)}
106
+ ref={isSelected ? activeRef : undefined}
107
+ role="option"
108
+ type="button"
109
+ >
110
+ {toLabel(minutes, hour12)}
111
+ </button>
112
+ );
113
+ })}
114
+ {options.length === 0 ? (
115
+ <p className="px-2.5 py-1.5 text-sm text-muted-foreground">No time in range.</p>
116
+ ) : null}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ interface TimePickerProps {
122
+ /** The selected time as `"HH:mm"` (controlled). */
123
+ value?: TimeValue | null;
124
+ onValueChange?: (value: TimeValue | undefined) => void;
125
+ placeholder?: string;
126
+ disabled?: boolean;
127
+ /** Minutes between offered times. 15 by default; 5 and 30 are the other useful ones. */
128
+ step?: number;
129
+ /** Bounds, inclusive, as `"HH:mm"`. */
130
+ min?: TimeValue;
131
+ max?: TimeValue;
132
+ /** `false` shows a 24-hour clock. The value on the wire is 24-hour either way. */
133
+ hour12?: boolean;
134
+ align?: "start" | "center" | "end";
135
+ className?: string;
136
+ id?: string;
137
+ "aria-label"?: string;
138
+ }
139
+
140
+ /**
141
+ * A time picker: a button that opens a list of times in a popover. The canonical way
142
+ * to take a time of day in this system — never a native `<input type="time">`, which
143
+ * draws the *browser's* control (light in dark mode, its own glyph, its own idea of a
144
+ * locale) in the middle of one of our forms, and asks somebody to type `--:-- --`.
145
+ *
146
+ * Deliberately a fixed list rather than two spinners: the times these are used for are
147
+ * office hours and appointment slots, which land on the quarter hour, and a list can be
148
+ * read and clicked in one gesture. Pass `step={5}` where a finer time is real.
149
+ */
150
+ function TimePicker({
151
+ value,
152
+ onValueChange,
153
+ placeholder = "Pick a time",
154
+ disabled,
155
+ step = 15,
156
+ min,
157
+ max,
158
+ hour12 = true,
159
+ align = "start",
160
+ className,
161
+ id,
162
+ ...props
163
+ }: TimePickerProps) {
164
+ const [open, setOpen] = React.useState(false);
165
+ const selected = toMinutes(value);
166
+ const options = React.useMemo(() => timeOptions(step, min, max), [step, min, max]);
167
+
168
+ return (
169
+ <Popover onOpenChange={setOpen} open={open}>
170
+ <PopoverTrigger
171
+ render={
172
+ <Button
173
+ aria-label={props["aria-label"]}
174
+ className={cn("w-full justify-start font-normal", selected === null && "text-muted-foreground", className)}
175
+ disabled={disabled}
176
+ id={id}
177
+ variant="outline"
178
+ />
179
+ }
180
+ >
181
+ <ClockIcon aria-hidden />
182
+ {selected === null ? <span>{placeholder}</span> : toLabel(selected, hour12)}
183
+ </PopoverTrigger>
184
+ <PopoverContent align={align} className="w-auto p-0" data-slot="time-picker">
185
+ {open ? (
186
+ <TimeList
187
+ hour12={hour12}
188
+ onSelect={(minutes) => {
189
+ onValueChange?.(toValue(minutes));
190
+ setOpen(false);
191
+ }}
192
+ options={options}
193
+ selected={selected}
194
+ />
195
+ ) : null}
196
+ </PopoverContent>
197
+ </Popover>
198
+ );
199
+ }
200
+
201
+ interface DateTimePickerProps {
202
+ /** The selected instant (controlled). */
203
+ value?: Date;
204
+ onValueChange?: (date: Date | undefined) => void;
205
+ placeholder?: string;
206
+ disabled?: boolean;
207
+ fromDate?: Date;
208
+ toDate?: Date;
209
+ step?: number;
210
+ hour12?: boolean;
211
+ /** The time a day picked before any time gets. */
212
+ defaultTime?: TimeValue;
213
+ /** date-fns format for the date half of the trigger label. */
214
+ displayFormat?: string;
215
+ align?: "start" | "center" | "end";
216
+ className?: string;
217
+ id?: string;
218
+ "aria-label"?: string;
219
+ }
220
+
221
+ /**
222
+ * A date *and* a time, in one popover: the calendar beside the list of times.
223
+ *
224
+ * One control and not two fields, because the two halves are one answer — "from when"
225
+ * — and two controls make it possible to submit half of it. Picking a day keeps the
226
+ * time already chosen (or `defaultTime`), and picking a time keeps the day, so the
227
+ * order they are pressed in does not matter.
228
+ */
229
+ function DateTimePicker({
230
+ value,
231
+ onValueChange,
232
+ placeholder = "Pick a date and time",
233
+ disabled,
234
+ fromDate,
235
+ toDate,
236
+ step = 15,
237
+ hour12 = true,
238
+ defaultTime = "09:00",
239
+ displayFormat = "LLL dd, y",
240
+ align = "start",
241
+ className,
242
+ id,
243
+ ...props
244
+ }: DateTimePickerProps) {
245
+ const [open, setOpen] = React.useState(false);
246
+ const options = React.useMemo(() => timeOptions(step), [step]);
247
+ const selectedMinutes = value ? value.getHours() * 60 + value.getMinutes() : null;
248
+
249
+ /** A new instant from a day and a time, without ever going through UTC. */
250
+ const combine = (day: Date, minutes: number): Date => {
251
+ const next = new Date(day);
252
+ next.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
253
+ return next;
254
+ };
255
+
256
+ return (
257
+ <Popover onOpenChange={setOpen} open={open}>
258
+ <PopoverTrigger
259
+ render={
260
+ <Button
261
+ aria-label={props["aria-label"]}
262
+ className={cn("w-full justify-start font-normal", !value && "text-muted-foreground", className)}
263
+ disabled={disabled}
264
+ id={id}
265
+ variant="outline"
266
+ />
267
+ }
268
+ >
269
+ <CalendarClockIcon aria-hidden />
270
+ {value && selectedMinutes !== null ? (
271
+ `${format(value, displayFormat)} · ${toLabel(selectedMinutes, hour12)}`
272
+ ) : (
273
+ <span>{placeholder}</span>
274
+ )}
275
+ </PopoverTrigger>
276
+ <PopoverContent align={align} className="w-auto p-0" data-slot="date-time-picker">
277
+ <div className="flex divide-x divide-border">
278
+ <Calendar
279
+ autoFocus
280
+ disabled={
281
+ fromDate || toDate
282
+ ? [...(fromDate ? [{ before: fromDate }] : []), ...(toDate ? [{ after: toDate }] : [])]
283
+ : undefined
284
+ }
285
+ mode="single"
286
+ onSelect={(day) =>
287
+ onValueChange?.(day ? combine(day, selectedMinutes ?? toMinutes(defaultTime) ?? 0) : undefined)
288
+ }
289
+ selected={value}
290
+ />
291
+ {open ? (
292
+ <TimeList
293
+ hour12={hour12}
294
+ onSelect={(minutes) => {
295
+ onValueChange?.(combine(value ?? new Date(), minutes));
296
+ setOpen(false);
297
+ }}
298
+ options={options}
299
+ selected={selectedMinutes}
300
+ />
301
+ ) : null}
302
+ </div>
303
+ </PopoverContent>
304
+ </Popover>
305
+ );
306
+ }
307
+
308
+ export { DateTimePicker, TimePicker, type DateTimePickerProps, type TimePickerProps, type TimeValue };
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../lib/utils/css";
6
+
7
+ function Timeline({ className, ...props }: React.ComponentProps<"ol">) {
8
+ return (
9
+ <ol
10
+ className={cn(
11
+ "flex flex-col [&>li:last-child_[data-slot=timeline-connector]]:hidden",
12
+ className,
13
+ )}
14
+ data-slot="timeline"
15
+ {...props}
16
+ />
17
+ );
18
+ }
19
+
20
+ function TimelineItem({ className, ...props }: React.ComponentProps<"li">) {
21
+ return (
22
+ <li
23
+ className={cn("group/timeline-item flex gap-4", className)}
24
+ data-slot="timeline-item"
25
+ {...props}
26
+ />
27
+ );
28
+ }
29
+
30
+ /** The marker column: a dot and the connecting line beneath it. */
31
+ function TimelineDot({
32
+ className,
33
+ children,
34
+ ...props
35
+ }: React.ComponentProps<"div">) {
36
+ return (
37
+ <div
38
+ className="flex w-7 shrink-0 flex-col items-center"
39
+ data-slot="timeline-marker"
40
+ >
41
+ <div
42
+ className={cn(
43
+ "mt-0.5 flex size-3 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-background [&_svg]:size-3.5 has-[svg]:mt-0 has-[svg]:size-7 has-[svg]:border-transparent has-[svg]:bg-primary has-[svg]:text-primary-foreground",
44
+ className,
45
+ )}
46
+ data-slot="timeline-dot"
47
+ {...props}
48
+ >
49
+ {children}
50
+ </div>
51
+ <div
52
+ className="my-1 w-0.5 flex-1 rounded-full bg-border"
53
+ data-slot="timeline-connector"
54
+ />
55
+ </div>
56
+ );
57
+ }
58
+
59
+ function TimelineContent({ className, ...props }: React.ComponentProps<"div">) {
60
+ return (
61
+ <div
62
+ className={cn(
63
+ "flex flex-1 flex-col gap-1 pb-8 group-last/timeline-item:pb-0",
64
+ className,
65
+ )}
66
+ data-slot="timeline-content"
67
+ {...props}
68
+ />
69
+ );
70
+ }
71
+
72
+ function TimelineTitle({ className, ...props }: React.ComponentProps<"div">) {
73
+ return (
74
+ <div
75
+ className={cn("font-medium text-sm leading-tight", className)}
76
+ data-slot="timeline-title"
77
+ {...props}
78
+ />
79
+ );
80
+ }
81
+
82
+ function TimelineTime({ className, ...props }: React.ComponentProps<"div">) {
83
+ return (
84
+ <div
85
+ className={cn(
86
+ "font-medium text-muted-foreground text-xs tabular-nums",
87
+ className,
88
+ )}
89
+ data-slot="timeline-time"
90
+ {...props}
91
+ />
92
+ );
93
+ }
94
+
95
+ function TimelineDescription({
96
+ className,
97
+ ...props
98
+ }: React.ComponentProps<"div">) {
99
+ return (
100
+ <div
101
+ className={cn("text-muted-foreground text-sm", className)}
102
+ data-slot="timeline-description"
103
+ {...props}
104
+ />
105
+ );
106
+ }
107
+
108
+ export {
109
+ Timeline,
110
+ TimelineItem,
111
+ TimelineDot,
112
+ TimelineContent,
113
+ TimelineTitle,
114
+ TimelineTime,
115
+ TimelineDescription,
116
+ };
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import { Toast } from "@base-ui/react/toast";
4
+ import {
5
+ CircleAlertIcon,
6
+ CircleCheckIcon,
7
+ InfoIcon,
8
+ LoaderCircleIcon,
9
+ TriangleAlertIcon,
10
+ } from "lucide-react";
11
+ import { buttonVariants } from "./button";
12
+ import { cn } from "../../lib/utils/css";
13
+
14
+ const toastManager = Toast.createToastManager();
15
+ const anchoredToastManager = Toast.createToastManager();
16
+
17
+ const TOAST_ICONS = {
18
+ error: CircleAlertIcon,
19
+ info: InfoIcon,
20
+ loading: LoaderCircleIcon,
21
+ success: CircleCheckIcon,
22
+ warning: TriangleAlertIcon,
23
+ } as const;
24
+
25
+ type ToastPosition =
26
+ | "top-left"
27
+ | "top-center"
28
+ | "top-right"
29
+ | "bottom-left"
30
+ | "bottom-center"
31
+ | "bottom-right";
32
+
33
+ interface ToastProviderProps extends Toast.Provider.Props {
34
+ position?: ToastPosition;
35
+ }
36
+
37
+ function ToastProvider({
38
+ children,
39
+ position = "bottom-right",
40
+ ...props
41
+ }: ToastProviderProps) {
42
+ return (
43
+ <Toast.Provider toastManager={toastManager} {...props}>
44
+ {children}
45
+ <Toasts position={position} />
46
+ </Toast.Provider>
47
+ );
48
+ }
49
+
50
+ function Toasts({ position = "bottom-right" }: { position: ToastPosition }) {
51
+ const { toasts } = Toast.useToastManager();
52
+ const isTop = position.startsWith("top");
53
+
54
+ return (
55
+ <Toast.Portal data-slot="toast-portal">
56
+ <Toast.Viewport
57
+ className={cn(
58
+ "fixed z-60 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
59
+ // Vertical positioning
60
+ "data-[position*=top]:top-(--toast-inset)",
61
+ "data-[position*=bottom]:bottom-(--toast-inset)",
62
+ // Horizontal positioning
63
+ "data-[position*=left]:left-(--toast-inset)",
64
+ "data-[position*=right]:right-(--toast-inset)",
65
+ "data-[position*=center]:-translate-x-1/2 data-[position*=center]:left-1/2",
66
+ )}
67
+ data-position={position}
68
+ data-slot="toast-viewport"
69
+ >
70
+ {toasts.map((toast) => {
71
+ const Icon = toast.type
72
+ ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS]
73
+ : null;
74
+
75
+ return (
76
+ <Toast.Root
77
+ className={cn(
78
+ "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
79
+ // Base positioning using data-position
80
+ "data-[position*=right]:right-0 data-[position*=right]:left-auto",
81
+ "data-[position*=left]:right-auto data-[position*=left]:left-0",
82
+ "data-[position*=center]:right-0 data-[position*=center]:left-0",
83
+ "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
84
+ "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
85
+ // Gap fill for hover
86
+ "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
87
+ "data-[position*=top]:after:top-full",
88
+ "data-[position*=bottom]:after:bottom-full",
89
+ // Define some variables
90
+ "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
91
+ // Define offset-y variable
92
+ "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
93
+ "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
94
+ // Default state transform
95
+ "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
96
+ "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
97
+ // Limited state
98
+ "data-limited:opacity-0",
99
+ // Expanded state
100
+ "data-expanded:h-(--toast-height)",
101
+ "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
102
+ // Starting and ending animations
103
+ "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
104
+ "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
105
+ "data-ending-style:opacity-0",
106
+ // Ending animations (direction-aware)
107
+ "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
108
+ "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
109
+ "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
110
+ "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
111
+ "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
112
+ // Ending animations (expanded)
113
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
114
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
115
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
116
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
117
+ )}
118
+ data-position={position}
119
+ key={toast.id}
120
+ swipeDirection={
121
+ position.includes("center")
122
+ ? [isTop ? "up" : "down"]
123
+ : position.includes("left")
124
+ ? ["left", isTop ? "up" : "down"]
125
+ : ["right", isTop ? "up" : "down"]
126
+ }
127
+ toast={toast}
128
+ >
129
+ <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100">
130
+ <div className="flex gap-2">
131
+ {Icon && (
132
+ <div
133
+ className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
134
+ data-slot="toast-icon"
135
+ >
136
+ <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
137
+ </div>
138
+ )}
139
+
140
+ <div className="flex flex-col gap-0.5">
141
+ <Toast.Title
142
+ className="font-medium"
143
+ data-slot="toast-title"
144
+ />
145
+ <Toast.Description
146
+ className="text-muted-foreground"
147
+ data-slot="toast-description"
148
+ />
149
+ </div>
150
+ </div>
151
+ {toast.actionProps && (
152
+ <Toast.Action
153
+ className={buttonVariants({ size: "xs" })}
154
+ data-slot="toast-action"
155
+ >
156
+ {toast.actionProps.children}
157
+ </Toast.Action>
158
+ )}
159
+ </Toast.Content>
160
+ </Toast.Root>
161
+ );
162
+ })}
163
+ </Toast.Viewport>
164
+ </Toast.Portal>
165
+ );
166
+ }
167
+
168
+ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
169
+ return (
170
+ <Toast.Provider toastManager={anchoredToastManager} {...props}>
171
+ {children}
172
+ <AnchoredToasts />
173
+ </Toast.Provider>
174
+ );
175
+ }
176
+
177
+ function AnchoredToasts() {
178
+ const { toasts } = Toast.useToastManager();
179
+
180
+ return (
181
+ <Toast.Portal data-slot="toast-portal-anchored">
182
+ <Toast.Viewport
183
+ className="outline-none"
184
+ data-slot="toast-viewport-anchored"
185
+ >
186
+ {toasts.map((toast) => {
187
+ const Icon = toast.type
188
+ ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS]
189
+ : null;
190
+ const tooltipStyle =
191
+ (toast.data as { tooltipStyle?: boolean })?.tooltipStyle ?? false;
192
+ const positionerProps = toast.positionerProps;
193
+
194
+ if (!positionerProps?.anchor) {
195
+ return null;
196
+ }
197
+
198
+ return (
199
+ <Toast.Positioner
200
+ className="z-50 max-w-[min(--spacing(64),var(--available-width))]"
201
+ data-slot="toast-positioner"
202
+ key={toast.id}
203
+ sideOffset={positionerProps.sideOffset ?? 4}
204
+ toast={toast}
205
+ >
206
+ <Toast.Root
207
+ className={cn(
208
+ "relative text-balance border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/6%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
209
+ tooltipStyle
210
+ ? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"
211
+ : "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",
212
+ )}
213
+ data-slot="toast-popup"
214
+ toast={toast}
215
+ >
216
+ {tooltipStyle ? (
217
+ <Toast.Content className="pointer-events-auto px-2 py-1">
218
+ <Toast.Title data-slot="toast-title" />
219
+ </Toast.Content>
220
+ ) : (
221
+ <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm">
222
+ <div className="flex gap-2">
223
+ {Icon && (
224
+ <div
225
+ className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
226
+ data-slot="toast-icon"
227
+ >
228
+ <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
229
+ </div>
230
+ )}
231
+
232
+ <div className="flex flex-col gap-0.5">
233
+ <Toast.Title
234
+ className="font-medium"
235
+ data-slot="toast-title"
236
+ />
237
+ <Toast.Description
238
+ className="text-muted-foreground"
239
+ data-slot="toast-description"
240
+ />
241
+ </div>
242
+ </div>
243
+ {toast.actionProps && (
244
+ <Toast.Action
245
+ className={buttonVariants({ size: "xs" })}
246
+ data-slot="toast-action"
247
+ >
248
+ {toast.actionProps.children}
249
+ </Toast.Action>
250
+ )}
251
+ </Toast.Content>
252
+ )}
253
+ </Toast.Root>
254
+ </Toast.Positioner>
255
+ );
256
+ })}
257
+ </Toast.Viewport>
258
+ </Toast.Portal>
259
+ );
260
+ }
261
+
262
+ export {
263
+ ToastProvider,
264
+ type ToastPosition,
265
+ toastManager,
266
+ AnchoredToastProvider,
267
+ anchoredToastManager,
268
+ };