@sabrenski/spire-ui 0.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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,1151 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import FileUpload from './FileUpload.vue'
5
+ import type { UploadFile } from './FileUpload.vue'
6
+
7
+ // Mock URL.createObjectURL and revokeObjectURL
8
+ const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
9
+ const mockRevokeObjectURL = vi.fn()
10
+
11
+ beforeEach(() => {
12
+ global.URL.createObjectURL = mockCreateObjectURL
13
+ global.URL.revokeObjectURL = mockRevokeObjectURL
14
+ })
15
+
16
+ afterEach(() => {
17
+ vi.restoreAllMocks()
18
+ mockCreateObjectURL.mockClear()
19
+ mockRevokeObjectURL.mockClear()
20
+ })
21
+
22
+ function createMockFile(name: string, size: number, type: string): File {
23
+ const file = new File([''], name, { type })
24
+ Object.defineProperty(file, 'size', { value: size })
25
+ return file
26
+ }
27
+
28
+ function createMockDataTransfer(files: File[]): DataTransfer {
29
+ return {
30
+ files: files as unknown as FileList,
31
+ items: [] as unknown as DataTransferItemList,
32
+ types: ['Files'],
33
+ dropEffect: 'none',
34
+ effectAllowed: 'all',
35
+ clearData: vi.fn(),
36
+ getData: vi.fn(),
37
+ setData: vi.fn(),
38
+ setDragImage: vi.fn()
39
+ } as unknown as DataTransfer
40
+ }
41
+
42
+ describe('FileUpload', () => {
43
+ describe('Rendering', () => {
44
+ it('renders dropzone with correct role', () => {
45
+ const wrapper = mount(FileUpload)
46
+ expect(wrapper.find('[role="button"]').exists()).toBe(true)
47
+ })
48
+
49
+ it('renders hidden file input', () => {
50
+ const wrapper = mount(FileUpload)
51
+ expect(wrapper.find('input[type="file"]').exists()).toBe(true)
52
+ })
53
+
54
+ it('renders label when provided', () => {
55
+ const wrapper = mount(FileUpload, {
56
+ props: { label: 'Upload files' }
57
+ })
58
+ expect(wrapper.find('label').text()).toBe('Upload files')
59
+ })
60
+
61
+ it('renders hint text when provided', () => {
62
+ const wrapper = mount(FileUpload, {
63
+ props: { hint: 'Maximum 5MB' }
64
+ })
65
+ expect(wrapper.find('.ui-file-upload__message--hint').text()).toBe('Maximum 5MB')
66
+ })
67
+
68
+ it('renders error text when provided', () => {
69
+ const wrapper = mount(FileUpload, {
70
+ props: { error: 'Upload failed' }
71
+ })
72
+ expect(wrapper.find('.ui-file-upload__message--error').text()).toBe('Upload failed')
73
+ })
74
+
75
+ it('displays file type restrictions', () => {
76
+ const wrapper = mount(FileUpload, {
77
+ props: { accept: 'image/*' }
78
+ })
79
+ expect(wrapper.text()).toContain('image/*')
80
+ })
81
+
82
+ it('displays max size restriction', () => {
83
+ const wrapper = mount(FileUpload, {
84
+ props: { maxSize: 5 * 1024 * 1024 }
85
+ })
86
+ expect(wrapper.text()).toContain('5 MB')
87
+ })
88
+ })
89
+
90
+ describe('Accessibility', () => {
91
+ it('dropzone has tabindex="0"', () => {
92
+ const wrapper = mount(FileUpload)
93
+ expect(wrapper.find('.ui-file-upload__dropzone').attributes('tabindex')).toBe('0')
94
+ })
95
+
96
+ it('input has tabindex="-1"', () => {
97
+ const wrapper = mount(FileUpload)
98
+ expect(wrapper.find('input').attributes('tabindex')).toBe('-1')
99
+ })
100
+
101
+ it('has keydown handler bound to dropzone', () => {
102
+ const wrapper = mount(FileUpload)
103
+ const dropzone = wrapper.find('.ui-file-upload__dropzone')
104
+ // Verify that keydown event listener is registered
105
+ // The actual behavior (calling triggerInput) is tested implicitly
106
+ // through the tabindex="0" which makes the element focusable
107
+ expect(dropzone.attributes('tabindex')).toBe('0')
108
+ expect(dropzone.attributes('role')).toBe('button')
109
+ })
110
+
111
+ it('dropzone has aria-disabled when disabled', () => {
112
+ const wrapper = mount(FileUpload, {
113
+ props: { disabled: true }
114
+ })
115
+ expect(wrapper.find('.ui-file-upload__dropzone').attributes('aria-disabled')).toBe('true')
116
+ })
117
+
118
+ it('aria-describedby links to hint', () => {
119
+ const wrapper = mount(FileUpload, {
120
+ props: { hint: 'Help text' }
121
+ })
122
+ const hintId = wrapper.find('.ui-file-upload__message--hint').attributes('id')
123
+ expect(wrapper.find('.ui-file-upload__dropzone').attributes('aria-describedby')).toBe(hintId)
124
+ })
125
+
126
+ it('aria-describedby links to error when present', () => {
127
+ const wrapper = mount(FileUpload, {
128
+ props: { hint: 'Help text', error: 'Error text' }
129
+ })
130
+ const errorId = wrapper.find('.ui-file-upload__message--error').attributes('id')
131
+ expect(wrapper.find('.ui-file-upload__dropzone').attributes('aria-describedby')).toBe(errorId)
132
+ })
133
+
134
+ it('remove button has aria-label', () => {
135
+ const files: UploadFile[] = [{
136
+ id: '1',
137
+ file: createMockFile('test.png', 1000, 'image/png'),
138
+ name: 'test.png',
139
+ size: 1000,
140
+ type: 'image/png',
141
+ progress: 0,
142
+ status: 'pending'
143
+ }]
144
+ const wrapper = mount(FileUpload, {
145
+ props: { modelValue: files }
146
+ })
147
+ expect(wrapper.find('.ui-file-upload__remove-btn').attributes('aria-label')).toBe('Remove test.png')
148
+ })
149
+ })
150
+
151
+ describe('File input', () => {
152
+ it('passes accept prop to input', () => {
153
+ const wrapper = mount(FileUpload, {
154
+ props: { accept: 'image/*,.pdf' }
155
+ })
156
+ expect(wrapper.find('input').attributes('accept')).toBe('image/*,.pdf')
157
+ })
158
+
159
+ it('passes multiple prop to input', () => {
160
+ const wrapper = mount(FileUpload, {
161
+ props: { multiple: true }
162
+ })
163
+ expect(wrapper.find('input').attributes('multiple')).toBeDefined()
164
+ })
165
+
166
+ it('passes disabled prop to input', () => {
167
+ const wrapper = mount(FileUpload, {
168
+ props: { disabled: true }
169
+ })
170
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
171
+ })
172
+ })
173
+
174
+ describe('Drag & Drop', () => {
175
+ it('sets active state on dragenter', async () => {
176
+ const wrapper = mount(FileUpload)
177
+ await wrapper.find('.ui-file-upload__dropzone').trigger('dragenter')
178
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(true)
179
+ })
180
+
181
+ it('removes active state on dragleave when counter reaches 0', async () => {
182
+ const wrapper = mount(FileUpload)
183
+ const dropzone = wrapper.find('.ui-file-upload__dropzone')
184
+
185
+ await dropzone.trigger('dragenter')
186
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(true)
187
+
188
+ await dropzone.trigger('dragleave')
189
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(false)
190
+ })
191
+
192
+ it('handles flicker bug with counter (nested elements)', async () => {
193
+ const wrapper = mount(FileUpload)
194
+ const dropzone = wrapper.find('.ui-file-upload__dropzone')
195
+
196
+ // Enter parent
197
+ await dropzone.trigger('dragenter')
198
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(true)
199
+
200
+ // Enter child (counter = 2)
201
+ await dropzone.trigger('dragenter')
202
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(true)
203
+
204
+ // Leave child (counter = 1)
205
+ await dropzone.trigger('dragleave')
206
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(true)
207
+
208
+ // Leave parent (counter = 0)
209
+ await dropzone.trigger('dragleave')
210
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(false)
211
+ })
212
+
213
+ it('resets counter on drop', async () => {
214
+ const wrapper = mount(FileUpload)
215
+ const dropzone = wrapper.find('.ui-file-upload__dropzone')
216
+
217
+ // Multiple dragenter events
218
+ await dropzone.trigger('dragenter')
219
+ await dropzone.trigger('dragenter')
220
+
221
+ // Drop should reset
222
+ await dropzone.trigger('drop', {
223
+ dataTransfer: createMockDataTransfer([])
224
+ })
225
+
226
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(false)
227
+ })
228
+
229
+ it('does not activate when disabled', async () => {
230
+ const wrapper = mount(FileUpload, {
231
+ props: { disabled: true }
232
+ })
233
+ await wrapper.find('.ui-file-upload__dropzone').trigger('dragenter')
234
+ expect(wrapper.find('.ui-file-upload__dropzone--active').exists()).toBe(false)
235
+ })
236
+
237
+ it('shows drop text when dragging', async () => {
238
+ const wrapper = mount(FileUpload)
239
+ await wrapper.find('.ui-file-upload__dropzone').trigger('dragenter')
240
+ expect(wrapper.find('.ui-file-upload__drop-text').text()).toBe('Drop files here')
241
+ })
242
+ })
243
+
244
+ describe('File processing', () => {
245
+ it('emits update:modelValue when files are added', async () => {
246
+ const wrapper = mount(FileUpload)
247
+ const file = createMockFile('test.png', 1000, 'image/png')
248
+
249
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
250
+ dataTransfer: createMockDataTransfer([file])
251
+ })
252
+
253
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
254
+ const emittedFiles = wrapper.emitted('update:modelValue')![0][0] as UploadFile[]
255
+ expect(emittedFiles).toHaveLength(1)
256
+ expect(emittedFiles[0].name).toBe('test.png')
257
+ })
258
+
259
+ it('emits files-added event', async () => {
260
+ const wrapper = mount(FileUpload)
261
+ const file = createMockFile('test.png', 1000, 'image/png')
262
+
263
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
264
+ dataTransfer: createMockDataTransfer([file])
265
+ })
266
+
267
+ expect(wrapper.emitted('files-added')).toBeTruthy()
268
+ })
269
+
270
+ it('creates preview URL for images', async () => {
271
+ const wrapper = mount(FileUpload, {
272
+ props: { showPreviews: true }
273
+ })
274
+ const file = createMockFile('test.png', 1000, 'image/png')
275
+
276
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
277
+ dataTransfer: createMockDataTransfer([file])
278
+ })
279
+
280
+ expect(mockCreateObjectURL).toHaveBeenCalled()
281
+ })
282
+
283
+ it('does not create preview for non-images', async () => {
284
+ const wrapper = mount(FileUpload, {
285
+ props: { showPreviews: true }
286
+ })
287
+ const file = createMockFile('test.pdf', 1000, 'application/pdf')
288
+
289
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
290
+ dataTransfer: createMockDataTransfer([file])
291
+ })
292
+
293
+ expect(mockCreateObjectURL).not.toHaveBeenCalled()
294
+ })
295
+
296
+ it('replaces file in single mode', async () => {
297
+ const existingFile: UploadFile = {
298
+ id: '1',
299
+ file: createMockFile('old.png', 1000, 'image/png'),
300
+ name: 'old.png',
301
+ size: 1000,
302
+ type: 'image/png',
303
+ progress: 0,
304
+ status: 'pending'
305
+ }
306
+ const wrapper = mount(FileUpload, {
307
+ props: { modelValue: [existingFile], multiple: false }
308
+ })
309
+ const newFile = createMockFile('new.png', 2000, 'image/png')
310
+
311
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
312
+ dataTransfer: createMockDataTransfer([newFile])
313
+ })
314
+
315
+ const emittedFiles = wrapper.emitted('update:modelValue')![0][0] as UploadFile[]
316
+ expect(emittedFiles).toHaveLength(1)
317
+ expect(emittedFiles[0].name).toBe('new.png')
318
+ })
319
+
320
+ it('appends files in multiple mode', async () => {
321
+ const existingFile: UploadFile = {
322
+ id: '1',
323
+ file: createMockFile('first.png', 1000, 'image/png'),
324
+ name: 'first.png',
325
+ size: 1000,
326
+ type: 'image/png',
327
+ progress: 0,
328
+ status: 'pending'
329
+ }
330
+ const wrapper = mount(FileUpload, {
331
+ props: { modelValue: [existingFile], multiple: true }
332
+ })
333
+ const newFile = createMockFile('second.png', 2000, 'image/png')
334
+
335
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
336
+ dataTransfer: createMockDataTransfer([newFile])
337
+ })
338
+
339
+ const emittedFiles = wrapper.emitted('update:modelValue')![0][0] as UploadFile[]
340
+ expect(emittedFiles).toHaveLength(2)
341
+ })
342
+ })
343
+
344
+ describe('Validation', () => {
345
+ it('rejects files exceeding maxSize', async () => {
346
+ const wrapper = mount(FileUpload, {
347
+ props: { maxSize: 1000 }
348
+ })
349
+ const file = createMockFile('large.png', 2000, 'image/png')
350
+
351
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
352
+ dataTransfer: createMockDataTransfer([file])
353
+ })
354
+
355
+ expect(wrapper.emitted('file-rejected')).toBeTruthy()
356
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
357
+ })
358
+
359
+ it('rejects files with wrong type', async () => {
360
+ const wrapper = mount(FileUpload, {
361
+ props: { accept: 'image/*' }
362
+ })
363
+ const file = createMockFile('doc.pdf', 1000, 'application/pdf')
364
+
365
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
366
+ dataTransfer: createMockDataTransfer([file])
367
+ })
368
+
369
+ expect(wrapper.emitted('file-rejected')).toBeTruthy()
370
+ })
371
+
372
+ it('accepts valid files with wildcard MIME', async () => {
373
+ const wrapper = mount(FileUpload, {
374
+ props: { accept: 'image/*' }
375
+ })
376
+ const file = createMockFile('photo.png', 1000, 'image/png')
377
+
378
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
379
+ dataTransfer: createMockDataTransfer([file])
380
+ })
381
+
382
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
383
+ })
384
+
385
+ it('accepts files with extension match', async () => {
386
+ const wrapper = mount(FileUpload, {
387
+ props: { accept: '.pdf' }
388
+ })
389
+ const file = createMockFile('doc.pdf', 1000, 'application/pdf')
390
+
391
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
392
+ dataTransfer: createMockDataTransfer([file])
393
+ })
394
+
395
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
396
+ })
397
+
398
+ it('rejects files exceeding maxFiles limit', async () => {
399
+ const existingFile: UploadFile = {
400
+ id: '1',
401
+ file: createMockFile('first.png', 1000, 'image/png'),
402
+ name: 'first.png',
403
+ size: 1000,
404
+ type: 'image/png',
405
+ progress: 0,
406
+ status: 'pending'
407
+ }
408
+ const wrapper = mount(FileUpload, {
409
+ props: { modelValue: [existingFile], multiple: true, maxFiles: 1 }
410
+ })
411
+ const newFile = createMockFile('second.png', 1000, 'image/png')
412
+
413
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
414
+ dataTransfer: createMockDataTransfer([newFile])
415
+ })
416
+
417
+ expect(wrapper.emitted('file-rejected')).toBeTruthy()
418
+ })
419
+
420
+ it('partially accepts valid files from batch', async () => {
421
+ const wrapper = mount(FileUpload, {
422
+ props: { accept: 'image/*', multiple: true }
423
+ })
424
+ const validFile = createMockFile('photo.png', 1000, 'image/png')
425
+ const invalidFile = createMockFile('doc.pdf', 1000, 'application/pdf')
426
+
427
+ await wrapper.find('.ui-file-upload__dropzone').trigger('drop', {
428
+ dataTransfer: createMockDataTransfer([validFile, invalidFile])
429
+ })
430
+
431
+ expect(wrapper.emitted('file-rejected')).toBeTruthy()
432
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
433
+ const emittedFiles = wrapper.emitted('update:modelValue')![0][0] as UploadFile[]
434
+ expect(emittedFiles).toHaveLength(1)
435
+ expect(emittedFiles[0].name).toBe('photo.png')
436
+ })
437
+ })
438
+
439
+ describe('File list', () => {
440
+ it('renders file list when files exist', () => {
441
+ const files: UploadFile[] = [{
442
+ id: '1',
443
+ file: createMockFile('test.png', 1000, 'image/png'),
444
+ name: 'test.png',
445
+ size: 1000,
446
+ type: 'image/png',
447
+ progress: 0,
448
+ status: 'pending'
449
+ }]
450
+ const wrapper = mount(FileUpload, {
451
+ props: { modelValue: files }
452
+ })
453
+ expect(wrapper.find('.ui-file-upload__list').exists()).toBe(true)
454
+ expect(wrapper.find('.ui-file-upload__file-name').text()).toBe('test.png')
455
+ })
456
+
457
+ it('displays file size', () => {
458
+ const files: UploadFile[] = [{
459
+ id: '1',
460
+ file: createMockFile('test.png', 1024, 'image/png'),
461
+ name: 'test.png',
462
+ size: 1024,
463
+ type: 'image/png',
464
+ progress: 0,
465
+ status: 'pending'
466
+ }]
467
+ const wrapper = mount(FileUpload, {
468
+ props: { modelValue: files }
469
+ })
470
+ expect(wrapper.find('.ui-file-upload__file-meta').text()).toContain('1 KB')
471
+ })
472
+
473
+ it('renders image preview', () => {
474
+ const files: UploadFile[] = [{
475
+ id: '1',
476
+ file: createMockFile('test.png', 1000, 'image/png'),
477
+ name: 'test.png',
478
+ size: 1000,
479
+ type: 'image/png',
480
+ preview: 'blob:mock-url',
481
+ progress: 0,
482
+ status: 'pending'
483
+ }]
484
+ const wrapper = mount(FileUpload, {
485
+ props: { modelValue: files }
486
+ })
487
+ expect(wrapper.find('.ui-file-upload__file-image').exists()).toBe(true)
488
+ expect(wrapper.find('.ui-file-upload__file-image').attributes('src')).toBe('blob:mock-url')
489
+ })
490
+
491
+ it('renders file icon for non-images', () => {
492
+ const files: UploadFile[] = [{
493
+ id: '1',
494
+ file: createMockFile('doc.pdf', 1000, 'application/pdf'),
495
+ name: 'doc.pdf',
496
+ size: 1000,
497
+ type: 'application/pdf',
498
+ progress: 0,
499
+ status: 'pending'
500
+ }]
501
+ const wrapper = mount(FileUpload, {
502
+ props: { modelValue: files }
503
+ })
504
+ expect(wrapper.find('.ui-file-upload__file-icon').exists()).toBe(true)
505
+ })
506
+ })
507
+
508
+ describe('File removal', () => {
509
+ it('emits file-removed when remove button clicked', async () => {
510
+ const files: UploadFile[] = [{
511
+ id: '1',
512
+ file: createMockFile('test.png', 1000, 'image/png'),
513
+ name: 'test.png',
514
+ size: 1000,
515
+ type: 'image/png',
516
+ progress: 0,
517
+ status: 'pending'
518
+ }]
519
+ const wrapper = mount(FileUpload, {
520
+ props: { modelValue: files }
521
+ })
522
+
523
+ await wrapper.find('.ui-file-upload__remove-btn').trigger('click')
524
+
525
+ expect(wrapper.emitted('file-removed')).toBeTruthy()
526
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy()
527
+ const emittedFiles = wrapper.emitted('update:modelValue')![0][0] as UploadFile[]
528
+ expect(emittedFiles).toHaveLength(0)
529
+ })
530
+
531
+ it('revokes preview URL when file removed', async () => {
532
+ const files: UploadFile[] = [{
533
+ id: '1',
534
+ file: createMockFile('test.png', 1000, 'image/png'),
535
+ name: 'test.png',
536
+ size: 1000,
537
+ type: 'image/png',
538
+ preview: 'blob:mock-url',
539
+ progress: 0,
540
+ status: 'pending'
541
+ }]
542
+ const wrapper = mount(FileUpload, {
543
+ props: { modelValue: files }
544
+ })
545
+
546
+ // Simulate adding the URL to internal tracking
547
+ ;(wrapper.vm as any).previewUrls.set('1', 'blob:mock-url')
548
+
549
+ await wrapper.find('.ui-file-upload__remove-btn').trigger('click')
550
+
551
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
552
+ })
553
+
554
+ it('does not show remove button during upload', () => {
555
+ const files: UploadFile[] = [{
556
+ id: '1',
557
+ file: createMockFile('test.png', 1000, 'image/png'),
558
+ name: 'test.png',
559
+ size: 1000,
560
+ type: 'image/png',
561
+ progress: 50,
562
+ status: 'uploading'
563
+ }]
564
+ const wrapper = mount(FileUpload, {
565
+ props: { modelValue: files }
566
+ })
567
+ expect(wrapper.find('.ui-file-upload__actions').exists()).toBe(false)
568
+ })
569
+ })
570
+
571
+ describe('Status states', () => {
572
+ it('shows progress bar when uploading', () => {
573
+ const files: UploadFile[] = [{
574
+ id: '1',
575
+ file: createMockFile('test.png', 1000, 'image/png'),
576
+ name: 'test.png',
577
+ size: 1000,
578
+ type: 'image/png',
579
+ progress: 50,
580
+ status: 'uploading'
581
+ }]
582
+ const wrapper = mount(FileUpload, {
583
+ props: { modelValue: files }
584
+ })
585
+ expect(wrapper.find('.ui-file-upload__progress').exists()).toBe(true)
586
+ expect(wrapper.find('.ui-file-upload__progress-bar').attributes('style')).toContain('width: 50%')
587
+ })
588
+
589
+ it('shows spinner when uploading', () => {
590
+ const files: UploadFile[] = [{
591
+ id: '1',
592
+ file: createMockFile('test.png', 1000, 'image/png'),
593
+ name: 'test.png',
594
+ size: 1000,
595
+ type: 'image/png',
596
+ progress: 50,
597
+ status: 'uploading'
598
+ }]
599
+ const wrapper = mount(FileUpload, {
600
+ props: { modelValue: files }
601
+ })
602
+ expect(wrapper.find('.ui-file-upload__spinner').exists()).toBe(true)
603
+ })
604
+
605
+ it('shows success checkmark', () => {
606
+ const files: UploadFile[] = [{
607
+ id: '1',
608
+ file: createMockFile('test.png', 1000, 'image/png'),
609
+ name: 'test.png',
610
+ size: 1000,
611
+ type: 'image/png',
612
+ progress: 100,
613
+ status: 'success'
614
+ }]
615
+ const wrapper = mount(FileUpload, {
616
+ props: { modelValue: files }
617
+ })
618
+ expect(wrapper.find('.ui-file-upload__status-icon--success').exists()).toBe(true)
619
+ expect(wrapper.find('.ui-file-upload__file-success').text()).toBe('Uploaded')
620
+ })
621
+
622
+ it('shows error state', () => {
623
+ const files: UploadFile[] = [{
624
+ id: '1',
625
+ file: createMockFile('test.png', 1000, 'image/png'),
626
+ name: 'test.png',
627
+ size: 1000,
628
+ type: 'image/png',
629
+ progress: 0,
630
+ status: 'error',
631
+ error: 'Upload failed'
632
+ }]
633
+ const wrapper = mount(FileUpload, {
634
+ props: { modelValue: files }
635
+ })
636
+ expect(wrapper.find('.ui-file-upload__status-icon--error').exists()).toBe(true)
637
+ expect(wrapper.find('.ui-file-upload__file--error').exists()).toBe(true)
638
+ expect(wrapper.find('.ui-file-upload__file-error').text()).toBe('Upload failed')
639
+ })
640
+ })
641
+
642
+ describe('Disabled state', () => {
643
+ it('applies disabled class', () => {
644
+ const wrapper = mount(FileUpload, {
645
+ props: { disabled: true }
646
+ })
647
+ expect(wrapper.find('.ui-file-upload--disabled').exists()).toBe(true)
648
+ expect(wrapper.find('.ui-file-upload__dropzone--disabled').exists()).toBe(true)
649
+ })
650
+
651
+ it('does not trigger input when disabled', async () => {
652
+ const wrapper = mount(FileUpload, {
653
+ props: { disabled: true }
654
+ })
655
+ const clickSpy = vi.spyOn(wrapper.find('input').element as HTMLInputElement, 'click')
656
+
657
+ await wrapper.find('.ui-file-upload__dropzone').trigger('click')
658
+
659
+ expect(clickSpy).not.toHaveBeenCalled()
660
+ })
661
+ })
662
+
663
+ describe('Compact mode', () => {
664
+ it('applies compact class', () => {
665
+ const wrapper = mount(FileUpload, {
666
+ props: { compact: true }
667
+ })
668
+ expect(wrapper.find('.ui-file-upload--compact').exists()).toBe(true)
669
+ })
670
+ })
671
+
672
+ describe('Exposed methods', () => {
673
+ it('exposes updateProgress method', () => {
674
+ const files: UploadFile[] = [{
675
+ id: '1',
676
+ file: createMockFile('test.png', 1000, 'image/png'),
677
+ name: 'test.png',
678
+ size: 1000,
679
+ type: 'image/png',
680
+ progress: 0,
681
+ status: 'pending'
682
+ }]
683
+ const wrapper = mount(FileUpload, {
684
+ props: { modelValue: files }
685
+ })
686
+
687
+ ;(wrapper.vm as any).updateProgress('1', 50)
688
+
689
+ expect(files[0].progress).toBe(50)
690
+ expect(files[0].status).toBe('uploading')
691
+ })
692
+
693
+ it('exposes markSuccess method', () => {
694
+ const files: UploadFile[] = [{
695
+ id: '1',
696
+ file: createMockFile('test.png', 1000, 'image/png'),
697
+ name: 'test.png',
698
+ size: 1000,
699
+ type: 'image/png',
700
+ progress: 50,
701
+ status: 'uploading'
702
+ }]
703
+ const wrapper = mount(FileUpload, {
704
+ props: { modelValue: files }
705
+ })
706
+
707
+ ;(wrapper.vm as any).markSuccess('1')
708
+
709
+ expect(files[0].progress).toBe(100)
710
+ expect(files[0].status).toBe('success')
711
+ })
712
+
713
+ it('exposes markError method', () => {
714
+ const files: UploadFile[] = [{
715
+ id: '1',
716
+ file: createMockFile('test.png', 1000, 'image/png'),
717
+ name: 'test.png',
718
+ size: 1000,
719
+ type: 'image/png',
720
+ progress: 50,
721
+ status: 'uploading'
722
+ }]
723
+ const wrapper = mount(FileUpload, {
724
+ props: { modelValue: files }
725
+ })
726
+
727
+ ;(wrapper.vm as any).markError('1', 'Network error')
728
+
729
+ expect(files[0].status).toBe('error')
730
+ expect(files[0].error).toBe('Network error')
731
+ })
732
+
733
+ it('exposes clearAll method', async () => {
734
+ const files: UploadFile[] = [{
735
+ id: '1',
736
+ file: createMockFile('test.png', 1000, 'image/png'),
737
+ name: 'test.png',
738
+ size: 1000,
739
+ type: 'image/png',
740
+ preview: 'blob:mock-url',
741
+ progress: 0,
742
+ status: 'pending'
743
+ }]
744
+ const wrapper = mount(FileUpload, {
745
+ props: { modelValue: files }
746
+ })
747
+
748
+ ;(wrapper.vm as any).previewUrls.set('1', 'blob:mock-url')
749
+ ;(wrapper.vm as any).clearAll()
750
+
751
+ expect(wrapper.emitted('update:modelValue')![0][0]).toEqual([])
752
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
753
+ })
754
+ })
755
+
756
+ describe('Paste to upload', () => {
757
+ function createMockClipboardEvent(files: File[]): ClipboardEvent {
758
+ const items = files.map(file => ({
759
+ kind: 'file',
760
+ type: file.type,
761
+ getAsFile: () => file
762
+ }))
763
+
764
+ return {
765
+ clipboardData: {
766
+ items: items as unknown as DataTransferItemList
767
+ },
768
+ preventDefault: vi.fn()
769
+ } as unknown as ClipboardEvent
770
+ }
771
+
772
+ it('processes pasted files when component is hovered', async () => {
773
+ const wrapper = mount(FileUpload)
774
+
775
+ // Simulate hover
776
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
777
+
778
+ const file = createMockFile('pasted.png', 1000, 'image/png')
779
+ const pasteEvent = createMockClipboardEvent([file])
780
+
781
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
782
+ await nextTick()
783
+
784
+ const emitted = wrapper.emitted('update:modelValue')
785
+ expect(emitted).toBeTruthy()
786
+ expect(emitted![0][0]).toHaveLength(1)
787
+ expect((emitted![0][0] as UploadFile[])[0].name).toBe('pasted.png')
788
+ })
789
+
790
+ it('processes pasted files when component is focused', async () => {
791
+ const wrapper = mount(FileUpload)
792
+
793
+ // Simulate focus
794
+ await wrapper.find('.ui-file-upload__dropzone').trigger('focus')
795
+
796
+ const file = createMockFile('pasted.png', 1000, 'image/png')
797
+ const pasteEvent = createMockClipboardEvent([file])
798
+
799
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
800
+ await nextTick()
801
+
802
+ const emitted = wrapper.emitted('update:modelValue')
803
+ expect(emitted).toBeTruthy()
804
+ expect(emitted![0][0]).toHaveLength(1)
805
+ })
806
+
807
+ it('ignores paste when component is not hovered or focused', async () => {
808
+ const wrapper = mount(FileUpload)
809
+
810
+ // Neither hovered nor focused
811
+ const file = createMockFile('pasted.png', 1000, 'image/png')
812
+ const pasteEvent = createMockClipboardEvent([file])
813
+
814
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
815
+ await nextTick()
816
+
817
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
818
+ })
819
+
820
+ it('ignores paste when allowPaste is false', async () => {
821
+ const wrapper = mount(FileUpload, {
822
+ props: { allowPaste: false }
823
+ })
824
+
825
+ const file = createMockFile('pasted.png', 1000, 'image/png')
826
+ const pasteEvent = createMockClipboardEvent([file])
827
+
828
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
829
+ await nextTick()
830
+
831
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
832
+ })
833
+
834
+ it('ignores paste when disabled even if hovered', async () => {
835
+ const wrapper = mount(FileUpload, {
836
+ props: { disabled: true }
837
+ })
838
+
839
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
840
+
841
+ const file = createMockFile('pasted.png', 1000, 'image/png')
842
+ const pasteEvent = createMockClipboardEvent([file])
843
+
844
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
845
+ await nextTick()
846
+
847
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
848
+ })
849
+
850
+ it('validates pasted files against accept', async () => {
851
+ const wrapper = mount(FileUpload, {
852
+ props: { accept: 'image/*' }
853
+ })
854
+
855
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
856
+
857
+ const file = createMockFile('doc.pdf', 1000, 'application/pdf')
858
+ const pasteEvent = createMockClipboardEvent([file])
859
+
860
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
861
+ await nextTick()
862
+
863
+ expect(wrapper.emitted('file-rejected')).toBeTruthy()
864
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
865
+ })
866
+
867
+ it('shows paste hint in dropzone when allowPaste is true', () => {
868
+ const wrapper = mount(FileUpload, {
869
+ props: { allowPaste: true }
870
+ })
871
+
872
+ expect(wrapper.text()).toContain('Paste (Ctrl+V)')
873
+ })
874
+
875
+ it('does not show paste hint when allowPaste is false', () => {
876
+ const wrapper = mount(FileUpload, {
877
+ props: { allowPaste: false }
878
+ })
879
+
880
+ expect(wrapper.text()).not.toContain('Paste (Ctrl+V)')
881
+ })
882
+
883
+ it('emits files-pasted event after paste', async () => {
884
+ const wrapper = mount(FileUpload)
885
+
886
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
887
+
888
+ const file = createMockFile('pasted.png', 1000, 'image/png')
889
+ const pasteEvent = createMockClipboardEvent([file])
890
+
891
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
892
+ await nextTick()
893
+
894
+ expect(wrapper.emitted('files-pasted')).toBeTruthy()
895
+ })
896
+
897
+ it('handles multiple pasted files', async () => {
898
+ const wrapper = mount(FileUpload, {
899
+ props: { multiple: true }
900
+ })
901
+
902
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
903
+
904
+ const files = [
905
+ createMockFile('image1.png', 1000, 'image/png'),
906
+ createMockFile('image2.jpg', 2000, 'image/jpeg')
907
+ ]
908
+ const pasteEvent = createMockClipboardEvent(files)
909
+
910
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
911
+ await nextTick()
912
+
913
+ const emitted = wrapper.emitted('update:modelValue')
914
+ expect(emitted![0][0]).toHaveLength(2)
915
+ })
916
+
917
+ it('ignores non-file items in clipboard when hovered', async () => {
918
+ const wrapper = mount(FileUpload)
919
+
920
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
921
+
922
+ // Create a paste event with only text items (kind: 'string')
923
+ const pasteEvent = {
924
+ clipboardData: {
925
+ items: [{
926
+ kind: 'string',
927
+ type: 'text/plain',
928
+ getAsFile: () => null
929
+ }] as unknown as DataTransferItemList
930
+ },
931
+ preventDefault: vi.fn()
932
+ } as unknown as ClipboardEvent
933
+
934
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
935
+ await nextTick()
936
+
937
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
938
+ })
939
+
940
+ it('handles paste with no clipboardData when hovered', async () => {
941
+ const wrapper = mount(FileUpload)
942
+
943
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
944
+
945
+ const pasteEvent = {
946
+ clipboardData: null,
947
+ preventDefault: vi.fn()
948
+ } as unknown as ClipboardEvent
949
+
950
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
951
+ await nextTick()
952
+
953
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
954
+ })
955
+
956
+ it('stops processing paste after mouseleave', async () => {
957
+ const wrapper = mount(FileUpload)
958
+
959
+ // Hover, then leave
960
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseenter')
961
+ await wrapper.find('.ui-file-upload__dropzone').trigger('mouseleave')
962
+
963
+ const file = createMockFile('pasted.png', 1000, 'image/png')
964
+ const pasteEvent = createMockClipboardEvent([file])
965
+
966
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
967
+ await nextTick()
968
+
969
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
970
+ })
971
+
972
+ it('stops processing paste after blur', async () => {
973
+ const wrapper = mount(FileUpload)
974
+
975
+ // Focus, then blur
976
+ await wrapper.find('.ui-file-upload__dropzone').trigger('focus')
977
+ await wrapper.find('.ui-file-upload__dropzone').trigger('blur')
978
+
979
+ const file = createMockFile('pasted.png', 1000, 'image/png')
980
+ const pasteEvent = createMockClipboardEvent([file])
981
+
982
+ ;(wrapper.vm as any).handlePaste(pasteEvent)
983
+ await nextTick()
984
+
985
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
986
+ })
987
+ })
988
+
989
+ describe('Replace file', () => {
990
+ it('exposes replaceFile method', () => {
991
+ const wrapper = mount(FileUpload)
992
+ expect(typeof (wrapper.vm as any).replaceFile).toBe('function')
993
+ })
994
+
995
+ it('replaces a file at the same position', async () => {
996
+ const files: UploadFile[] = [
997
+ {
998
+ id: '1',
999
+ file: createMockFile('old.png', 1000, 'image/png'),
1000
+ name: 'old.png',
1001
+ size: 1000,
1002
+ type: 'image/png',
1003
+ progress: 0,
1004
+ status: 'pending'
1005
+ },
1006
+ {
1007
+ id: '2',
1008
+ file: createMockFile('other.png', 2000, 'image/png'),
1009
+ name: 'other.png',
1010
+ size: 2000,
1011
+ type: 'image/png',
1012
+ progress: 0,
1013
+ status: 'pending'
1014
+ }
1015
+ ]
1016
+ const wrapper = mount(FileUpload, {
1017
+ props: { modelValue: files, multiple: true }
1018
+ })
1019
+
1020
+ // Trigger replace mode for first file
1021
+ ;(wrapper.vm as any).replacingFileId = '1'
1022
+
1023
+ // Simulate new file selection
1024
+ const newFile = createMockFile('new.png', 3000, 'image/png')
1025
+ ;(wrapper.vm as any).handleReplaceFile(newFile)
1026
+ await nextTick()
1027
+
1028
+ const emitted = wrapper.emitted('update:modelValue')
1029
+ expect(emitted).toBeTruthy()
1030
+
1031
+ const newFiles = emitted![0][0] as UploadFile[]
1032
+ expect(newFiles).toHaveLength(2)
1033
+ expect(newFiles[0].name).toBe('new.png')
1034
+ expect(newFiles[1].name).toBe('other.png')
1035
+ })
1036
+
1037
+ it('emits file-replaced event', async () => {
1038
+ const files: UploadFile[] = [{
1039
+ id: '1',
1040
+ file: createMockFile('old.png', 1000, 'image/png'),
1041
+ name: 'old.png',
1042
+ size: 1000,
1043
+ type: 'image/png',
1044
+ progress: 0,
1045
+ status: 'pending'
1046
+ }]
1047
+ const wrapper = mount(FileUpload, {
1048
+ props: { modelValue: files }
1049
+ })
1050
+
1051
+ ;(wrapper.vm as any).replacingFileId = '1'
1052
+
1053
+ const newFile = createMockFile('new.png', 2000, 'image/png')
1054
+ ;(wrapper.vm as any).handleReplaceFile(newFile)
1055
+ await nextTick()
1056
+
1057
+ const emitted = wrapper.emitted('file-replaced')
1058
+ expect(emitted).toBeTruthy()
1059
+ expect((emitted![0][0] as UploadFile).name).toBe('old.png')
1060
+ expect((emitted![0][1] as UploadFile).name).toBe('new.png')
1061
+ })
1062
+
1063
+ it('validates replacement file', async () => {
1064
+ const files: UploadFile[] = [{
1065
+ id: '1',
1066
+ file: createMockFile('image.png', 1000, 'image/png'),
1067
+ name: 'image.png',
1068
+ size: 1000,
1069
+ type: 'image/png',
1070
+ progress: 0,
1071
+ status: 'pending'
1072
+ }]
1073
+ const wrapper = mount(FileUpload, {
1074
+ props: { modelValue: files, accept: 'image/*' }
1075
+ })
1076
+
1077
+ ;(wrapper.vm as any).replacingFileId = '1'
1078
+
1079
+ // Try to replace with invalid file type
1080
+ const pdfFile = createMockFile('doc.pdf', 1000, 'application/pdf')
1081
+ ;(wrapper.vm as any).handleReplaceFile(pdfFile)
1082
+ await nextTick()
1083
+
1084
+ expect(wrapper.emitted('file-rejected')).toBeTruthy()
1085
+ expect(wrapper.emitted('file-replaced')).toBeFalsy()
1086
+ })
1087
+
1088
+ it('revokes old preview URL when replacing', async () => {
1089
+ const files: UploadFile[] = [{
1090
+ id: '1',
1091
+ file: createMockFile('old.png', 1000, 'image/png'),
1092
+ name: 'old.png',
1093
+ size: 1000,
1094
+ type: 'image/png',
1095
+ preview: 'blob:old-url',
1096
+ progress: 0,
1097
+ status: 'pending'
1098
+ }]
1099
+ const wrapper = mount(FileUpload, {
1100
+ props: { modelValue: files }
1101
+ })
1102
+
1103
+ // Set up old preview URL
1104
+ ;(wrapper.vm as any).previewUrls.set('1', 'blob:old-url')
1105
+ ;(wrapper.vm as any).replacingFileId = '1'
1106
+
1107
+ const newFile = createMockFile('new.png', 2000, 'image/png')
1108
+ ;(wrapper.vm as any).handleReplaceFile(newFile)
1109
+ await nextTick()
1110
+
1111
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:old-url')
1112
+ })
1113
+
1114
+ it('renders replace button for each file', () => {
1115
+ const files: UploadFile[] = [{
1116
+ id: '1',
1117
+ file: createMockFile('test.png', 1000, 'image/png'),
1118
+ name: 'test.png',
1119
+ size: 1000,
1120
+ type: 'image/png',
1121
+ progress: 0,
1122
+ status: 'pending'
1123
+ }]
1124
+ const wrapper = mount(FileUpload, {
1125
+ props: { modelValue: files }
1126
+ })
1127
+
1128
+ const actions = wrapper.find('.ui-file-upload__actions')
1129
+ expect(actions.exists()).toBe(true)
1130
+ // Should have 2 buttons (replace and remove)
1131
+ expect(actions.findAll('button').length).toBe(2)
1132
+ })
1133
+
1134
+ it('does not show action buttons during upload', () => {
1135
+ const files: UploadFile[] = [{
1136
+ id: '1',
1137
+ file: createMockFile('test.png', 1000, 'image/png'),
1138
+ name: 'test.png',
1139
+ size: 1000,
1140
+ type: 'image/png',
1141
+ progress: 50,
1142
+ status: 'uploading'
1143
+ }]
1144
+ const wrapper = mount(FileUpload, {
1145
+ props: { modelValue: files }
1146
+ })
1147
+
1148
+ expect(wrapper.find('.ui-file-upload__actions').exists()).toBe(false)
1149
+ })
1150
+ })
1151
+ })