@getmicdrop/svelte-components 5.17.1 → 5.17.3

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 (382) hide show
  1. package/dist/calendar/AboutShow/AboutShow.svelte +187 -187
  2. package/dist/calendar/Calendar/MiniMonthCalendar.svelte +782 -782
  3. package/dist/calendar/FAQs/FAQs.svelte +77 -77
  4. package/dist/calendar/MonthSwitcher/MonthSwitcher.svelte +126 -126
  5. package/dist/calendar/OrderSummary/OrderSummary.svelte +457 -457
  6. package/dist/calendar/PublicCard/PublicCard.svelte +146 -146
  7. package/dist/calendar/ShowCard/ShowCard.svelte +157 -157
  8. package/dist/calendar/ShowTimeCard/ShowTimeCard.svelte +61 -61
  9. package/dist/components/Heading.spec.d.ts +2 -0
  10. package/dist/components/Heading.spec.d.ts.map +1 -0
  11. package/dist/components/Heading.spec.js +89 -0
  12. package/dist/components/Heading.svelte +60 -60
  13. package/dist/components/Layout/AppShell.svelte +104 -104
  14. package/dist/components/Layout/ContentSection.svelte +80 -80
  15. package/dist/components/Layout/Grid.svelte +4 -4
  16. package/dist/components/Layout/Heading.svelte +81 -81
  17. package/dist/components/Layout/PageContainer.svelte +69 -69
  18. package/dist/components/Layout/Responsive.svelte +75 -75
  19. package/dist/components/Layout/Section.svelte +80 -80
  20. package/dist/components/Layout/ShowOnDesktop.svelte +37 -37
  21. package/dist/components/Layout/ShowOnMobile.svelte +37 -37
  22. package/dist/components/Layout/Sidebar.svelte +108 -108
  23. package/dist/components/Layout/Stack.spec.js +1 -1
  24. package/dist/components/Layout/Stack.svelte +6 -6
  25. package/dist/components/Layout/Text.svelte +87 -87
  26. package/dist/components/Layout/TwoColumn.svelte +108 -108
  27. package/dist/components/Layout/__tests__/AppShell.test.js +140 -0
  28. package/dist/components/Text.spec.d.ts +2 -0
  29. package/dist/components/Text.spec.d.ts.map +1 -0
  30. package/dist/components/Text.spec.js +89 -0
  31. package/dist/components/Text.svelte +53 -53
  32. package/dist/constants/validation.js +91 -91
  33. package/dist/constants/validation.spec.js +64 -64
  34. package/dist/datetime/__tests__/format.test.js +1 -1
  35. package/dist/datetime/__tests__/parse.test.js +1 -1
  36. package/dist/datetime/__tests__/timezone.test.js +1 -1
  37. package/dist/datetime/parse.js +1 -1
  38. package/dist/forms/createFormStore.svelte.js +1 -0
  39. package/dist/forms/createFormStore.svelte.spec.d.ts +2 -0
  40. package/dist/forms/createFormStore.svelte.spec.d.ts.map +1 -0
  41. package/dist/forms/createFormStore.svelte.spec.js +388 -0
  42. package/dist/index.js +57 -57
  43. package/dist/index.spec.js +369 -369
  44. package/dist/patterns/chat/ChatActivityNotice.spec.d.ts +2 -0
  45. package/dist/patterns/chat/ChatActivityNotice.spec.d.ts.map +1 -0
  46. package/dist/patterns/chat/ChatActivityNotice.spec.js +59 -0
  47. package/dist/patterns/chat/ChatActivityNotice.svelte +41 -41
  48. package/dist/patterns/chat/ChatBubble.spec.d.ts +2 -0
  49. package/dist/patterns/chat/ChatBubble.spec.d.ts.map +1 -0
  50. package/dist/patterns/chat/ChatBubble.spec.js +91 -0
  51. package/dist/patterns/chat/ChatBubble.svelte +95 -95
  52. package/dist/patterns/chat/ChatContainer.spec.d.ts +2 -0
  53. package/dist/patterns/chat/ChatContainer.spec.d.ts.map +1 -0
  54. package/dist/patterns/chat/ChatContainer.spec.js +30 -0
  55. package/dist/patterns/chat/ChatContainer.svelte +46 -46
  56. package/dist/patterns/chat/ChatDateDivider.spec.d.ts +2 -0
  57. package/dist/patterns/chat/ChatDateDivider.spec.d.ts.map +1 -0
  58. package/dist/patterns/chat/ChatDateDivider.spec.js +30 -0
  59. package/dist/patterns/chat/ChatDateDivider.svelte +27 -27
  60. package/dist/patterns/chat/ChatInvitationBubble.spec.d.ts +2 -0
  61. package/dist/patterns/chat/ChatInvitationBubble.spec.d.ts.map +1 -0
  62. package/dist/patterns/chat/ChatInvitationBubble.spec.js +46 -0
  63. package/dist/patterns/chat/ChatInvitationBubble.svelte +37 -37
  64. package/dist/patterns/chat/ChatInvitationNotice.spec.d.ts +2 -0
  65. package/dist/patterns/chat/ChatInvitationNotice.spec.d.ts.map +1 -0
  66. package/dist/patterns/chat/ChatInvitationNotice.spec.js +32 -0
  67. package/dist/patterns/chat/ChatInvitationNotice.svelte +27 -27
  68. package/dist/patterns/chat/ChatMessageGroup.spec.d.ts +2 -0
  69. package/dist/patterns/chat/ChatMessageGroup.spec.d.ts.map +1 -0
  70. package/dist/patterns/chat/ChatMessageGroup.spec.js +58 -0
  71. package/dist/patterns/chat/ChatMessageGroup.svelte +57 -57
  72. package/dist/patterns/chat/ChatSlotUpdate.spec.d.ts +2 -0
  73. package/dist/patterns/chat/ChatSlotUpdate.spec.d.ts.map +1 -0
  74. package/dist/patterns/chat/ChatSlotUpdate.spec.js +65 -0
  75. package/dist/patterns/chat/ChatSlotUpdate.svelte +46 -46
  76. package/dist/patterns/chat/ChatStatusBadge.spec.d.ts +2 -0
  77. package/dist/patterns/chat/ChatStatusBadge.spec.d.ts.map +1 -0
  78. package/dist/patterns/chat/ChatStatusBadge.spec.js +79 -0
  79. package/dist/patterns/chat/ChatStatusBadge.svelte +91 -91
  80. package/dist/patterns/chat/ChatStatusTransition.spec.d.ts +2 -0
  81. package/dist/patterns/chat/ChatStatusTransition.spec.d.ts.map +1 -0
  82. package/dist/patterns/chat/ChatStatusTransition.spec.js +81 -0
  83. package/dist/patterns/chat/ChatStatusTransition.svelte +64 -64
  84. package/dist/patterns/chat/ChatTextBubble.spec.d.ts +2 -0
  85. package/dist/patterns/chat/ChatTextBubble.spec.d.ts.map +1 -0
  86. package/dist/patterns/chat/ChatTextBubble.spec.js +35 -0
  87. package/dist/patterns/chat/ChatTextBubble.svelte +41 -41
  88. package/dist/patterns/chat/index.js +22 -22
  89. package/dist/patterns/data/DataGrid.svelte +45 -45
  90. package/dist/patterns/data/DataList.svelte +24 -24
  91. package/dist/patterns/data/DataTable.spec.js +61 -0
  92. package/dist/patterns/data/DataTable.svelte +36 -36
  93. package/dist/patterns/forms/FormActions.spec.js +95 -95
  94. package/dist/patterns/forms/FormActions.stories.svelte +97 -97
  95. package/dist/patterns/forms/FormActions.svelte +46 -46
  96. package/dist/patterns/forms/FormGrid.spec.js +34 -0
  97. package/dist/patterns/forms/FormGrid.svelte +33 -33
  98. package/dist/patterns/forms/FormSection.svelte +32 -32
  99. package/dist/patterns/forms/FormValidationSummary.stories.svelte +83 -83
  100. package/dist/patterns/forms/FormValidationSummary.svelte +74 -74
  101. package/dist/patterns/index.js +21 -21
  102. package/dist/patterns/layout/Sidebar.spec.js +240 -1
  103. package/dist/patterns/layout/Sidebar.svelte +39 -39
  104. package/dist/patterns/layout/SidebarTestWrapper.svelte +34 -0
  105. package/dist/patterns/layout/SidebarTestWrapper.svelte.d.ts +23 -0
  106. package/dist/patterns/layout/SidebarTestWrapper.svelte.d.ts.map +1 -0
  107. package/dist/patterns/layout/index.js +29 -29
  108. package/dist/patterns/navigation/BottomNav.stories.svelte +117 -117
  109. package/dist/patterns/navigation/BottomNav.svelte +74 -74
  110. package/dist/patterns/navigation/Header.spec.js +123 -0
  111. package/dist/patterns/navigation/Header.stories.svelte +77 -77
  112. package/dist/patterns/navigation/Header.svelte +251 -251
  113. package/dist/patterns/page/PageHeader.svelte +18 -18
  114. package/dist/patterns/page/PageLayout.svelte +40 -40
  115. package/dist/patterns/page/PageLoader.spec.js +57 -57
  116. package/dist/patterns/page/PageLoader.stories.svelte +137 -137
  117. package/dist/patterns/page/PageLoader.svelte +24 -24
  118. package/dist/patterns/page/SectionHeader.svelte +29 -29
  119. package/dist/presets/badges.js +112 -112
  120. package/dist/presets/buttons.js +76 -76
  121. package/dist/presets/index.js +9 -9
  122. package/dist/primitives/Accordion/Accordion.spec.js +112 -2
  123. package/dist/primitives/Accordion/Accordion.stories.svelte +75 -75
  124. package/dist/primitives/Accordion/Accordion.svelte +42 -42
  125. package/dist/primitives/Accordion/AccordionItem.svelte +95 -95
  126. package/dist/primitives/Accordion/AccordionToggleWrapper.test.svelte +28 -0
  127. package/dist/primitives/Accordion/AccordionToggleWrapper.test.svelte.d.ts +7 -0
  128. package/dist/primitives/Accordion/AccordionToggleWrapper.test.svelte.d.ts.map +1 -0
  129. package/dist/primitives/Alert/Alert.spec.js +173 -173
  130. package/dist/primitives/Alert/Alert.stories.svelte +88 -88
  131. package/dist/primitives/Alert/Alert.svelte +27 -27
  132. package/dist/primitives/Avatar/Avatar.spec.js +23 -0
  133. package/dist/primitives/Avatar/Avatar.stories.svelte +94 -94
  134. package/dist/primitives/Avatar/Avatar.svelte +66 -66
  135. package/dist/primitives/AvatarButton/AvatarButton.svelte +57 -57
  136. package/dist/primitives/Badges/Badge.spec.js +144 -144
  137. package/dist/primitives/Badges/Badge.stories.svelte +86 -86
  138. package/dist/primitives/Badges/Badge.svelte +99 -99
  139. package/dist/primitives/BottomSheet/BottomSheet.spec.js +238 -136
  140. package/dist/primitives/BottomSheet/BottomSheet.stories.svelte +83 -83
  141. package/dist/primitives/BottomSheet/BottomSheet.svelte +115 -115
  142. package/dist/primitives/BottomSheet/BottomSheetWithActions.test.svelte +20 -0
  143. package/dist/primitives/BottomSheet/BottomSheetWithActions.test.svelte.d.ts +10 -0
  144. package/dist/primitives/BottomSheet/BottomSheetWithActions.test.svelte.d.ts.map +1 -0
  145. package/dist/primitives/Breadcrumb/Breadcrumb.spec.js +123 -123
  146. package/dist/primitives/Breadcrumb/Breadcrumb.stories.svelte +23 -23
  147. package/dist/primitives/Breadcrumb/Breadcrumb.svelte +99 -99
  148. package/dist/primitives/Button/Button.spec.js +225 -225
  149. package/dist/primitives/Button/Button.stories.svelte +76 -76
  150. package/dist/primitives/Button/Button.svelte +278 -278
  151. package/dist/primitives/Button/ButtonGroup.spec.d.ts +2 -0
  152. package/dist/primitives/Button/ButtonGroup.spec.d.ts.map +1 -0
  153. package/dist/primitives/Button/ButtonGroup.spec.js +44 -0
  154. package/dist/primitives/Button/ButtonGroup.svelte +50 -50
  155. package/dist/primitives/Button/ButtonSaveDemo.spec.js +146 -146
  156. package/dist/primitives/Button/ButtonSaveDemo.svelte +25 -25
  157. package/dist/primitives/Button/ButtonVariantShowcase.svelte +129 -129
  158. package/dist/primitives/Card.spec.js +49 -49
  159. package/dist/primitives/Card.stories.svelte +22 -22
  160. package/dist/primitives/Card.svelte +28 -28
  161. package/dist/primitives/CardAction/CardAction.svelte +68 -68
  162. package/dist/primitives/Checkbox/Checkbox.spec.js +32 -0
  163. package/dist/primitives/Checkbox/Checkbox.stories.svelte +84 -84
  164. package/dist/primitives/Checkbox/Checkbox.svelte +88 -88
  165. package/dist/primitives/DarkModeToggle.spec.js +390 -390
  166. package/dist/primitives/DarkModeToggle.stories.svelte +57 -57
  167. package/dist/primitives/DarkModeToggle.svelte +136 -136
  168. package/dist/primitives/Drawer/Drawer.spec.js +437 -0
  169. package/dist/primitives/Drawer/Drawer.stories.svelte +80 -80
  170. package/dist/primitives/Drawer/Drawer.svelte +224 -224
  171. package/dist/primitives/Drawer/DrawerTestWrapper.svelte +86 -0
  172. package/dist/primitives/Drawer/DrawerTestWrapper.svelte.d.ts +26 -0
  173. package/dist/primitives/Drawer/DrawerTestWrapper.svelte.d.ts.map +1 -0
  174. package/dist/primitives/Dropdown/Dropdown.spec.js +116 -0
  175. package/dist/primitives/Dropdown/Dropdown.stories.svelte +137 -137
  176. package/dist/primitives/Dropdown/Dropdown.svelte +170 -170
  177. package/dist/primitives/Dropdown/DropdownDivider.spec.d.ts +2 -0
  178. package/dist/primitives/Dropdown/DropdownDivider.spec.d.ts.map +1 -0
  179. package/dist/primitives/Dropdown/DropdownDivider.spec.js +30 -0
  180. package/dist/primitives/Dropdown/DropdownDivider.svelte +9 -9
  181. package/dist/primitives/Dropdown/DropdownItem.spec.js +155 -1
  182. package/dist/primitives/Dropdown/DropdownItem.svelte +80 -80
  183. package/dist/primitives/Dropdown/DropdownItemTestWrapper.svelte +43 -0
  184. package/dist/primitives/Dropdown/DropdownItemTestWrapper.svelte.d.ts +17 -0
  185. package/dist/primitives/Dropdown/DropdownItemTestWrapper.svelte.d.ts.map +1 -0
  186. package/dist/primitives/Helper/Helper.spec.d.ts +2 -0
  187. package/dist/primitives/Helper/Helper.spec.d.ts.map +1 -0
  188. package/dist/primitives/Helper/Helper.spec.js +57 -0
  189. package/dist/primitives/Helper/Helper.svelte +33 -33
  190. package/dist/primitives/Icons/ArrowLeft.svelte +8 -8
  191. package/dist/primitives/Icons/ArrowRight.svelte +8 -8
  192. package/dist/primitives/Icons/Availability.svelte +14 -14
  193. package/dist/primitives/Icons/Back.svelte +14 -14
  194. package/dist/primitives/Icons/CheckCircle.svelte +6 -6
  195. package/dist/primitives/Icons/CheckCircleOutline.svelte +15 -15
  196. package/dist/primitives/Icons/ChevronLeft.svelte +4 -4
  197. package/dist/primitives/Icons/ChevronRight.svelte +4 -4
  198. package/dist/primitives/Icons/Copy.svelte +15 -15
  199. package/dist/primitives/Icons/Cross.svelte +5 -5
  200. package/dist/primitives/Icons/DownArrow.svelte +8 -8
  201. package/dist/primitives/Icons/ErrorCircle.svelte +6 -6
  202. package/dist/primitives/Icons/FacebookIcon.svelte +2 -2
  203. package/dist/primitives/Icons/Home.svelte +15 -15
  204. package/dist/primitives/Icons/Icon.spec.js +169 -169
  205. package/dist/primitives/Icons/Icon.stories.svelte +100 -100
  206. package/dist/primitives/Icons/Icon.svelte +52 -52
  207. package/dist/primitives/Icons/IconGallery.stories.svelte +235 -235
  208. package/dist/primitives/Icons/Info.svelte +7 -7
  209. package/dist/primitives/Icons/InstagramIcon.svelte +4 -4
  210. package/dist/primitives/Icons/LogoInstagram.svelte +2 -2
  211. package/dist/primitives/Icons/Message.svelte +15 -15
  212. package/dist/primitives/Icons/MoonIcon.svelte +5 -5
  213. package/dist/primitives/Icons/More.svelte +21 -21
  214. package/dist/primitives/Icons/MoreHori.spec.js +61 -61
  215. package/dist/primitives/Icons/MoreHori.svelte +22 -22
  216. package/dist/primitives/Icons/Notification.svelte +14 -14
  217. package/dist/primitives/Icons/Payment.svelte +14 -14
  218. package/dist/primitives/Icons/Profile.svelte +21 -21
  219. package/dist/primitives/Icons/Reload.svelte +29 -29
  220. package/dist/primitives/Icons/Shows.svelte +21 -21
  221. package/dist/primitives/Icons/Signout.svelte +21 -21
  222. package/dist/primitives/Icons/SunIcon.svelte +8 -8
  223. package/dist/primitives/Icons/TiktokIcon.svelte +2 -2
  224. package/dist/primitives/Icons/TwitterIcon.svelte +2 -2
  225. package/dist/primitives/Icons/WarningIcon.spec.js +18 -18
  226. package/dist/primitives/Icons/WarningIcon.svelte +5 -5
  227. package/dist/primitives/Input/Input.spec.js +1235 -573
  228. package/dist/primitives/Input/Input.stories.svelte +139 -139
  229. package/dist/primitives/Input/Input.svelte +423 -423
  230. package/dist/primitives/Input/Select.spec.js +632 -218
  231. package/dist/primitives/Input/Select.stories.svelte +112 -112
  232. package/dist/primitives/Input/Select.svelte +252 -252
  233. package/dist/primitives/Input/Textarea.stories.svelte +137 -137
  234. package/dist/primitives/Input/Textarea.svelte +105 -105
  235. package/dist/primitives/Label/Label.spec.js +9 -0
  236. package/dist/primitives/Label/Label.svelte +37 -37
  237. package/dist/primitives/LandingButton/LandingButton.spec.d.ts +2 -0
  238. package/dist/primitives/LandingButton/LandingButton.spec.d.ts.map +1 -0
  239. package/dist/primitives/LandingButton/LandingButton.spec.js +61 -0
  240. package/dist/primitives/LandingButton/LandingButton.svelte +92 -92
  241. package/dist/primitives/MenuItem/MenuItem.spec.d.ts +2 -0
  242. package/dist/primitives/MenuItem/MenuItem.spec.d.ts.map +1 -0
  243. package/dist/primitives/MenuItem/MenuItem.spec.js +130 -0
  244. package/dist/primitives/MenuItem/MenuItem.svelte +85 -85
  245. package/dist/primitives/Modal/Modal.spec.js +314 -99
  246. package/dist/primitives/Modal/Modal.stories.svelte +86 -86
  247. package/dist/primitives/Modal/Modal.svelte +181 -181
  248. package/dist/primitives/NavItem/NavItem.spec.d.ts +2 -0
  249. package/dist/primitives/NavItem/NavItem.spec.d.ts.map +1 -0
  250. package/dist/primitives/NavItem/NavItem.spec.js +97 -0
  251. package/dist/primitives/NavItem/NavItem.svelte +75 -75
  252. package/dist/primitives/Pagination/Pagination.stories.svelte +76 -76
  253. package/dist/primitives/Pagination/Pagination.svelte +261 -261
  254. package/dist/primitives/Radio/Radio.stories.svelte +80 -80
  255. package/dist/primitives/Radio/Radio.svelte +67 -67
  256. package/dist/primitives/SearchResultItem/SearchResultItem.spec.d.ts +2 -0
  257. package/dist/primitives/SearchResultItem/SearchResultItem.spec.d.ts.map +1 -0
  258. package/dist/primitives/SearchResultItem/SearchResultItem.spec.js +78 -0
  259. package/dist/primitives/SearchResultItem/SearchResultItem.svelte +109 -109
  260. package/dist/primitives/SidebarToggle/SidebarToggle.spec.d.ts +2 -0
  261. package/dist/primitives/SidebarToggle/SidebarToggle.spec.d.ts.map +1 -0
  262. package/dist/primitives/SidebarToggle/SidebarToggle.spec.js +61 -0
  263. package/dist/primitives/SidebarToggle/SidebarToggle.svelte +55 -55
  264. package/dist/primitives/Skeleton/CardPlaceholder.svelte +87 -87
  265. package/dist/primitives/Skeleton/ImagePlaceholder.svelte +59 -59
  266. package/dist/primitives/Skeleton/ListPlaceholder.svelte +76 -76
  267. package/dist/primitives/Skeleton/Skeleton.stories.svelte +151 -151
  268. package/dist/primitives/Skeleton/Skeleton.svelte +26 -26
  269. package/dist/primitives/Spinner/Spinner.spec.js +84 -71
  270. package/dist/primitives/Spinner/Spinner.stories.svelte +29 -29
  271. package/dist/primitives/Spinner/Spinner.svelte +20 -20
  272. package/dist/primitives/Tabs/TabItem.svelte +49 -49
  273. package/dist/primitives/Tabs/Tabs.stories.svelte +112 -112
  274. package/dist/primitives/Tabs/Tabs.svelte +137 -137
  275. package/dist/primitives/Toggle.spec.js +221 -146
  276. package/dist/primitives/Toggle.stories.svelte +92 -92
  277. package/dist/primitives/Toggle.svelte +141 -141
  278. package/dist/primitives/ToggleTestWrapper.svelte +30 -0
  279. package/dist/primitives/ToggleTestWrapper.svelte.d.ts +29 -0
  280. package/dist/primitives/ToggleTestWrapper.svelte.d.ts.map +1 -0
  281. package/dist/primitives/Tooltip/Tooltip.spec.d.ts +2 -0
  282. package/dist/primitives/Tooltip/Tooltip.spec.d.ts.map +1 -0
  283. package/dist/primitives/Tooltip/Tooltip.spec.js +126 -0
  284. package/dist/primitives/Tooltip/Tooltip.svelte +83 -83
  285. package/dist/primitives/Typography/Typography.svelte +53 -53
  286. package/dist/primitives/ValidationError.spec.js +103 -103
  287. package/dist/primitives/ValidationError.stories.svelte +69 -69
  288. package/dist/primitives/ValidationError.svelte +29 -29
  289. package/dist/primitives/index.js +113 -113
  290. package/dist/recipes/CropImage/CropImage.spec.js +208 -208
  291. package/dist/recipes/CropImage/CropImage.stories.svelte +104 -104
  292. package/dist/recipes/CropImage/CropImage.svelte +241 -241
  293. package/dist/recipes/ImageUploader/ImageUploader.stories.svelte +125 -125
  294. package/dist/recipes/ImageUploader/ImageUploader.svelte +992 -992
  295. package/dist/recipes/SuperLogin/SuperLogin.spec.js +2 -2
  296. package/dist/recipes/SuperLogin/SuperLogin.svelte +1 -1
  297. package/dist/recipes/Toaster/Toaster.stories.svelte +62 -62
  298. package/dist/recipes/feedback/EmptyState/EmptyState.svelte +1 -1
  299. package/dist/recipes/feedback/ErrorDisplay.spec.js +69 -69
  300. package/dist/recipes/feedback/ErrorDisplay.stories.svelte +101 -101
  301. package/dist/recipes/feedback/ErrorDisplay.svelte +1 -1
  302. package/dist/recipes/feedback/StatusIndicator/StatusIndicator.spec.js +133 -133
  303. package/dist/recipes/feedback/StatusIndicator/StatusIndicator.svelte +157 -157
  304. package/dist/recipes/fields/CheckboxField.svelte +85 -85
  305. package/dist/recipes/fields/FormField.svelte +58 -58
  306. package/dist/recipes/fields/RadioGroup.svelte +95 -95
  307. package/dist/recipes/fields/SelectField.svelte +82 -82
  308. package/dist/recipes/fields/TextareaField.svelte +97 -97
  309. package/dist/recipes/fields/ToggleField.svelte +60 -60
  310. package/dist/recipes/fields/index.js +7 -7
  311. package/dist/recipes/inputs/MultiSelect.spec.js +263 -263
  312. package/dist/recipes/inputs/MultiSelect.stories.svelte +133 -133
  313. package/dist/recipes/inputs/MultiSelect.svelte +283 -283
  314. package/dist/recipes/inputs/OTPInput.spec.js +251 -251
  315. package/dist/recipes/inputs/OTPInput.stories.svelte +162 -162
  316. package/dist/recipes/inputs/OTPInput.svelte +117 -117
  317. package/dist/recipes/inputs/PasswordInput.svelte +22 -22
  318. package/dist/recipes/inputs/PasswordStrengthIndicator/PasswordStrengthIndicator.svelte +131 -131
  319. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.stories.svelte +123 -123
  320. package/dist/recipes/inputs/PlaceAutocomplete/PlaceAutocomplete.svelte +336 -336
  321. package/dist/recipes/inputs/Search.spec.js +66 -2
  322. package/dist/recipes/inputs/Search.svelte +102 -102
  323. package/dist/recipes/inputs/index.js +7 -7
  324. package/dist/recipes/modals/AlertModal.svelte +130 -130
  325. package/dist/recipes/modals/ConfirmationModal.spec.js +396 -206
  326. package/dist/recipes/modals/ConfirmationModal.stories.svelte +119 -119
  327. package/dist/recipes/modals/ConfirmationModal.svelte +162 -162
  328. package/dist/recipes/modals/InputModal.svelte +182 -182
  329. package/dist/recipes/modals/ModalStateManager.spec.js +100 -100
  330. package/dist/recipes/modals/ModalStateManager.svelte +77 -77
  331. package/dist/recipes/modals/ModalTestWrapper.svelte +65 -65
  332. package/dist/recipes/modals/StatusModal.svelte +206 -206
  333. package/dist/services/EventService.js +79 -79
  334. package/dist/services/EventService.spec.js +217 -217
  335. package/dist/services/ShowService.js +144 -144
  336. package/dist/services/ShowService.spec.js +345 -345
  337. package/dist/stores/auth.svelte.spec.d.ts +2 -0
  338. package/dist/stores/auth.svelte.spec.d.ts.map +1 -0
  339. package/dist/stores/auth.svelte.spec.js +112 -0
  340. package/dist/stores/formDataStore.svelte.spec.d.ts +2 -0
  341. package/dist/stores/formDataStore.svelte.spec.d.ts.map +1 -0
  342. package/dist/stores/formDataStore.svelte.spec.js +150 -0
  343. package/dist/stores/formSave.svelte.spec.d.ts +2 -0
  344. package/dist/stores/formSave.svelte.spec.d.ts.map +1 -0
  345. package/dist/stores/formSave.svelte.spec.js +196 -0
  346. package/dist/stores/navigation.spec.d.ts +2 -0
  347. package/dist/stores/navigation.spec.d.ts.map +1 -0
  348. package/dist/stores/navigation.spec.js +53 -0
  349. package/dist/stores/toaster.js +13 -13
  350. package/dist/stories/ButtonAuditReview.stories.svelte +14 -14
  351. package/dist/stories/ButtonAuditReview.svelte +427 -427
  352. package/dist/stories/PatternsGallery.stories.svelte +19 -19
  353. package/dist/stories/PatternsGallery.svelte +206 -206
  354. package/dist/stories/PrimitivesGallery.stories.svelte +19 -19
  355. package/dist/stories/PrimitivesGallery.svelte +756 -756
  356. package/dist/stories/RecipesGallery.stories.svelte +19 -19
  357. package/dist/stories/RecipesGallery.svelte +454 -454
  358. package/dist/stories/button-audit-manifest.json +11186 -11186
  359. package/dist/tailwind/preset.cjs +82 -82
  360. package/dist/telemetry.js +402 -402
  361. package/dist/telemetry.server.js +212 -212
  362. package/dist/telemetry.server.spec.js +437 -437
  363. package/dist/telemetry.spec.js +1168 -1168
  364. package/dist/tokens/tokens.css +87 -87
  365. package/dist/tokens/typography-base.css +163 -163
  366. package/dist/tokens/utilities.css +353 -353
  367. package/dist/utils/apiConfig.js +117 -117
  368. package/dist/utils/apiConfig.spec.js +219 -219
  369. package/dist/utils/greetings.js +187 -187
  370. package/dist/utils/haptic.spec.d.ts +2 -0
  371. package/dist/utils/haptic.spec.d.ts.map +1 -0
  372. package/dist/utils/haptic.spec.js +153 -0
  373. package/dist/utils/imageOptimizer.spec.d.ts +2 -0
  374. package/dist/utils/imageOptimizer.spec.d.ts.map +1 -0
  375. package/dist/utils/imageOptimizer.spec.js +201 -0
  376. package/dist/utils/imageValidation.js +121 -121
  377. package/dist/utils/logger.spec.d.ts +2 -0
  378. package/dist/utils/logger.spec.d.ts.map +1 -0
  379. package/dist/utils/logger.spec.js +95 -0
  380. package/dist/utils/transitions.js +4 -4
  381. package/dist/utils/utils.js +693 -693
  382. package/package.json +292 -293
@@ -1,992 +1,992 @@
1
- <script lang="ts">
2
- /**
3
- * ImageUploader - Unified image upload component
4
- *
5
- * Supports:
6
- * - Single image mode (event posters, venue images)
7
- * - Multi-image mode with grid layout (performer photos)
8
- * - Optional cropping with aspect ratio selection
9
- * - Optional drag-to-reorder for multi-image
10
- * - Different shapes: square, wide
11
- * - Image compression before upload callback
12
- */
13
-
14
- import { browser } from '../../__LIB_ENVIRONMENT__.js';
15
- import FilePond, { registerPlugin } from 'svelte-filepond';
16
- import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
17
- import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
18
- import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
19
- import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
20
- import 'filepond/dist/filepond.min.css';
21
- import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
22
-
23
- import Sortable from 'sortablejs';
24
- import CropImage from '../CropImage/CropImage.svelte';
25
- import { optimizeImage } from '../../utils/imageOptimizer';
26
- import Badge from '../../primitives/Badges/Badge.svelte';
27
- import Button from '../../primitives/Button/Button.svelte';
28
- import { CloseOutline, ImageOutline, PlusOutline, TrashBinOutline } from '../../primitives/Icons';
29
- import { typography } from '../../tokens/typography';
30
-
31
- // Register FilePond plugins once
32
- registerPlugin(
33
- FilePondPluginImagePreview,
34
- FilePondPluginImageExifOrientation,
35
- FilePondPluginFileValidateType,
36
- FilePondPluginFileValidateSize
37
- );
38
-
39
- // Types
40
- type Shape = 'square' | 'wide';
41
- type AspectRatio = '1:1' | '2:1' | '16:9' | '4:3' | 'free';
42
- type Size = 'sm' | 'md' | 'lg';
43
-
44
- /**
45
- * Default labels for user-visible strings in ImageUploader.
46
- * Consumers can override via the `labels` prop.
47
- */
48
- const defaultLabels = {
49
- uploadPhotoTitle: 'Upload photo',
50
- mainBadge: 'Main',
51
- replace: 'Replace',
52
- delete: 'Delete',
53
- removePhoto: 'Remove photo',
54
- addPhotoAria: 'Add photo - drag and drop or click to upload',
55
- fileTypes: 'JPG, PNG, WebP',
56
- uploadedAlt: 'Uploaded',
57
- closeModal: 'Close',
58
- };
59
-
60
- interface Props {
61
- /** Single image or array of images (URLs) */
62
- images?: string | string[];
63
- /** Maximum number of images (only for multi mode) */
64
- maxImages?: number;
65
- /** Enable cropping step */
66
- enableCrop?: boolean;
67
- /** Crop aspect ratio */
68
- cropAspectRatio?: AspectRatio;
69
- /** Enable drag-to-reorder (only for multi mode) */
70
- enableReorder?: boolean;
71
- /** Dropzone shape - affects aspect ratio and styling */
72
- shape?: Shape;
73
- /** Size of image slots: sm (~64px), md (~96px), lg (~200px+) */
74
- size?: Size;
75
- /** Show "Main" badge on first image (multi mode) */
76
- showMainBadge?: boolean;
77
- /** Maximum file size (e.g., "10MB") */
78
- maxFileSize?: string;
79
- /** Accepted file types */
80
- acceptedTypes?: string[];
81
- /** Label shown when empty */
82
- emptyLabel?: string;
83
- /** Constraints text shown inside dropzone (e.g., "Max 10MB, 2160px") */
84
- constraintsText?: string;
85
- /** Helper text shown below dropzone */
86
- helperText?: string;
87
- /** Callback when image is uploaded - receives File, optionally returns URL
88
- * If returns URL: component adds to images (self-managed)
89
- * If returns void/null: parent must manage images (controlled) */
90
- onUpload?: (file: File) => Promise<string | null | void> | void;
91
- /** Callback when image is removed */
92
- onRemove?: (index: number) => void;
93
- /** Callback when images are reordered - receives new array */
94
- onReorder?: (images: string[]) => void;
95
- /** Callback when images are reordered - receives indices (for store integration) */
96
- onReorderIndices?: (data: { from: number; to: number }) => void;
97
- /** Callback when user clicks to set main image (moves to first position) */
98
- onSetMain?: (data: { index: number }) => void;
99
- /** Callback when images change (single source of truth) */
100
- onchange?: (images: string[]) => void;
101
- /** Show upload error */
102
- error?: string;
103
- /** Disabled state */
104
- disabled?: boolean;
105
- /** Custom class for wrapper */
106
- class?: string;
107
- /** Override user-visible strings */
108
- labels?: Partial<typeof defaultLabels>;
109
- }
110
-
111
- let {
112
- images = $bindable([]),
113
- maxImages = 6,
114
- enableCrop = false,
115
- cropAspectRatio = '1:1',
116
- enableReorder = true,
117
- shape = 'square',
118
- size = 'md',
119
- showMainBadge = true,
120
- maxFileSize = '20MB',
121
- acceptedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
122
- emptyLabel = 'Drag & drop or click to upload',
123
- constraintsText = '',
124
- helperText = '',
125
- onUpload,
126
- onRemove,
127
- onReorder,
128
- onReorderIndices,
129
- onSetMain,
130
- onchange,
131
- error = '',
132
- disabled = false,
133
- class: className = '',
134
- labels: userLabels = {},
135
- }: Props = $props();
136
-
137
- let labels = $derived({ ...defaultLabels, ...userLabels });
138
-
139
- // Normalize images to array
140
- let imageArray = $derived(
141
- Array.isArray(images)
142
- ? images.filter(Boolean)
143
- : images
144
- ? [images]
145
- : []
146
- );
147
-
148
- // Determine mode based on maxImages
149
- let isMultiMode = $derived(maxImages > 1);
150
- let canAddMore = $derived(imageArray.length < maxImages);
151
-
152
- // FilePond state
153
- let filePondFiles = $state<unknown[]>([]);
154
- let filePondInstance = $state<unknown>(undefined);
155
- let showFilePondModal = $state(false);
156
- let targetSlotIndex = $state<number | null>(null);
157
-
158
- // Crop state
159
- let showCropModal = $state(false);
160
- let imageForCrop = $state('');
161
- let isUploading = $state(false);
162
-
163
- // Reorder state
164
- let gridContainer = $state<HTMLDivElement | undefined>(undefined);
165
- let sortableInstance: Sortable | null = null;
166
- let isDragging = $state(false);
167
- let gridRenderKey = $state(0);
168
-
169
- // Direct drag-drop state for dropzone
170
- let isDropzoneDragOver = $state(false);
171
-
172
- // Native modal dropzone state
173
- let isModalDragOver = $state(false);
174
- let modalFileInput = $state<HTMLInputElement | undefined>(undefined);
175
-
176
- // Single-mode file input for replace functionality
177
- let singleModeFileInput = $state<HTMLInputElement | undefined>(undefined);
178
-
179
- // Prevent browser from opening dragged files in new tab
180
- // This MUST be at document level to intercept before browser default behavior
181
- $effect(() => {
182
- if (!browser) return;
183
-
184
- const preventBrowserDefault = (e: DragEvent) => {
185
- // Only prevent if it's a file drag (not text selection, etc.)
186
- if (e.dataTransfer?.types?.includes('Files')) {
187
- e.preventDefault();
188
- }
189
- };
190
-
191
- window.addEventListener('dragover', preventBrowserDefault, true);
192
- window.addEventListener('drop', preventBrowserDefault, true);
193
-
194
- return () => {
195
- window.removeEventListener('dragover', preventBrowserDefault, true);
196
- window.removeEventListener('drop', preventBrowserDefault, true);
197
- };
198
- });
199
-
200
- // Shape to aspect ratio mapping
201
- const shapeAspects: Record<Shape, string> = {
202
- square: 'aspect-square',
203
- wide: 'aspect-video',
204
- };
205
-
206
- // Size configuration - layout classes, slot width, and element sizes
207
- const sizeConfig: Record<Size, { containerClass: string; slotClass: string; iconClass: string; badgeSize: 'small' | 'medium'; removeSize: string }> = {
208
- sm: {
209
- containerClass: 'flex flex-wrap gap-1.5',
210
- slotClass: 'w-16 h-16', // 64px fixed
211
- iconClass: 'w-4 h-4',
212
- badgeSize: 'small',
213
- removeSize: 'w-4 h-4',
214
- },
215
- md: {
216
- containerClass: 'flex flex-wrap gap-2',
217
- slotClass: 'w-24 h-24', // 96px fixed
218
- iconClass: 'w-5 h-5',
219
- badgeSize: 'small',
220
- removeSize: 'w-5 h-5',
221
- },
222
- lg: {
223
- containerClass: 'grid grid-cols-3 gap-4',
224
- slotClass: '', // Let grid control width (uses aspect-square)
225
- iconClass: 'w-8 h-8',
226
- badgeSize: 'medium',
227
- removeSize: 'w-6 h-6',
228
- },
229
- };
230
-
231
- // Get current size config
232
- let currentSizeConfig = $derived(sizeConfig[size]);
233
-
234
- // Crop aspect ratio to number
235
- const aspectRatioValues: Record<AspectRatio, number> = {
236
- '1:1': 1,
237
- '2:1': 2,
238
- '16:9': 16 / 9,
239
- '4:3': 4 / 3,
240
- 'free': 0,
241
- };
242
-
243
- // FilePond options
244
- const filePondOptions = $derived({
245
- allowMultiple: false,
246
- maxFiles: 1,
247
- acceptedFileTypes: acceptedTypes,
248
- maxFileSize: maxFileSize,
249
- // Drag-drop configuration
250
- allowDrop: true,
251
- dropOnPage: false, // Only accept drops on the FilePond element
252
- dropOnElement: true,
253
- labelIdle: `
254
- <div class="filepond-custom-label">
255
- <svg class="w-8 h-8 mb-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
256
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
257
- </svg>
258
- <span class="text-sm font-medium text-gray-600 dark:text-gray-300">${emptyLabel}</span>
259
- <span class="text-[11px] text-gray-400 dark:text-gray-500 mt-2 block">${labels.fileTypes}</span>
260
- ${constraintsText ? `<span class="text-[10px] text-gray-400/50 dark:text-gray-500/50 mt-1 block">${constraintsText}</span>` : ''}
261
- </div>
262
- `,
263
- credits: false,
264
- instantUpload: false,
265
- allowRevert: true,
266
- allowProcess: false,
267
- imagePreviewHeight: shape === 'wide' ? 150 : 200,
268
- stylePanelLayout: 'compact',
269
- stylePanelAspectRatio: shape === 'wide' ? '16:9' : '1:1',
270
- });
271
-
272
- // Handle FilePond file add
273
- function handleFilePondAddFile(err: unknown, fileItem: unknown) {
274
- if (err) {
275
- console.error('FilePond error:', err);
276
- return;
277
- }
278
-
279
- const item = fileItem as { file: File } | undefined;
280
- const file = item?.file;
281
- if (!file) return;
282
-
283
- if (enableCrop) {
284
- // Show crop modal
285
- const objectUrl = URL.createObjectURL(file);
286
- imageForCrop = objectUrl;
287
- showCropModal = true;
288
- } else {
289
- // Optimize then upload
290
- optimizeImage(file).then((optimizedFile) => {
291
- handleUpload(optimizedFile);
292
- });
293
- }
294
-
295
- // Clear FilePond
296
- setTimeout(() => {
297
- if (filePondInstance && typeof (filePondInstance as { removeFiles: () => void }).removeFiles === 'function') {
298
- (filePondInstance as { removeFiles: () => void }).removeFiles();
299
- }
300
- }, 100);
301
-
302
- showFilePondModal = false;
303
- }
304
-
305
- // Handle crop save
306
- async function handleCropSave(croppedFile: File) {
307
- showCropModal = false;
308
- if (imageForCrop.startsWith('blob:')) {
309
- URL.revokeObjectURL(imageForCrop);
310
- }
311
- imageForCrop = '';
312
- await handleUpload(croppedFile);
313
- }
314
-
315
- // Handle crop cancel
316
- function handleCropCancel() {
317
- showCropModal = false;
318
- if (imageForCrop.startsWith('blob:')) {
319
- URL.revokeObjectURL(imageForCrop);
320
- }
321
- imageForCrop = '';
322
- }
323
-
324
- // Handle upload
325
- // Supports two modes:
326
- // 1. Self-managed: onUpload returns URL, component adds to images
327
- // 2. Controlled: onUpload returns void, parent manages images array
328
- async function handleUpload(file: File) {
329
- if (!onUpload) {
330
- console.warn('ImageUploader: onUpload callback not provided');
331
- return;
332
- }
333
-
334
- isUploading = true;
335
-
336
- try {
337
- const result = await onUpload(file);
338
-
339
- // If onUpload returns a URL, component manages adding to images (self-managed mode)
340
- // If onUpload returns void/null, parent is expected to manage images (controlled mode)
341
- if (typeof result === 'string' && result) {
342
- const url = result;
343
- const newImages = [...imageArray];
344
- if (targetSlotIndex !== null && targetSlotIndex < newImages.length) {
345
- // Replace existing image
346
- newImages[targetSlotIndex] = url;
347
- } else {
348
- // Add new image
349
- newImages.push(url);
350
- }
351
-
352
- // Update images and notify
353
- if (Array.isArray(images)) {
354
- images = newImages;
355
- } else {
356
- images = newImages[0] || '';
357
- }
358
- onchange?.(newImages);
359
- }
360
- // If result is void/null/undefined, parent handles adding to images
361
- } catch (err) {
362
- console.error('Upload failed:', err);
363
- } finally {
364
- isUploading = false;
365
- targetSlotIndex = null;
366
- }
367
- }
368
-
369
- // Handle remove
370
- // In controlled mode (onReorderIndices provided), just fire callback
371
- // In self-managed mode, update images internally
372
- function handleRemoveImage(index: number) {
373
- // Always fire the callback
374
- onRemove?.(index);
375
-
376
- // Only manage state internally if not in controlled mode
377
- if (!onReorderIndices) {
378
- const newImages = [...imageArray];
379
- newImages.splice(index, 1);
380
-
381
- if (Array.isArray(images)) {
382
- images = newImages;
383
- } else {
384
- images = newImages[0] || '';
385
- }
386
-
387
- onchange?.(newImages);
388
- }
389
-
390
- gridRenderKey++;
391
- }
392
-
393
- // Open upload modal/dropzone
394
- function openUploadModal(slotIndex?: number) {
395
- if (disabled) return;
396
- targetSlotIndex = slotIndex ?? null;
397
- if (isMultiMode) {
398
- showFilePondModal = true;
399
- }
400
- }
401
-
402
- // Handle direct drag-drop on dropzone (bypasses modal)
403
- function handleDropzoneDragOver(e: DragEvent) {
404
- e.preventDefault();
405
- e.stopPropagation();
406
- if (disabled || !canAddMore) return;
407
- isDropzoneDragOver = true;
408
- }
409
-
410
- function handleDropzoneDragLeave(e: DragEvent) {
411
- e.preventDefault();
412
- e.stopPropagation();
413
- isDropzoneDragOver = false;
414
- }
415
-
416
- function handleDropzoneDrop(e: DragEvent, slotIndex?: number) {
417
- e.preventDefault();
418
- e.stopPropagation();
419
- isDropzoneDragOver = false;
420
-
421
- if (disabled || !canAddMore) return;
422
-
423
- const files = e.dataTransfer?.files;
424
- if (!files || files.length === 0) return;
425
-
426
- const file = files[0];
427
-
428
- // Check if it's an accepted image type
429
- if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
430
- console.warn('File type not accepted:', file.type);
431
- return;
432
- }
433
-
434
- targetSlotIndex = slotIndex ?? null;
435
-
436
- if (enableCrop) {
437
- // Show crop modal
438
- const objectUrl = URL.createObjectURL(file);
439
- imageForCrop = objectUrl;
440
- showCropModal = true;
441
- } else {
442
- // Optimize then upload
443
- optimizeImage(file).then((optimizedFile) => {
444
- handleUpload(optimizedFile);
445
- });
446
- }
447
- }
448
-
449
- // Handle modal dropzone drop
450
- function handleModalDrop(e: DragEvent) {
451
- e.preventDefault();
452
- e.stopPropagation();
453
- isModalDragOver = false;
454
- showFilePondModal = false;
455
-
456
- if (disabled || !canAddMore) return;
457
-
458
- const files = e.dataTransfer?.files;
459
- if (!files || files.length === 0) return;
460
-
461
- const file = files[0];
462
-
463
- // Check if it's an accepted image type
464
- if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
465
- console.warn('File type not accepted:', file.type);
466
- return;
467
- }
468
-
469
- if (enableCrop) {
470
- const objectUrl = URL.createObjectURL(file);
471
- imageForCrop = objectUrl;
472
- showCropModal = true;
473
- } else {
474
- optimizeImage(file).then((optimizedFile) => {
475
- handleUpload(optimizedFile);
476
- });
477
- }
478
- }
479
-
480
- // Handle modal file input selection
481
- function handleModalFileSelect(e: Event) {
482
- const input = e.target as HTMLInputElement;
483
- const file = input?.files?.[0];
484
- if (!file) return;
485
-
486
- showFilePondModal = false;
487
-
488
- if (enableCrop) {
489
- const objectUrl = URL.createObjectURL(file);
490
- imageForCrop = objectUrl;
491
- showCropModal = true;
492
- } else {
493
- optimizeImage(file).then((optimizedFile) => {
494
- handleUpload(optimizedFile);
495
- });
496
- }
497
-
498
- // Clear input for next selection
499
- input.value = '';
500
- }
501
-
502
- // Handle single-mode file input selection (for replace)
503
- function handleSingleModeFileSelect(e: Event) {
504
- const input = e.target as HTMLInputElement;
505
- const file = input?.files?.[0];
506
- if (!file) return;
507
-
508
- targetSlotIndex = 0; // Replace the existing image
509
-
510
- if (enableCrop) {
511
- const objectUrl = URL.createObjectURL(file);
512
- imageForCrop = objectUrl;
513
- showCropModal = true;
514
- } else {
515
- optimizeImage(file).then((optimizedFile) => {
516
- handleUpload(optimizedFile);
517
- });
518
- }
519
-
520
- // Clear input for next selection
521
- input.value = '';
522
- }
523
-
524
- // Initialize sortable for multi-image grid
525
- function initSortable() {
526
- if (!browser || !gridContainer || !enableReorder || !isMultiMode) return;
527
-
528
- if (sortableInstance) {
529
- sortableInstance.destroy();
530
- }
531
-
532
- sortableInstance = new Sortable(gridContainer, {
533
- animation: 200,
534
- ghostClass: 'sortable-ghost',
535
- chosenClass: 'sortable-chosen',
536
- dragClass: 'sortable-drag',
537
- delay: 150,
538
- delayOnTouchOnly: true,
539
- touchStartThreshold: 5,
540
- filter: '.empty-slot, .remove-btn',
541
- preventOnFilter: false,
542
- onStart: () => {
543
- isDragging = true;
544
- },
545
- onEnd: (evt) => {
546
- isDragging = false;
547
- const { oldIndex, newIndex } = evt;
548
- if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
549
-
550
- // Fire index-based callback for store integration (controlled mode)
551
- onReorderIndices?.({ from: oldIndex, to: newIndex });
552
-
553
- // For self-managed mode, update images internally
554
- if (!onReorderIndices) {
555
- const newImages = [...imageArray];
556
- const [moved] = newImages.splice(oldIndex, 1);
557
- newImages.splice(newIndex, 0, moved);
558
-
559
- if (Array.isArray(images)) {
560
- images = newImages;
561
- }
562
-
563
- onReorder?.(newImages);
564
- onchange?.(newImages);
565
- }
566
-
567
- gridRenderKey++;
568
-
569
- // Re-init after state update
570
- if (sortableInstance) {
571
- sortableInstance.destroy();
572
- sortableInstance = null;
573
- }
574
- },
575
- });
576
- }
577
-
578
- // Setup sortable on mount and when images change
579
- $effect(() => {
580
- if (browser && gridContainer && !isDragging && isMultiMode && enableReorder) {
581
- // Use setTimeout to ensure DOM is ready
582
- setTimeout(initSortable, 0);
583
- }
584
- });
585
-
586
- // Cleanup
587
- $effect(() => {
588
- return () => {
589
- if (sortableInstance) {
590
- sortableInstance.destroy();
591
- }
592
- };
593
- });
594
-
595
- // Generate slots with progressive disclosure
596
- // Only show filled images + 1 empty slot (up to maxImages)
597
- let slots = $derived.by(() => {
598
- const slotCount = Math.min(imageArray.length + 1, maxImages);
599
- return Array.from({ length: slotCount }, (_, i) => ({
600
- id: `slot-${i}`,
601
- image: imageArray[i] || null,
602
- isNext: i === imageArray.length,
603
- }));
604
- });
605
- </script>
606
-
607
-
608
- <!-- Single Image Mode -->
609
- {#if !isMultiMode}
610
- <div class="image-uploader-single {className}">
611
- {#if imageArray.length > 0}
612
- <!-- Show existing image with remove option -->
613
- <div class="relative {shapeAspects[shape]} bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden group">
614
- <img
615
- src={imageArray[0]}
616
- alt={labels.uploadedAlt}
617
- class="w-full h-full object-contain"
618
- />
619
- {#if !disabled}
620
- <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
621
- <Button
622
- variant="default"
623
- size="sm"
624
- onclick={() => singleModeFileInput?.click()}
625
- >
626
- <ImageOutline class="w-4 h-4 mr-1.5" />
627
- {labels.replace}
628
- </Button>
629
- <Button
630
- variant="red"
631
- size="sm"
632
- onclick={() => handleRemoveImage(0)}
633
- >
634
- <TrashBinOutline class="w-4 h-4 mr-1.5" />
635
- {labels.delete}
636
- </Button>
637
- </div>
638
- <!-- Hidden file input for replace -->
639
- <input
640
- bind:this={singleModeFileInput}
641
- type="file"
642
- accept={acceptedTypes.join(',')}
643
- class="hidden"
644
- data-testid="image-upload-input"
645
- onchange={handleSingleModeFileSelect}
646
- />
647
- {/if}
648
- </div>
649
- {:else}
650
- <!-- Empty dropzone with direct drag-drop support -->
651
- <div
652
- class="{shapeAspects[shape]} filepond-wrapper-single rounded-xl {shape === 'wide' ? 'filepond-wide' : ''}
653
- {isDropzoneDragOver ? 'dropzone-drag-over' : ''}"
654
- role="button"
655
- tabindex="0"
656
- ondragover={handleDropzoneDragOver}
657
- ondragleave={handleDropzoneDragLeave}
658
- ondrop={(e) => handleDropzoneDrop(e, 0)}
659
- >
660
- <FilePond
661
- bind:this={filePondInstance}
662
- bind:files={filePondFiles}
663
- {...filePondOptions}
664
- onaddfile={handleFilePondAddFile}
665
- />
666
- </div>
667
- {/if}
668
-
669
- {#if helperText}
670
- <p class={`${typography.smMuted} mt-2`}>{helperText}</p>
671
- {/if}
672
-
673
- {#if error}
674
- <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
675
- {/if}
676
- </div>
677
-
678
- <!-- Multi Image Mode (Grid) -->
679
- {:else}
680
- <div class="image-uploader-multi {className}">
681
- {#key gridRenderKey}
682
- <div
683
- bind:this={gridContainer}
684
- class="{currentSizeConfig.containerClass}"
685
- >
686
- {#each slots as slot, index (slot.id)}
687
- <div
688
- class="relative {size === 'lg' ? shapeAspects[shape] : ''} {currentSizeConfig.slotClass} rounded-lg overflow-hidden"
689
- class:cursor-grab={slot.image && enableReorder && !disabled}
690
- class:empty-slot={!slot.image}
691
- data-slot-id={slot.id}
692
- >
693
- {#if slot.image}
694
- <!-- Filled slot -->
695
- <!-- svelte-ignore a11y_click_events_have_key_events -->
696
- <!-- svelte-ignore a11y_no_static_element_interactions -->
697
- <div
698
- class="relative w-full h-full bg-gray-100 dark:bg-gray-800 group"
699
- class:cursor-pointer={onSetMain && index !== 0 && !disabled}
700
- onclick={() => {
701
- if (onSetMain && index !== 0 && !disabled) {
702
- onSetMain({ index });
703
- }
704
- }}
705
- >
706
- <img
707
- src={slot.image}
708
- alt={labels.uploadedAlt}
709
- class="w-full h-full object-contain pointer-events-none"
710
- draggable="false"
711
- />
712
- {#if showMainBadge && index === 0}
713
- <div class="absolute bottom-1 left-1 z-10 pointer-events-none">
714
- <Badge variant="info" size={currentSizeConfig.badgeSize}>{labels.mainBadge}</Badge>
715
- </div>
716
- {/if}
717
- {#if !disabled}
718
- <button
719
- type="button"
720
- class="remove-btn absolute top-1 right-1 {currentSizeConfig.removeSize} bg-white/90 dark:bg-gray-900/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-900"
721
- onclick={(e) => { e.stopPropagation(); handleRemoveImage(index); }}
722
- aria-label={labels.removePhoto}
723
- >
724
- <CloseOutline class="w-3 h-3 text-gray-700 dark:text-gray-300" />
725
- </button>
726
- {/if}
727
- </div>
728
- {:else if slot.isNext && canAddMore && !disabled}
729
- <!-- Next available slot - dropzone with direct drag-drop support -->
730
- <!-- svelte-ignore a11y_no_static_element_interactions -->
731
- <div
732
- class="w-full h-full border-2 border-dashed rounded-lg transition-colors flex items-center justify-center cursor-pointer
733
- {isDropzoneDragOver
734
- ? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40'
735
- : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
736
- role="button"
737
- tabindex="0"
738
- onclick={() => openUploadModal(index)}
739
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openUploadModal(index); }}
740
- ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
741
- ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
742
- ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = false; }}
743
- ondrop={(e) => handleDropzoneDrop(e, index)}
744
- aria-label={labels.addPhotoAria}
745
- >
746
- <PlusOutline class={currentSizeConfig.iconClass + ' text-gray-400 dark:text-gray-500'} />
747
- </div>
748
- {/if}
749
- </div>
750
- {/each}
751
- </div>
752
- {/key}
753
-
754
- {#if helperText}
755
- <p class={`${typography.smMuted} mt-3`}>{helperText}</p>
756
- {/if}
757
-
758
- {#if error}
759
- <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
760
- {/if}
761
- </div>
762
-
763
- <!-- Upload Modal for multi-image (native dropzone - more reliable than FilePond) -->
764
- {#if showFilePondModal}
765
- <!-- svelte-ignore a11y_click_events_have_key_events -->
766
- <!-- svelte-ignore a11y_no_static_element_interactions -->
767
- <div
768
- class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4"
769
- onclick={() => (showFilePondModal = false)}
770
- ondragover={(e) => e.preventDefault()}
771
- ondrop={(e) => e.preventDefault()}
772
- >
773
- <!-- svelte-ignore a11y_click_events_have_key_events -->
774
- <!-- svelte-ignore a11y_no_static_element_interactions -->
775
- <div
776
- class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"
777
- onclick={(e) => e.stopPropagation()}
778
- >
779
- <div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
780
- <h3 class={typography.h3}>{labels.uploadPhotoTitle}</h3>
781
- <button
782
- type="button"
783
- class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
784
- onclick={() => (showFilePondModal = false)}
785
- aria-label={labels.closeModal}
786
- >
787
- <CloseOutline class="w-5 h-5" />
788
- </button>
789
- </div>
790
- <div class="p-4">
791
- <!-- Native dropzone with file input -->
792
- <div
793
- class="dropzone-native border-2 border-dashed rounded-lg transition-all duration-200 min-h-[200px] flex flex-col items-center justify-center cursor-pointer
794
- {isModalDragOver
795
- ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
796
- : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-900/20'}"
797
- role="button"
798
- tabindex="0"
799
- onclick={() => modalFileInput?.click()}
800
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') modalFileInput?.click(); }}
801
- ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
802
- ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
803
- ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = false; }}
804
- ondrop={(e) => handleModalDrop(e)}
805
- >
806
- <PlusOutline class="w-8 h-8 mb-2 text-gray-400" />
807
- <span class={typography.smMuted}>{emptyLabel}</span>
808
- <span class={`${typography.xsMuted} mt-1`}>{labels.fileTypes}</span>
809
- </div>
810
- <input
811
- bind:this={modalFileInput}
812
- type="file"
813
- accept={acceptedTypes.join(',')}
814
- class="hidden"
815
- data-testid="image-upload-modal-input"
816
- onchange={handleModalFileSelect}
817
- />
818
- </div>
819
- </div>
820
- </div>
821
- {/if}
822
- {/if}
823
-
824
- <!-- Crop Modal -->
825
- {#if enableCrop}
826
- <CropImage
827
- bind:showModal={showCropModal}
828
- imageSrc={imageForCrop}
829
- onSave={handleCropSave}
830
- onCancel={handleCropCancel}
831
- isUploadingImage={isUploading}
832
- />
833
- {/if}
834
-
835
- <style>
836
- /* FilePond customizations */
837
- :global(.filepond-wrapper .filepond--root) {
838
- font-family: inherit;
839
- min-height: 180px;
840
- }
841
-
842
- :global(.filepond-wrapper .filepond--panel-root) {
843
- background-color: transparent;
844
- }
845
-
846
- :global(.filepond-wrapper .filepond--drop-label) {
847
- color: inherit;
848
- min-height: 180px;
849
- display: flex;
850
- align-items: center;
851
- justify-content: center;
852
- }
853
-
854
- /* Note: FilePond dark mode is handled via runtime JS injection
855
- because Svelte's scoped :global() can't reliably override
856
- FilePond's CSS that loads from node_modules */
857
-
858
- :global(.filepond-wrapper .filepond--drop-label label) {
859
- display: flex;
860
- flex-direction: column;
861
- align-items: center;
862
- justify-content: center;
863
- width: 100%;
864
- height: 100%;
865
- cursor: pointer;
866
- }
867
-
868
- :global(.filepond-wrapper .filepond--label-action) {
869
- text-decoration: none;
870
- color: inherit;
871
- }
872
-
873
- /* FilePond drag hover state */
874
- :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
875
- border-color: rgb(59 130 246); /* blue-500 */
876
- background-color: rgb(239 246 255); /* blue-50 */
877
- }
878
-
879
- :global(.dark .filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
880
- background-color: rgba(30, 58, 138, 0.3); /* blue-900/30 */
881
- }
882
-
883
- :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"] .filepond--panel-root) {
884
- border-color: rgb(59 130 246); /* blue-500 */
885
- }
886
-
887
- /* Modal FilePond styling */
888
- :global(.filepond-wrapper) {
889
- border: 2px dashed rgb(209 213 219); /* gray-300 */
890
- border-radius: 0.5rem; /* rounded-lg */
891
- background-color: rgb(249 250 251); /* gray-50 */
892
- transition-property: color, background-color, border-color;
893
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
894
- transition-duration: 150ms;
895
- }
896
-
897
- :global(.dark .filepond-wrapper) {
898
- border-color: rgb(75 85 99); /* gray-600 */
899
- background-color: rgb(31 41 55); /* gray-800 */
900
- }
901
-
902
- :global(.filepond-wrapper:hover) {
903
- border-color: rgb(96 165 250); /* blue-400 */
904
- }
905
-
906
- /* Single mode dropzone styling */
907
- .filepond-wrapper-single {
908
- border: 2px dashed rgb(209 213 219); /* gray-300 */
909
- background-color: rgb(249 250 251); /* gray-50 */
910
- transition-property: color, background-color, border-color;
911
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
912
- transition-duration: 150ms;
913
- }
914
-
915
- :global(.dark) .filepond-wrapper-single {
916
- border-color: rgb(75 85 99); /* gray-600 */
917
- background-color: rgb(31 41 55); /* gray-800 */
918
- }
919
-
920
- .filepond-wrapper-single:hover {
921
- border-color: rgb(96 165 250); /* blue-400 */
922
- background-color: rgb(239 246 255); /* blue-50 */
923
- }
924
-
925
- :global(.dark) .filepond-wrapper-single:hover {
926
- background-color: rgba(30, 58, 138, 0.2); /* blue-900/20 */
927
- }
928
-
929
- .filepond-wrapper-single :global(.filepond--root) {
930
- height: 100%;
931
- }
932
-
933
- .filepond-wrapper-single :global(.filepond--root .filepond--drop-label) {
934
- height: 100%;
935
- min-height: 0;
936
- }
937
-
938
- /* Wide shape specific styling */
939
- .filepond-wide {
940
- aspect-ratio: 16 / 9;
941
- }
942
-
943
- .filepond-wide :global(.filepond--root .filepond--drop-label label) {
944
- display: flex;
945
- flex-direction: column;
946
- align-items: center;
947
- justify-content: center;
948
- height: 100%;
949
- padding-top: 2rem;
950
- padding-bottom: 2rem;
951
- }
952
-
953
- /* Direct drag-over state for dropzone */
954
- .dropzone-drag-over {
955
- border-color: rgb(59 130 246); /* blue-500 */
956
- background-color: rgb(219 234 254); /* blue-100 */
957
- }
958
-
959
- :global(.dark) .dropzone-drag-over {
960
- background-color: rgba(30, 58, 138, 0.4); /* blue-900/40 */
961
- }
962
-
963
- /* Custom label styling inside FilePond */
964
- :global(.filepond-custom-label) {
965
- display: flex;
966
- flex-direction: column;
967
- align-items: center;
968
- justify-content: center;
969
- }
970
-
971
- /* Cursor for draggable items */
972
- .cursor-grab {
973
- cursor: grab;
974
- }
975
-
976
- .cursor-grab:active {
977
- cursor: grabbing;
978
- }
979
-
980
- /* SortableJS classes - must be single class names, not space-separated */
981
- :global(.sortable-ghost) {
982
- opacity: 0.5;
983
- }
984
-
985
- :global(.sortable-chosen) {
986
- box-shadow: 0 0 0 2px rgb(59 130 246); /* ring-2 ring-blue-500 */
987
- }
988
-
989
- :global(.sortable-drag) {
990
- cursor: grabbing;
991
- }
992
- </style>
1
+ <script lang="ts">
2
+ /**
3
+ * ImageUploader - Unified image upload component
4
+ *
5
+ * Supports:
6
+ * - Single image mode (event posters, venue images)
7
+ * - Multi-image mode with grid layout (performer photos)
8
+ * - Optional cropping with aspect ratio selection
9
+ * - Optional drag-to-reorder for multi-image
10
+ * - Different shapes: square, wide
11
+ * - Image compression before upload callback
12
+ */
13
+
14
+ import { browser } from '../../__LIB_ENVIRONMENT__.js';
15
+ import FilePond, { registerPlugin } from 'svelte-filepond';
16
+ import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
17
+ import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
18
+ import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
19
+ import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
20
+ import 'filepond/dist/filepond.min.css';
21
+ import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
22
+
23
+ import Sortable from 'sortablejs';
24
+ import CropImage from '../CropImage/CropImage.svelte';
25
+ import { optimizeImage } from '../../utils/imageOptimizer';
26
+ import Badge from '../../primitives/Badges/Badge.svelte';
27
+ import Button from '../../primitives/Button/Button.svelte';
28
+ import { CloseOutline, ImageOutline, PlusOutline, TrashBinOutline } from '../../primitives/Icons';
29
+ import { typography } from '../../tokens/typography';
30
+
31
+ // Register FilePond plugins once
32
+ registerPlugin(
33
+ FilePondPluginImagePreview,
34
+ FilePondPluginImageExifOrientation,
35
+ FilePondPluginFileValidateType,
36
+ FilePondPluginFileValidateSize
37
+ );
38
+
39
+ // Types
40
+ type Shape = 'square' | 'wide';
41
+ type AspectRatio = '1:1' | '2:1' | '16:9' | '4:3' | 'free';
42
+ type Size = 'sm' | 'md' | 'lg';
43
+
44
+ /**
45
+ * Default labels for user-visible strings in ImageUploader.
46
+ * Consumers can override via the `labels` prop.
47
+ */
48
+ const defaultLabels = {
49
+ uploadPhotoTitle: 'Upload photo',
50
+ mainBadge: 'Main',
51
+ replace: 'Replace',
52
+ delete: 'Delete',
53
+ removePhoto: 'Remove photo',
54
+ addPhotoAria: 'Add photo - drag and drop or click to upload',
55
+ fileTypes: 'JPG, PNG, WebP',
56
+ uploadedAlt: 'Uploaded',
57
+ closeModal: 'Close',
58
+ };
59
+
60
+ interface Props {
61
+ /** Single image or array of images (URLs) */
62
+ images?: string | string[];
63
+ /** Maximum number of images (only for multi mode) */
64
+ maxImages?: number;
65
+ /** Enable cropping step */
66
+ enableCrop?: boolean;
67
+ /** Crop aspect ratio */
68
+ cropAspectRatio?: AspectRatio;
69
+ /** Enable drag-to-reorder (only for multi mode) */
70
+ enableReorder?: boolean;
71
+ /** Dropzone shape - affects aspect ratio and styling */
72
+ shape?: Shape;
73
+ /** Size of image slots: sm (~64px), md (~96px), lg (~200px+) */
74
+ size?: Size;
75
+ /** Show "Main" badge on first image (multi mode) */
76
+ showMainBadge?: boolean;
77
+ /** Maximum file size (e.g., "10MB") */
78
+ maxFileSize?: string;
79
+ /** Accepted file types */
80
+ acceptedTypes?: string[];
81
+ /** Label shown when empty */
82
+ emptyLabel?: string;
83
+ /** Constraints text shown inside dropzone (e.g., "Max 10MB, 2160px") */
84
+ constraintsText?: string;
85
+ /** Helper text shown below dropzone */
86
+ helperText?: string;
87
+ /** Callback when image is uploaded - receives File, optionally returns URL
88
+ * If returns URL: component adds to images (self-managed)
89
+ * If returns void/null: parent must manage images (controlled) */
90
+ onUpload?: (file: File) => Promise<string | null | void> | void;
91
+ /** Callback when image is removed */
92
+ onRemove?: (index: number) => void;
93
+ /** Callback when images are reordered - receives new array */
94
+ onReorder?: (images: string[]) => void;
95
+ /** Callback when images are reordered - receives indices (for store integration) */
96
+ onReorderIndices?: (data: { from: number; to: number }) => void;
97
+ /** Callback when user clicks to set main image (moves to first position) */
98
+ onSetMain?: (data: { index: number }) => void;
99
+ /** Callback when images change (single source of truth) */
100
+ onchange?: (images: string[]) => void;
101
+ /** Show upload error */
102
+ error?: string;
103
+ /** Disabled state */
104
+ disabled?: boolean;
105
+ /** Custom class for wrapper */
106
+ class?: string;
107
+ /** Override user-visible strings */
108
+ labels?: Partial<typeof defaultLabels>;
109
+ }
110
+
111
+ let {
112
+ images = $bindable([]),
113
+ maxImages = 6,
114
+ enableCrop = false,
115
+ cropAspectRatio = '1:1',
116
+ enableReorder = true,
117
+ shape = 'square',
118
+ size = 'md',
119
+ showMainBadge = true,
120
+ maxFileSize = '20MB',
121
+ acceptedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
122
+ emptyLabel = 'Drag & drop or click to upload',
123
+ constraintsText = '',
124
+ helperText = '',
125
+ onUpload,
126
+ onRemove,
127
+ onReorder,
128
+ onReorderIndices,
129
+ onSetMain,
130
+ onchange,
131
+ error = '',
132
+ disabled = false,
133
+ class: className = '',
134
+ labels: userLabels = {},
135
+ }: Props = $props();
136
+
137
+ let labels = $derived({ ...defaultLabels, ...userLabels });
138
+
139
+ // Normalize images to array
140
+ let imageArray = $derived(
141
+ Array.isArray(images)
142
+ ? images.filter(Boolean)
143
+ : images
144
+ ? [images]
145
+ : []
146
+ );
147
+
148
+ // Determine mode based on maxImages
149
+ let isMultiMode = $derived(maxImages > 1);
150
+ let canAddMore = $derived(imageArray.length < maxImages);
151
+
152
+ // FilePond state
153
+ let filePondFiles = $state<unknown[]>([]);
154
+ let filePondInstance = $state<unknown>(undefined);
155
+ let showFilePondModal = $state(false);
156
+ let targetSlotIndex = $state<number | null>(null);
157
+
158
+ // Crop state
159
+ let showCropModal = $state(false);
160
+ let imageForCrop = $state('');
161
+ let isUploading = $state(false);
162
+
163
+ // Reorder state
164
+ let gridContainer = $state<HTMLDivElement | undefined>(undefined);
165
+ let sortableInstance: Sortable | null = null;
166
+ let isDragging = $state(false);
167
+ let gridRenderKey = $state(0);
168
+
169
+ // Direct drag-drop state for dropzone
170
+ let isDropzoneDragOver = $state(false);
171
+
172
+ // Native modal dropzone state
173
+ let isModalDragOver = $state(false);
174
+ let modalFileInput = $state<HTMLInputElement | undefined>(undefined);
175
+
176
+ // Single-mode file input for replace functionality
177
+ let singleModeFileInput = $state<HTMLInputElement | undefined>(undefined);
178
+
179
+ // Prevent browser from opening dragged files in new tab
180
+ // This MUST be at document level to intercept before browser default behavior
181
+ $effect(() => {
182
+ if (!browser) return;
183
+
184
+ const preventBrowserDefault = (e: DragEvent) => {
185
+ // Only prevent if it's a file drag (not text selection, etc.)
186
+ if (e.dataTransfer?.types?.includes('Files')) {
187
+ e.preventDefault();
188
+ }
189
+ };
190
+
191
+ window.addEventListener('dragover', preventBrowserDefault, true);
192
+ window.addEventListener('drop', preventBrowserDefault, true);
193
+
194
+ return () => {
195
+ window.removeEventListener('dragover', preventBrowserDefault, true);
196
+ window.removeEventListener('drop', preventBrowserDefault, true);
197
+ };
198
+ });
199
+
200
+ // Shape to aspect ratio mapping
201
+ const shapeAspects: Record<Shape, string> = {
202
+ square: 'aspect-square',
203
+ wide: 'aspect-video',
204
+ };
205
+
206
+ // Size configuration - layout classes, slot width, and element sizes
207
+ const sizeConfig: Record<Size, { containerClass: string; slotClass: string; iconClass: string; badgeSize: 'small' | 'medium'; removeSize: string }> = {
208
+ sm: {
209
+ containerClass: 'flex flex-wrap gap-1.5',
210
+ slotClass: 'w-16 h-16', // 64px fixed
211
+ iconClass: 'w-4 h-4',
212
+ badgeSize: 'small',
213
+ removeSize: 'w-4 h-4',
214
+ },
215
+ md: {
216
+ containerClass: 'flex flex-wrap gap-2',
217
+ slotClass: 'w-24 h-24', // 96px fixed
218
+ iconClass: 'w-5 h-5',
219
+ badgeSize: 'small',
220
+ removeSize: 'w-5 h-5',
221
+ },
222
+ lg: {
223
+ containerClass: 'grid grid-cols-3 gap-4',
224
+ slotClass: '', // Let grid control width (uses aspect-square)
225
+ iconClass: 'w-8 h-8',
226
+ badgeSize: 'medium',
227
+ removeSize: 'w-6 h-6',
228
+ },
229
+ };
230
+
231
+ // Get current size config
232
+ let currentSizeConfig = $derived(sizeConfig[size]);
233
+
234
+ // Crop aspect ratio to number
235
+ const aspectRatioValues: Record<AspectRatio, number> = {
236
+ '1:1': 1,
237
+ '2:1': 2,
238
+ '16:9': 16 / 9,
239
+ '4:3': 4 / 3,
240
+ 'free': 0,
241
+ };
242
+
243
+ // FilePond options
244
+ const filePondOptions = $derived({
245
+ allowMultiple: false,
246
+ maxFiles: 1,
247
+ acceptedFileTypes: acceptedTypes,
248
+ maxFileSize: maxFileSize,
249
+ // Drag-drop configuration
250
+ allowDrop: true,
251
+ dropOnPage: false, // Only accept drops on the FilePond element
252
+ dropOnElement: true,
253
+ labelIdle: `
254
+ <div class="filepond-custom-label">
255
+ <svg class="w-8 h-8 mb-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
256
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
257
+ </svg>
258
+ <span class="text-sm font-medium text-gray-600 dark:text-gray-300">${emptyLabel}</span>
259
+ <span class="text-[11px] text-gray-400 dark:text-gray-500 mt-2 block">${labels.fileTypes}</span>
260
+ ${constraintsText ? `<span class="text-[10px] text-gray-400/50 dark:text-gray-500/50 mt-1 block">${constraintsText}</span>` : ''}
261
+ </div>
262
+ `,
263
+ credits: false,
264
+ instantUpload: false,
265
+ allowRevert: true,
266
+ allowProcess: false,
267
+ imagePreviewHeight: shape === 'wide' ? 150 : 200,
268
+ stylePanelLayout: 'compact',
269
+ stylePanelAspectRatio: shape === 'wide' ? '16:9' : '1:1',
270
+ });
271
+
272
+ // Handle FilePond file add
273
+ function handleFilePondAddFile(err: unknown, fileItem: unknown) {
274
+ if (err) {
275
+ console.error('FilePond error:', err);
276
+ return;
277
+ }
278
+
279
+ const item = fileItem as { file: File } | undefined;
280
+ const file = item?.file;
281
+ if (!file) return;
282
+
283
+ if (enableCrop) {
284
+ // Show crop modal
285
+ const objectUrl = URL.createObjectURL(file);
286
+ imageForCrop = objectUrl;
287
+ showCropModal = true;
288
+ } else {
289
+ // Optimize then upload
290
+ optimizeImage(file).then((optimizedFile) => {
291
+ handleUpload(optimizedFile);
292
+ });
293
+ }
294
+
295
+ // Clear FilePond
296
+ setTimeout(() => {
297
+ if (filePondInstance && typeof (filePondInstance as { removeFiles: () => void }).removeFiles === 'function') {
298
+ (filePondInstance as { removeFiles: () => void }).removeFiles();
299
+ }
300
+ }, 100);
301
+
302
+ showFilePondModal = false;
303
+ }
304
+
305
+ // Handle crop save
306
+ async function handleCropSave(croppedFile: File) {
307
+ showCropModal = false;
308
+ if (imageForCrop.startsWith('blob:')) {
309
+ URL.revokeObjectURL(imageForCrop);
310
+ }
311
+ imageForCrop = '';
312
+ await handleUpload(croppedFile);
313
+ }
314
+
315
+ // Handle crop cancel
316
+ function handleCropCancel() {
317
+ showCropModal = false;
318
+ if (imageForCrop.startsWith('blob:')) {
319
+ URL.revokeObjectURL(imageForCrop);
320
+ }
321
+ imageForCrop = '';
322
+ }
323
+
324
+ // Handle upload
325
+ // Supports two modes:
326
+ // 1. Self-managed: onUpload returns URL, component adds to images
327
+ // 2. Controlled: onUpload returns void, parent manages images array
328
+ async function handleUpload(file: File) {
329
+ if (!onUpload) {
330
+ console.warn('ImageUploader: onUpload callback not provided');
331
+ return;
332
+ }
333
+
334
+ isUploading = true;
335
+
336
+ try {
337
+ const result = await onUpload(file);
338
+
339
+ // If onUpload returns a URL, component manages adding to images (self-managed mode)
340
+ // If onUpload returns void/null, parent is expected to manage images (controlled mode)
341
+ if (typeof result === 'string' && result) {
342
+ const url = result;
343
+ const newImages = [...imageArray];
344
+ if (targetSlotIndex !== null && targetSlotIndex < newImages.length) {
345
+ // Replace existing image
346
+ newImages[targetSlotIndex] = url;
347
+ } else {
348
+ // Add new image
349
+ newImages.push(url);
350
+ }
351
+
352
+ // Update images and notify
353
+ if (Array.isArray(images)) {
354
+ images = newImages;
355
+ } else {
356
+ images = newImages[0] || '';
357
+ }
358
+ onchange?.(newImages);
359
+ }
360
+ // If result is void/null/undefined, parent handles adding to images
361
+ } catch (err) {
362
+ console.error('Upload failed:', err);
363
+ } finally {
364
+ isUploading = false;
365
+ targetSlotIndex = null;
366
+ }
367
+ }
368
+
369
+ // Handle remove
370
+ // In controlled mode (onReorderIndices provided), just fire callback
371
+ // In self-managed mode, update images internally
372
+ function handleRemoveImage(index: number) {
373
+ // Always fire the callback
374
+ onRemove?.(index);
375
+
376
+ // Only manage state internally if not in controlled mode
377
+ if (!onReorderIndices) {
378
+ const newImages = [...imageArray];
379
+ newImages.splice(index, 1);
380
+
381
+ if (Array.isArray(images)) {
382
+ images = newImages;
383
+ } else {
384
+ images = newImages[0] || '';
385
+ }
386
+
387
+ onchange?.(newImages);
388
+ }
389
+
390
+ gridRenderKey++;
391
+ }
392
+
393
+ // Open upload modal/dropzone
394
+ function openUploadModal(slotIndex?: number) {
395
+ if (disabled) return;
396
+ targetSlotIndex = slotIndex ?? null;
397
+ if (isMultiMode) {
398
+ showFilePondModal = true;
399
+ }
400
+ }
401
+
402
+ // Handle direct drag-drop on dropzone (bypasses modal)
403
+ function handleDropzoneDragOver(e: DragEvent) {
404
+ e.preventDefault();
405
+ e.stopPropagation();
406
+ if (disabled || !canAddMore) return;
407
+ isDropzoneDragOver = true;
408
+ }
409
+
410
+ function handleDropzoneDragLeave(e: DragEvent) {
411
+ e.preventDefault();
412
+ e.stopPropagation();
413
+ isDropzoneDragOver = false;
414
+ }
415
+
416
+ function handleDropzoneDrop(e: DragEvent, slotIndex?: number) {
417
+ e.preventDefault();
418
+ e.stopPropagation();
419
+ isDropzoneDragOver = false;
420
+
421
+ if (disabled || !canAddMore) return;
422
+
423
+ const files = e.dataTransfer?.files;
424
+ if (!files || files.length === 0) return;
425
+
426
+ const file = files[0];
427
+
428
+ // Check if it's an accepted image type
429
+ if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
430
+ console.warn('File type not accepted:', file.type);
431
+ return;
432
+ }
433
+
434
+ targetSlotIndex = slotIndex ?? null;
435
+
436
+ if (enableCrop) {
437
+ // Show crop modal
438
+ const objectUrl = URL.createObjectURL(file);
439
+ imageForCrop = objectUrl;
440
+ showCropModal = true;
441
+ } else {
442
+ // Optimize then upload
443
+ optimizeImage(file).then((optimizedFile) => {
444
+ handleUpload(optimizedFile);
445
+ });
446
+ }
447
+ }
448
+
449
+ // Handle modal dropzone drop
450
+ function handleModalDrop(e: DragEvent) {
451
+ e.preventDefault();
452
+ e.stopPropagation();
453
+ isModalDragOver = false;
454
+ showFilePondModal = false;
455
+
456
+ if (disabled || !canAddMore) return;
457
+
458
+ const files = e.dataTransfer?.files;
459
+ if (!files || files.length === 0) return;
460
+
461
+ const file = files[0];
462
+
463
+ // Check if it's an accepted image type
464
+ if (!acceptedTypes.some(type => file.type === type || type.includes('*'))) {
465
+ console.warn('File type not accepted:', file.type);
466
+ return;
467
+ }
468
+
469
+ if (enableCrop) {
470
+ const objectUrl = URL.createObjectURL(file);
471
+ imageForCrop = objectUrl;
472
+ showCropModal = true;
473
+ } else {
474
+ optimizeImage(file).then((optimizedFile) => {
475
+ handleUpload(optimizedFile);
476
+ });
477
+ }
478
+ }
479
+
480
+ // Handle modal file input selection
481
+ function handleModalFileSelect(e: Event) {
482
+ const input = e.target as HTMLInputElement;
483
+ const file = input?.files?.[0];
484
+ if (!file) return;
485
+
486
+ showFilePondModal = false;
487
+
488
+ if (enableCrop) {
489
+ const objectUrl = URL.createObjectURL(file);
490
+ imageForCrop = objectUrl;
491
+ showCropModal = true;
492
+ } else {
493
+ optimizeImage(file).then((optimizedFile) => {
494
+ handleUpload(optimizedFile);
495
+ });
496
+ }
497
+
498
+ // Clear input for next selection
499
+ input.value = '';
500
+ }
501
+
502
+ // Handle single-mode file input selection (for replace)
503
+ function handleSingleModeFileSelect(e: Event) {
504
+ const input = e.target as HTMLInputElement;
505
+ const file = input?.files?.[0];
506
+ if (!file) return;
507
+
508
+ targetSlotIndex = 0; // Replace the existing image
509
+
510
+ if (enableCrop) {
511
+ const objectUrl = URL.createObjectURL(file);
512
+ imageForCrop = objectUrl;
513
+ showCropModal = true;
514
+ } else {
515
+ optimizeImage(file).then((optimizedFile) => {
516
+ handleUpload(optimizedFile);
517
+ });
518
+ }
519
+
520
+ // Clear input for next selection
521
+ input.value = '';
522
+ }
523
+
524
+ // Initialize sortable for multi-image grid
525
+ function initSortable() {
526
+ if (!browser || !gridContainer || !enableReorder || !isMultiMode) return;
527
+
528
+ if (sortableInstance) {
529
+ sortableInstance.destroy();
530
+ }
531
+
532
+ sortableInstance = new Sortable(gridContainer, {
533
+ animation: 200,
534
+ ghostClass: 'sortable-ghost',
535
+ chosenClass: 'sortable-chosen',
536
+ dragClass: 'sortable-drag',
537
+ delay: 150,
538
+ delayOnTouchOnly: true,
539
+ touchStartThreshold: 5,
540
+ filter: '.empty-slot, .remove-btn',
541
+ preventOnFilter: false,
542
+ onStart: () => {
543
+ isDragging = true;
544
+ },
545
+ onEnd: (evt) => {
546
+ isDragging = false;
547
+ const { oldIndex, newIndex } = evt;
548
+ if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return;
549
+
550
+ // Fire index-based callback for store integration (controlled mode)
551
+ onReorderIndices?.({ from: oldIndex, to: newIndex });
552
+
553
+ // For self-managed mode, update images internally
554
+ if (!onReorderIndices) {
555
+ const newImages = [...imageArray];
556
+ const [moved] = newImages.splice(oldIndex, 1);
557
+ newImages.splice(newIndex, 0, moved);
558
+
559
+ if (Array.isArray(images)) {
560
+ images = newImages;
561
+ }
562
+
563
+ onReorder?.(newImages);
564
+ onchange?.(newImages);
565
+ }
566
+
567
+ gridRenderKey++;
568
+
569
+ // Re-init after state update
570
+ if (sortableInstance) {
571
+ sortableInstance.destroy();
572
+ sortableInstance = null;
573
+ }
574
+ },
575
+ });
576
+ }
577
+
578
+ // Setup sortable on mount and when images change
579
+ $effect(() => {
580
+ if (browser && gridContainer && !isDragging && isMultiMode && enableReorder) {
581
+ // Use setTimeout to ensure DOM is ready
582
+ setTimeout(initSortable, 0);
583
+ }
584
+ });
585
+
586
+ // Cleanup
587
+ $effect(() => {
588
+ return () => {
589
+ if (sortableInstance) {
590
+ sortableInstance.destroy();
591
+ }
592
+ };
593
+ });
594
+
595
+ // Generate slots with progressive disclosure
596
+ // Only show filled images + 1 empty slot (up to maxImages)
597
+ let slots = $derived.by(() => {
598
+ const slotCount = Math.min(imageArray.length + 1, maxImages);
599
+ return Array.from({ length: slotCount }, (_, i) => ({
600
+ id: `slot-${i}`,
601
+ image: imageArray[i] || null,
602
+ isNext: i === imageArray.length,
603
+ }));
604
+ });
605
+ </script>
606
+
607
+
608
+ <!-- Single Image Mode -->
609
+ {#if !isMultiMode}
610
+ <div class="image-uploader-single {className}">
611
+ {#if imageArray.length > 0}
612
+ <!-- Show existing image with remove option -->
613
+ <div class="relative {shapeAspects[shape]} bg-gray-100 dark:bg-gray-800 rounded-xl overflow-hidden group">
614
+ <img
615
+ src={imageArray[0]}
616
+ alt={labels.uploadedAlt}
617
+ class="w-full h-full object-contain"
618
+ />
619
+ {#if !disabled}
620
+ <div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
621
+ <Button
622
+ variant="default"
623
+ size="sm"
624
+ onclick={() => singleModeFileInput?.click()}
625
+ >
626
+ <ImageOutline class="w-4 h-4 mr-1.5" />
627
+ {labels.replace}
628
+ </Button>
629
+ <Button
630
+ variant="red"
631
+ size="sm"
632
+ onclick={() => handleRemoveImage(0)}
633
+ >
634
+ <TrashBinOutline class="w-4 h-4 mr-1.5" />
635
+ {labels.delete}
636
+ </Button>
637
+ </div>
638
+ <!-- Hidden file input for replace -->
639
+ <input
640
+ bind:this={singleModeFileInput}
641
+ type="file"
642
+ accept={acceptedTypes.join(',')}
643
+ class="hidden"
644
+ data-testid="image-upload-input"
645
+ onchange={handleSingleModeFileSelect}
646
+ />
647
+ {/if}
648
+ </div>
649
+ {:else}
650
+ <!-- Empty dropzone with direct drag-drop support -->
651
+ <div
652
+ class="{shapeAspects[shape]} filepond-wrapper-single rounded-xl {shape === 'wide' ? 'filepond-wide' : ''}
653
+ {isDropzoneDragOver ? 'dropzone-drag-over' : ''}"
654
+ role="button"
655
+ tabindex="0"
656
+ ondragover={handleDropzoneDragOver}
657
+ ondragleave={handleDropzoneDragLeave}
658
+ ondrop={(e) => handleDropzoneDrop(e, 0)}
659
+ >
660
+ <FilePond
661
+ bind:this={filePondInstance}
662
+ bind:files={filePondFiles}
663
+ {...filePondOptions}
664
+ onaddfile={handleFilePondAddFile}
665
+ />
666
+ </div>
667
+ {/if}
668
+
669
+ {#if helperText}
670
+ <p class={`${typography.smMuted} mt-2`}>{helperText}</p>
671
+ {/if}
672
+
673
+ {#if error}
674
+ <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
675
+ {/if}
676
+ </div>
677
+
678
+ <!-- Multi Image Mode (Grid) -->
679
+ {:else}
680
+ <div class="image-uploader-multi {className}">
681
+ {#key gridRenderKey}
682
+ <div
683
+ bind:this={gridContainer}
684
+ class="{currentSizeConfig.containerClass}"
685
+ >
686
+ {#each slots as slot, index (slot.id)}
687
+ <div
688
+ class="relative {size === 'lg' ? shapeAspects[shape] : ''} {currentSizeConfig.slotClass} rounded-lg overflow-hidden"
689
+ class:cursor-grab={slot.image && enableReorder && !disabled}
690
+ class:empty-slot={!slot.image}
691
+ data-slot-id={slot.id}
692
+ >
693
+ {#if slot.image}
694
+ <!-- Filled slot -->
695
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
696
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
697
+ <div
698
+ class="relative w-full h-full bg-gray-100 dark:bg-gray-800 group"
699
+ class:cursor-pointer={onSetMain && index !== 0 && !disabled}
700
+ onclick={() => {
701
+ if (onSetMain && index !== 0 && !disabled) {
702
+ onSetMain({ index });
703
+ }
704
+ }}
705
+ >
706
+ <img
707
+ src={slot.image}
708
+ alt={labels.uploadedAlt}
709
+ class="w-full h-full object-contain pointer-events-none"
710
+ draggable="false"
711
+ />
712
+ {#if showMainBadge && index === 0}
713
+ <div class="absolute bottom-1 left-1 z-10 pointer-events-none">
714
+ <Badge variant="info" size={currentSizeConfig.badgeSize}>{labels.mainBadge}</Badge>
715
+ </div>
716
+ {/if}
717
+ {#if !disabled}
718
+ <button
719
+ type="button"
720
+ class="remove-btn absolute top-1 right-1 {currentSizeConfig.removeSize} bg-white/90 dark:bg-gray-900/90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-900"
721
+ onclick={(e) => { e.stopPropagation(); handleRemoveImage(index); }}
722
+ aria-label={labels.removePhoto}
723
+ >
724
+ <CloseOutline class="w-3 h-3 text-gray-700 dark:text-gray-300" />
725
+ </button>
726
+ {/if}
727
+ </div>
728
+ {:else if slot.isNext && canAddMore && !disabled}
729
+ <!-- Next available slot - dropzone with direct drag-drop support -->
730
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
731
+ <div
732
+ class="w-full h-full border-2 border-dashed rounded-lg transition-colors flex items-center justify-center cursor-pointer
733
+ {isDropzoneDragOver
734
+ ? 'border-blue-500 bg-blue-100 dark:bg-blue-900/40'
735
+ : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
736
+ role="button"
737
+ tabindex="0"
738
+ onclick={() => openUploadModal(index)}
739
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') openUploadModal(index); }}
740
+ ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
741
+ ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = true; }}
742
+ ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isDropzoneDragOver = false; }}
743
+ ondrop={(e) => handleDropzoneDrop(e, index)}
744
+ aria-label={labels.addPhotoAria}
745
+ >
746
+ <PlusOutline class={currentSizeConfig.iconClass + ' text-gray-400 dark:text-gray-500'} />
747
+ </div>
748
+ {/if}
749
+ </div>
750
+ {/each}
751
+ </div>
752
+ {/key}
753
+
754
+ {#if helperText}
755
+ <p class={`${typography.smMuted} mt-3`}>{helperText}</p>
756
+ {/if}
757
+
758
+ {#if error}
759
+ <p class={`${typography.smMuted} text-red-500 mt-2`}>{error}</p>
760
+ {/if}
761
+ </div>
762
+
763
+ <!-- Upload Modal for multi-image (native dropzone - more reliable than FilePond) -->
764
+ {#if showFilePondModal}
765
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
766
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
767
+ <div
768
+ class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 dark:bg-gray-900/80 p-4"
769
+ onclick={() => (showFilePondModal = false)}
770
+ ondragover={(e) => e.preventDefault()}
771
+ ondrop={(e) => e.preventDefault()}
772
+ >
773
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
774
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
775
+ <div
776
+ class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden"
777
+ onclick={(e) => e.stopPropagation()}
778
+ >
779
+ <div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
780
+ <h3 class={typography.h3}>{labels.uploadPhotoTitle}</h3>
781
+ <button
782
+ type="button"
783
+ class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
784
+ onclick={() => (showFilePondModal = false)}
785
+ aria-label={labels.closeModal}
786
+ >
787
+ <CloseOutline class="w-5 h-5" />
788
+ </button>
789
+ </div>
790
+ <div class="p-4">
791
+ <!-- Native dropzone with file input -->
792
+ <div
793
+ class="dropzone-native border-2 border-dashed rounded-lg transition-all duration-200 min-h-[200px] flex flex-col items-center justify-center cursor-pointer
794
+ {isModalDragOver
795
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30'
796
+ : 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-900/20'}"
797
+ role="button"
798
+ tabindex="0"
799
+ onclick={() => modalFileInput?.click()}
800
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') modalFileInput?.click(); }}
801
+ ondragenter={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
802
+ ondragover={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = true; }}
803
+ ondragleave={(e) => { e.preventDefault(); e.stopPropagation(); isModalDragOver = false; }}
804
+ ondrop={(e) => handleModalDrop(e)}
805
+ >
806
+ <PlusOutline class="w-8 h-8 mb-2 text-gray-400" />
807
+ <span class={typography.smMuted}>{emptyLabel}</span>
808
+ <span class={`${typography.xsMuted} mt-1`}>{labels.fileTypes}</span>
809
+ </div>
810
+ <input
811
+ bind:this={modalFileInput}
812
+ type="file"
813
+ accept={acceptedTypes.join(',')}
814
+ class="hidden"
815
+ data-testid="image-upload-modal-input"
816
+ onchange={handleModalFileSelect}
817
+ />
818
+ </div>
819
+ </div>
820
+ </div>
821
+ {/if}
822
+ {/if}
823
+
824
+ <!-- Crop Modal -->
825
+ {#if enableCrop}
826
+ <CropImage
827
+ bind:showModal={showCropModal}
828
+ imageSrc={imageForCrop}
829
+ onSave={handleCropSave}
830
+ onCancel={handleCropCancel}
831
+ isUploadingImage={isUploading}
832
+ />
833
+ {/if}
834
+
835
+ <style>
836
+ /* FilePond customizations */
837
+ :global(.filepond-wrapper .filepond--root) {
838
+ font-family: inherit;
839
+ min-height: 180px;
840
+ }
841
+
842
+ :global(.filepond-wrapper .filepond--panel-root) {
843
+ background-color: transparent;
844
+ }
845
+
846
+ :global(.filepond-wrapper .filepond--drop-label) {
847
+ color: inherit;
848
+ min-height: 180px;
849
+ display: flex;
850
+ align-items: center;
851
+ justify-content: center;
852
+ }
853
+
854
+ /* Note: FilePond dark mode is handled via runtime JS injection
855
+ because Svelte's scoped :global() can't reliably override
856
+ FilePond's CSS that loads from node_modules */
857
+
858
+ :global(.filepond-wrapper .filepond--drop-label label) {
859
+ display: flex;
860
+ flex-direction: column;
861
+ align-items: center;
862
+ justify-content: center;
863
+ width: 100%;
864
+ height: 100%;
865
+ cursor: pointer;
866
+ }
867
+
868
+ :global(.filepond-wrapper .filepond--label-action) {
869
+ text-decoration: none;
870
+ color: inherit;
871
+ }
872
+
873
+ /* FilePond drag hover state */
874
+ :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
875
+ border-color: rgb(59 130 246); /* blue-500 */
876
+ background-color: rgb(239 246 255); /* blue-50 */
877
+ }
878
+
879
+ :global(.dark .filepond-wrapper .filepond--root[data-hopper-state="drag-over"]) {
880
+ background-color: rgba(30, 58, 138, 0.3); /* blue-900/30 */
881
+ }
882
+
883
+ :global(.filepond-wrapper .filepond--root[data-hopper-state="drag-over"] .filepond--panel-root) {
884
+ border-color: rgb(59 130 246); /* blue-500 */
885
+ }
886
+
887
+ /* Modal FilePond styling */
888
+ :global(.filepond-wrapper) {
889
+ border: 2px dashed rgb(209 213 219); /* gray-300 */
890
+ border-radius: 0.5rem; /* rounded-lg */
891
+ background-color: rgb(249 250 251); /* gray-50 */
892
+ transition-property: color, background-color, border-color;
893
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
894
+ transition-duration: 150ms;
895
+ }
896
+
897
+ :global(.dark .filepond-wrapper) {
898
+ border-color: rgb(75 85 99); /* gray-600 */
899
+ background-color: rgb(31 41 55); /* gray-800 */
900
+ }
901
+
902
+ :global(.filepond-wrapper:hover) {
903
+ border-color: rgb(96 165 250); /* blue-400 */
904
+ }
905
+
906
+ /* Single mode dropzone styling */
907
+ .filepond-wrapper-single {
908
+ border: 2px dashed rgb(209 213 219); /* gray-300 */
909
+ background-color: rgb(249 250 251); /* gray-50 */
910
+ transition-property: color, background-color, border-color;
911
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
912
+ transition-duration: 150ms;
913
+ }
914
+
915
+ :global(.dark) .filepond-wrapper-single {
916
+ border-color: rgb(75 85 99); /* gray-600 */
917
+ background-color: rgb(31 41 55); /* gray-800 */
918
+ }
919
+
920
+ .filepond-wrapper-single:hover {
921
+ border-color: rgb(96 165 250); /* blue-400 */
922
+ background-color: rgb(239 246 255); /* blue-50 */
923
+ }
924
+
925
+ :global(.dark) .filepond-wrapper-single:hover {
926
+ background-color: rgba(30, 58, 138, 0.2); /* blue-900/20 */
927
+ }
928
+
929
+ .filepond-wrapper-single :global(.filepond--root) {
930
+ height: 100%;
931
+ }
932
+
933
+ .filepond-wrapper-single :global(.filepond--root .filepond--drop-label) {
934
+ height: 100%;
935
+ min-height: 0;
936
+ }
937
+
938
+ /* Wide shape specific styling */
939
+ .filepond-wide {
940
+ aspect-ratio: 16 / 9;
941
+ }
942
+
943
+ .filepond-wide :global(.filepond--root .filepond--drop-label label) {
944
+ display: flex;
945
+ flex-direction: column;
946
+ align-items: center;
947
+ justify-content: center;
948
+ height: 100%;
949
+ padding-top: 2rem;
950
+ padding-bottom: 2rem;
951
+ }
952
+
953
+ /* Direct drag-over state for dropzone */
954
+ .dropzone-drag-over {
955
+ border-color: rgb(59 130 246); /* blue-500 */
956
+ background-color: rgb(219 234 254); /* blue-100 */
957
+ }
958
+
959
+ :global(.dark) .dropzone-drag-over {
960
+ background-color: rgba(30, 58, 138, 0.4); /* blue-900/40 */
961
+ }
962
+
963
+ /* Custom label styling inside FilePond */
964
+ :global(.filepond-custom-label) {
965
+ display: flex;
966
+ flex-direction: column;
967
+ align-items: center;
968
+ justify-content: center;
969
+ }
970
+
971
+ /* Cursor for draggable items */
972
+ .cursor-grab {
973
+ cursor: grab;
974
+ }
975
+
976
+ .cursor-grab:active {
977
+ cursor: grabbing;
978
+ }
979
+
980
+ /* SortableJS classes - must be single class names, not space-separated */
981
+ :global(.sortable-ghost) {
982
+ opacity: 0.5;
983
+ }
984
+
985
+ :global(.sortable-chosen) {
986
+ box-shadow: 0 0 0 2px rgb(59 130 246); /* ring-2 ring-blue-500 */
987
+ }
988
+
989
+ :global(.sortable-drag) {
990
+ cursor: grabbing;
991
+ }
992
+ </style>