@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.
- package/.storybook/main.ts +12 -0
- package/.storybook/preview.tsx +16 -0
- package/package.json +40 -0
- package/pnpm-workspace.yaml +2 -0
- package/postcss.config.js +6 -0
- package/react-shim.js +2 -0
- package/src/components/BottomSheet.tsx +113 -0
- package/src/components/Button.tsx +92 -0
- package/src/components/Card.tsx +102 -0
- package/src/components/Collapse.tsx +177 -0
- package/src/components/DatePicker.tsx +334 -0
- package/src/components/Drawer.tsx +63 -0
- package/src/components/Header.tsx +58 -0
- package/src/components/Modal.tsx +127 -0
- package/src/components/OTPInput.tsx +178 -0
- package/src/components/SearchBar.tsx +141 -0
- package/src/components/Tabs.tsx +121 -0
- package/src/components/TimePicker.tsx +271 -0
- package/src/components/Toast.tsx +133 -0
- package/src/components/VirtualList.tsx +81 -0
- package/src/index.css +3 -0
- package/src/index.ts +14 -0
- package/src/stories/Button.stories.tsx +76 -0
- package/src/stories/Card.stories.tsx +77 -0
- package/src/stories/Collapse.stories.tsx +56 -0
- package/src/stories/DatePicker.stories.tsx +69 -0
- package/src/stories/Modal.stories.tsx +73 -0
- package/src/stories/OTPInput.stories.tsx +73 -0
- package/src/stories/SearchBar.stories.tsx +78 -0
- package/src/stories/Tabs.stories.tsx +66 -0
- package/src/stories/TimePicker.stories.tsx +77 -0
- package/src/stories/VirtualList.stories.tsx +76 -0
- package/src/stories/assets/accessibility.png +0 -0
- package/src/stories/assets/accessibility.svg +1 -0
- package/src/stories/assets/addon-library.png +0 -0
- package/src/stories/assets/assets.png +0 -0
- package/src/stories/assets/avif-test-image.avif +0 -0
- package/src/stories/assets/context.png +0 -0
- package/src/stories/assets/discord.svg +1 -0
- package/src/stories/assets/docs.png +0 -0
- package/src/stories/assets/figma-plugin.png +0 -0
- package/src/stories/assets/github.svg +1 -0
- package/src/stories/assets/share.png +0 -0
- package/src/stories/assets/styling.png +0 -0
- package/src/stories/assets/testing.png +0 -0
- package/src/stories/assets/theming.png +0 -0
- package/src/stories/assets/tutorials.svg +1 -0
- package/src/stories/assets/youtube.svg +1 -0
- package/src/vite-env.d.ts +4 -0
- package/tailwind.config.js +10 -0
- package/tsconfig.json +16 -0
- package/tsup.config.ts +15 -0
- package/vitest.config.ts +36 -0
- 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
|
+
}
|