@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,178 @@
1
+ import { useRef, useState, KeyboardEvent, ClipboardEvent } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ interface OTPInputProps {
7
+ length?: number;
8
+ onComplete?: (otp: string) => void;
9
+ onChange?: (otp: string) => void;
10
+ label?: string;
11
+ hint?: string;
12
+ error?: string;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export function OTPInput({
17
+ length = 6,
18
+ onComplete,
19
+ onChange,
20
+ label,
21
+ hint,
22
+ error,
23
+ disabled = false,
24
+ }: OTPInputProps) {
25
+ const [values, setValues] = useState<string[]>(Array(length).fill(""));
26
+ const [activeIndex, setActiveIndex] = useState<number>(-1);
27
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
28
+
29
+ function updateValues(newValues: string[]) {
30
+ setValues(newValues);
31
+ const otp = newValues.join("");
32
+ onChange?.(otp);
33
+ if (otp.length === length && newValues.every(Boolean)) {
34
+ onComplete?.(otp);
35
+ }
36
+ }
37
+
38
+ function handleChange(index: number, val: string) {
39
+ // Chỉ nhận số
40
+ const digit = val.replace(/\D/g, "").slice(-1);
41
+ const newValues = [...values];
42
+ newValues[index] = digit;
43
+ updateValues(newValues);
44
+
45
+ // Auto focus next
46
+ if (digit && index < length - 1) {
47
+ inputRefs.current[index + 1]?.focus();
48
+ }
49
+ }
50
+
51
+ function handleKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
52
+ if (e.key === "Backspace") {
53
+ if (values[index]) {
54
+ const newValues = [...values];
55
+ newValues[index] = "";
56
+ updateValues(newValues);
57
+ } else if (index > 0) {
58
+ inputRefs.current[index - 1]?.focus();
59
+ const newValues = [...values];
60
+ newValues[index - 1] = "";
61
+ updateValues(newValues);
62
+ }
63
+ }
64
+
65
+ if (e.key === "ArrowLeft" && index > 0) {
66
+ inputRefs.current[index - 1]?.focus();
67
+ }
68
+
69
+ if (e.key === "ArrowRight" && index < length - 1) {
70
+ inputRefs.current[index + 1]?.focus();
71
+ }
72
+ }
73
+
74
+ function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
75
+ e.preventDefault();
76
+ const pasted = e.clipboardData
77
+ .getData("text")
78
+ .replace(/\D/g, "")
79
+ .slice(0, length);
80
+ if (!pasted) return;
81
+
82
+ const newValues = Array(length).fill("");
83
+ pasted.split("").forEach((char, i) => {
84
+ newValues[i] = char;
85
+ });
86
+ updateValues(newValues);
87
+
88
+ // Focus last filled or next empty
89
+ const lastIndex = Math.min(pasted.length, length - 1);
90
+ inputRefs.current[lastIndex]?.focus();
91
+ }
92
+
93
+ function handleFocus(index: number) {
94
+ setActiveIndex(index);
95
+ // Select content khi focus
96
+ inputRefs.current[index]?.select();
97
+ }
98
+
99
+ function handleBlur() {
100
+ setActiveIndex(-1);
101
+ }
102
+
103
+ const isComplete = values.every(Boolean);
104
+
105
+ return (
106
+ <div className="flex flex-col gap-3">
107
+ {/* Label */}
108
+ {label && (
109
+ <label className="text-sm font-medium text-gray-700">{label}</label>
110
+ )}
111
+
112
+ {/* OTP inputs */}
113
+ <div className="flex gap-3 justify-center">
114
+ {Array(length)
115
+ .fill(null)
116
+ .map((_, index) => (
117
+ <input
118
+ key={index}
119
+ ref={(el) => {
120
+ inputRefs.current[index] = el;
121
+ }}
122
+ type="text"
123
+ inputMode="numeric"
124
+ maxLength={1}
125
+ value={values[index]}
126
+ disabled={disabled}
127
+ onChange={(e) => handleChange(index, e.target.value)}
128
+ onKeyDown={(e) => handleKeyDown(index, e)}
129
+ onPaste={handlePaste}
130
+ onFocus={() => handleFocus(index)}
131
+ onBlur={handleBlur}
132
+ className={[
133
+ "w-12 h-14 text-center text-xl font-bold rounded-2xl border-2 outline-none",
134
+ "transition-all duration-150 select-none",
135
+ "disabled:opacity-50 disabled:cursor-not-allowed",
136
+ error
137
+ ? "border-red-400 bg-red-50 text-red-600"
138
+ : activeIndex === index
139
+ ? "border-indigo-500 bg-indigo-50 text-indigo-700 scale-105 shadow-md shadow-indigo-100"
140
+ : values[index]
141
+ ? "border-indigo-300 bg-white text-gray-900"
142
+ : "border-gray-200 bg-gray-50 text-gray-900",
143
+ ].join(" ")}
144
+ />
145
+ ))}
146
+ </div>
147
+
148
+ {/* Success state */}
149
+ {isComplete && !error && (
150
+ <div className="flex items-center justify-center gap-1.5">
151
+ <div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
152
+ <svg
153
+ width="10"
154
+ height="10"
155
+ viewBox="0 0 24 24"
156
+ fill="none"
157
+ stroke="white"
158
+ strokeWidth="3"
159
+ >
160
+ <polyline points="20 6 9 17 4 12" />
161
+ </svg>
162
+ </div>
163
+ <span className="text-sm text-green-600 font-medium">
164
+ Xác nhận thành công
165
+ </span>
166
+ </div>
167
+ )}
168
+
169
+ {/* Error */}
170
+ {error && <p className="text-sm text-red-500 text-center">{error}</p>}
171
+
172
+ {/* Hint */}
173
+ {hint && !error && (
174
+ <p className="text-xs text-gray-400 text-center">{hint}</p>
175
+ )}
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,141 @@
1
+ import { useRef, useState, InputHTMLAttributes, KeyboardEvent } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ interface SearchBarProps extends Omit<
7
+ InputHTMLAttributes<HTMLInputElement>,
8
+ "onChange"
9
+ > {
10
+ value?: string;
11
+ onChange?: (val: string) => void;
12
+ onSearch?: (val: string) => void;
13
+ onClear?: () => void;
14
+ onCancel?: () => void;
15
+ showCancel?: boolean;
16
+ cancelText?: string;
17
+ }
18
+
19
+ export function SearchBar({
20
+ value,
21
+ onChange,
22
+ onSearch,
23
+ onClear,
24
+ onCancel,
25
+ showCancel = false,
26
+ cancelText = "Hủy",
27
+ placeholder = "Tìm kiếm...",
28
+ className = "",
29
+ disabled = false,
30
+ ...props
31
+ }: SearchBarProps) {
32
+ const inputRef = useRef<HTMLInputElement>(null);
33
+ const [isFocused, setIsFocused] = useState(false);
34
+
35
+ // Fallback internal state nếu không truyền value từ ngoài vào
36
+ const [internalValue, setInternalValue] = useState("");
37
+ const displayValue = value !== undefined ? value : internalValue;
38
+
39
+ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
40
+ const val = e.target.value;
41
+ setInternalValue(val);
42
+ onChange?.(val);
43
+ }
44
+
45
+ function handleClear() {
46
+ setInternalValue("");
47
+ onChange?.("");
48
+ onClear?.();
49
+ // Giữ focus lại vào input sau khi clear để bàn phím không bị thụt xuống
50
+ inputRef.current?.focus();
51
+ }
52
+
53
+ function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
54
+ if (e.key === "Enter") {
55
+ // Ẩn bàn phím khi submit search trên mobile
56
+ inputRef.current?.blur();
57
+ onSearch?.(displayValue);
58
+ }
59
+ }
60
+
61
+ const isShowCancel = showCancel || (isFocused && onCancel);
62
+
63
+ return (
64
+ <div className={`flex items-center gap-3 w-full ${className}`}>
65
+ {/* Khung Search */}
66
+ <div
67
+ className={[
68
+ "flex-1 flex items-center h-10 px-3 rounded-xl transition-colors duration-200",
69
+ disabled
70
+ ? "bg-gray-100 opacity-60"
71
+ : "bg-gray-100 focus-within:bg-gray-200/60",
72
+ ].join(" ")}
73
+ >
74
+ {/* Icon Search */}
75
+ <svg
76
+ width="18"
77
+ height="18"
78
+ viewBox="0 0 24 24"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ strokeWidth="2.5"
82
+ className="text-gray-400 flex-shrink-0"
83
+ >
84
+ <circle cx="11" cy="11" r="8" />
85
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
86
+ </svg>
87
+
88
+ {/* Input */}
89
+ <input
90
+ ref={inputRef}
91
+ type="search"
92
+ enterKeyHint="search"
93
+ value={displayValue}
94
+ onChange={handleChange}
95
+ onKeyDown={handleKeyDown}
96
+ onFocus={() => setIsFocused(true)}
97
+ onBlur={() => setIsFocused(false)}
98
+ placeholder={placeholder}
99
+ disabled={disabled}
100
+ className={[
101
+ "flex-1 w-full bg-transparent border-none outline-none px-2 text-[15px] text-gray-900 placeholder:text-gray-500",
102
+ // Ẩn nút X mặc định cực xấu của Webkit
103
+ "[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden",
104
+ ].join(" ")}
105
+ {...props}
106
+ />
107
+
108
+ {/* Nút Clear (x) */}
109
+ {displayValue.length > 0 && !disabled && (
110
+ <button
111
+ type="button"
112
+ onClick={handleClear}
113
+ className="w-5 h-5 flex items-center justify-center rounded-full bg-gray-300 hover:bg-gray-400 active:bg-gray-500 text-white transition-colors flex-shrink-0"
114
+ >
115
+ <svg
116
+ width="12"
117
+ height="12"
118
+ viewBox="0 0 24 24"
119
+ fill="none"
120
+ stroke="currentColor"
121
+ strokeWidth="3"
122
+ >
123
+ <line x1="18" y1="6" x2="6" y2="18" />
124
+ <line x1="6" y1="6" x2="18" y2="18" />
125
+ </svg>
126
+ </button>
127
+ )}
128
+ </div>
129
+
130
+ {/* Nút Cancel (Thường dùng kiểu iOS) */}
131
+ {isShowCancel && (
132
+ <button
133
+ onClick={onCancel}
134
+ className="text-[15px] font-medium text-indigo-600 hover:text-indigo-700 active:text-indigo-800 transition-colors animate-in slide-in-from-right-4 fade-in duration-200"
135
+ >
136
+ {cancelText}
137
+ </button>
138
+ )}
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,121 @@
1
+ import { ReactNode, useState } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ interface TabItem {
7
+ id: string;
8
+ label: string;
9
+ icon?: ReactNode;
10
+ badge?: number;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ interface TabsProps {
15
+ items: TabItem[];
16
+ defaultActive?: string;
17
+ onChange?: (id: string) => void;
18
+ variant?: "line" | "pill" | "card";
19
+ children?: (activeId: string) => ReactNode;
20
+ }
21
+
22
+ // ================================
23
+ // Tabs
24
+ // ================================
25
+ export function Tabs({
26
+ items,
27
+ defaultActive,
28
+ onChange,
29
+ variant = "line",
30
+ children,
31
+ }: TabsProps) {
32
+ const [activeId, setActiveId] = useState(defaultActive ?? items[0]?.id);
33
+
34
+ function handleChange(id: string) {
35
+ setActiveId(id);
36
+ onChange?.(id);
37
+ }
38
+
39
+ return (
40
+ <div className="flex flex-col w-full">
41
+ {/* Tab bar */}
42
+ <div
43
+ className={[
44
+ "flex w-full",
45
+ variant === "line" ? "border-b border-gray-200" : "",
46
+ variant === "pill" ? "bg-gray-100 rounded-xl p-1 gap-1" : "",
47
+ variant === "card" ? "gap-2" : "",
48
+ ].join(" ")}
49
+ >
50
+ {items.map((item) => (
51
+ <TabButton
52
+ key={item.id}
53
+ item={item}
54
+ isActive={activeId === item.id}
55
+ variant={variant}
56
+ onClick={() => !item.disabled && handleChange(item.id)}
57
+ />
58
+ ))}
59
+ </div>
60
+
61
+ {/* Content */}
62
+ {children && <div className="flex-1 mt-4">{children(activeId)}</div>}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ // ================================
68
+ // Tab Button
69
+ // ================================
70
+ interface TabButtonProps {
71
+ item: TabItem;
72
+ isActive: boolean;
73
+ variant: "line" | "pill" | "card";
74
+ onClick: () => void;
75
+ }
76
+
77
+ function TabButton({ item, isActive, variant, onClick }: TabButtonProps) {
78
+ const base =
79
+ "flex items-center justify-center gap-1.5 transition-all duration-200 select-none relative";
80
+
81
+ const styles = {
82
+ line: [
83
+ base,
84
+ "flex-1 py-3 text-sm font-medium border-b-2 -mb-px",
85
+ isActive
86
+ ? "border-indigo-600 text-indigo-600"
87
+ : "border-transparent text-gray-500 hover:text-gray-700",
88
+ item.disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
89
+ ].join(" "),
90
+
91
+ pill: [
92
+ base,
93
+ "flex-1 py-2 px-3 text-sm font-medium rounded-lg",
94
+ isActive
95
+ ? "bg-white text-indigo-600 shadow-sm"
96
+ : "text-gray-500 hover:text-gray-700",
97
+ item.disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
98
+ ].join(" "),
99
+
100
+ card: [
101
+ base,
102
+ "flex-1 py-3 px-4 text-sm font-medium rounded-xl border-2",
103
+ isActive
104
+ ? "border-indigo-600 bg-indigo-50 text-indigo-600"
105
+ : "border-gray-200 bg-white text-gray-500 hover:border-gray-300",
106
+ item.disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer",
107
+ ].join(" "),
108
+ };
109
+
110
+ return (
111
+ <button className={styles[variant]} onClick={onClick}>
112
+ {item.icon && <span className="flex-shrink-0">{item.icon}</span>}
113
+ <span>{item.label}</span>
114
+ {item.badge !== undefined && item.badge > 0 && (
115
+ <span className="ml-1 min-w-[18px] h-[18px] flex items-center justify-center bg-red-500 text-white text-xs font-bold rounded-full px-1">
116
+ {item.badge > 99 ? "99+" : item.badge}
117
+ </span>
118
+ )}
119
+ </button>
120
+ );
121
+ }