@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.
Files changed (99) hide show
  1. package/dist/index.mjs +215 -214
  2. package/package.json +4 -2
  3. package/src/__tests__/use-intersection-observer.test.tsx +216 -0
  4. package/src/__tests__/use-local-storage.test.tsx +174 -0
  5. package/src/__tests__/use-pro-access.test.tsx +183 -0
  6. package/src/components/advanced-chart/advanced-chart.test.tsx +281 -0
  7. package/src/components/advanced-chart/index.tsx +412 -0
  8. package/src/components/advanced-forms/index.tsx +431 -0
  9. package/src/components/animated-button/index.tsx +202 -0
  10. package/src/components/calendar/event-dialog.tsx +372 -0
  11. package/src/components/calendar/index.tsx +557 -0
  12. package/src/components/color-picker/index.tsx +434 -0
  13. package/src/components/dashboard/index.tsx +334 -0
  14. package/src/components/data-table/data-table.test.tsx +187 -0
  15. package/src/components/data-table/index.tsx +368 -0
  16. package/src/components/draggable-list/index.tsx +100 -0
  17. package/src/components/enhanced/button.tsx +360 -0
  18. package/src/components/enhanced/card.tsx +272 -0
  19. package/src/components/enhanced/dialog.tsx +248 -0
  20. package/src/components/enhanced/index.ts +3 -0
  21. package/src/components/error-boundary/index.tsx +111 -0
  22. package/src/components/file-upload/file-upload.test.tsx +242 -0
  23. package/src/components/file-upload/index.tsx +362 -0
  24. package/src/components/floating-action-button/index.tsx +209 -0
  25. package/src/components/github-stars/index.tsx +414 -0
  26. package/src/components/health-check/index.tsx +441 -0
  27. package/src/components/hover-card-3d/index.tsx +170 -0
  28. package/src/components/index.ts +76 -0
  29. package/src/components/kanban/index.tsx +436 -0
  30. package/src/components/lazy-component/index.tsx +342 -0
  31. package/src/components/magnetic-button/index.tsx +170 -0
  32. package/src/components/memory-efficient-data/index.tsx +352 -0
  33. package/src/components/optimized-image/index.tsx +427 -0
  34. package/src/components/performance-debugger/index.tsx +591 -0
  35. package/src/components/performance-monitor/index.tsx +775 -0
  36. package/src/components/pinch-zoom/index.tsx +172 -0
  37. package/src/components/rich-text-editor/index-old-backup.tsx +443 -0
  38. package/src/components/rich-text-editor/index.tsx +1537 -0
  39. package/src/components/rich-text-editor/slash-commands-extension.ts +220 -0
  40. package/src/components/rich-text-editor/slash-commands.css +35 -0
  41. package/src/components/rich-text-editor/table-styles.css +65 -0
  42. package/src/components/spotlight-card/index.tsx +194 -0
  43. package/src/components/swipeable-card/index.tsx +100 -0
  44. package/src/components/timeline/index.tsx +333 -0
  45. package/src/components/ui/animated-button.tsx +185 -0
  46. package/src/components/ui/avatar.tsx +135 -0
  47. package/src/components/ui/badge.tsx +225 -0
  48. package/src/components/ui/button.tsx +221 -0
  49. package/src/components/ui/card.tsx +141 -0
  50. package/src/components/ui/checkbox.tsx +256 -0
  51. package/src/components/ui/color-picker.tsx +95 -0
  52. package/src/components/ui/dialog.tsx +332 -0
  53. package/src/components/ui/dropdown-menu.tsx +200 -0
  54. package/src/components/ui/hover-card-3d.tsx +103 -0
  55. package/src/components/ui/index.ts +33 -0
  56. package/src/components/ui/input.tsx +219 -0
  57. package/src/components/ui/label.tsx +26 -0
  58. package/src/components/ui/magnetic-button.tsx +129 -0
  59. package/src/components/ui/popover.tsx +183 -0
  60. package/src/components/ui/select.tsx +273 -0
  61. package/src/components/ui/separator.tsx +140 -0
  62. package/src/components/ui/slider.tsx +351 -0
  63. package/src/components/ui/spotlight-card.tsx +119 -0
  64. package/src/components/ui/switch.tsx +83 -0
  65. package/src/components/ui/tabs.tsx +195 -0
  66. package/src/components/ui/textarea.tsx +25 -0
  67. package/src/components/ui/toast.tsx +313 -0
  68. package/src/components/ui/tooltip.tsx +152 -0
  69. package/src/components/virtual-list/index.tsx +369 -0
  70. package/src/hooks/use-chart.ts +205 -0
  71. package/src/hooks/use-data-table.ts +182 -0
  72. package/src/hooks/use-docs-pro-access.ts +13 -0
  73. package/src/hooks/use-license-check.ts +65 -0
  74. package/src/hooks/use-subscription.ts +19 -0
  75. package/src/index.ts +14 -0
  76. package/src/lib/micro-interactions.ts +255 -0
  77. package/src/lib/utils.ts +6 -0
  78. package/src/patterns/login-form/index.tsx +276 -0
  79. package/src/patterns/login-form/types.ts +67 -0
  80. package/src/setupTests.ts +41 -0
  81. package/src/styles/design-system.css +365 -0
  82. package/src/styles/index.css +4 -0
  83. package/src/styles/tailwind.css +6 -0
  84. package/src/styles/tokens.css +453 -0
  85. package/src/types/moonui.d.ts +22 -0
  86. package/src/use-intersection-observer.tsx +154 -0
  87. package/src/use-local-storage.tsx +71 -0
  88. package/src/use-paddle.ts +138 -0
  89. package/src/use-performance-optimizer.ts +379 -0
  90. package/src/use-pro-access.ts +141 -0
  91. package/src/use-scroll-animation.ts +221 -0
  92. package/src/use-subscription.ts +37 -0
  93. package/src/use-toast.ts +32 -0
  94. package/src/utils/chart-helpers.ts +257 -0
  95. package/src/utils/cn.ts +69 -0
  96. package/src/utils/data-processing.ts +151 -0
  97. package/src/utils/license-guard.tsx +177 -0
  98. package/src/utils/license-validator.tsx +183 -0
  99. 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,3 @@
1
+ export * from './button'
2
+ export * from './card'
3
+ export * from './dialog'
@@ -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
+ })