@getmicdrop/svelte-components 5.21.0 → 5.21.2

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