@baodev/mini-app-component 1.0.1

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 (54) hide show
  1. package/.storybook/main.ts +12 -0
  2. package/.storybook/preview.tsx +16 -0
  3. package/package.json +40 -0
  4. package/pnpm-workspace.yaml +2 -0
  5. package/postcss.config.js +6 -0
  6. package/react-shim.js +2 -0
  7. package/src/components/BottomSheet.tsx +113 -0
  8. package/src/components/Button.tsx +92 -0
  9. package/src/components/Card.tsx +102 -0
  10. package/src/components/Collapse.tsx +177 -0
  11. package/src/components/DatePicker.tsx +334 -0
  12. package/src/components/Drawer.tsx +63 -0
  13. package/src/components/Header.tsx +58 -0
  14. package/src/components/Modal.tsx +127 -0
  15. package/src/components/OTPInput.tsx +178 -0
  16. package/src/components/SearchBar.tsx +141 -0
  17. package/src/components/Tabs.tsx +121 -0
  18. package/src/components/TimePicker.tsx +271 -0
  19. package/src/components/Toast.tsx +133 -0
  20. package/src/components/VirtualList.tsx +81 -0
  21. package/src/index.css +3 -0
  22. package/src/index.ts +14 -0
  23. package/src/stories/Button.stories.tsx +76 -0
  24. package/src/stories/Card.stories.tsx +77 -0
  25. package/src/stories/Collapse.stories.tsx +56 -0
  26. package/src/stories/DatePicker.stories.tsx +69 -0
  27. package/src/stories/Modal.stories.tsx +73 -0
  28. package/src/stories/OTPInput.stories.tsx +73 -0
  29. package/src/stories/SearchBar.stories.tsx +78 -0
  30. package/src/stories/Tabs.stories.tsx +66 -0
  31. package/src/stories/TimePicker.stories.tsx +77 -0
  32. package/src/stories/VirtualList.stories.tsx +76 -0
  33. package/src/stories/assets/accessibility.png +0 -0
  34. package/src/stories/assets/accessibility.svg +1 -0
  35. package/src/stories/assets/addon-library.png +0 -0
  36. package/src/stories/assets/assets.png +0 -0
  37. package/src/stories/assets/avif-test-image.avif +0 -0
  38. package/src/stories/assets/context.png +0 -0
  39. package/src/stories/assets/discord.svg +1 -0
  40. package/src/stories/assets/docs.png +0 -0
  41. package/src/stories/assets/figma-plugin.png +0 -0
  42. package/src/stories/assets/github.svg +1 -0
  43. package/src/stories/assets/share.png +0 -0
  44. package/src/stories/assets/styling.png +0 -0
  45. package/src/stories/assets/testing.png +0 -0
  46. package/src/stories/assets/theming.png +0 -0
  47. package/src/stories/assets/tutorials.svg +1 -0
  48. package/src/stories/assets/youtube.svg +1 -0
  49. package/src/vite-env.d.ts +4 -0
  50. package/tailwind.config.js +10 -0
  51. package/tsconfig.json +16 -0
  52. package/tsup.config.ts +15 -0
  53. package/vitest.config.ts +36 -0
  54. package/vitest.shims.d.ts +1 -0
@@ -0,0 +1,334 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { BottomSheet } from "./BottomSheet";
3
+
4
+ // ================================
5
+ // Types
6
+ // ================================
7
+ interface DatePickerProps {
8
+ value?: Date;
9
+ onChange?: (date: Date) => void;
10
+ minDate?: Date;
11
+ maxDate?: Date;
12
+ locale?: "vi" | "en";
13
+ placeholder?: string;
14
+ label?: string;
15
+ }
16
+
17
+ interface WheelColumnProps {
18
+ items: { label: string; value: number }[];
19
+ selectedIndex: number;
20
+ onChange: (index: number) => void;
21
+ align?: "left" | "right" | "center";
22
+ }
23
+
24
+ // ================================
25
+ // Wheel Column (Giao diện 3D chuẩn iOS)
26
+ // ================================
27
+ function WheelColumn({
28
+ items,
29
+ selectedIndex,
30
+ onChange,
31
+ align = "center",
32
+ }: WheelColumnProps) {
33
+ const containerRef = useRef<HTMLDivElement>(null);
34
+ const ITEM_HEIGHT = 44;
35
+
36
+ useEffect(() => {
37
+ const container = containerRef.current;
38
+ if (!container) return;
39
+ container.scrollTop = selectedIndex * ITEM_HEIGHT;
40
+ }, [selectedIndex, items]);
41
+
42
+ // const handleScroll = useCallback(() => {
43
+ // const container = containerRef.current;
44
+ // if (!container) return;
45
+ // const index = Math.round(container.scrollTop / ITEM_HEIGHT);
46
+ // const clamped = Math.max(0, Math.min(index, items.length - 1));
47
+ // if (clamped !== selectedIndex) onChange(clamped);
48
+ // }, [items.length, selectedIndex, onChange]);
49
+
50
+ const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
51
+
52
+ const handleScroll = useCallback(() => {
53
+ // Debounce — chờ scroll dừng hẳn mới xử lý
54
+ if (scrollTimer.current) clearTimeout(scrollTimer.current)
55
+ scrollTimer.current = setTimeout(() => {
56
+ const container = containerRef.current
57
+ if (!container) return
58
+ const index = Math.round(container.scrollTop / ITEM_HEIGHT)
59
+ const clamped = Math.max(0, Math.min(index, items.length - 1))
60
+
61
+ // Snap về đúng vị trí
62
+ container.scrollTo({
63
+ top: clamped * ITEM_HEIGHT,
64
+ behavior: 'smooth',
65
+ })
66
+
67
+ if (clamped !== selectedIndex) onChange(clamped)
68
+ }, 100) // chờ 100ms sau khi scroll dừng
69
+ }, [items.length, selectedIndex, onChange])
70
+
71
+ const alignStyles = {
72
+ left: "justify-start pl-4",
73
+ right: "justify-end pr-4",
74
+ center: "justify-center",
75
+ };
76
+
77
+ return (
78
+ <div
79
+ className="relative flex-1 h-full z-10"
80
+ onTouchStart={(e) => e.stopPropagation()}
81
+ onTouchMove={(e) => e.stopPropagation()}
82
+ onTouchEnd={(e) => e.stopPropagation()}
83
+ >
84
+ <div
85
+ ref={containerRef}
86
+ onScroll={handleScroll}
87
+ className="h-full w-full overflow-y-scroll snap-y snap-mandatory overscroll-contain [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
88
+ style={{ touchAction: "pan-y", WebkitOverflowScrolling: 'auto' }}
89
+ >
90
+ <div style={{ height: ITEM_HEIGHT * 2 }} />
91
+ {items.map((item, i) => {
92
+ const distance = Math.abs(i - selectedIndex);
93
+ const isSelected = distance === 0;
94
+ const opacity = isSelected ? 1 : Math.max(0.25, 1 - distance * 0.3);
95
+ const scale = isSelected ? 1 : Math.max(0.85, 1 - distance * 0.08);
96
+
97
+ return (
98
+ <div
99
+ key={item.value}
100
+ className={`flex items-center snap-center cursor-pointer select-none transition-all duration-150 ${alignStyles[align]}`}
101
+ style={{
102
+ height: ITEM_HEIGHT,
103
+ opacity,
104
+ transform: `scale(${scale})`,
105
+ transformOrigin:
106
+ align === "right"
107
+ ? "right center"
108
+ : align === "left"
109
+ ? "left center"
110
+ : "center center",
111
+ }}
112
+ onClick={() => {
113
+ onChange(i);
114
+ containerRef.current!.scrollTo({
115
+ top: i * ITEM_HEIGHT,
116
+ behavior: "smooth",
117
+ });
118
+ }}
119
+ >
120
+ <span
121
+ className={`text-[19px] ${isSelected ? "text-gray-900 font-semibold" : "text-gray-500 font-medium"}`}
122
+ >
123
+ {item.label}
124
+ </span>
125
+ </div>
126
+ );
127
+ })}
128
+ <div style={{ height: ITEM_HEIGHT * 2 }} />
129
+ </div>
130
+ </div>
131
+ );
132
+ }
133
+
134
+ // ================================
135
+ // Helpers
136
+ // ================================
137
+ const VI_MONTHS = Array.from({ length: 12 }, (_, i) => `Tháng ${i + 1}`);
138
+ const EN_MONTHS = [
139
+ "January",
140
+ "February",
141
+ "March",
142
+ "April",
143
+ "May",
144
+ "June",
145
+ "July",
146
+ "August",
147
+ "September",
148
+ "October",
149
+ "November",
150
+ "December",
151
+ ];
152
+
153
+ function getDaysInMonth(month: number, year: number) {
154
+ return new Date(year, month + 1, 0).getDate();
155
+ }
156
+
157
+ function generateDays(month: number, year: number) {
158
+ return Array.from({ length: getDaysInMonth(month, year) }, (_, i) => ({
159
+ label: String(i + 1).padStart(2, "0"),
160
+ value: i + 1,
161
+ }));
162
+ }
163
+
164
+ function generateMonths(locale: "vi" | "en") {
165
+ return (locale === "vi" ? VI_MONTHS : EN_MONTHS).map((name, i) => ({
166
+ label: locale === "vi" ? name : name.slice(0, 3),
167
+ value: i,
168
+ }));
169
+ }
170
+
171
+ function generateYears(minDate: Date, maxDate: Date) {
172
+ const years = [];
173
+ for (let y = minDate.getFullYear(); y <= maxDate.getFullYear(); y++) {
174
+ years.push({ label: String(y), value: y });
175
+ }
176
+ return years;
177
+ }
178
+
179
+ function formatDate(date: Date, locale: "vi" | "en") {
180
+ if (locale === "vi") {
181
+ return `${String(date.getDate()).padStart(2, "0")}/${String(date.getMonth() + 1).padStart(2, "0")}/${date.getFullYear()}`;
182
+ }
183
+ return `${EN_MONTHS[date.getMonth()].slice(0, 3)} ${String(date.getDate()).padStart(2, "0")}, ${date.getFullYear()}`;
184
+ }
185
+
186
+ // ================================
187
+ // DatePicker Component
188
+ // ================================
189
+ export function DatePicker({
190
+ value,
191
+ onChange,
192
+ minDate = new Date(1990, 0, 1),
193
+ maxDate = new Date(2035, 11, 31),
194
+ locale = "vi",
195
+ placeholder,
196
+ label,
197
+ }: DatePickerProps) {
198
+ const [isOpen, setIsOpen] = useState(false);
199
+ const today = value ?? new Date();
200
+
201
+ const years = generateYears(minDate, maxDate);
202
+ const months = generateMonths(locale);
203
+
204
+ const [yearIndex, setYearIndex] = useState(
205
+ Math.max(
206
+ 0,
207
+ years.findIndex((y) => y.value === today.getFullYear()),
208
+ ),
209
+ );
210
+ const [monthIndex, setMonthIndex] = useState(today.getMonth());
211
+ const [dayIndex, setDayIndex] = useState(today.getDate() - 1);
212
+
213
+ const selectedYear = years[yearIndex]?.value ?? today.getFullYear();
214
+ const days = generateDays(monthIndex, selectedYear);
215
+
216
+ // Ép dayIndex về khoảng hợp lệ khi user đổi Tháng/Năm đột ngột
217
+ useEffect(() => {
218
+ const maxDay = getDaysInMonth(monthIndex, selectedYear);
219
+ if (dayIndex >= maxDay) {
220
+ setDayIndex(maxDay - 1);
221
+ }
222
+ }, [monthIndex, selectedYear, dayIndex]);
223
+
224
+ const selectedDate = new Date(
225
+ selectedYear,
226
+ monthIndex,
227
+ Math.min(dayIndex + 1, getDaysInMonth(monthIndex, selectedYear)),
228
+ );
229
+
230
+ function handleConfirm() {
231
+ onChange?.(selectedDate);
232
+ setIsOpen(false);
233
+ }
234
+
235
+ return (
236
+ <>
237
+ <div className="flex flex-col gap-1.5">
238
+ {label && (
239
+ <label className="text-sm font-medium text-gray-700">{label}</label>
240
+ )}
241
+ <button
242
+ onClick={() => setIsOpen(true)}
243
+ className={[
244
+ "flex items-center justify-between w-full px-4 py-3 rounded-xl border transition-all bg-white text-left",
245
+ value
246
+ ? "border-indigo-300 text-gray-900"
247
+ : "border-gray-200 text-gray-400",
248
+ "hover:border-indigo-400 focus:outline-none focus:border-indigo-500",
249
+ ].join(" ")}
250
+ >
251
+ <span className="text-sm">
252
+ {value
253
+ ? formatDate(value, locale)
254
+ : (placeholder ??
255
+ (locale === "vi" ? "Chọn ngày" : "Select date"))}
256
+ </span>
257
+ <svg
258
+ width="18"
259
+ height="18"
260
+ viewBox="0 0 24 24"
261
+ fill="none"
262
+ stroke="currentColor"
263
+ strokeWidth="2"
264
+ className="text-gray-400 flex-shrink-0"
265
+ >
266
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
267
+ <line x1="16" y1="2" x2="16" y2="6" />
268
+ <line x1="8" y1="2" x2="8" y2="6" />
269
+ <line x1="3" y1="10" x2="21" y2="10" />
270
+ </svg>
271
+ </button>
272
+ </div>
273
+
274
+ <BottomSheet
275
+ isOpen={isOpen}
276
+ onClose={() => setIsOpen(false)}
277
+ title={locale === "vi" ? "Chọn ngày" : "Select date"}
278
+ >
279
+ <div className="flex flex-col gap-5 pt-2">
280
+ {/* Toàn bộ khối Picker */}
281
+ <div className="relative h-[220px] mx-4 rounded-xl overflow-hidden bg-white">
282
+ {/* Thanh Highlight đè lên 3 cột */}
283
+ <div className="absolute top-1/2 left-0 right-0 -translate-y-1/2 h-[44px] bg-gray-100/80 rounded-lg pointer-events-none" />
284
+
285
+ <div className="absolute inset-0 flex items-center justify-center">
286
+ {/* Cột Ngày */}
287
+ <WheelColumn
288
+ items={days}
289
+ selectedIndex={Math.min(dayIndex, days.length - 1)}
290
+ onChange={setDayIndex}
291
+ align="right"
292
+ />
293
+
294
+ {/* Cột Tháng */}
295
+ <WheelColumn
296
+ items={months}
297
+ selectedIndex={monthIndex}
298
+ onChange={setMonthIndex}
299
+ align="center"
300
+ />
301
+
302
+ {/* Cột Năm */}
303
+ <WheelColumn
304
+ items={years}
305
+ selectedIndex={yearIndex}
306
+ onChange={setYearIndex}
307
+ align="left"
308
+ />
309
+ </div>
310
+
311
+ {/* Các lớp Fade che mờ nội dung trên/dưới */}
312
+ <div className="absolute top-0 left-0 right-0 h-[88px] bg-gradient-to-b from-white via-white/80 to-transparent pointer-events-none z-20" />
313
+ <div className="absolute bottom-0 left-0 right-0 h-[88px] bg-gradient-to-t from-white via-white/80 to-transparent pointer-events-none z-20" />
314
+ </div>
315
+
316
+ {/* Hộp Preview */}
317
+ <div className="mx-4 py-3 bg-indigo-50/70 rounded-xl text-center">
318
+ <span className="text-base font-bold text-indigo-600">
319
+ {formatDate(selectedDate, locale)}
320
+ </span>
321
+ </div>
322
+
323
+ {/* Nút Xác nhận */}
324
+ <button
325
+ onClick={handleConfirm}
326
+ className="mx-4 mb-2 py-4 bg-indigo-600 text-white font-semibold rounded-2xl hover:bg-indigo-700 active:bg-indigo-800 active:scale-95 transition-all text-[17px]"
327
+ >
328
+ {locale === "vi" ? "Xong" : "Done"}
329
+ </button>
330
+ </div>
331
+ </BottomSheet>
332
+ </>
333
+ );
334
+ }
@@ -0,0 +1,63 @@
1
+ import { ReactNode, useEffect } from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+
4
+ interface DrawerProps {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ position?: "left" | "right";
8
+ children: ReactNode;
9
+ }
10
+
11
+ export function Drawer({
12
+ isOpen,
13
+ onClose,
14
+ position = "right",
15
+ children,
16
+ }: DrawerProps) {
17
+ // Khóa cuộn trang khi mở
18
+ useEffect(() => {
19
+ if (isOpen) document.body.style.overflow = "hidden";
20
+ else document.body.style.overflow = "";
21
+ return () => {
22
+ document.body.style.overflow = "";
23
+ };
24
+ }, [isOpen]);
25
+
26
+ const isRight = position === "right";
27
+
28
+ return (
29
+ // AnimatePresence giữ component sống thêm một chút để chạy hiệu ứng Exit
30
+ <AnimatePresence>
31
+ {isOpen && (
32
+ <div className="fixed inset-0 z-50 flex">
33
+ {/* 1. Lớp Overlay (Nền đen mờ) */}
34
+ <motion.div
35
+ initial={{ opacity: 0 }}
36
+ animate={{ opacity: 1 }}
37
+ exit={{ opacity: 0 }}
38
+ transition={{ duration: 0.2 }}
39
+ className="absolute inset-0 bg-black/40"
40
+ onClick={onClose}
41
+ />
42
+
43
+ {/* 2. Panel Nội dung (Trượt ra mượt mà với hiệu ứng Spring) */}
44
+ <motion.div
45
+ initial={{ x: isRight ? "100%" : "-100%" }}
46
+ animate={{ x: 0 }}
47
+ exit={{ x: isRight ? "100%" : "-100%" }}
48
+ transition={{
49
+ type: "spring",
50
+ damping: 25, // Độ hãm phanh (càng thấp càng nảy)
51
+ stiffness: 250, // Độ căng của lò xo (càng cao chạy càng nhanh)
52
+ }}
53
+ className={`absolute top-0 bottom-0 w-[80vw] max-w-sm bg-white shadow-2xl flex flex-col ${
54
+ isRight ? "right-0" : "left-0"
55
+ }`}
56
+ >
57
+ {children}
58
+ </motion.div>
59
+ </div>
60
+ )}
61
+ </AnimatePresence>
62
+ );
63
+ }
@@ -0,0 +1,58 @@
1
+ import { ReactNode } from "react";
2
+
3
+ interface HeaderProps {
4
+ title: string;
5
+ subtitle?: string;
6
+ leftAction?: ReactNode;
7
+ rightAction?: ReactNode;
8
+ onBack?: () => void;
9
+ }
10
+
11
+ export function Header({
12
+ title,
13
+ subtitle,
14
+ leftAction,
15
+ rightAction,
16
+ onBack,
17
+ }: HeaderProps) {
18
+ return (
19
+ <div className="flex items-center justify-between px-4 py-3 bg-white border-b border-gray-100 sticky top-0 z-10">
20
+ {/* Left */}
21
+ <div className="w-10 flex items-center">
22
+ {leftAction ??
23
+ (onBack ? (
24
+ <button
25
+ onClick={onBack}
26
+ className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
27
+ >
28
+ <svg
29
+ width="20"
30
+ height="20"
31
+ viewBox="0 0 24 24"
32
+ fill="none"
33
+ stroke="currentColor"
34
+ strokeWidth="2.5"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ >
38
+ <path d="M15 18l-6-6 6-6" />
39
+ </svg>
40
+ </button>
41
+ ) : null)}
42
+ </div>
43
+
44
+ {/* Center */}
45
+ <div className="flex-1 text-center">
46
+ <h1 className="text-base font-semibold text-gray-900 leading-tight">
47
+ {title}
48
+ </h1>
49
+ {subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
50
+ </div>
51
+
52
+ {/* Right */}
53
+ <div className="w-10 flex items-center justify-end">
54
+ {rightAction ?? null}
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,127 @@
1
+ import { ReactNode, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { motion, AnimatePresence } from "framer-motion";
4
+
5
+ // ================================
6
+ // Types
7
+ // ================================
8
+ interface ModalProps {
9
+ isOpen: boolean;
10
+ onClose: () => void;
11
+ title?: string;
12
+ children: ReactNode;
13
+ size?: "sm" | "md" | "lg" | "full";
14
+ showCloseButton?: boolean;
15
+ closeOnBackdrop?: boolean;
16
+ footer?: ReactNode;
17
+ }
18
+
19
+ const sizeStyles: Record<string, string> = {
20
+ sm: "max-w-sm",
21
+ md: "max-w-md",
22
+ lg: "max-w-lg",
23
+ full: "max-w-full mx-4",
24
+ };
25
+
26
+ export function Modal({
27
+ isOpen,
28
+ onClose,
29
+ title,
30
+ children,
31
+ size = "md",
32
+ showCloseButton = true,
33
+ closeOnBackdrop = true,
34
+ footer,
35
+ }: ModalProps) {
36
+ // Lock scroll
37
+ useEffect(() => {
38
+ document.body.style.overflow = isOpen ? "hidden" : "";
39
+ return () => {
40
+ document.body.style.overflow = "";
41
+ };
42
+ }, [isOpen]);
43
+
44
+ // Close on Escape
45
+ useEffect(() => {
46
+ const handleKeyDown = (e: KeyboardEvent) => {
47
+ if (e.key === "Escape" && isOpen) onClose();
48
+ };
49
+ document.addEventListener("keydown", handleKeyDown);
50
+ return () => document.removeEventListener("keydown", handleKeyDown);
51
+ }, [isOpen, onClose]);
52
+
53
+ // Để an toàn với SSR/Next.js (nếu sau này dùng), kiểm tra document có tồn tại không
54
+ if (typeof document === "undefined") return null;
55
+
56
+ return createPortal(
57
+ <AnimatePresence>
58
+ {isOpen && (
59
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
60
+ {/* Backdrop (Lớp mờ đen) */}
61
+ <motion.div
62
+ initial={{ opacity: 0 }}
63
+ animate={{ opacity: 1 }}
64
+ exit={{ opacity: 0 }}
65
+ transition={{ duration: 0.2 }}
66
+ className="absolute inset-0 bg-black/50"
67
+ onClick={closeOnBackdrop ? onClose : undefined}
68
+ />
69
+
70
+ {/* Modal box (Khối nội dung chính) */}
71
+ <motion.div
72
+ initial={{ opacity: 0, scale: 0.95, y: 16 }}
73
+ animate={{ opacity: 1, scale: 1, y: 0 }}
74
+ exit={{ opacity: 0, scale: 0.95, y: 16 }}
75
+ transition={{
76
+ type: "spring",
77
+ damping: 25,
78
+ stiffness: 350,
79
+ }}
80
+ className={`relative bg-white rounded-2xl shadow-2xl w-full flex flex-col ${sizeStyles[size]}`}
81
+ onClick={(e) => e.stopPropagation()} // Chặn click xuyên xuống backdrop
82
+ >
83
+ {/* Header */}
84
+ {(title || showCloseButton) && (
85
+ <div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 flex-shrink-0">
86
+ {title && (
87
+ <h3 className="text-base font-semibold text-gray-900">
88
+ {title}
89
+ </h3>
90
+ )}
91
+ {showCloseButton && (
92
+ <button
93
+ onClick={onClose}
94
+ className="ml-auto w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors text-gray-400 hover:text-gray-600"
95
+ >
96
+ <svg
97
+ width="16"
98
+ height="16"
99
+ viewBox="0 0 24 24"
100
+ fill="none"
101
+ stroke="currentColor"
102
+ strokeWidth="2.5"
103
+ >
104
+ <line x1="18" y1="6" x2="6" y2="18" />
105
+ <line x1="6" y1="6" x2="18" y2="18" />
106
+ </svg>
107
+ </button>
108
+ )}
109
+ </div>
110
+ )}
111
+
112
+ {/* Content */}
113
+ <div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
114
+
115
+ {/* Footer */}
116
+ {footer && (
117
+ <div className="flex-shrink-0 px-5 py-4 border-t border-gray-100">
118
+ {footer}
119
+ </div>
120
+ )}
121
+ </motion.div>
122
+ </div>
123
+ )}
124
+ </AnimatePresence>,
125
+ document.body,
126
+ );
127
+ }