@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,271 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { BottomSheet } from "./BottomSheet";
|
|
3
|
+
|
|
4
|
+
// ================================
|
|
5
|
+
// Types
|
|
6
|
+
// ================================
|
|
7
|
+
interface TimePickerProps {
|
|
8
|
+
value?: { hour: number; minute: number };
|
|
9
|
+
onChange?: (time: { hour: number; minute: number }) => void;
|
|
10
|
+
minuteStep?: 1 | 5 | 10 | 15 | 30;
|
|
11
|
+
locale?: "vi" | "en";
|
|
12
|
+
label?: string;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
minHour?: number;
|
|
15
|
+
maxHour?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface WheelColumnProps {
|
|
19
|
+
items: { label: string; value: number }[];
|
|
20
|
+
selectedIndex: number;
|
|
21
|
+
onChange: (index: number) => void;
|
|
22
|
+
align?: "left" | "right" | "center";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ================================
|
|
26
|
+
// Wheel Column (Giao diện chuẩn iOS)
|
|
27
|
+
// ================================
|
|
28
|
+
function WheelColumn({
|
|
29
|
+
items,
|
|
30
|
+
selectedIndex,
|
|
31
|
+
onChange,
|
|
32
|
+
align = "center",
|
|
33
|
+
}: WheelColumnProps) {
|
|
34
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const ITEM_HEIGHT = 44; // Chiều cao chuẩn xác để tạo độ khít như iOS
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const container = containerRef.current;
|
|
39
|
+
if (!container) return;
|
|
40
|
+
container.scrollTop = selectedIndex * ITEM_HEIGHT;
|
|
41
|
+
}, [selectedIndex]);
|
|
42
|
+
|
|
43
|
+
const handleScroll = useCallback(() => {
|
|
44
|
+
const container = containerRef.current;
|
|
45
|
+
if (!container) return;
|
|
46
|
+
const index = Math.round(container.scrollTop / ITEM_HEIGHT);
|
|
47
|
+
const clamped = Math.max(0, Math.min(index, items.length - 1));
|
|
48
|
+
if (clamped !== selectedIndex) onChange(clamped);
|
|
49
|
+
}, [items.length, selectedIndex, onChange]);
|
|
50
|
+
|
|
51
|
+
const alignStyles = {
|
|
52
|
+
left: "justify-start pl-8",
|
|
53
|
+
right: "justify-end pr-8",
|
|
54
|
+
center: "justify-center",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className="relative flex-1 h-full z-10"
|
|
60
|
+
onTouchStart={(e) => e.stopPropagation()}
|
|
61
|
+
onTouchMove={(e) => e.stopPropagation()}
|
|
62
|
+
onTouchEnd={(e) => e.stopPropagation()}
|
|
63
|
+
>
|
|
64
|
+
<div
|
|
65
|
+
ref={containerRef}
|
|
66
|
+
onScroll={handleScroll}
|
|
67
|
+
className="h-full w-full overflow-y-scroll snap-y snap-mandatory overscroll-contain [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] scrollbar-none"
|
|
68
|
+
style={{ touchAction: "pan-y" }}
|
|
69
|
+
>
|
|
70
|
+
{/* Khoảng trống để có thể cuộn item đầu/cuối vào giữa */}
|
|
71
|
+
<div style={{ height: ITEM_HEIGHT * 2 }} />
|
|
72
|
+
|
|
73
|
+
{items.map((item, i) => {
|
|
74
|
+
// Tính toán hiệu ứng 3D giả lập dựa trên khoảng cách tới trung tâm
|
|
75
|
+
const distance = Math.abs(i - selectedIndex);
|
|
76
|
+
const isSelected = distance === 0;
|
|
77
|
+
const opacity = isSelected ? 1 : Math.max(0.3, 1 - distance * 0.25);
|
|
78
|
+
const scale = isSelected ? 1 : Math.max(0.85, 1 - distance * 0.08);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
key={item.value}
|
|
83
|
+
className={`flex items-center snap-center cursor-pointer select-none transition-all duration-150 ${alignStyles[align]}`}
|
|
84
|
+
style={{
|
|
85
|
+
height: ITEM_HEIGHT,
|
|
86
|
+
opacity,
|
|
87
|
+
transform: `scale(${scale})`,
|
|
88
|
+
transformOrigin:
|
|
89
|
+
align === "right"
|
|
90
|
+
? "right center"
|
|
91
|
+
: align === "left"
|
|
92
|
+
? "left center"
|
|
93
|
+
: "center center",
|
|
94
|
+
}}
|
|
95
|
+
onClick={() => {
|
|
96
|
+
onChange(i);
|
|
97
|
+
containerRef.current!.scrollTo({
|
|
98
|
+
top: i * ITEM_HEIGHT,
|
|
99
|
+
behavior: "smooth",
|
|
100
|
+
});
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<span
|
|
104
|
+
className={`${isSelected ? "text-gray-900 font-semibold" : "text-gray-600 font-medium"}`}
|
|
105
|
+
style={{ fontSize: 24 }}
|
|
106
|
+
>
|
|
107
|
+
{item.label}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
|
|
113
|
+
<div style={{ height: ITEM_HEIGHT * 2 }} />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ================================
|
|
120
|
+
// Helpers
|
|
121
|
+
// ================================
|
|
122
|
+
function generateHours(min = 0, max = 23) {
|
|
123
|
+
return Array.from({ length: max - min + 1 }, (_, i) => ({
|
|
124
|
+
label: String(min + i).padStart(2, "0"),
|
|
125
|
+
value: min + i,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function generateMinutes(step: number) {
|
|
130
|
+
const items = [];
|
|
131
|
+
for (let m = 0; m < 60; m += step) {
|
|
132
|
+
items.push({ label: String(m).padStart(2, "0"), value: m });
|
|
133
|
+
}
|
|
134
|
+
return items;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatTime(hour: number, minute: number, locale: "vi" | "en") {
|
|
138
|
+
if (locale === "en") {
|
|
139
|
+
const period = hour >= 12 ? "PM" : "AM";
|
|
140
|
+
const h = hour % 12 || 12;
|
|
141
|
+
return `${String(h).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${period}`;
|
|
142
|
+
}
|
|
143
|
+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ================================
|
|
147
|
+
// TimePicker
|
|
148
|
+
// ================================
|
|
149
|
+
export function TimePicker({
|
|
150
|
+
value,
|
|
151
|
+
onChange,
|
|
152
|
+
minuteStep = 1, // Để step=1 cho giống báo thức thực tế
|
|
153
|
+
locale = "vi",
|
|
154
|
+
label,
|
|
155
|
+
placeholder,
|
|
156
|
+
minHour = 0,
|
|
157
|
+
maxHour = 23,
|
|
158
|
+
}: TimePickerProps) {
|
|
159
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
160
|
+
|
|
161
|
+
const hours = generateHours(minHour, maxHour);
|
|
162
|
+
const minutes = generateMinutes(minuteStep);
|
|
163
|
+
|
|
164
|
+
const defaultHour = value?.hour ?? new Date().getHours();
|
|
165
|
+
const defaultMinute = value?.minute ?? 0;
|
|
166
|
+
|
|
167
|
+
const [hourIndex, setHourIndex] = useState(
|
|
168
|
+
Math.max(
|
|
169
|
+
0,
|
|
170
|
+
hours.findIndex((h) => h.value === defaultHour),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
const [minuteIndex, setMinuteIndex] = useState(
|
|
174
|
+
Math.max(
|
|
175
|
+
0,
|
|
176
|
+
minutes.findIndex((m) => m.value === defaultMinute),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const selectedHour = hours[hourIndex]?.value ?? defaultHour;
|
|
181
|
+
const selectedMinute = minutes[minuteIndex]?.value ?? 0;
|
|
182
|
+
|
|
183
|
+
function handleConfirm() {
|
|
184
|
+
onChange?.({ hour: selectedHour, minute: selectedMinute });
|
|
185
|
+
setIsOpen(false);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
<div className="flex flex-col gap-1.5">
|
|
191
|
+
{label && (
|
|
192
|
+
<label className="text-sm font-medium text-gray-700">{label}</label>
|
|
193
|
+
)}
|
|
194
|
+
<button
|
|
195
|
+
onClick={() => setIsOpen(true)}
|
|
196
|
+
className={[
|
|
197
|
+
"flex items-center justify-between w-full px-4 py-3 rounded-xl border transition-all bg-white text-left",
|
|
198
|
+
value
|
|
199
|
+
? "border-indigo-300 text-gray-900"
|
|
200
|
+
: "border-gray-200 text-gray-400",
|
|
201
|
+
"hover:border-indigo-400 focus:outline-none focus:border-indigo-500",
|
|
202
|
+
].join(" ")}
|
|
203
|
+
>
|
|
204
|
+
<span className="text-sm">
|
|
205
|
+
{value
|
|
206
|
+
? formatTime(value.hour, value.minute, locale)
|
|
207
|
+
: (placeholder ?? (locale === "vi" ? "Chọn giờ" : "Select time"))}
|
|
208
|
+
</span>
|
|
209
|
+
<svg
|
|
210
|
+
width="18"
|
|
211
|
+
height="18"
|
|
212
|
+
viewBox="0 0 24 24"
|
|
213
|
+
fill="none"
|
|
214
|
+
stroke="currentColor"
|
|
215
|
+
strokeWidth="2"
|
|
216
|
+
className="text-gray-400 shrink-0"
|
|
217
|
+
>
|
|
218
|
+
<circle cx="12" cy="12" r="10" />
|
|
219
|
+
<polyline points="12 6 12 12 16 14" />
|
|
220
|
+
</svg>
|
|
221
|
+
</button>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<BottomSheet
|
|
225
|
+
isOpen={isOpen}
|
|
226
|
+
onClose={() => setIsOpen(false)}
|
|
227
|
+
title={locale === "vi" ? "Chọn giờ" : "Select time"}
|
|
228
|
+
>
|
|
229
|
+
<div className="flex flex-col gap-6 pt-2">
|
|
230
|
+
{/* ĐÃ SỬA: Thay h-55 thành h-[220px] hoặc h-56 */}
|
|
231
|
+
<div className="relative h-[220px] mx-4 rounded-xl overflow-hidden bg-white">
|
|
232
|
+
<div className="absolute top-1/2 left-0 right-0 -translate-y-1/2 h-11 bg-gray-100/80 rounded-lg pointer-events-none" />
|
|
233
|
+
|
|
234
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
235
|
+
<WheelColumn
|
|
236
|
+
items={hours}
|
|
237
|
+
selectedIndex={hourIndex}
|
|
238
|
+
onChange={setHourIndex}
|
|
239
|
+
align="right"
|
|
240
|
+
/>
|
|
241
|
+
|
|
242
|
+
<div className="w-10 flex items-center justify-center pb-1 z-10 select-none pointer-events-none">
|
|
243
|
+
<span className="text-2xl font-bold text-gray-900 animate-pulse">
|
|
244
|
+
:
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<WheelColumn
|
|
249
|
+
items={minutes}
|
|
250
|
+
selectedIndex={minuteIndex}
|
|
251
|
+
onChange={setMinuteIndex}
|
|
252
|
+
align="left"
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* ĐÃ SỬA: Thay h-22 thành h-[88px] hoặc h-24 */}
|
|
257
|
+
<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" />
|
|
258
|
+
<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" />
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<button
|
|
262
|
+
onClick={handleConfirm}
|
|
263
|
+
className="mx-4 mb-2 py-4 bg-indigo-600 text-white text-[17px] font-semibold rounded-2xl hover:bg-indigo-700 active:bg-indigo-800 active:scale-95 transition-all"
|
|
264
|
+
>
|
|
265
|
+
{locale === "vi" ? "Xong" : "Done"}
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
</BottomSheet>
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
ReactNode,
|
|
7
|
+
useCallback,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
10
|
+
|
|
11
|
+
// ================================
|
|
12
|
+
// Types & Context (Giữ nguyên của bạn)
|
|
13
|
+
// ================================
|
|
14
|
+
type ToastType = "success" | "error" | "warning" | "info";
|
|
15
|
+
|
|
16
|
+
interface ToastItem {
|
|
17
|
+
id: string;
|
|
18
|
+
message: string;
|
|
19
|
+
type: ToastType;
|
|
20
|
+
duration?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ToastContextType {
|
|
24
|
+
show: (message: string, type?: ToastType, duration?: number) => void;
|
|
25
|
+
success: (message: string) => void;
|
|
26
|
+
error: (message: string) => void;
|
|
27
|
+
warning: (message: string) => void;
|
|
28
|
+
info: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ToastContext = createContext<ToastContextType | null>(null);
|
|
32
|
+
|
|
33
|
+
export function useToast() {
|
|
34
|
+
const ctx = useContext(ToastContext);
|
|
35
|
+
if (!ctx) throw new Error("useToast phải dùng trong ToastProvider!");
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const toastStyles: Record<ToastType, { bg: string; icon: string }> = {
|
|
40
|
+
success: { bg: "bg-green-500", icon: "✅" },
|
|
41
|
+
error: { bg: "bg-red-500", icon: "❌" },
|
|
42
|
+
warning: { bg: "bg-amber-500", icon: "⚠️" },
|
|
43
|
+
info: { bg: "bg-blue-500", icon: "ℹ️" },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ================================
|
|
47
|
+
// Single Toast component (Đã gỡ logic dư thừa)
|
|
48
|
+
// ================================
|
|
49
|
+
function ToastMessage({
|
|
50
|
+
toast,
|
|
51
|
+
onRemove,
|
|
52
|
+
}: {
|
|
53
|
+
toast: ToastItem;
|
|
54
|
+
onRemove: (id: string) => void;
|
|
55
|
+
}) {
|
|
56
|
+
const { bg, icon } = toastStyles[toast.type];
|
|
57
|
+
|
|
58
|
+
// Chỉ lo việc đếm ngược thời gian, animation để Framer lo
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
onRemove(toast.id);
|
|
62
|
+
}, toast.duration ?? 3000);
|
|
63
|
+
return () => clearTimeout(timer);
|
|
64
|
+
}, [toast, onRemove]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<motion.div
|
|
68
|
+
layout // Giúp các Toast tự động trượt lên lấp chỗ trống khi có 1 cái biến mất
|
|
69
|
+
initial={{ opacity: 0, y: 30, scale: 0.9 }}
|
|
70
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
71
|
+
exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.2 } }}
|
|
72
|
+
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
|
73
|
+
className={`flex items-center gap-3 px-4 py-3 rounded-2xl shadow-lg text-white text-sm font-medium ${bg}`}
|
|
74
|
+
style={{ minWidth: 240, maxWidth: 320 }}
|
|
75
|
+
>
|
|
76
|
+
<span>{icon}</span>
|
|
77
|
+
<span className="flex-1">{toast.message}</span>
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => onRemove(toast.id)}
|
|
80
|
+
className="opacity-70 hover:opacity-100 transition-opacity"
|
|
81
|
+
>
|
|
82
|
+
✕
|
|
83
|
+
</button>
|
|
84
|
+
</motion.div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ================================
|
|
89
|
+
// Toast Provider
|
|
90
|
+
// ================================
|
|
91
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
92
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
93
|
+
|
|
94
|
+
const remove = useCallback((id: string) => {
|
|
95
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const show = useCallback(
|
|
99
|
+
(message: string, type: ToastType = "info", duration = 3000) => {
|
|
100
|
+
const id = Math.random().toString(36).slice(2);
|
|
101
|
+
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
|
102
|
+
},
|
|
103
|
+
[],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const success = useCallback((msg: string) => show(msg, "success"), [show]);
|
|
107
|
+
const error = useCallback((msg: string) => show(msg, "error"), [show]);
|
|
108
|
+
const warning = useCallback((msg: string) => show(msg, "warning"), [show]);
|
|
109
|
+
const info = useCallback((msg: string) => show(msg, "info"), [show]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<ToastContext.Provider value={{ show, success, error, warning, info }}>
|
|
113
|
+
{children}
|
|
114
|
+
|
|
115
|
+
<div
|
|
116
|
+
className="fixed left-1/2 -translate-x-1/2 z-50 flex flex-col gap-2 items-center pointer-events-none"
|
|
117
|
+
style={{
|
|
118
|
+
bottom:
|
|
119
|
+
"calc(var(--ejsc-safe-bottom, env(safe-area-inset-bottom, 0px)) + 16px)",
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<AnimatePresence mode="popLayout">
|
|
123
|
+
{toasts.map((toast) => (
|
|
124
|
+
// Thêm pointer-events-auto vào từng toast để bắt click được nút ✕
|
|
125
|
+
<div key={toast.id} className="pointer-events-auto">
|
|
126
|
+
<ToastMessage toast={toast} onRemove={remove} />
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</AnimatePresence>
|
|
130
|
+
</div>
|
|
131
|
+
</ToastContext.Provider>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useRef, useState, useMemo, ReactNode, UIEvent } from "react";
|
|
2
|
+
|
|
3
|
+
// ================================
|
|
4
|
+
// Types
|
|
5
|
+
// ================================
|
|
6
|
+
interface VirtualListProps<T> {
|
|
7
|
+
items: T[];
|
|
8
|
+
itemHeight: number;
|
|
9
|
+
renderItem: (item: T, index: number) => ReactNode;
|
|
10
|
+
containerHeight?: number | string;
|
|
11
|
+
overscan?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function VirtualList<T>({
|
|
16
|
+
items,
|
|
17
|
+
itemHeight,
|
|
18
|
+
renderItem,
|
|
19
|
+
containerHeight = "100%",
|
|
20
|
+
overscan = 3, // Render dư ra 3 item phía trên và dưới để chống chớp trắng khi scroll nhanh
|
|
21
|
+
className = "",
|
|
22
|
+
}: VirtualListProps<T>) {
|
|
23
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
25
|
+
|
|
26
|
+
// Tổng chiều cao giả lập của toàn bộ list (VD: 5000 items * 80px = 400,000px)
|
|
27
|
+
const totalHeight = items.length * itemHeight;
|
|
28
|
+
|
|
29
|
+
// Cập nhật vị trí cuộn mỗi khi user vuốt
|
|
30
|
+
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
|
|
31
|
+
// Dùng requestAnimationFrame nếu muốn tối ưu cực độ, nhưng với React 18+ auto-batching thì setState thẳng ở đây vẫn rất mượt
|
|
32
|
+
setScrollTop(e.currentTarget.scrollTop);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Tính toán chỉ số của các item cần render
|
|
36
|
+
const { startIndex, endIndex } = useMemo(() => {
|
|
37
|
+
const start = Math.floor(scrollTop / itemHeight);
|
|
38
|
+
|
|
39
|
+
// Nếu chưa render DOM (clientHeight = 0), lấy tạm một giá trị ước lượng cho màn hình mobile (VD: 800px)
|
|
40
|
+
const clientHeight = containerRef.current?.clientHeight || 800;
|
|
41
|
+
const visibleNodeCount = Math.ceil(clientHeight / itemHeight);
|
|
42
|
+
|
|
43
|
+
const end = Math.min(items.length - 1, start + visibleNodeCount);
|
|
44
|
+
|
|
45
|
+
return { startIndex: start, endIndex: end };
|
|
46
|
+
}, [scrollTop, itemHeight, items.length]);
|
|
47
|
+
|
|
48
|
+
// Cộng trừ thêm vùng overscan an toàn
|
|
49
|
+
const renderStart = Math.max(0, startIndex - overscan);
|
|
50
|
+
const renderEnd = Math.min(items.length - 1, endIndex + overscan);
|
|
51
|
+
|
|
52
|
+
// Cắt mảng data gốc lấy đúng đoạn cần thiết
|
|
53
|
+
const visibleItems = items.slice(renderStart, renderEnd + 1);
|
|
54
|
+
|
|
55
|
+
// Tính độ dời (offset) để đẩy đoạn DOM này xuống đúng vị trí trên thanh cuộn
|
|
56
|
+
const offsetY = renderStart * itemHeight;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
ref={containerRef}
|
|
61
|
+
onScroll={handleScroll}
|
|
62
|
+
className={`overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] [&::-webkit-scrollbar]:hidden ${className}`}
|
|
63
|
+
style={{ height: containerHeight }}
|
|
64
|
+
>
|
|
65
|
+
{/* Container giả lập chiều cao tổng */}
|
|
66
|
+
<div style={{ height: totalHeight, position: "relative" }}>
|
|
67
|
+
{/* Khối chứa các phần tử thực sự được render, dùng translateY đẩy xuống để tăng tốc độ GPU */}
|
|
68
|
+
<div style={{ transform: `translateY(${offsetY}px)` }}>
|
|
69
|
+
{visibleItems.map((item, index) => {
|
|
70
|
+
const actualIndex = renderStart + index;
|
|
71
|
+
return (
|
|
72
|
+
<div key={actualIndex} style={{ height: itemHeight }}>
|
|
73
|
+
{renderItem(item, actualIndex)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
package/src/index.css
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { Button } from "./components/Button";
|
|
2
|
+
export { Header } from "./components/Header";
|
|
3
|
+
export { BottomSheet } from "./components/BottomSheet";
|
|
4
|
+
export { ToastProvider, useToast } from "./components/Toast";
|
|
5
|
+
export { DatePicker } from "./components/DatePicker";
|
|
6
|
+
export { TimePicker } from "./components/TimePicker";
|
|
7
|
+
export { OTPInput } from "./components/OTPInput";
|
|
8
|
+
export { Card } from "./components/Card";
|
|
9
|
+
export { VirtualList } from "./components/VirtualList";
|
|
10
|
+
export { SearchBar } from "./components/SearchBar";
|
|
11
|
+
export { Modal } from './components/Modal'
|
|
12
|
+
export { Tabs } from './components/Tabs'
|
|
13
|
+
export { Collapse } from './components/Collapse'
|
|
14
|
+
export { Drawer } from './components/Drawer'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Button } from "../components/Button";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Button> = {
|
|
5
|
+
title: "mini-app-component/Button",
|
|
6
|
+
component: Button,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof Button>;
|
|
13
|
+
|
|
14
|
+
export const Variants: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<div
|
|
17
|
+
style={{ width: 320, display: "flex", flexDirection: "column", gap: 12 }}
|
|
18
|
+
>
|
|
19
|
+
<Button variant="primary" onPress={() => alert("Primary tapped")}>
|
|
20
|
+
Primary Button
|
|
21
|
+
</Button>
|
|
22
|
+
<Button variant="secondary" onPress={() => alert("Secondary tapped")}>
|
|
23
|
+
Secondary Button
|
|
24
|
+
</Button>
|
|
25
|
+
<Button variant="danger" onPress={() => alert("Danger tapped")}>
|
|
26
|
+
Danger Button
|
|
27
|
+
</Button>
|
|
28
|
+
<Button variant="ghost" onPress={() => alert("Ghost tapped")}>
|
|
29
|
+
Ghost Button
|
|
30
|
+
</Button>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const Sizes: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
width: 320,
|
|
40
|
+
display: "flex",
|
|
41
|
+
alignItems: "center",
|
|
42
|
+
gap: 12,
|
|
43
|
+
flexWrap: "wrap",
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<Button size="sm">Small (32px)</Button>
|
|
47
|
+
<Button size="md">Medium (44px)</Button>
|
|
48
|
+
<Button size="lg">Large (52px)</Button>
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const States: Story = {
|
|
54
|
+
render: () => (
|
|
55
|
+
<div
|
|
56
|
+
style={{ width: 320, display: "flex", flexDirection: "column", gap: 12 }}
|
|
57
|
+
>
|
|
58
|
+
<Button loading variant="primary">
|
|
59
|
+
Đang tải dữ liệu
|
|
60
|
+
</Button>
|
|
61
|
+
<Button disabled variant="primary">
|
|
62
|
+
Không thể tương tác
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const FullWidth: Story = {
|
|
69
|
+
render: () => (
|
|
70
|
+
<div style={{ width: 320 }}>
|
|
71
|
+
<Button fullWidth variant="primary" size="lg">
|
|
72
|
+
Thanh toán ngay
|
|
73
|
+
</Button>
|
|
74
|
+
</div>
|
|
75
|
+
),
|
|
76
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Card } from "../components/Card";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Card> = {
|
|
5
|
+
title: "mini-app-component/Card",
|
|
6
|
+
component: Card,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof Card>;
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<div style={{ width: 320 }}>
|
|
17
|
+
<Card>
|
|
18
|
+
<Card.Header>
|
|
19
|
+
<h3 className="font-semibold text-gray-900">Chi tiết đơn hàng</h3>
|
|
20
|
+
<span className="text-xs text-indigo-600 bg-indigo-50 px-2 py-1 rounded-md">
|
|
21
|
+
Mới
|
|
22
|
+
</span>
|
|
23
|
+
</Card.Header>
|
|
24
|
+
<Card.Body>
|
|
25
|
+
Đơn hàng #12345 đã được xác nhận và đang chờ giao cho đơn vị vận
|
|
26
|
+
chuyển.
|
|
27
|
+
</Card.Body>
|
|
28
|
+
<Card.Footer>
|
|
29
|
+
<span className="text-xs text-gray-500">Cập nhật 2 phút trước</span>
|
|
30
|
+
</Card.Footer>
|
|
31
|
+
</Card>
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const Pressable: Story = {
|
|
37
|
+
render: () => (
|
|
38
|
+
<div
|
|
39
|
+
style={{ width: 320, display: "flex", flexDirection: "column", gap: 12 }}
|
|
40
|
+
>
|
|
41
|
+
<Card isPressable onClick={() => alert("Đã chọn thanh toán MoMo!")}>
|
|
42
|
+
<Card.Body className="flex items-center gap-3">
|
|
43
|
+
<div className="w-8 h-8 bg-pink-500 rounded-lg flex items-center justify-center text-white font-bold text-xs">
|
|
44
|
+
M
|
|
45
|
+
</div>
|
|
46
|
+
<span className="font-medium text-gray-900">Thanh toán qua MoMo</span>
|
|
47
|
+
</Card.Body>
|
|
48
|
+
</Card>
|
|
49
|
+
|
|
50
|
+
<Card isPressable onClick={() => alert("Đã chọn thanh toán ZaloPay!")}>
|
|
51
|
+
<Card.Body className="flex items-center gap-3">
|
|
52
|
+
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-xs">
|
|
53
|
+
Z
|
|
54
|
+
</div>
|
|
55
|
+
<span className="font-medium text-gray-900">
|
|
56
|
+
Thanh toán qua ZaloPay
|
|
57
|
+
</span>
|
|
58
|
+
</Card.Body>
|
|
59
|
+
</Card>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Disabled: Story = {
|
|
65
|
+
render: () => (
|
|
66
|
+
<div style={{ width: 320 }}>
|
|
67
|
+
<Card disabled isPressable>
|
|
68
|
+
<Card.Body className="flex items-center justify-between">
|
|
69
|
+
<span className="font-medium text-gray-900">Voucher giảm 50K</span>
|
|
70
|
+
<span className="text-xs text-red-500 bg-red-50 px-2 py-1 rounded-md">
|
|
71
|
+
Đã hết hạn
|
|
72
|
+
</span>
|
|
73
|
+
</Card.Body>
|
|
74
|
+
</Card>
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
};
|