@moontra/moonui-pro 2.0.22 → 2.1.0
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/dist/index.mjs +215 -214
- package/package.json +4 -2
- package/src/__tests__/use-intersection-observer.test.tsx +216 -0
- package/src/__tests__/use-local-storage.test.tsx +174 -0
- package/src/__tests__/use-pro-access.test.tsx +183 -0
- package/src/components/advanced-chart/advanced-chart.test.tsx +281 -0
- package/src/components/advanced-chart/index.tsx +412 -0
- package/src/components/advanced-forms/index.tsx +431 -0
- package/src/components/animated-button/index.tsx +202 -0
- package/src/components/calendar/event-dialog.tsx +372 -0
- package/src/components/calendar/index.tsx +557 -0
- package/src/components/color-picker/index.tsx +434 -0
- package/src/components/dashboard/index.tsx +334 -0
- package/src/components/data-table/data-table.test.tsx +187 -0
- package/src/components/data-table/index.tsx +368 -0
- package/src/components/draggable-list/index.tsx +100 -0
- package/src/components/enhanced/button.tsx +360 -0
- package/src/components/enhanced/card.tsx +272 -0
- package/src/components/enhanced/dialog.tsx +248 -0
- package/src/components/enhanced/index.ts +3 -0
- package/src/components/error-boundary/index.tsx +111 -0
- package/src/components/file-upload/file-upload.test.tsx +242 -0
- package/src/components/file-upload/index.tsx +362 -0
- package/src/components/floating-action-button/index.tsx +209 -0
- package/src/components/github-stars/index.tsx +414 -0
- package/src/components/health-check/index.tsx +441 -0
- package/src/components/hover-card-3d/index.tsx +170 -0
- package/src/components/index.ts +76 -0
- package/src/components/kanban/index.tsx +436 -0
- package/src/components/lazy-component/index.tsx +342 -0
- package/src/components/magnetic-button/index.tsx +170 -0
- package/src/components/memory-efficient-data/index.tsx +352 -0
- package/src/components/optimized-image/index.tsx +427 -0
- package/src/components/performance-debugger/index.tsx +591 -0
- package/src/components/performance-monitor/index.tsx +775 -0
- package/src/components/pinch-zoom/index.tsx +172 -0
- package/src/components/rich-text-editor/index-old-backup.tsx +443 -0
- package/src/components/rich-text-editor/index.tsx +1537 -0
- package/src/components/rich-text-editor/slash-commands-extension.ts +220 -0
- package/src/components/rich-text-editor/slash-commands.css +35 -0
- package/src/components/rich-text-editor/table-styles.css +65 -0
- package/src/components/spotlight-card/index.tsx +194 -0
- package/src/components/swipeable-card/index.tsx +100 -0
- package/src/components/timeline/index.tsx +333 -0
- package/src/components/ui/animated-button.tsx +185 -0
- package/src/components/ui/avatar.tsx +135 -0
- package/src/components/ui/badge.tsx +225 -0
- package/src/components/ui/button.tsx +221 -0
- package/src/components/ui/card.tsx +141 -0
- package/src/components/ui/checkbox.tsx +256 -0
- package/src/components/ui/color-picker.tsx +95 -0
- package/src/components/ui/dialog.tsx +332 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/hover-card-3d.tsx +103 -0
- package/src/components/ui/index.ts +33 -0
- package/src/components/ui/input.tsx +219 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/magnetic-button.tsx +129 -0
- package/src/components/ui/popover.tsx +183 -0
- package/src/components/ui/select.tsx +273 -0
- package/src/components/ui/separator.tsx +140 -0
- package/src/components/ui/slider.tsx +351 -0
- package/src/components/ui/spotlight-card.tsx +119 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/tabs.tsx +195 -0
- package/src/components/ui/textarea.tsx +25 -0
- package/src/components/ui/toast.tsx +313 -0
- package/src/components/ui/tooltip.tsx +152 -0
- package/src/components/virtual-list/index.tsx +369 -0
- package/src/hooks/use-chart.ts +205 -0
- package/src/hooks/use-data-table.ts +182 -0
- package/src/hooks/use-docs-pro-access.ts +13 -0
- package/src/hooks/use-license-check.ts +65 -0
- package/src/hooks/use-subscription.ts +19 -0
- package/src/index.ts +14 -0
- package/src/lib/micro-interactions.ts +255 -0
- package/src/lib/utils.ts +6 -0
- package/src/patterns/login-form/index.tsx +276 -0
- package/src/patterns/login-form/types.ts +67 -0
- package/src/setupTests.ts +41 -0
- package/src/styles/design-system.css +365 -0
- package/src/styles/index.css +4 -0
- package/src/styles/tailwind.css +6 -0
- package/src/styles/tokens.css +453 -0
- package/src/types/moonui.d.ts +22 -0
- package/src/use-intersection-observer.tsx +154 -0
- package/src/use-local-storage.tsx +71 -0
- package/src/use-paddle.ts +138 -0
- package/src/use-performance-optimizer.ts +379 -0
- package/src/use-pro-access.ts +141 -0
- package/src/use-scroll-animation.ts +221 -0
- package/src/use-subscription.ts +37 -0
- package/src/use-toast.ts +32 -0
- package/src/utils/chart-helpers.ts +257 -0
- package/src/utils/cn.ts +69 -0
- package/src/utils/data-processing.ts +151 -0
- package/src/utils/license-guard.tsx +177 -0
- package/src/utils/license-validator.tsx +183 -0
- package/src/utils/package-guard.ts +60 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
5
|
+
import { motion, AnimatePresence } from "framer-motion"
|
|
6
|
+
import { X } from "lucide-react"
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
|
|
9
|
+
const Dialog = DialogPrimitive.Root
|
|
10
|
+
const DialogTrigger = DialogPrimitive.Trigger
|
|
11
|
+
const DialogPortal = DialogPrimitive.Portal
|
|
12
|
+
const DialogClose = DialogPrimitive.Close
|
|
13
|
+
|
|
14
|
+
interface EnhancedDialogOverlayProps
|
|
15
|
+
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> {
|
|
16
|
+
blur?: "none" | "sm" | "md" | "lg" | "xl"
|
|
17
|
+
variant?: "default" | "dark" | "light" | "gradient"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const EnhancedDialogOverlay = React.forwardRef<
|
|
21
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
22
|
+
EnhancedDialogOverlayProps
|
|
23
|
+
>(({ className, blur = "md", variant = "default", ...props }, ref) => {
|
|
24
|
+
const blurClasses = {
|
|
25
|
+
none: "",
|
|
26
|
+
sm: "backdrop-blur-sm",
|
|
27
|
+
md: "backdrop-blur-md",
|
|
28
|
+
lg: "backdrop-blur-lg",
|
|
29
|
+
xl: "backdrop-blur-xl",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const variantClasses = {
|
|
33
|
+
default: "bg-black/80",
|
|
34
|
+
dark: "bg-black/90",
|
|
35
|
+
light: "bg-white/80",
|
|
36
|
+
gradient: "bg-gradient-to-br from-black/80 via-gray-900/80 to-black/80",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<DialogPrimitive.Overlay
|
|
41
|
+
ref={ref}
|
|
42
|
+
className={cn(
|
|
43
|
+
"fixed inset-0 z-50",
|
|
44
|
+
blurClasses[blur],
|
|
45
|
+
variantClasses[variant],
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
EnhancedDialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
53
|
+
|
|
54
|
+
interface EnhancedDialogContentProps
|
|
55
|
+
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
|
56
|
+
animation?: "fade" | "scale" | "slide" | "rotate" | "bounce"
|
|
57
|
+
animationDuration?: number
|
|
58
|
+
overlay?: boolean
|
|
59
|
+
overlayProps?: EnhancedDialogOverlayProps
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EnhancedDialogContent = React.forwardRef<
|
|
63
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
64
|
+
EnhancedDialogContentProps
|
|
65
|
+
>(({
|
|
66
|
+
className,
|
|
67
|
+
children,
|
|
68
|
+
animation = "scale",
|
|
69
|
+
animationDuration = 0.3,
|
|
70
|
+
overlay = true,
|
|
71
|
+
overlayProps = {},
|
|
72
|
+
...props
|
|
73
|
+
}, ref) => {
|
|
74
|
+
const animationVariants = {
|
|
75
|
+
fade: {
|
|
76
|
+
initial: { opacity: 0 },
|
|
77
|
+
animate: { opacity: 1 },
|
|
78
|
+
exit: { opacity: 0 }
|
|
79
|
+
},
|
|
80
|
+
scale: {
|
|
81
|
+
initial: { opacity: 0, scale: 0.9, y: 20 },
|
|
82
|
+
animate: { opacity: 1, scale: 1, y: 0 },
|
|
83
|
+
exit: { opacity: 0, scale: 0.9, y: 20 }
|
|
84
|
+
},
|
|
85
|
+
slide: {
|
|
86
|
+
initial: { opacity: 0, y: 100 },
|
|
87
|
+
animate: { opacity: 1, y: 0 },
|
|
88
|
+
exit: { opacity: 0, y: 100 }
|
|
89
|
+
},
|
|
90
|
+
rotate: {
|
|
91
|
+
initial: { opacity: 0, scale: 0.9, rotate: -10 },
|
|
92
|
+
animate: { opacity: 1, scale: 1, rotate: 0 },
|
|
93
|
+
exit: { opacity: 0, scale: 0.9, rotate: 10 }
|
|
94
|
+
},
|
|
95
|
+
bounce: {
|
|
96
|
+
initial: { opacity: 0, scale: 0.3, y: -100 },
|
|
97
|
+
animate: {
|
|
98
|
+
opacity: 1,
|
|
99
|
+
scale: 1,
|
|
100
|
+
y: 0,
|
|
101
|
+
transition: {
|
|
102
|
+
type: "spring",
|
|
103
|
+
duration: animationDuration,
|
|
104
|
+
bounce: 0.5
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
exit: { opacity: 0, scale: 0.5, y: -50 }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<AnimatePresence>
|
|
113
|
+
<DialogPortal>
|
|
114
|
+
{overlay && (
|
|
115
|
+
<motion.div
|
|
116
|
+
initial={{ opacity: 0 }}
|
|
117
|
+
animate={{ opacity: 1 }}
|
|
118
|
+
exit={{ opacity: 0 }}
|
|
119
|
+
transition={{ duration: animationDuration * 0.8 }}
|
|
120
|
+
>
|
|
121
|
+
<EnhancedDialogOverlay {...overlayProps} />
|
|
122
|
+
</motion.div>
|
|
123
|
+
)}
|
|
124
|
+
<motion.div
|
|
125
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
126
|
+
initial={{ opacity: 0 }}
|
|
127
|
+
animate={{ opacity: 1 }}
|
|
128
|
+
exit={{ opacity: 0 }}
|
|
129
|
+
>
|
|
130
|
+
<DialogPrimitive.Content
|
|
131
|
+
ref={ref}
|
|
132
|
+
asChild
|
|
133
|
+
className={cn(
|
|
134
|
+
"relative w-full max-w-lg",
|
|
135
|
+
className
|
|
136
|
+
)}
|
|
137
|
+
{...props}
|
|
138
|
+
>
|
|
139
|
+
<motion.div
|
|
140
|
+
variants={animationVariants[animation]}
|
|
141
|
+
initial="initial"
|
|
142
|
+
animate="animate"
|
|
143
|
+
exit="exit"
|
|
144
|
+
transition={{
|
|
145
|
+
duration: animationDuration,
|
|
146
|
+
ease: "easeInOut"
|
|
147
|
+
}}
|
|
148
|
+
className={cn(
|
|
149
|
+
"relative bg-background rounded-lg shadow-2xl",
|
|
150
|
+
"border border-border",
|
|
151
|
+
"w-full max-w-lg",
|
|
152
|
+
"max-h-[90vh] overflow-auto",
|
|
153
|
+
className
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
{/* Glass effect overlay */}
|
|
157
|
+
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-white/0 pointer-events-none" />
|
|
158
|
+
|
|
159
|
+
{/* Content wrapper with padding */}
|
|
160
|
+
<div className="relative p-6">
|
|
161
|
+
{children}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Close button with enhanced styling */}
|
|
165
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-all hover:opacity-100 hover:rotate-90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
166
|
+
<X className="h-4 w-4" />
|
|
167
|
+
<span className="sr-only">Close</span>
|
|
168
|
+
</DialogPrimitive.Close>
|
|
169
|
+
</motion.div>
|
|
170
|
+
</DialogPrimitive.Content>
|
|
171
|
+
</motion.div>
|
|
172
|
+
</DialogPortal>
|
|
173
|
+
</AnimatePresence>
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
EnhancedDialogContent.displayName = DialogPrimitive.Content.displayName
|
|
177
|
+
|
|
178
|
+
const EnhancedDialogHeader = ({
|
|
179
|
+
className,
|
|
180
|
+
...props
|
|
181
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
182
|
+
<motion.div
|
|
183
|
+
className={cn(
|
|
184
|
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
185
|
+
className
|
|
186
|
+
)}
|
|
187
|
+
initial={{ opacity: 0, y: -10 }}
|
|
188
|
+
animate={{ opacity: 1, y: 0 }}
|
|
189
|
+
transition={{ delay: 0.1, duration: 0.3 }}
|
|
190
|
+
{...props}
|
|
191
|
+
/>
|
|
192
|
+
)
|
|
193
|
+
EnhancedDialogHeader.displayName = "EnhancedDialogHeader"
|
|
194
|
+
|
|
195
|
+
const EnhancedDialogFooter = ({
|
|
196
|
+
className,
|
|
197
|
+
...props
|
|
198
|
+
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
199
|
+
<motion.div
|
|
200
|
+
className={cn(
|
|
201
|
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
202
|
+
className
|
|
203
|
+
)}
|
|
204
|
+
initial={{ opacity: 0, y: 10 }}
|
|
205
|
+
animate={{ opacity: 1, y: 0 }}
|
|
206
|
+
transition={{ delay: 0.2, duration: 0.3 }}
|
|
207
|
+
{...props}
|
|
208
|
+
/>
|
|
209
|
+
)
|
|
210
|
+
EnhancedDialogFooter.displayName = "EnhancedDialogFooter"
|
|
211
|
+
|
|
212
|
+
const EnhancedDialogTitle = React.forwardRef<
|
|
213
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
214
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
215
|
+
>(({ className, ...props }, ref) => (
|
|
216
|
+
<DialogPrimitive.Title
|
|
217
|
+
ref={ref}
|
|
218
|
+
className={cn(
|
|
219
|
+
"text-lg font-semibold leading-none tracking-tight",
|
|
220
|
+
className
|
|
221
|
+
)}
|
|
222
|
+
{...props}
|
|
223
|
+
/>
|
|
224
|
+
))
|
|
225
|
+
EnhancedDialogTitle.displayName = DialogPrimitive.Title.displayName
|
|
226
|
+
|
|
227
|
+
const EnhancedDialogDescription = React.forwardRef<
|
|
228
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
229
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
230
|
+
>(({ className, ...props }, ref) => (
|
|
231
|
+
<DialogPrimitive.Description
|
|
232
|
+
ref={ref}
|
|
233
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
234
|
+
{...props}
|
|
235
|
+
/>
|
|
236
|
+
))
|
|
237
|
+
EnhancedDialogDescription.displayName = DialogPrimitive.Description.displayName
|
|
238
|
+
|
|
239
|
+
export {
|
|
240
|
+
Dialog as EnhancedDialog,
|
|
241
|
+
DialogTrigger as EnhancedDialogTrigger,
|
|
242
|
+
EnhancedDialogContent,
|
|
243
|
+
EnhancedDialogHeader,
|
|
244
|
+
EnhancedDialogFooter,
|
|
245
|
+
EnhancedDialogTitle,
|
|
246
|
+
EnhancedDialogDescription,
|
|
247
|
+
DialogClose as EnhancedDialogClose,
|
|
248
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { Component, ErrorInfo, ReactNode } from "react"
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
|
5
|
+
import { Button } from "../ui/button"
|
|
6
|
+
import { AlertTriangle, RefreshCw, Lock, Sparkles } from "lucide-react"
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
import { useSubscription } from "../../hooks/use-subscription"
|
|
9
|
+
|
|
10
|
+
export interface ErrorBoundaryProps {
|
|
11
|
+
children: ReactNode
|
|
12
|
+
fallback?: ReactNode
|
|
13
|
+
className?: string
|
|
14
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ErrorBoundaryState {
|
|
18
|
+
hasError: boolean
|
|
19
|
+
error?: Error
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class ErrorBoundaryInternal extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
23
|
+
constructor(props: ErrorBoundaryProps) {
|
|
24
|
+
super(props)
|
|
25
|
+
this.state = { hasError: false }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
29
|
+
return { hasError: true, error }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
33
|
+
this.props.onError?.(error, errorInfo)
|
|
34
|
+
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
render() {
|
|
38
|
+
if (this.state.hasError) {
|
|
39
|
+
if (this.props.fallback) {
|
|
40
|
+
return this.props.fallback
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={cn("flex items-center justify-center min-h-[200px] p-4", this.props.className)}>
|
|
45
|
+
<Card className="w-full max-w-md">
|
|
46
|
+
<CardHeader className="text-center">
|
|
47
|
+
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
|
48
|
+
<AlertTriangle className="h-6 w-6 text-red-600" />
|
|
49
|
+
</div>
|
|
50
|
+
<CardTitle>Something went wrong</CardTitle>
|
|
51
|
+
<CardDescription>
|
|
52
|
+
An error occurred while rendering this component
|
|
53
|
+
</CardDescription>
|
|
54
|
+
</CardHeader>
|
|
55
|
+
<CardContent className="text-center">
|
|
56
|
+
<Button
|
|
57
|
+
onClick={() => this.setState({ hasError: false, error: undefined })}
|
|
58
|
+
className="mt-4"
|
|
59
|
+
>
|
|
60
|
+
<RefreshCw className="mr-2 h-4 w-4" />
|
|
61
|
+
Try again
|
|
62
|
+
</Button>
|
|
63
|
+
</CardContent>
|
|
64
|
+
</Card>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return this.props.children
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ErrorBoundaryWrapper(props: ErrorBoundaryProps) {
|
|
74
|
+
// Check if we're in docs mode or have pro access
|
|
75
|
+
const docsProAccess = { hasAccess: true } // Pro access assumed in package
|
|
76
|
+
const { hasProAccess, isLoading } = useSubscription()
|
|
77
|
+
|
|
78
|
+
// In docs mode, always show the component
|
|
79
|
+
const canShowComponent = docsProAccess.isDocsMode || hasProAccess
|
|
80
|
+
|
|
81
|
+
// If not in docs mode and no pro access, show upgrade prompt
|
|
82
|
+
if (!docsProAccess.isDocsMode && !isLoading && !hasProAccess) {
|
|
83
|
+
return (
|
|
84
|
+
<Card className={cn("w-fit", props.className)}>
|
|
85
|
+
<CardContent className="py-6 text-center">
|
|
86
|
+
<div className="space-y-4">
|
|
87
|
+
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-3 w-fit mx-auto">
|
|
88
|
+
<Lock className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<h3 className="font-semibold text-sm mb-2">Pro Feature</h3>
|
|
92
|
+
<p className="text-muted-foreground text-xs mb-4">
|
|
93
|
+
Error Boundary is available exclusively to MoonUI Pro subscribers.
|
|
94
|
+
</p>
|
|
95
|
+
<a href="/pricing">
|
|
96
|
+
<Button size="sm">
|
|
97
|
+
<Sparkles className="mr-2 h-4 w-4" />
|
|
98
|
+
Upgrade to Pro
|
|
99
|
+
</Button>
|
|
100
|
+
</a>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</CardContent>
|
|
104
|
+
</Card>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return <ErrorBoundaryInternal {...props} />
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const ErrorBoundary = ErrorBoundaryWrapper
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
2
|
+
import '@testing-library/jest-dom'
|
|
3
|
+
import { FileUpload } from './index'
|
|
4
|
+
|
|
5
|
+
// Mock FileReader
|
|
6
|
+
const mockFileReader = {
|
|
7
|
+
readAsDataURL: jest.fn(),
|
|
8
|
+
result: 'data:image/png;base64,mock-base64-data',
|
|
9
|
+
onload: null as any,
|
|
10
|
+
onerror: null as any,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
global.FileReader = jest.fn(() => mockFileReader) as any
|
|
14
|
+
|
|
15
|
+
// Mock file objects
|
|
16
|
+
const createMockFile = (name: string, size: number, type: string) => {
|
|
17
|
+
const file = new File(['mock content'], name, { type })
|
|
18
|
+
Object.defineProperty(file, 'size', { value: size })
|
|
19
|
+
return file
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('FileUpload', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('renders without crashing', () => {
|
|
28
|
+
render(<FileUpload />)
|
|
29
|
+
|
|
30
|
+
expect(screen.getByText('File Upload')).toBeInTheDocument()
|
|
31
|
+
expect(screen.getByText('Drag and drop files here, or click to select')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders with custom title and description', () => {
|
|
35
|
+
render(
|
|
36
|
+
<FileUpload
|
|
37
|
+
accept="image/*"
|
|
38
|
+
maxSize={5}
|
|
39
|
+
multiple
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(screen.getByText('Upload up to 5 files (max 5MB each)')).toBeInTheDocument()
|
|
44
|
+
expect(screen.getByText('Accepted types: image/*')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('handles file selection', async () => {
|
|
48
|
+
const mockOnUpload = jest.fn()
|
|
49
|
+
render(<FileUpload onUpload={mockOnUpload} />)
|
|
50
|
+
|
|
51
|
+
const file = createMockFile('test.jpg', 1024, 'image/jpeg')
|
|
52
|
+
const input = screen.getByRole('button', { hidden: true }) // Hidden file input
|
|
53
|
+
|
|
54
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
55
|
+
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(mockOnUpload).toHaveBeenCalledWith([file])
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('validates file size', () => {
|
|
62
|
+
const mockOnUpload = jest.fn()
|
|
63
|
+
render(<FileUpload onUpload={mockOnUpload} maxSize={1} />)
|
|
64
|
+
|
|
65
|
+
const file = createMockFile('large-file.jpg', 2 * 1024 * 1024, 'image/jpeg') // 2MB
|
|
66
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
67
|
+
|
|
68
|
+
// Mock alert
|
|
69
|
+
global.alert = jest.fn()
|
|
70
|
+
|
|
71
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
72
|
+
|
|
73
|
+
expect(global.alert).toHaveBeenCalledWith('large-file.jpg: File size must be less than 1MB')
|
|
74
|
+
expect(mockOnUpload).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('validates file type', () => {
|
|
78
|
+
const mockOnUpload = jest.fn()
|
|
79
|
+
render(
|
|
80
|
+
<FileUpload
|
|
81
|
+
onUpload={mockOnUpload}
|
|
82
|
+
allowedTypes={['image/jpeg', 'image/png']}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const file = createMockFile('document.pdf', 1024, 'application/pdf')
|
|
87
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
88
|
+
|
|
89
|
+
global.alert = jest.fn()
|
|
90
|
+
|
|
91
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
92
|
+
|
|
93
|
+
expect(global.alert).toHaveBeenCalledWith('document.pdf: File type application/pdf is not allowed')
|
|
94
|
+
expect(mockOnUpload).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('handles multiple files', async () => {
|
|
98
|
+
const mockOnUpload = jest.fn()
|
|
99
|
+
render(<FileUpload onUpload={mockOnUpload} multiple />)
|
|
100
|
+
|
|
101
|
+
const files = [
|
|
102
|
+
createMockFile('file1.jpg', 1024, 'image/jpeg'),
|
|
103
|
+
createMockFile('file2.png', 2048, 'image/png'),
|
|
104
|
+
]
|
|
105
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
106
|
+
|
|
107
|
+
fireEvent.change(input, { target: { files } })
|
|
108
|
+
|
|
109
|
+
await waitFor(() => {
|
|
110
|
+
expect(mockOnUpload).toHaveBeenCalledWith(files)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('respects maxFiles limit', () => {
|
|
115
|
+
const mockOnUpload = jest.fn()
|
|
116
|
+
render(<FileUpload onUpload={mockOnUpload} maxFiles={2} />)
|
|
117
|
+
|
|
118
|
+
const files = [
|
|
119
|
+
createMockFile('file1.jpg', 1024, 'image/jpeg'),
|
|
120
|
+
createMockFile('file2.png', 1024, 'image/png'),
|
|
121
|
+
createMockFile('file3.gif', 1024, 'image/gif'),
|
|
122
|
+
]
|
|
123
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
124
|
+
|
|
125
|
+
global.alert = jest.fn()
|
|
126
|
+
|
|
127
|
+
fireEvent.change(input, { target: { files } })
|
|
128
|
+
|
|
129
|
+
expect(global.alert).toHaveBeenCalledWith('Maximum 2 files allowed')
|
|
130
|
+
expect(mockOnUpload).not.toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('handles drag and drop', () => {
|
|
134
|
+
const mockOnUpload = jest.fn()
|
|
135
|
+
render(<FileUpload onUpload={mockOnUpload} />)
|
|
136
|
+
|
|
137
|
+
const dropZone = screen.getByText('Drag and drop files here, or click to select').closest('div')
|
|
138
|
+
const file = createMockFile('test.jpg', 1024, 'image/jpeg')
|
|
139
|
+
|
|
140
|
+
fireEvent.dragOver(dropZone!, {
|
|
141
|
+
dataTransfer: { files: [file] },
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(dropZone).toHaveClass('border-primary')
|
|
145
|
+
|
|
146
|
+
fireEvent.drop(dropZone!, {
|
|
147
|
+
dataTransfer: { files: [file] },
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(dropZone).not.toHaveClass('border-primary')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('handles file removal', async () => {
|
|
154
|
+
const mockOnRemove = jest.fn()
|
|
155
|
+
render(<FileUpload onRemove={mockOnRemove} />)
|
|
156
|
+
|
|
157
|
+
const file = createMockFile('test.jpg', 1024, 'image/jpeg')
|
|
158
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
159
|
+
|
|
160
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
161
|
+
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(screen.getByText('test.jpg')).toBeInTheDocument()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const removeButton = screen.getByRole('button', { name: /remove/i })
|
|
167
|
+
fireEvent.click(removeButton)
|
|
168
|
+
|
|
169
|
+
expect(mockOnRemove).toHaveBeenCalledWith(file)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('shows progress during upload', async () => {
|
|
173
|
+
const mockOnUpload = jest.fn().mockResolvedValue(undefined)
|
|
174
|
+
render(<FileUpload onUpload={mockOnUpload} />)
|
|
175
|
+
|
|
176
|
+
const file = createMockFile('test.jpg', 1024, 'image/jpeg')
|
|
177
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
178
|
+
|
|
179
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
180
|
+
|
|
181
|
+
await waitFor(() => {
|
|
182
|
+
expect(screen.getByText('Uploading files...')).toBeInTheDocument()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('handles upload success', async () => {
|
|
187
|
+
const mockOnUpload = jest.fn().mockResolvedValue(undefined)
|
|
188
|
+
render(<FileUpload onUpload={mockOnUpload} />)
|
|
189
|
+
|
|
190
|
+
const file = createMockFile('test.jpg', 1024, 'image/jpeg')
|
|
191
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
192
|
+
|
|
193
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(screen.getByText('success')).toBeInTheDocument()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('handles upload error', async () => {
|
|
201
|
+
const mockOnUpload = jest.fn().mockRejectedValue(new Error('Upload failed'))
|
|
202
|
+
render(<FileUpload onUpload={mockOnUpload} />)
|
|
203
|
+
|
|
204
|
+
const file = createMockFile('test.jpg', 1024, 'image/jpeg')
|
|
205
|
+
const input = screen.getByRole('button', { hidden: true })
|
|
206
|
+
|
|
207
|
+
fireEvent.change(input, { target: { files: [file] } })
|
|
208
|
+
|
|
209
|
+
await waitFor(() => {
|
|
210
|
+
expect(screen.getByText('error')).toBeInTheDocument()
|
|
211
|
+
expect(screen.getByText('Upload failed')).toBeInTheDocument()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('is disabled when disabled prop is true', () => {
|
|
216
|
+
render(<FileUpload disabled />)
|
|
217
|
+
|
|
218
|
+
const dropZone = screen.getByText('Drag and drop files here, or click to select').closest('div')
|
|
219
|
+
expect(dropZone).toHaveClass('cursor-not-allowed')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('applies custom className', () => {
|
|
223
|
+
render(<FileUpload className="custom-upload" />)
|
|
224
|
+
|
|
225
|
+
const container = screen.getByText('File Upload').closest('div')
|
|
226
|
+
expect(container).toHaveClass('custom-upload')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('formats file sizes correctly', () => {
|
|
230
|
+
render(<FileUpload />)
|
|
231
|
+
|
|
232
|
+
// This would be tested through the file display after upload
|
|
233
|
+
// The formatFileSize function should be tested separately
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('displays correct file icons', () => {
|
|
237
|
+
render(<FileUpload />)
|
|
238
|
+
|
|
239
|
+
// This would be tested through the file display after upload
|
|
240
|
+
// The getFileIcon function should be tested separately
|
|
241
|
+
})
|
|
242
|
+
})
|