@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,12 @@
1
+ import type { StorybookConfig } from "@storybook/react-vite";
2
+
3
+ const config: StorybookConfig = {
4
+ stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
5
+ addons: ["@storybook/addon-onboarding", "@storybook/addon-docs"],
6
+ framework: {
7
+ name: "@storybook/react-vite",
8
+ options: {},
9
+ },
10
+ };
11
+
12
+ export default config;
@@ -0,0 +1,16 @@
1
+ import type { Preview } from '@storybook/react'
2
+ // @ts-ignore
3
+ import '../src/index.css'
4
+
5
+ const preview: Preview = {
6
+ parameters: {
7
+ controls: {
8
+ matchers: {
9
+ color: /(background|color)$/i,
10
+ date: /Date$/i,
11
+ },
12
+ },
13
+ },
14
+ }
15
+
16
+ export default preview
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@baodev/mini-app-component",
3
+ "version": "1.0.1",
4
+ "type": "module",
5
+ "description": "WEBVIEW COMPONENT",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "sideEffects": false,
9
+ "peerDependencies": {
10
+ "@types/react-dom": ">=18.0.0",
11
+ "react": ">=18.0.0",
12
+ "react-dom": ">=18.0.0"
13
+ },
14
+ "devDependencies": {
15
+ "@chromatic-com/storybook": "^5.2.1",
16
+ "@storybook/addon-a11y": "^10.4.1",
17
+ "@storybook/addon-docs": "^10.4.1",
18
+ "@storybook/addon-mcp": "^0.6.0",
19
+ "@storybook/addon-vitest": "^10.4.1",
20
+ "@storybook/react-vite": "^10.4.1",
21
+ "@types/react": "latest",
22
+ "@vitest/browser-playwright": "^4.1.7",
23
+ "@vitest/coverage-v8": "^4.1.7",
24
+ "playwright": "^1.60.0",
25
+ "storybook": "^10.4.1",
26
+ "tailwindcss": "^3.4.19",
27
+ "tsup": "latest",
28
+ "typescript": "latest",
29
+ "vitest": "^4.1.7"
30
+ },
31
+ "dependencies": {
32
+ "@storybook/react": "^10.4.1",
33
+ "framer-motion": "^12.40.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "storybook": "storybook dev -p 6006",
38
+ "build-storybook": "storybook build"
39
+ }
40
+ }
@@ -0,0 +1,2 @@
1
+ allowBuilds:
2
+ esbuild: false
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
package/react-shim.js ADDED
@@ -0,0 +1,2 @@
1
+ import * as React from 'react';
2
+ export { React };
@@ -0,0 +1,113 @@
1
+ import { ReactNode, useEffect } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { motion, AnimatePresence, useDragControls } from "framer-motion";
4
+
5
+ interface BottomSheetProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ title?: string;
9
+ children: ReactNode;
10
+ snapPoints?: "half" | "full" | "auto";
11
+ }
12
+
13
+ const snapStyles: Record<string, string> = {
14
+ half: "max-h-[50vh]",
15
+ full: "max-h-[90vh]",
16
+ auto: "max-h-[80vh]",
17
+ };
18
+
19
+ export function BottomSheet({
20
+ isOpen,
21
+ onClose,
22
+ title,
23
+ children,
24
+ snapPoints = "auto",
25
+ }: BottomSheetProps) {
26
+ // Điều khiển vùng vuốt bằng Framer Motion
27
+ const dragControls = useDragControls();
28
+
29
+ // Lock scroll
30
+ useEffect(() => {
31
+ document.body.style.overflow = isOpen ? "hidden" : "";
32
+ return () => {
33
+ document.body.style.overflow = "";
34
+ };
35
+ }, [isOpen]);
36
+
37
+ if (typeof document === "undefined") return null;
38
+
39
+ return createPortal(
40
+ <AnimatePresence>
41
+ {isOpen && (
42
+ <>
43
+ {/* Backdrop */}
44
+ <motion.div
45
+ initial={{ opacity: 0 }}
46
+ animate={{ opacity: 1 }}
47
+ exit={{ opacity: 0 }}
48
+ transition={{ duration: 0.3 }}
49
+ className="fixed inset-0 bg-black/40 z-40 pointer-events-auto"
50
+ onClick={onClose}
51
+ />
52
+
53
+ {/* Sheet */}
54
+ <motion.div
55
+ initial={{ y: "100%" }}
56
+ animate={{ y: 0 }}
57
+ exit={{ y: "100%" }}
58
+ transition={{ type: "spring", damping: 25, stiffness: 250 }}
59
+ // --- LOGIC VUỐT CỦA FRAMER MOTION ---
60
+ drag="y"
61
+ dragControls={dragControls}
62
+ dragListener={false} // Tắt drag toàn sheet, chỉ bật ở handle
63
+ dragConstraints={{ top: 0 }} // Không cho vuốt lên quá trần
64
+ dragElastic={0.2} // Độ rít khi cố vuốt lên
65
+ onDragEnd={(e, info) => {
66
+ // Vuốt xuống nhanh hoặc dài hơn 100px thì đóng
67
+ if (info.offset.y > 100 || info.velocity.y > 500) {
68
+ onClose();
69
+ }
70
+ }}
71
+ // ------------------------------------
72
+ className={`fixed bottom-0 left-0 right-0 z-50 bg-white rounded-t-3xl shadow-2xl flex flex-col ${snapStyles[snapPoints]}`}
73
+ >
74
+ {/* Handle bar — chỉ vùng này mới swipe được */}
75
+ <div
76
+ onPointerDown={(e) => dragControls.start(e)} // Kích hoạt kéo ở đây
77
+ className="flex justify-center pt-3 pb-2 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none"
78
+ >
79
+ <div className="w-10 h-1 bg-gray-300 rounded-full" />
80
+ </div>
81
+
82
+ {/* Header */}
83
+ {title && (
84
+ <div className="flex items-center justify-between px-5 py-3 border-b border-gray-100 flex-shrink-0">
85
+ <h3 className="text-base font-semibold text-gray-900">
86
+ {title}
87
+ </h3>
88
+ <button
89
+ onClick={onClose}
90
+ className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors text-gray-500 text-lg"
91
+ >
92
+
93
+ </button>
94
+ </div>
95
+ )}
96
+
97
+ {/* Content — scroll độc lập */}
98
+ <div
99
+ className="flex-1 overflow-y-auto overscroll-contain px-5 py-4"
100
+ style={{
101
+ paddingBottom:
102
+ "calc(var(--ejsc-safe-bottom, env(safe-area-inset-bottom, 0px)) + 16px)",
103
+ }}
104
+ >
105
+ {children}
106
+ </div>
107
+ </motion.div>
108
+ </>
109
+ )}
110
+ </AnimatePresence>,
111
+ document.body,
112
+ );
113
+ }
@@ -0,0 +1,92 @@
1
+ import { ReactNode, MouseEvent } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
7
+ type ButtonSize = "sm" | "md" | "lg";
8
+
9
+ interface ButtonProps {
10
+ children: ReactNode;
11
+ variant?: ButtonVariant;
12
+ size?: ButtonSize;
13
+ fullWidth?: boolean;
14
+ disabled?: boolean;
15
+ loading?: boolean;
16
+ onPress?: (e: MouseEvent<HTMLButtonElement>) => void;
17
+ className?: string;
18
+ }
19
+
20
+ // Tối ưu hóa Mobile: Thay vì dùng hover, tập trung vào trạng thái active khi ngón tay chạm vào
21
+ const variantStyles: Record<ButtonVariant, string> = {
22
+ primary: "bg-indigo-600 text-white active:bg-indigo-700",
23
+ secondary: "bg-gray-100 text-gray-800 active:bg-gray-200",
24
+ danger: "bg-red-500 text-white active:bg-red-600",
25
+ ghost: "bg-transparent text-indigo-600 active:bg-indigo-50",
26
+ };
27
+
28
+ const sizeStyles: Record<ButtonSize, string> = {
29
+ sm: "h-8 px-3 text-sm rounded-lg",
30
+ md: "h-11 px-4 text-base rounded-xl", // Chiều cao 44px chuẩn tap target tối thiểu của iOS
31
+ lg: "h-13 px-6 text-lg rounded-2xl",
32
+ };
33
+
34
+ export function Button({
35
+ children,
36
+ variant = "primary",
37
+ size = "md",
38
+ fullWidth = false,
39
+ disabled = false,
40
+ loading = false,
41
+ onPress,
42
+ className = "",
43
+ }: ButtonProps) {
44
+ return (
45
+ <button
46
+ onClick={disabled || loading ? undefined : onPress}
47
+ disabled={disabled || loading}
48
+ className={[
49
+ // Layout & Typography
50
+ "font-semibold flex items-center justify-center gap-2 select-none outline-none transition-all duration-100",
51
+
52
+ // Mobile UX
53
+ "[-webkit-tap-highlight-color:transparent]", // Xóa khung highlight xấu xí khi tap trên Android
54
+ "active:scale-[0.97]", // Hiệu ứng lún nút nhẹ khi nhấn giữ giống iOS
55
+
56
+ // Trạng thái khóa
57
+ "disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100",
58
+
59
+ variantStyles[variant],
60
+ sizeStyles[size],
61
+ fullWidth ? "w-full" : "w-auto",
62
+ className,
63
+ ]
64
+ .filter(Boolean)
65
+ .join(" ")}
66
+ >
67
+ {/* Icon Loading xoay tròn */}
68
+ {loading && (
69
+ <svg
70
+ className="animate-spin h-4 w-4 text-current"
71
+ viewBox="0 0 24 24"
72
+ fill="none"
73
+ >
74
+ <circle
75
+ className="opacity-25"
76
+ cx="12"
77
+ cy="12"
78
+ r="10"
79
+ stroke="currentColor"
80
+ strokeWidth="4"
81
+ />
82
+ <path
83
+ className="opacity-75"
84
+ fill="currentColor"
85
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
86
+ />
87
+ </svg>
88
+ )}
89
+ <span>{children}</span>
90
+ </button>
91
+ );
92
+ }
@@ -0,0 +1,102 @@
1
+ import { ReactNode, HTMLAttributes } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ interface CardProps extends HTMLAttributes<HTMLDivElement> {
7
+ children: ReactNode;
8
+ isPressable?: boolean;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ // ================================
13
+ // Main Card Component
14
+ // ================================
15
+ function Card({
16
+ children,
17
+ isPressable = false,
18
+ disabled = false,
19
+ className = "",
20
+ onClick,
21
+ ...props
22
+ }: CardProps) {
23
+ return (
24
+ <div
25
+ onClick={disabled ? undefined : onClick}
26
+ className={[
27
+ // Layout cơ bản
28
+ "bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden",
29
+
30
+ // Mobile UX: Bỏ hiệu ứng chớp màu xanh/xám mặc định khi tap trên Android/iOS
31
+ "[-webkit-tap-highlight-color:transparent]",
32
+
33
+ // Trạng thái tương tác (nếu Card có thể click)
34
+ isPressable && !disabled
35
+ ? "cursor-pointer active:scale-[0.98] active:bg-gray-50/80 transition-all duration-150 ease-out"
36
+ : "",
37
+
38
+ // Trạng thái khóa
39
+ disabled ? "opacity-60 cursor-not-allowed" : "",
40
+
41
+ className,
42
+ ]
43
+ .filter(Boolean)
44
+ .join(" ")}
45
+ {...props}
46
+ >
47
+ {children}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ // ================================
53
+ // Sub-components
54
+ // ================================
55
+ function Header({
56
+ children,
57
+ className = "",
58
+ ...props
59
+ }: HTMLAttributes<HTMLDivElement>) {
60
+ return (
61
+ <div
62
+ className={`px-4 py-3 border-b border-gray-50 flex items-center justify-between ${className}`}
63
+ {...props}
64
+ >
65
+ {children}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ function Body({
71
+ children,
72
+ className = "",
73
+ ...props
74
+ }: HTMLAttributes<HTMLDivElement>) {
75
+ return (
76
+ <div className={`px-4 py-4 text-sm text-gray-700 ${className}`} {...props}>
77
+ {children}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function Footer({
83
+ children,
84
+ className = "",
85
+ ...props
86
+ }: HTMLAttributes<HTMLDivElement>) {
87
+ return (
88
+ <div
89
+ className={`px-4 py-3 border-t border-gray-50 bg-gray-50/50 flex items-center ${className}`}
90
+ {...props}
91
+ >
92
+ {children}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ // Gắn các sub-components vào Card chính để tiện import
98
+ Card.Header = Header;
99
+ Card.Body = Body;
100
+ Card.Footer = Footer;
101
+
102
+ export { Card };
@@ -0,0 +1,177 @@
1
+ import { ReactNode, useState, useRef, useEffect } from "react";
2
+
3
+ // ================================
4
+ // Types
5
+ // ================================
6
+ interface CollapseItem {
7
+ id: string;
8
+ title: string;
9
+ subtitle?: string;
10
+ icon?: ReactNode;
11
+ children: ReactNode;
12
+ }
13
+
14
+ interface CollapseProps {
15
+ items: CollapseItem[];
16
+ defaultOpenId?: string | string[];
17
+ multiple?: boolean; // cho phép mở nhiều cùng lúc
18
+ variant?: "default" | "card" | "flush";
19
+ }
20
+
21
+ // ================================
22
+ // Single Collapse Item
23
+ // ================================
24
+ function CollapsePanel({
25
+ item,
26
+ isOpen,
27
+ onToggle,
28
+ variant,
29
+ }: {
30
+ item: CollapseItem;
31
+ isOpen: boolean;
32
+ onToggle: () => void;
33
+ variant: "default" | "card" | "flush";
34
+ }) {
35
+ const contentRef = useRef<HTMLDivElement>(null);
36
+ const [height, setHeight] = useState<number>(0);
37
+
38
+ useEffect(() => {
39
+ if (!contentRef.current) return;
40
+ if (isOpen) {
41
+ setHeight(contentRef.current.scrollHeight);
42
+ } else {
43
+ setHeight(0);
44
+ }
45
+ }, [isOpen]);
46
+
47
+ const wrapperStyles = {
48
+ default: "border border-gray-200 rounded-xl overflow-hidden",
49
+ card: "bg-white rounded-xl shadow-sm overflow-hidden",
50
+ flush: "border-b border-gray-200 last:border-b-0",
51
+ };
52
+
53
+ const headerStyles = {
54
+ default: "px-4 py-3.5 bg-white hover:bg-gray-50",
55
+ card: "px-4 py-3.5 bg-white hover:bg-gray-50",
56
+ flush: "px-0 py-3.5 hover:bg-transparent",
57
+ };
58
+
59
+ return (
60
+ <div className={wrapperStyles[variant]}>
61
+ {/* Header */}
62
+ <button
63
+ onClick={onToggle}
64
+ className={[
65
+ "w-full flex items-center justify-between gap-3 text-left transition-colors duration-150",
66
+ headerStyles[variant],
67
+ ].join(" ")}
68
+ >
69
+ <div className="flex items-center gap-3 flex-1 min-w-0">
70
+ {item.icon && (
71
+ <span className="flex-shrink-0 text-indigo-600">{item.icon}</span>
72
+ )}
73
+ <div className="flex-1 min-w-0">
74
+ <p
75
+ className={[
76
+ "font-medium text-gray-900 truncate",
77
+ isOpen ? "text-indigo-600" : "",
78
+ ].join(" ")}
79
+ >
80
+ {item.title}
81
+ </p>
82
+ {item.subtitle && (
83
+ <p className="text-xs text-gray-400 mt-0.5 truncate">
84
+ {item.subtitle}
85
+ </p>
86
+ )}
87
+ </div>
88
+ </div>
89
+
90
+ {/* Chevron */}
91
+ <svg
92
+ width="18"
93
+ height="18"
94
+ viewBox="0 0 24 24"
95
+ fill="none"
96
+ stroke="currentColor"
97
+ strokeWidth="2.5"
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ className={[
101
+ "flex-shrink-0 text-gray-400 transition-transform duration-300",
102
+ isOpen ? "rotate-180 text-indigo-600" : "",
103
+ ].join(" ")}
104
+ >
105
+ <polyline points="6 9 12 15 18 9" />
106
+ </svg>
107
+ </button>
108
+
109
+ {/* Content với smooth animation */}
110
+ <div
111
+ style={{
112
+ height: `${height}px`,
113
+ overflow: "hidden",
114
+ transition: "height 0.3s ease",
115
+ }}
116
+ >
117
+ <div ref={contentRef}>
118
+ <div
119
+ className={[
120
+ "text-sm text-gray-600",
121
+ variant === "flush" ? "pb-4" : "px-4 pb-4",
122
+ ].join(" ")}
123
+ >
124
+ {item.children}
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ // ================================
133
+ // Collapse
134
+ // ================================
135
+ export function Collapse({
136
+ items,
137
+ defaultOpenId,
138
+ multiple = false,
139
+ variant = "default",
140
+ }: CollapseProps) {
141
+ const defaultOpen = defaultOpenId
142
+ ? Array.isArray(defaultOpenId)
143
+ ? defaultOpenId
144
+ : [defaultOpenId]
145
+ : [];
146
+
147
+ const [openIds, setOpenIds] = useState<string[]>(defaultOpen);
148
+
149
+ function toggle(id: string) {
150
+ if (multiple) {
151
+ setOpenIds((prev) =>
152
+ prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
153
+ );
154
+ } else {
155
+ setOpenIds((prev) => (prev.includes(id) ? [] : [id]));
156
+ }
157
+ }
158
+
159
+ return (
160
+ <div
161
+ className={[
162
+ "w-full",
163
+ variant !== "flush" ? "flex flex-col gap-2" : "",
164
+ ].join(" ")}
165
+ >
166
+ {items.map((item) => (
167
+ <CollapsePanel
168
+ key={item.id}
169
+ item={item}
170
+ isOpen={openIds.includes(item.id)}
171
+ onToggle={() => toggle(item.id)}
172
+ variant={variant}
173
+ />
174
+ ))}
175
+ </div>
176
+ );
177
+ }