@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,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
|
+
}
|
package/react-shim.js
ADDED
|
@@ -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
|
+
}
|