@suphark/ui 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 (147) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/components.json +21 -0
  5. package/package.json +108 -0
  6. package/scripts/audit-base-ui-props.mjs +179 -0
  7. package/scripts/audit-tailwind-typography.mjs +100 -0
  8. package/src/components/design-system/design-system-showcase.tsx +138 -0
  9. package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
  10. package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
  11. package/src/components/design-system/sections/showcase-forms.tsx +373 -0
  12. package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
  13. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
  14. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
  15. package/src/components/design-system/sections/showcase-overview.tsx +368 -0
  16. package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
  17. package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
  18. package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
  19. package/src/components/shared/button/add-new-button.tsx +19 -0
  20. package/src/components/shared/button/base/action-button.tsx +134 -0
  21. package/src/components/shared/button/base/link-button.tsx +130 -0
  22. package/src/components/shared/button/cancel-button.tsx +27 -0
  23. package/src/components/shared/button/confirm-button.tsx +26 -0
  24. package/src/components/shared/button/delete-button.tsx +36 -0
  25. package/src/components/shared/button/floating-save-button.tsx +89 -0
  26. package/src/components/shared/button/index.ts +13 -0
  27. package/src/components/shared/button/print-button.tsx +67 -0
  28. package/src/components/shared/button/refresh-button.tsx +63 -0
  29. package/src/components/shared/button/save-button.tsx +29 -0
  30. package/src/components/shared/button/submit-button.tsx +58 -0
  31. package/src/components/shared/client-redirect.tsx +11 -0
  32. package/src/components/shared/combobox.tsx +116 -0
  33. package/src/components/shared/data-list.tsx +65 -0
  34. package/src/components/shared/date/date-range-picker.tsx +106 -0
  35. package/src/components/shared/description-list.tsx +27 -0
  36. package/src/components/shared/empty-row.tsx +13 -0
  37. package/src/components/shared/empty-state.tsx +31 -0
  38. package/src/components/shared/env-snippet.tsx +62 -0
  39. package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
  40. package/src/components/shared/form/form-field-boolean.tsx +93 -0
  41. package/src/components/shared/form/form-field-color-select.tsx +86 -0
  42. package/src/components/shared/form/form-field-combobox.tsx +167 -0
  43. package/src/components/shared/form/form-field-date.tsx +55 -0
  44. package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
  45. package/src/components/shared/form/form-field-input.tsx +372 -0
  46. package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
  47. package/src/components/shared/form/form-field-radio-group.tsx +56 -0
  48. package/src/components/shared/form/form-field-select.tsx +312 -0
  49. package/src/components/shared/form/simple-select.tsx +152 -0
  50. package/src/components/shared/form-dialog.tsx +109 -0
  51. package/src/components/shared/form-message.tsx +30 -0
  52. package/src/components/shared/initials.tsx +33 -0
  53. package/src/components/shared/layout/error-state.tsx +160 -0
  54. package/src/components/shared/layout/page-shell.tsx +57 -0
  55. package/src/components/shared/link/transition-link.tsx +72 -0
  56. package/src/components/shared/multi-select-combobox.tsx +392 -0
  57. package/src/components/shared/name-id-fields.tsx +133 -0
  58. package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
  59. package/src/components/shared/notice.tsx +35 -0
  60. package/src/components/shared/number/formatted-number.tsx +133 -0
  61. package/src/components/shared/page-header.tsx +25 -0
  62. package/src/components/shared/page-skeleton.tsx +18 -0
  63. package/src/components/shared/row-actions.tsx +93 -0
  64. package/src/components/shared/row-menu.tsx +55 -0
  65. package/src/components/shared/secret-reveal.tsx +53 -0
  66. package/src/components/shared/section-card.tsx +44 -0
  67. package/src/components/shared/stat.tsx +26 -0
  68. package/src/components/shared/status-badge.tsx +47 -0
  69. package/src/components/shared/text/primary-secondary-text.tsx +186 -0
  70. package/src/components/shared/theme/index.ts +4 -0
  71. package/src/components/shared/theme/theme-menu-items.tsx +43 -0
  72. package/src/components/shared/theme/theme-provider.tsx +39 -0
  73. package/src/components/shared/theme/theme-switch.tsx +36 -0
  74. package/src/components/shared/theme/theme-toggle.tsx +76 -0
  75. package/src/components/shared/time/client-date-formatter.tsx +188 -0
  76. package/src/components/shared/ui-text.tsx +47 -0
  77. package/src/components/ui/__tests__/combobox.test.tsx +40 -0
  78. package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
  79. package/src/components/ui/__tests__/slider.test.tsx +18 -0
  80. package/src/components/ui/__tests__/tabs.test.tsx +33 -0
  81. package/src/components/ui/accordion.tsx +69 -0
  82. package/src/components/ui/alert-dialog.tsx +161 -0
  83. package/src/components/ui/alert.tsx +68 -0
  84. package/src/components/ui/aspect-ratio.tsx +22 -0
  85. package/src/components/ui/attachment.tsx +194 -0
  86. package/src/components/ui/avatar.tsx +92 -0
  87. package/src/components/ui/badge.tsx +48 -0
  88. package/src/components/ui/breadcrumb.tsx +102 -0
  89. package/src/components/ui/button-group.tsx +77 -0
  90. package/src/components/ui/button.tsx +57 -0
  91. package/src/components/ui/calendar.tsx +184 -0
  92. package/src/components/ui/card.tsx +87 -0
  93. package/src/components/ui/carousel.tsx +230 -0
  94. package/src/components/ui/chart.tsx +338 -0
  95. package/src/components/ui/checkbox.tsx +27 -0
  96. package/src/components/ui/collapsible.tsx +17 -0
  97. package/src/components/ui/combobox.tsx +272 -0
  98. package/src/components/ui/command.tsx +180 -0
  99. package/src/components/ui/context-menu.tsx +244 -0
  100. package/src/components/ui/date-picker.tsx +308 -0
  101. package/src/components/ui/dialog.tsx +137 -0
  102. package/src/components/ui/drawer.tsx +208 -0
  103. package/src/components/ui/dropdown-menu.tsx +257 -0
  104. package/src/components/ui/empty.tsx +93 -0
  105. package/src/components/ui/field.tsx +223 -0
  106. package/src/components/ui/form.tsx +161 -0
  107. package/src/components/ui/hover-card.tsx +46 -0
  108. package/src/components/ui/input-group.tsx +145 -0
  109. package/src/components/ui/input-otp.tsx +85 -0
  110. package/src/components/ui/input.tsx +19 -0
  111. package/src/components/ui/item.tsx +187 -0
  112. package/src/components/ui/kbd.tsx +26 -0
  113. package/src/components/ui/label.tsx +19 -0
  114. package/src/components/ui/menubar.tsx +266 -0
  115. package/src/components/ui/navigation-menu.tsx +160 -0
  116. package/src/components/ui/pagination.tsx +117 -0
  117. package/src/components/ui/popover.tsx +76 -0
  118. package/src/components/ui/progress.tsx +66 -0
  119. package/src/components/ui/radio-group.tsx +38 -0
  120. package/src/components/ui/relative-date.tsx +40 -0
  121. package/src/components/ui/resizable.tsx +41 -0
  122. package/src/components/ui/scroll-area.tsx +67 -0
  123. package/src/components/ui/searchable-select.tsx +124 -0
  124. package/src/components/ui/select.tsx +189 -0
  125. package/src/components/ui/separator.tsx +21 -0
  126. package/src/components/ui/sheet.tsx +124 -0
  127. package/src/components/ui/sidebar.tsx +691 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +55 -0
  130. package/src/components/ui/sonner.tsx +45 -0
  131. package/src/components/ui/spinner.tsx +16 -0
  132. package/src/components/ui/switch.tsx +32 -0
  133. package/src/components/ui/table.tsx +95 -0
  134. package/src/components/ui/tabs.tsx +79 -0
  135. package/src/components/ui/textarea.tsx +17 -0
  136. package/src/components/ui/toggle-group.tsx +86 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +54 -0
  139. package/src/hooks/use-copy-to-clipboard.ts +27 -0
  140. package/src/hooks/use-mobile.ts +21 -0
  141. package/src/index.ts +156 -0
  142. package/src/lib/formatters.ts +44 -0
  143. package/src/lib/permission.ts +75 -0
  144. package/src/lib/status-colors.ts +52 -0
  145. package/src/lib/utils.ts +6 -0
  146. package/src/next.ts +15 -0
  147. package/src/styles.css +214 -0
@@ -0,0 +1,308 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import { Calendar } from "@suphark/ui/components/ui/calendar";
5
+ import {
6
+ InputGroup,
7
+ InputGroupAddon,
8
+ InputGroupButton,
9
+ InputGroupInput,
10
+ } from "@suphark/ui/components/ui/input-group";
11
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
12
+ import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
13
+ import { cn } from "@suphark/ui/lib/utils";
14
+ import { addDays, addMonths, addWeeks, subDays, subMonths, subWeeks } from "date-fns";
15
+ import { CalendarIcon, Check, Clock, X } from "lucide-react";
16
+ import { useMemo, useState } from "react";
17
+
18
+ /**
19
+ * Smart date parser supporting intuitive Thai & international date inputs:
20
+ * - "1/7" -> 1 July of current year
21
+ * - "1/7/25" -> 1 July 2025 CE
22
+ * - "1/7/68" -> 1 July 2025 CE (BE 2568)
23
+ * - "1/7/2568" -> 1 July 2025 CE (BE 2568)
24
+ */
25
+ export function parseSmartDateInput(input: string): Date | null {
26
+ if (!input?.trim()) return null;
27
+ const clean = input.trim().replace(/\s+/g, "");
28
+
29
+ const parts = clean.split(/[/\-.]/);
30
+ if (parts.length < 2 || parts.length > 3) return null;
31
+
32
+ const day = parseInt(parts[0] ?? "", 10);
33
+ const month = parseInt(parts[1] ?? "", 10);
34
+ if (Number.isNaN(day) || Number.isNaN(month) || day < 1 || day > 31 || month < 1 || month > 12) {
35
+ return null;
36
+ }
37
+
38
+ const currentYear = new Date().getFullYear();
39
+ let year = currentYear;
40
+
41
+ if (parts.length === 3 && parts[2]) {
42
+ const rawYear = parseInt(parts[2], 10);
43
+ if (Number.isNaN(rawYear)) return null;
44
+
45
+ if (rawYear > 2400) {
46
+ // Full Buddhist Era year (e.g. 2568 -> 2025 CE)
47
+ year = rawYear - 543;
48
+ } else if (rawYear >= 50 && rawYear < 100) {
49
+ // Short BE year (e.g. 68 -> 2568 -> 2025 CE)
50
+ year = 2500 + rawYear - 543;
51
+ } else if (rawYear < 50) {
52
+ // Short AD year (e.g. 25 -> 2025 CE)
53
+ year = 2000 + rawYear;
54
+ } else {
55
+ year = rawYear;
56
+ }
57
+ }
58
+
59
+ const d = new Date(year, month - 1, day);
60
+ if (d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) {
61
+ return d;
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ interface DatePickerProps {
68
+ id?: string;
69
+ value: Date | string | number | null | undefined;
70
+ onChange: (date: Date | null) => void;
71
+ placeholder?: string;
72
+ disabled?: boolean;
73
+ className?: string;
74
+ allowClear?: boolean;
75
+ showPresets?: boolean;
76
+ showSmartInput?: boolean;
77
+ }
78
+
79
+ export function DatePicker({
80
+ id,
81
+ value,
82
+ onChange,
83
+ placeholder = "เลือกวันที่อ้างอิงราคา...",
84
+ disabled = false,
85
+ className,
86
+ allowClear = true,
87
+ showPresets = true,
88
+ showSmartInput = true,
89
+ }: DatePickerProps) {
90
+ const [open, setOpen] = useState(false);
91
+ const [smartText, setSmartText] = useState("");
92
+
93
+ const selectedDate = value ? new Date(value) : undefined;
94
+ const isValidDate = selectedDate && !Number.isNaN(selectedDate.getTime());
95
+
96
+ const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
97
+
98
+ const handleSelectDate = (date: Date | undefined) => {
99
+ onChange(date || null);
100
+ setOpen(false);
101
+ setSmartText("");
102
+ };
103
+
104
+ const applyPreset = (date: Date | null) => {
105
+ onChange(date);
106
+ setOpen(false);
107
+ setSmartText("");
108
+ };
109
+
110
+ const handleSmartSubmit = () => {
111
+ if (parsedSmartDate) {
112
+ onChange(parsedSmartDate);
113
+ setOpen(false);
114
+ setSmartText("");
115
+ }
116
+ };
117
+
118
+ const today = new Date();
119
+ const displayMonth = parsedSmartDate || (isValidDate ? selectedDate : undefined);
120
+
121
+ return (
122
+ <Popover
123
+ open={open}
124
+ onOpenChange={(o) => {
125
+ setOpen(o);
126
+ if (!o) setSmartText("");
127
+ }}
128
+ >
129
+ <PopoverTrigger
130
+ render={
131
+ <Button
132
+ id={id}
133
+ variant="outline"
134
+ disabled={disabled}
135
+ className={cn(
136
+ "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
137
+ !isValidDate && "text-muted-foreground",
138
+ className,
139
+ )}
140
+ />
141
+ }
142
+ >
143
+ <div className="flex items-center gap-2 truncate">
144
+ <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-slate-500" />
145
+ <span className="truncate">
146
+ {isValidDate ? (
147
+ <>
148
+ <span className="font-medium text-foreground">
149
+ {formatThaiFullDate(selectedDate)}
150
+ </span>
151
+ <span className="ml-1 text-muted-foreground text-xs">
152
+ ({formatRelativeThaiDate(selectedDate)})
153
+ </span>
154
+ </>
155
+ ) : (
156
+ <span>{placeholder}</span>
157
+ )}
158
+ </span>
159
+ </div>
160
+ {allowClear && isValidDate && !disabled && (
161
+ <span
162
+ role="button"
163
+ tabIndex={0}
164
+ className="ml-1 cursor-pointer rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
165
+ onClick={(e) => {
166
+ e.stopPropagation();
167
+ onChange(null);
168
+ }}
169
+ >
170
+ <X className="h-3.5 w-3.5" />
171
+ </span>
172
+ )}
173
+ </PopoverTrigger>
174
+ <PopoverContent
175
+ className="w-[320px] border bg-background p-3 shadow-xl sm:w-[340px]"
176
+ align="start"
177
+ >
178
+ {/* Smart Date Text Input */}
179
+ {showSmartInput && (
180
+ <div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
181
+ <InputGroup className="h-8">
182
+ <InputGroupInput
183
+ value={smartText}
184
+ onChange={(e) => setSmartText(e.target.value)}
185
+ onKeyDown={(event) => {
186
+ if (event.key === "Enter") {
187
+ event.preventDefault();
188
+ event.stopPropagation();
189
+ handleSmartSubmit();
190
+ }
191
+ }}
192
+ placeholder="เช่น 1/7, 1/7/25, 1-7-2568..."
193
+ className="h-8 text-xs"
194
+ />
195
+ <InputGroupAddon align="inline-end">
196
+ <InputGroupButton
197
+ type="submit"
198
+ variant="default"
199
+ size="xs"
200
+ onClick={handleSmartSubmit}
201
+ disabled={!parsedSmartDate}
202
+ >
203
+ ตกลง
204
+ </InputGroupButton>
205
+ </InputGroupAddon>
206
+ </InputGroup>
207
+ {parsedSmartDate && (
208
+ <span className="flex items-center gap-1 font-medium text-emerald-600 text-xs">
209
+ <Check className="size-3" />
210
+ {formatThaiFullDate(parsedSmartDate)}
211
+ </span>
212
+ )}
213
+ </div>
214
+ )}
215
+
216
+ {/* Quick Presets Section (Flex Wrap & Clean Spacing) */}
217
+ {showPresets && (
218
+ <div className="mb-3 border-b pb-2.5">
219
+ <div className="mb-1.5 flex items-center gap-1 font-semibold text-muted-foreground text-xs">
220
+ <Clock className="h-3 w-3" />
221
+ Quick Preset
222
+ </div>
223
+ <div className="flex flex-wrap items-center gap-1.5">
224
+ <Button
225
+ type="button"
226
+ variant="outline"
227
+ size="sm"
228
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
229
+ onClick={() => applyPreset(today)}
230
+ >
231
+ Today
232
+ </Button>
233
+ <Button
234
+ type="button"
235
+ variant="outline"
236
+ size="sm"
237
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
238
+ onClick={() => applyPreset(subDays(today, 1))}
239
+ >
240
+ Yesterday
241
+ </Button>
242
+ <Button
243
+ type="button"
244
+ variant="outline"
245
+ size="sm"
246
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
247
+ onClick={() => applyPreset(addDays(today, 1))}
248
+ >
249
+ Tomorrow
250
+ </Button>
251
+ <Button
252
+ type="button"
253
+ variant="outline"
254
+ size="sm"
255
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
256
+ onClick={() => applyPreset(addWeeks(today, 1))}
257
+ >
258
+ +1 Week
259
+ </Button>
260
+ <Button
261
+ type="button"
262
+ variant="outline"
263
+ size="sm"
264
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
265
+ onClick={() => applyPreset(subWeeks(today, 1))}
266
+ >
267
+ -1 Week
268
+ </Button>
269
+ <Button
270
+ type="button"
271
+ variant="outline"
272
+ size="sm"
273
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
274
+ onClick={() => applyPreset(addMonths(today, 1))}
275
+ >
276
+ +1 Month
277
+ </Button>
278
+ <Button
279
+ type="button"
280
+ variant="outline"
281
+ size="sm"
282
+ className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
283
+ onClick={() => applyPreset(subMonths(today, 1))}
284
+ >
285
+ -1 Month
286
+ </Button>
287
+ </div>
288
+ </div>
289
+ )}
290
+
291
+ {/* Visual DayPicker Calendar */}
292
+ <div className="flex w-full justify-center">
293
+ <Calendar
294
+ mode="single"
295
+ selected={isValidDate ? selectedDate : undefined}
296
+ onSelect={handleSelectDate}
297
+ disabled={disabled}
298
+ captionLayout="dropdown"
299
+ startMonth={new Date(1990, 0)}
300
+ endMonth={new Date(2040, 11)}
301
+ defaultMonth={displayMonth}
302
+ className="p-0"
303
+ />
304
+ </div>
305
+ </PopoverContent>
306
+ </Popover>
307
+ );
308
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { XIcon } from "lucide-react";
7
+ import type * as React from "react";
8
+
9
+ function Dialog({ ...props }: DialogPrimitive.Root.Props) {
10
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
11
+ }
12
+
13
+ function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
14
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
15
+ }
16
+
17
+ function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
18
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
19
+ }
20
+
21
+ function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
22
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
23
+ }
24
+
25
+ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
26
+ return (
27
+ <DialogPrimitive.Backdrop
28
+ data-slot="dialog-overlay"
29
+ className={cn(
30
+ "data-open:fade-in-0 data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
31
+ className,
32
+ )}
33
+ {...props}
34
+ />
35
+ );
36
+ }
37
+
38
+ function DialogContent({
39
+ className,
40
+ children,
41
+ showCloseButton = true,
42
+ ...props
43
+ }: DialogPrimitive.Popup.Props & {
44
+ showCloseButton?: boolean;
45
+ }) {
46
+ return (
47
+ <DialogPortal>
48
+ <DialogOverlay />
49
+ <DialogPrimitive.Popup
50
+ data-slot="dialog-content"
51
+ className={cn(
52
+ "data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground text-sm outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in sm:max-w-sm",
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ {children}
58
+ {showCloseButton && (
59
+ <DialogPrimitive.Close
60
+ data-slot="dialog-close"
61
+ render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
62
+ >
63
+ <XIcon />
64
+ <span className="sr-only">Close</span>
65
+ </DialogPrimitive.Close>
66
+ )}
67
+ </DialogPrimitive.Popup>
68
+ </DialogPortal>
69
+ );
70
+ }
71
+
72
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
73
+ return (
74
+ <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
75
+ );
76
+ }
77
+
78
+ function DialogFooter({
79
+ className,
80
+ showCloseButton = false,
81
+ children,
82
+ ...props
83
+ }: React.ComponentProps<"div"> & {
84
+ showCloseButton?: boolean;
85
+ }) {
86
+ return (
87
+ <div
88
+ data-slot="dialog-footer"
89
+ className={cn(
90
+ "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
91
+ className,
92
+ )}
93
+ {...props}
94
+ >
95
+ {children}
96
+ {showCloseButton && (
97
+ <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
104
+ return (
105
+ <DialogPrimitive.Title
106
+ data-slot="dialog-title"
107
+ className={cn("font-medium text-base leading-none", className)}
108
+ {...props}
109
+ />
110
+ );
111
+ }
112
+
113
+ function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
114
+ return (
115
+ <DialogPrimitive.Description
116
+ data-slot="dialog-description"
117
+ className={cn(
118
+ "text-muted-foreground text-sm *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
119
+ className,
120
+ )}
121
+ {...props}
122
+ />
123
+ );
124
+ }
125
+
126
+ export {
127
+ Dialog,
128
+ DialogClose,
129
+ DialogContent,
130
+ DialogDescription,
131
+ DialogFooter,
132
+ DialogHeader,
133
+ DialogOverlay,
134
+ DialogPortal,
135
+ DialogTitle,
136
+ DialogTrigger,
137
+ };
@@ -0,0 +1,208 @@
1
+ "use client";
2
+
3
+ import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import * as React from "react";
6
+
7
+ type DrawerContextProps = {
8
+ hasSnapPoints: boolean;
9
+ modal: DrawerPrimitive.Root.Props["modal"];
10
+ showSwipeHandle: boolean;
11
+ swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
12
+ };
13
+
14
+ const DrawerContext = React.createContext<DrawerContextProps | null>(null);
15
+
16
+ function useDrawer() {
17
+ const context = React.useContext(DrawerContext);
18
+
19
+ if (!context) {
20
+ throw new Error("useDrawer must be used within a Drawer.");
21
+ }
22
+
23
+ return context;
24
+ }
25
+
26
+ function Drawer({
27
+ modal = true,
28
+ showSwipeHandle = false,
29
+ snapPoints,
30
+ swipeDirection = "down",
31
+ ...props
32
+ }: DrawerPrimitive.Root.Props & {
33
+ showSwipeHandle?: boolean;
34
+ }) {
35
+ const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
36
+ const contextValue = React.useMemo(
37
+ () => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
38
+ [hasSnapPoints, modal, showSwipeHandle, swipeDirection],
39
+ );
40
+
41
+ return (
42
+ <DrawerContext.Provider value={contextValue}>
43
+ <DrawerPrimitive.Root
44
+ data-slot="drawer"
45
+ modal={modal}
46
+ snapPoints={snapPoints}
47
+ swipeDirection={swipeDirection}
48
+ {...props}
49
+ />
50
+ </DrawerContext.Provider>
51
+ );
52
+ }
53
+
54
+ function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
55
+ return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
56
+ }
57
+
58
+ function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
59
+ return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
60
+ }
61
+
62
+ function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
63
+ return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
64
+ }
65
+
66
+ function DrawerOverlay({ className, ...props }: DrawerPrimitive.Backdrop.Props) {
67
+ return (
68
+ <DrawerPrimitive.Backdrop
69
+ data-slot="drawer-overlay"
70
+ className={cn(
71
+ "fixed inset-0 z-50 min-h-dvh select-none bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:pointer-events-none data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute supports-backdrop-filter:backdrop-blur-xs data-snap-points:[--drawer-overlay-min-opacity:0.5]",
72
+ className,
73
+ )}
74
+ {...props}
75
+ />
76
+ );
77
+ }
78
+
79
+ function DrawerSwipeHandle({ className, ...props }: React.ComponentProps<"div">) {
80
+ return (
81
+ <div
82
+ data-slot="drawer-swipe-handle"
83
+ aria-hidden="true"
84
+ className={cn(
85
+ "relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 after:block after:shrink-0 after:rounded-full after:bg-muted active:cursor-grabbing group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-direction=up]/drawer-popup:items-start group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24",
86
+ className,
87
+ )}
88
+ {...props}
89
+ />
90
+ );
91
+ }
92
+
93
+ function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) {
94
+ const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer();
95
+ const swipeAxis = swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";
96
+
97
+ return (
98
+ <DrawerPortal data-slot="drawer-portal">
99
+ {modal === true && <DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />}
100
+ <DrawerPrimitive.Viewport
101
+ data-slot="drawer-viewport"
102
+ data-modal={modal}
103
+ className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
104
+ >
105
+ <DrawerPrimitive.Popup
106
+ data-slot="drawer-popup"
107
+ data-swipe-axis={swipeAxis}
108
+ data-snap-points={hasSnapPoints ? "" : undefined}
109
+ className={cn(
110
+ // Base.
111
+ "group/drawer-popup transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) select-none flex-col bg-popover text-popover-foreground text-sm outline-none transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:border-r data-[swipe-direction=up]:border-b data-[swipe-direction=right]:border-l",
112
+ // Nested.
113
+ "data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
114
+ // Bleed.
115
+ "after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=x]:after:inset-y-0 data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=up]:after:bottom-full data-[swipe-direction=right]:after:left-full data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-axis=x]:after:w-(--bleed)",
116
+ // Sizing.
117
+ "[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
118
+ // Stack.
119
+ "[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
120
+ // Transitions.
121
+ "data-ending-style:transform-(--closed-transform) data-starting-style:transform-(--closed-transform) data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-swiping:duration-0",
122
+ // Axis: y.
123
+ "data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height) data-[swipe-axis=y]:inset-x-0",
124
+ // Axis: x.
125
+ "data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
126
+ // Direction: down.
127
+ "data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
128
+ // Direction: up.
129
+ "data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
130
+ // Direction: left.
131
+ "data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
132
+ // Direction: right.
133
+ "data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
134
+ className,
135
+ )}
136
+ {...props}
137
+ >
138
+ {showSwipeHandle && <DrawerSwipeHandle />}
139
+ <DrawerPrimitive.Content
140
+ data-slot="drawer-content"
141
+ className={cn(
142
+ "flex min-h-0 flex-1 select-text flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] group-data-swiping/drawer-popup:select-none group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100",
143
+ )}
144
+ >
145
+ {children}
146
+ </DrawerPrimitive.Content>
147
+ </DrawerPrimitive.Popup>
148
+ </DrawerPrimitive.Viewport>
149
+ </DrawerPortal>
150
+ );
151
+ }
152
+
153
+ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
154
+ return (
155
+ <div
156
+ data-slot="drawer-header"
157
+ className={cn(
158
+ "flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left",
159
+ className,
160
+ )}
161
+ {...props}
162
+ />
163
+ );
164
+ }
165
+
166
+ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
167
+ return (
168
+ <div
169
+ data-slot="drawer-footer"
170
+ className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
171
+ {...props}
172
+ />
173
+ );
174
+ }
175
+
176
+ function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
177
+ return (
178
+ <DrawerPrimitive.Title
179
+ data-slot="drawer-title"
180
+ className={cn("font-medium text-base text-foreground", className)}
181
+ {...props}
182
+ />
183
+ );
184
+ }
185
+
186
+ function DrawerDescription({ className, ...props }: DrawerPrimitive.Description.Props) {
187
+ return (
188
+ <DrawerPrimitive.Description
189
+ data-slot="drawer-description"
190
+ className={cn("text-balance text-muted-foreground text-sm", className)}
191
+ {...props}
192
+ />
193
+ );
194
+ }
195
+
196
+ export {
197
+ Drawer,
198
+ DrawerClose,
199
+ DrawerContent,
200
+ DrawerDescription,
201
+ DrawerFooter,
202
+ DrawerHeader,
203
+ DrawerOverlay,
204
+ DrawerPortal,
205
+ DrawerSwipeHandle,
206
+ DrawerTitle,
207
+ DrawerTrigger,
208
+ };