zabi-components 8.1.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/CHANGELOG.md +1011 -0
  2. package/README.md +1 -1
  3. package/THEME.md +18 -5
  4. package/THEMING.md +308 -74
  5. package/dist/atoms/ActionPanel.svelte +1 -1
  6. package/dist/atoms/Avatar.svelte +19 -3
  7. package/dist/atoms/Avatar.svelte.d.ts +12 -0
  8. package/dist/atoms/Badge.svelte +37 -8
  9. package/dist/atoms/Badge.svelte.d.ts +6 -0
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +52 -15
  13. package/dist/atoms/Button.svelte.d.ts +3 -2
  14. package/dist/atoms/Card.svelte +181 -25
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/Checkbox.svelte +33 -0
  17. package/dist/atoms/Checkbox.svelte.d.ts +27 -0
  18. package/dist/atoms/Chip.svelte +351 -0
  19. package/dist/atoms/Chip.svelte.d.ts +57 -0
  20. package/dist/atoms/CodeField.svelte +385 -0
  21. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  22. package/dist/atoms/ColorPicker.svelte +1 -0
  23. package/dist/atoms/DateField.svelte +41 -3
  24. package/dist/atoms/DateField.svelte.d.ts +31 -3
  25. package/dist/atoms/FloatingActionButton.svelte +5 -3
  26. package/dist/atoms/FloatingActionButton.svelte.d.ts +1 -1
  27. package/dist/atoms/Heading.svelte +27 -1
  28. package/dist/atoms/Heading.svelte.d.ts +7 -0
  29. package/dist/atoms/IconButton.svelte +81 -9
  30. package/dist/atoms/IconButton.svelte.d.ts +13 -0
  31. package/dist/atoms/Input.svelte +10 -5
  32. package/dist/atoms/Input.svelte.d.ts +3 -2
  33. package/dist/atoms/List.svelte +3 -2
  34. package/dist/atoms/ListItem.svelte +31 -7
  35. package/dist/atoms/ListItem.svelte.d.ts +6 -0
  36. package/dist/atoms/PickerField.svelte +239 -0
  37. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  38. package/dist/atoms/Progress.svelte +78 -17
  39. package/dist/atoms/Progress.svelte.d.ts +11 -1
  40. package/dist/atoms/Radio.svelte +32 -0
  41. package/dist/atoms/Radio.svelte.d.ts +26 -0
  42. package/dist/atoms/Rating.svelte +132 -6
  43. package/dist/atoms/Rating.svelte.d.ts +24 -3
  44. package/dist/atoms/Select.svelte +24 -26
  45. package/dist/atoms/Select.svelte.d.ts +12 -0
  46. package/dist/atoms/SelectionControl.svelte +99 -6
  47. package/dist/atoms/SelectionControl.svelte.d.ts +25 -0
  48. package/dist/atoms/Skeleton.svelte +114 -18
  49. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  50. package/dist/atoms/SkeletonGroup.svelte +43 -0
  51. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  52. package/dist/atoms/Slider.svelte +35 -0
  53. package/dist/atoms/Sparkline.svelte +109 -0
  54. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  55. package/dist/atoms/SpeechBubble.svelte +187 -0
  56. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  57. package/dist/atoms/Stat.svelte +72 -0
  58. package/dist/atoms/Stat.svelte.d.ts +19 -0
  59. package/dist/atoms/TemporalField.svelte +342 -10
  60. package/dist/atoms/TemporalField.svelte.d.ts +20 -4
  61. package/dist/atoms/Textarea.svelte +100 -5
  62. package/dist/atoms/Textarea.svelte.d.ts +21 -0
  63. package/dist/atoms/TimeColumn.svelte +150 -0
  64. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  65. package/dist/atoms/TimeField.svelte +46 -3
  66. package/dist/atoms/TimeField.svelte.d.ts +34 -3
  67. package/dist/atoms/Toast.svelte +6 -5
  68. package/dist/atoms/Toggle.svelte +61 -10
  69. package/dist/atoms/Toggle.svelte.d.ts +15 -0
  70. package/dist/atoms/Tooltip.svelte +20 -1
  71. package/dist/atoms/Tooltip.svelte.d.ts +7 -0
  72. package/dist/atoms/index.d.ts +21 -0
  73. package/dist/atoms/index.js +17 -0
  74. package/dist/atoms/selection-control.styles.d.ts +15 -4
  75. package/dist/atoms/selection-control.styles.js +16 -5
  76. package/dist/components/atoms/index.d.ts +21 -0
  77. package/dist/components/atoms/index.d.ts.map +1 -1
  78. package/dist/components/atoms/selection-control.styles.d.ts +15 -4
  79. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  80. package/dist/components/index.d.ts +27 -3
  81. package/dist/components/index.d.ts.map +1 -1
  82. package/dist/components/map.d.ts +11 -0
  83. package/dist/components/map.d.ts.map +1 -0
  84. package/dist/components/molecules/index.d.ts +7 -2
  85. package/dist/components/molecules/index.d.ts.map +1 -1
  86. package/dist/components/molecules/toast-store.d.ts +30 -0
  87. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  88. package/dist/components/organisms/index.d.ts +6 -1
  89. package/dist/components/organisms/index.d.ts.map +1 -1
  90. package/dist/components/types/variants.d.ts +12 -3
  91. package/dist/components/types/variants.d.ts.map +1 -1
  92. package/dist/components/util/app-shell.d.ts +55 -4
  93. package/dist/components/util/app-shell.d.ts.map +1 -1
  94. package/dist/components/util/avatar.d.ts +3 -0
  95. package/dist/components/util/avatar.d.ts.map +1 -1
  96. package/dist/components/util/bottom-tab-bar.d.ts +6 -0
  97. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -1
  98. package/dist/components/util/chart.d.ts +140 -0
  99. package/dist/components/util/chart.d.ts.map +1 -0
  100. package/dist/components/util/chip.d.ts +66 -0
  101. package/dist/components/util/chip.d.ts.map +1 -0
  102. package/dist/components/util/cn.d.ts.map +1 -1
  103. package/dist/components/util/code-field.d.ts +63 -0
  104. package/dist/components/util/code-field.d.ts.map +1 -0
  105. package/dist/components/util/collapsible.d.ts +6 -0
  106. package/dist/components/util/collapsible.d.ts.map +1 -1
  107. package/dist/components/util/display-voice.d.ts +10 -0
  108. package/dist/components/util/display-voice.d.ts.map +1 -0
  109. package/dist/components/util/field-control.d.ts +30 -0
  110. package/dist/components/util/field-control.d.ts.map +1 -0
  111. package/dist/components/util/field.d.ts +6 -0
  112. package/dist/components/util/field.d.ts.map +1 -1
  113. package/dist/components/util/focus-utils.d.ts +2 -0
  114. package/dist/components/util/focus-utils.d.ts.map +1 -1
  115. package/dist/components/util/map-view.d.ts +144 -0
  116. package/dist/components/util/map-view.d.ts.map +1 -0
  117. package/dist/components/util/photo.d.ts +19 -0
  118. package/dist/components/util/photo.d.ts.map +1 -1
  119. package/dist/components/util/progress.d.ts +19 -0
  120. package/dist/components/util/progress.d.ts.map +1 -0
  121. package/dist/components/util/scroll-edge.d.ts +33 -0
  122. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  123. package/dist/components/util/sheet-drag.d.ts +25 -1
  124. package/dist/components/util/sheet-drag.d.ts.map +1 -1
  125. package/dist/components/util/skeleton.d.ts +15 -0
  126. package/dist/components/util/skeleton.d.ts.map +1 -0
  127. package/dist/components/util/sliding-indicator.d.ts +25 -0
  128. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  129. package/dist/components/util/temporal-field.d.ts +89 -0
  130. package/dist/components/util/temporal-field.d.ts.map +1 -0
  131. package/dist/components/util/toaster.d.ts +40 -0
  132. package/dist/components/util/toaster.d.ts.map +1 -1
  133. package/dist/components/util/tone-surface.d.ts +34 -0
  134. package/dist/components/util/tone-surface.d.ts.map +1 -0
  135. package/dist/components/util/top-navbar.d.ts +26 -2
  136. package/dist/components/util/top-navbar.d.ts.map +1 -1
  137. package/dist/components/util/zabi-strings.d.ts +9 -0
  138. package/dist/components/util/zabi-strings.d.ts.map +1 -1
  139. package/dist/create-theme/cli.js +6 -2
  140. package/dist/create-theme/index.d.ts +17 -3
  141. package/dist/create-theme/index.js +252 -35
  142. package/dist/create-theme/lib/resolve.js +97 -0
  143. package/dist/create-theme/theme-data.js +1618 -90
  144. package/dist/index.d.ts +27 -3
  145. package/dist/index.js +18 -0
  146. package/dist/map.d.ts +10 -0
  147. package/dist/map.js +9 -0
  148. package/dist/molecules/Alert.svelte +31 -12
  149. package/dist/molecules/Alert.svelte.d.ts +12 -1
  150. package/dist/molecules/AppBar.svelte +287 -28
  151. package/dist/molecules/AppBar.svelte.d.ts +62 -0
  152. package/dist/molecules/AvatarGroup.svelte +1 -1
  153. package/dist/molecules/BarChart.svelte +381 -0
  154. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  155. package/dist/molecules/BottomSheet.svelte +88 -20
  156. package/dist/molecules/BottomSheet.svelte.d.ts +17 -1
  157. package/dist/molecules/BottomTabBar.svelte +134 -28
  158. package/dist/molecules/BottomTabBar.svelte.d.ts +36 -2
  159. package/dist/molecules/Calendar.svelte.d.ts +1 -1
  160. package/dist/molecules/ChipGroup.svelte +325 -0
  161. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  162. package/dist/molecules/Collapsible.svelte +158 -54
  163. package/dist/molecules/Collapsible.svelte.d.ts +23 -6
  164. package/dist/molecules/CollapsibleGroup.svelte +16 -6
  165. package/dist/molecules/ConfirmDialog.svelte +5 -1
  166. package/dist/molecules/ConfirmDialog.svelte.d.ts +3 -0
  167. package/dist/molecules/Drawer.svelte +33 -12
  168. package/dist/molecules/Dropdown.svelte +9 -1
  169. package/dist/molecules/Dropdown.svelte.d.ts +2 -0
  170. package/dist/molecules/DropdownItem.svelte +1 -1
  171. package/dist/molecules/ImageUpload.svelte +230 -120
  172. package/dist/molecules/ImageUpload.svelte.d.ts +38 -3
  173. package/dist/molecules/LineChart.svelte +459 -0
  174. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  175. package/dist/molecules/MapView.svelte +565 -0
  176. package/dist/molecules/MapView.svelte.d.ts +88 -0
  177. package/dist/molecules/Modal.svelte +11 -6
  178. package/dist/molecules/PhotoGrid.svelte +100 -10
  179. package/dist/molecules/PhotoGrid.svelte.d.ts +11 -0
  180. package/dist/molecules/PhotoViewer.svelte +121 -24
  181. package/dist/molecules/PhotoViewer.svelte.d.ts +18 -0
  182. package/dist/molecules/PieChart.svelte +421 -0
  183. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  184. package/dist/molecules/RadioGroup.svelte +20 -4
  185. package/dist/molecules/RadioGroup.svelte.d.ts +5 -0
  186. package/dist/molecules/SegmentedControl.svelte +136 -3
  187. package/dist/molecules/SegmentedControl.svelte.d.ts +6 -0
  188. package/dist/molecules/SidebarFooter.svelte +1 -1
  189. package/dist/molecules/SidebarNavSection.svelte +1 -1
  190. package/dist/molecules/SortableList.svelte +1 -0
  191. package/dist/molecules/Stepper.svelte +21 -7
  192. package/dist/molecules/StickyActionBar.svelte +67 -8
  193. package/dist/molecules/StickyActionBar.svelte.d.ts +14 -1
  194. package/dist/molecules/Tabs.svelte +79 -2
  195. package/dist/molecules/Toaster.svelte +171 -7
  196. package/dist/molecules/Toaster.svelte.d.ts +60 -2
  197. package/dist/molecules/ToasterToast.svelte +292 -60
  198. package/dist/molecules/ToasterToast.svelte.d.ts +6 -0
  199. package/dist/molecules/UnsavedChangesBar.svelte +13 -2
  200. package/dist/molecules/index.d.ts +7 -2
  201. package/dist/molecules/index.js +4 -0
  202. package/dist/molecules/toast-store.d.ts +30 -0
  203. package/dist/molecules/toast-store.js +4 -1
  204. package/dist/organisms/AppNavigation.svelte +440 -0
  205. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  206. package/dist/organisms/AppShell.svelte +539 -39
  207. package/dist/organisms/AppShell.svelte.d.ts +102 -6
  208. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  209. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  210. package/dist/organisms/SidebarNavigation.svelte +23 -36
  211. package/dist/organisms/SidebarPanel.svelte +10 -14
  212. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  213. package/dist/organisms/SidebarShell.svelte +2 -3
  214. package/dist/organisms/TopNavbar.svelte +117 -28
  215. package/dist/organisms/TopNavbar.svelte.d.ts +22 -1
  216. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  217. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  218. package/dist/organisms/index.d.ts +6 -1
  219. package/dist/organisms/index.js +4 -0
  220. package/dist/types/components.d.ts +17 -1
  221. package/dist/types/components.d.ts.map +1 -1
  222. package/dist/types/index.d.ts +497 -10
  223. package/dist/types/index.d.ts.map +1 -1
  224. package/dist/types/variants.ts +14 -3
  225. package/dist/util/app-shell.d.ts +55 -4
  226. package/dist/util/app-shell.js +32 -7
  227. package/dist/util/avatar.d.ts +3 -0
  228. package/dist/util/bottom-tab-bar.d.ts +6 -0
  229. package/dist/util/chart.d.ts +139 -0
  230. package/dist/util/chart.js +305 -0
  231. package/dist/util/chip.d.ts +65 -0
  232. package/dist/util/chip.js +58 -0
  233. package/dist/util/cn.js +28 -2
  234. package/dist/util/code-field.d.ts +62 -0
  235. package/dist/util/code-field.js +112 -0
  236. package/dist/util/collapsible.d.ts +6 -0
  237. package/dist/util/display-voice.d.ts +9 -0
  238. package/dist/util/display-voice.js +9 -0
  239. package/dist/util/field-control.d.ts +29 -0
  240. package/dist/util/field-control.js +32 -0
  241. package/dist/util/field.d.ts +6 -0
  242. package/dist/util/focus-utils.d.ts +2 -0
  243. package/dist/util/focus-utils.js +4 -0
  244. package/dist/util/map-view.d.ts +143 -0
  245. package/dist/util/map-view.js +121 -0
  246. package/dist/util/photo.d.ts +19 -0
  247. package/dist/util/photo.js +3 -0
  248. package/dist/util/progress.d.ts +18 -0
  249. package/dist/util/progress.js +17 -0
  250. package/dist/util/scroll-edge.d.ts +32 -0
  251. package/dist/util/scroll-edge.js +87 -0
  252. package/dist/util/sheet-drag.d.ts +25 -1
  253. package/dist/util/sheet-drag.js +71 -1
  254. package/dist/util/skeleton.d.ts +14 -0
  255. package/dist/util/skeleton.js +22 -0
  256. package/dist/util/sliding-indicator.d.ts +24 -0
  257. package/dist/util/sliding-indicator.js +44 -0
  258. package/dist/util/temporal-field.d.ts +88 -0
  259. package/dist/util/temporal-field.js +136 -0
  260. package/dist/util/toaster.d.ts +40 -0
  261. package/dist/util/toaster.js +55 -0
  262. package/dist/util/tone-surface.d.ts +33 -0
  263. package/dist/util/tone-surface.js +35 -0
  264. package/dist/util/top-navbar.d.ts +26 -2
  265. package/dist/util/top-navbar.js +23 -0
  266. package/dist/util/zabi-strings.d.ts +9 -0
  267. package/dist/zabi-components-colors.css +9 -3
  268. package/dist/zabi-components-theme-dark-only.css +474 -164
  269. package/dist/zabi-components-theme-dark.css +474 -164
  270. package/dist/zabi-components-theme-only.css +933 -99
  271. package/dist/zabi-components-theme.css +933 -99
  272. package/dist/zabi-components.css +2275 -321
  273. package/package.json +11 -1
@@ -7,6 +7,7 @@
7
7
  import { cn } from "../util/cn.js";
8
8
  import { isVideoPath, isVideoUrl } from "../util/media.js";
9
9
  import { generateId } from "../util/ssr-safe.js";
10
+ import type { ButtonSize, ButtonVariant } from "../types/variants.js";
10
11
 
11
12
  export type ImageUploadFileDetail = {
12
13
  /** Selected file, or `null` when the image is removed. */
@@ -19,6 +20,14 @@
19
20
 
20
21
  export type ImageUploadActionsPlacement = "overlay" | "strip";
21
22
 
23
+ export type ImageUploadVariant = "dropzone" | "button";
24
+
25
+ /**
26
+ * Why a file was turned away: `accept` does not allow it, it is over
27
+ * `maxSize`, or (only with `multiple` and `maxFiles`) it is one too many.
28
+ */
29
+ export type ImageUploadRejectReason = "type" | "size" | "count";
30
+
22
31
  export type ImageUploadPreviewDetail = {
23
32
  /** The current `value`. */
24
33
  url: string;
@@ -30,9 +39,35 @@
30
39
  type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick" | "id"> & {
31
40
  /** Extra classes for the host element. */
32
41
  class?: string;
42
+ /**
43
+ * `dropzone` (default): the drop area, and the preview with Change and
44
+ * Remove once there is a value. `button`: only a button that opens the
45
+ * file chooser, for a row of choices; no drop area, no preview and no
46
+ * Remove. The picked file comes through `onfileselect` and `bind:value`,
47
+ * and the app shows it where it wants.
48
+ */
49
+ variant?: ImageUploadVariant;
50
+ /** `variant="button"`: the trigger's `Button` variant. */
51
+ buttonVariant?: ButtonVariant;
52
+ /** `variant="button"`: the trigger's `Button` size. */
53
+ buttonSize?: ButtonSize;
54
+ /** The largest file taken, in bytes. A larger one goes to `onfilereject` with `"size"`. */
55
+ maxSize?: number;
56
+ /**
57
+ * The chooser and a drop take several files, handed over together
58
+ * through `onfiles`, in the order they were chosen. The component
59
+ * then keeps none of them: no preview, `value` is not set and
60
+ * `onfileselect` is not called. The page shows them where it wants
61
+ * (a `PhotoGrid`, say).
62
+ */
63
+ multiple?: boolean;
64
+ /** With `multiple`: the most files taken in one go. The rest go to `onfilereject` with `"count"`. */
65
+ maxFiles?: number;
66
+ /** `variant="button"`: the trigger's content, in place of `label`. */
67
+ children?: Snippet;
33
68
  /** Id of the control the label points at; generated when omitted. */
34
69
  id?: string;
35
- /** Visible label, associated with the dropzone. */
70
+ /** Visible label, associated with the dropzone. With `variant="button"` it is the button's text. */
36
71
  label?: string;
37
72
  /** Preview URL; `bind:value` to read it. */
38
73
  value?: string | null;
@@ -75,12 +110,20 @@
75
110
  onbrowse?: (event: Event) => void;
76
111
  /** Selected file and preview URL; `{ file: null, url: null }` on remove. */
77
112
  onfileselect?: (detail: ImageUploadFileDetail) => void;
78
- /** A dropped file that `accept` does not allow. */
79
- onfilereject?: (file: File) => void;
113
+ /** A dropped file that `accept` does not allow, or a dropped or chosen file over `maxSize`. */
114
+ onfilereject?: (file: File, reason: ImageUploadRejectReason) => void;
115
+ /** With `multiple`: every file that was taken, in order. Not called with none. */
116
+ onfiles?: (files: File[]) => void;
80
117
  };
81
118
 
82
119
  let {
83
120
  class: className = "",
121
+ variant = "dropzone",
122
+ buttonVariant = "secondary",
123
+ buttonSize = "md",
124
+ maxSize,
125
+ multiple = false,
126
+ maxFiles,
84
127
  id: idProp,
85
128
  label = "",
86
129
  value = $bindable<Exclude<Props["value"], undefined>>(),
@@ -106,7 +149,8 @@
106
149
  onbrowse,
107
150
  onfileselect,
108
151
  onfilereject,
109
- children: _children,
152
+ onfiles,
153
+ children,
110
154
  ...restProps
111
155
  }: Props = $props();
112
156
 
@@ -244,13 +288,45 @@
244
288
  const input = event.target as HTMLInputElement;
245
289
  if (!input.files || input.files.length === 0) return;
246
290
 
291
+ if (multiple) {
292
+ const files = Array.from(input.files);
293
+ onchange?.(event);
294
+ // Nothing here holds the files: cleared so the same ones can be chosen again.
295
+ input.value = "";
296
+ takeFiles(files, false);
297
+ return;
298
+ }
299
+
247
300
  const file = input.files[0];
301
+ if (tooLarge(file)) {
302
+ // Cleared so the same file can be chosen again after the app has said why.
303
+ input.value = "";
304
+ onfilereject?.(file, "size");
305
+ return;
306
+ }
248
307
  const url = selectFile(file);
249
308
 
250
309
  onchange?.(event);
251
310
  onfileselect?.({ file, url });
252
311
  }
253
312
 
313
+ /**
314
+ * `multiple`: the files that pass go to `onfiles` together; each of the
315
+ * others goes to `onfilereject` with why. A drop is checked against
316
+ * `accept` here, as the chooser has already done for its own.
317
+ */
318
+ function takeFiles(files: File[], checkType: boolean) {
319
+ const limit = maxFiles !== undefined && maxFiles >= 0 ? Math.floor(maxFiles) : Infinity;
320
+ const taken: File[] = [];
321
+ for (const file of files) {
322
+ if (checkType && !matchesAccept(file)) onfilereject?.(file, "type");
323
+ else if (tooLarge(file)) onfilereject?.(file, "size");
324
+ else if (taken.length >= limit) onfilereject?.(file, "count");
325
+ else taken.push(file);
326
+ }
327
+ if (taken.length > 0) onfiles?.(taken);
328
+ }
329
+
254
330
  function removeImage() {
255
331
  if (disabled) return;
256
332
 
@@ -273,6 +349,10 @@
273
349
  fileInput?.click();
274
350
  }
275
351
 
352
+ function tooLarge(file: File) {
353
+ return maxSize !== undefined && file.size > maxSize;
354
+ }
355
+
276
356
  /** Same rules the browser applies to the `accept` attribute. */
277
357
  function matchesAccept(file: File) {
278
358
  const tokens = accept
@@ -319,10 +399,19 @@
319
399
  event.preventDefault();
320
400
  if (disabled) return;
321
401
 
402
+ if (multiple) {
403
+ takeFiles(Array.from(event.dataTransfer?.files ?? []), true);
404
+ return;
405
+ }
406
+
322
407
  const file = event.dataTransfer?.files?.[0];
323
408
  if (!file) return;
324
409
  if (!matchesAccept(file)) {
325
- onfilereject?.(file);
410
+ onfilereject?.(file, "type");
411
+ return;
412
+ }
413
+ if (tooLarge(file)) {
414
+ onfilereject?.(file, "size");
326
415
  return;
327
416
  }
328
417
 
@@ -337,137 +426,158 @@
337
426
  });
338
427
  </script>
339
428
 
340
- <div bind:this={host} class={cn("space-y-3", className)} {...restProps}>
341
- {#if label}
342
- <label
343
- id={labelId}
344
- for={controlId}
345
- class={cn("block text-sm font-medium text-label", disabled && "opacity-60")}
429
+ <div
430
+ bind:this={host}
431
+ class={cn(variant === "button" ? "inline-block space-y-2" : "space-y-3", className)}
432
+ data-variant={variant}
433
+ {...restProps}
434
+ >
435
+ {#if variant === "button"}
436
+ <!-- Only the trigger: one choice in a row of others. The value is kept
437
+ and announced as in the dropzone, but nothing is drawn for it. -->
438
+ <Button
439
+ id={controlId}
440
+ variant={buttonVariant}
441
+ size={buttonSize}
442
+ onclick={triggerFileSelect}
443
+ aria-describedby={errorMessage ? errorId : undefined}
444
+ {disabled}
346
445
  >
347
- {label}
348
- </label>
349
- {/if}
350
-
351
- <!-- Wraps both states so a drop is handled the same over either. -->
352
- <div
353
- role="presentation"
354
- class={disabled ? "cursor-not-allowed" : undefined}
355
- ondragenter={handleDragEnter}
356
- ondragover={handleDragOver}
357
- ondragleave={handleDragLeave}
358
- ondrop={handleDrop}
359
- data-drag-over={isDragOver ? "" : undefined}
360
- >
361
- {#if value}
362
- <div
363
- class="relative group"
364
- role={label ? "group" : undefined}
365
- aria-labelledby={label ? labelId : undefined}
446
+ {#if children}{@render children()}{:else}{label || browseText}{/if}
447
+ </Button>
448
+ {:else}
449
+ {#if label}
450
+ <label
451
+ id={labelId}
452
+ for={controlId}
453
+ class={cn("block text-sm font-medium text-label", disabled && "opacity-60")}
366
454
  >
367
- {#if preview}
368
- {@render preview({ url: value, type: resolvedPreviewType, alt })}
369
- {:else if resolvedPreviewType === "video"}
370
- <!-- Never autoplays, so there is no motion to reduce; muted
371
- until the viewer unmutes it from the native controls. -->
372
- <video
373
- src={value}
374
- controls
375
- muted
376
- playsinline
377
- preload="metadata"
378
- aria-label={alt || undefined}
379
- class="w-full h-32 object-cover rounded-container border-0"
380
- ></video>
381
- {:else}
382
- <img
383
- src={value}
384
- {alt}
385
- class="w-full h-32 object-cover rounded-container border-0"
386
- />
387
- {/if}
388
- <!-- Hover cannot reveal the actions on a touch screen, so there
389
- they stay visible as a strip that leaves the preview in
390
- view. The layer itself never takes the pointer: only the
391
- plate under the buttons does, so a video beneath stays
392
- operable. The plate is opaque so the labels keep their
393
- contrast whatever the image behind them. -->
455
+ {label}
456
+ </label>
457
+ {/if}
458
+
459
+ <!-- Wraps both states so a drop is handled the same over either. -->
460
+ <div
461
+ role="presentation"
462
+ class={disabled ? "cursor-not-allowed" : undefined}
463
+ ondragenter={handleDragEnter}
464
+ ondragover={handleDragOver}
465
+ ondragleave={handleDragLeave}
466
+ ondrop={handleDrop}
467
+ data-drag-over={isDragOver ? "" : undefined}
468
+ >
469
+ {#if value}
394
470
  <div
395
- class={cn(
396
- "absolute inset-0 p-1 border border-border-overlay bg-surface-overlay/60 backdrop-blur-md opacity-0 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 transition-opacity motion-reduce:transition-none rounded-container flex items-center justify-center pointer-events-none",
397
- "pointer-coarse:opacity-100 [@media(hover:none)]:opacity-100",
398
- actionsInStrip
399
- ? "bottom-auto"
400
- : "pointer-coarse:bottom-auto [@media(hover:none)]:bottom-auto",
401
- isDragOver && "opacity-100 border-action-primary",
402
- )}
403
- data-testid="image-upload-actions"
471
+ class="relative group"
472
+ role={label ? "group" : undefined}
473
+ aria-labelledby={label ? labelId : undefined}
404
474
  >
475
+ {#if preview}
476
+ {@render preview({ url: value, type: resolvedPreviewType, alt })}
477
+ {:else if resolvedPreviewType === "video"}
478
+ <!-- Never autoplays, so there is no motion to reduce; muted
479
+ until the viewer unmutes it from the native controls. -->
480
+ <video
481
+ src={value}
482
+ controls
483
+ muted
484
+ playsinline
485
+ preload="metadata"
486
+ aria-label={alt || undefined}
487
+ class="w-full h-32 object-cover rounded-container border-0"
488
+ ></video>
489
+ {:else}
490
+ <img
491
+ src={value}
492
+ {alt}
493
+ class="w-full h-32 object-cover rounded-container border-0"
494
+ />
495
+ {/if}
496
+ <!-- Hover cannot reveal the actions on a touch screen, so there
497
+ they stay visible as a strip that leaves the preview in
498
+ view. The layer itself never takes the pointer: only the
499
+ plate under the buttons does, so a video beneath stays
500
+ operable. The plate is opaque so the labels keep their
501
+ contrast whatever the image behind them. -->
405
502
  <div
406
- class="pointer-events-auto flex min-w-0 flex-wrap justify-center gap-2 rounded-control bg-surface-overlay p-1"
503
+ class={cn(
504
+ "absolute inset-0 p-1 border border-border-overlay material-thin opacity-0 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 transition-opacity motion-reduce:transition-none rounded-container flex items-center justify-center pointer-events-none",
505
+ "pointer-coarse:opacity-100 [@media(hover:none)]:opacity-100",
506
+ actionsInStrip
507
+ ? "bottom-auto"
508
+ : "pointer-coarse:bottom-auto [@media(hover:none)]:bottom-auto",
509
+ isDragOver && "opacity-100 border-action-primary",
510
+ )}
511
+ data-testid="image-upload-actions"
407
512
  >
408
- <Button
409
- id={controlId}
410
- variant="secondary"
411
- size="sm"
412
- class="min-w-0"
413
- onclick={triggerFileSelect}
414
- aria-label={changeName}
415
- aria-describedby={errorMessage ? errorId : undefined}
416
- {disabled}
513
+ <div
514
+ class="pointer-events-auto flex min-w-0 flex-wrap justify-center gap-2 rounded-control bg-surface-overlay p-1"
417
515
  >
418
- <span class="truncate">{changeText}</span>
419
- </Button>
420
- <Button
421
- variant="danger"
422
- size="sm"
423
- class="min-w-0"
424
- onclick={removeImage}
425
- aria-label={removeName}
426
- {disabled}
427
- >
428
- <span class="truncate">{removeText}</span>
429
- </Button>
516
+ <Button
517
+ id={controlId}
518
+ variant="secondary"
519
+ size="sm"
520
+ class="min-w-0"
521
+ onclick={triggerFileSelect}
522
+ aria-label={changeName}
523
+ aria-describedby={errorMessage ? errorId : undefined}
524
+ {disabled}
525
+ >
526
+ <span class="truncate">{changeText}</span>
527
+ </Button>
528
+ <Button
529
+ variant="danger"
530
+ size="sm"
531
+ class="min-w-0"
532
+ onclick={removeImage}
533
+ aria-label={removeName}
534
+ {disabled}
535
+ >
536
+ <span class="truncate">{removeText}</span>
537
+ </Button>
538
+ </div>
430
539
  </div>
431
540
  </div>
432
- </div>
433
- {:else}
434
- <button
435
- type="button"
436
- id={controlId}
437
- class={cn(
438
- "focus-ring block w-full border-2 border-dashed border-input-border rounded-container p-6 text-center enabled:hover:border-action-primary enabled:active:bg-surface-active transition-colors",
439
- // Disabled form controls can swallow pointer events; let a
440
- // drag land on the wrapper, which cancels it.
441
- disabled ? "pointer-events-none opacity-50" : "cursor-pointer",
442
- isDragOver && "border-action-primary bg-action-primary-subtle",
443
- )}
444
- onclick={triggerFileSelect}
445
- {disabled}
446
- aria-describedby={[label ? hintId : "", errorMessage ? errorId : ""]
447
- .filter(Boolean)
448
- .join(" ") || undefined}
449
- >
450
- <span class="block space-y-3">
451
- <span
452
- class="w-12 h-12 mx-auto bg-action-secondary rounded-control flex items-center justify-center"
453
- >
454
- <Image size={24} class="text-description" aria-hidden="true" />
455
- </span>
456
- <span id={hintId} class="block">
457
- <span class="block font-medium text-headline">{placeholder}</span>
458
- <span class="block text-sm text-description">
459
- {browseText}
541
+ {:else}
542
+ <button
543
+ type="button"
544
+ id={controlId}
545
+ class={cn(
546
+ "focus-ring block w-full border-2 border-dashed border-input-border rounded-container p-6 text-center enabled:hover:border-action-primary enabled:active:bg-surface-active transition-colors",
547
+ // Disabled form controls can swallow pointer events; let a
548
+ // drag land on the wrapper, which cancels it.
549
+ disabled ? "pointer-events-none opacity-50" : "cursor-pointer",
550
+ isDragOver && "border-action-primary bg-action-primary-subtle",
551
+ )}
552
+ onclick={triggerFileSelect}
553
+ {disabled}
554
+ aria-describedby={[label ? hintId : "", errorMessage ? errorId : ""]
555
+ .filter(Boolean)
556
+ .join(" ") || undefined}
557
+ >
558
+ <span class="block space-y-3">
559
+ <span
560
+ class="w-12 h-12 mx-auto bg-action-secondary rounded-control flex items-center justify-center"
561
+ >
562
+ <Image size={24} class="text-description" aria-hidden="true" />
563
+ </span>
564
+ <span id={hintId} class="block">
565
+ <span class="block font-medium text-headline">{placeholder}</span>
566
+ <span class="block text-sm text-description">
567
+ {browseText}
568
+ </span>
460
569
  </span>
461
570
  </span>
462
- </span>
463
- </button>
464
- {/if}
465
- </div>
571
+ </button>
572
+ {/if}
573
+ </div>
574
+ {/if}
466
575
 
467
576
  <input
468
577
  bind:this={fileInput}
469
578
  type="file"
470
579
  {accept}
580
+ {multiple}
471
581
  onchange={handleFileSelect}
472
582
  {disabled}
473
583
  class="hidden"
@@ -1,5 +1,6 @@
1
1
  import { type Snippet } from "svelte";
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
+ import type { ButtonSize, ButtonVariant } from "../types/variants.js";
3
4
  export type ImageUploadFileDetail = {
4
5
  /** Selected file, or `null` when the image is removed. */
5
6
  file: File | null;
@@ -8,6 +9,12 @@ export type ImageUploadFileDetail = {
8
9
  };
9
10
  export type ImageUploadPreviewType = "image" | "video";
10
11
  export type ImageUploadActionsPlacement = "overlay" | "strip";
12
+ export type ImageUploadVariant = "dropzone" | "button";
13
+ /**
14
+ * Why a file was turned away: `accept` does not allow it, it is over
15
+ * `maxSize`, or (only with `multiple` and `maxFiles`) it is one too many.
16
+ */
17
+ export type ImageUploadRejectReason = "type" | "size" | "count";
11
18
  export type ImageUploadPreviewDetail = {
12
19
  /** The current `value`. */
13
20
  url: string;
@@ -18,9 +25,35 @@ export type ImageUploadPreviewDetail = {
18
25
  type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick" | "id"> & {
19
26
  /** Extra classes for the host element. */
20
27
  class?: string;
28
+ /**
29
+ * `dropzone` (default): the drop area, and the preview with Change and
30
+ * Remove once there is a value. `button`: only a button that opens the
31
+ * file chooser, for a row of choices; no drop area, no preview and no
32
+ * Remove. The picked file comes through `onfileselect` and `bind:value`,
33
+ * and the app shows it where it wants.
34
+ */
35
+ variant?: ImageUploadVariant;
36
+ /** `variant="button"`: the trigger's `Button` variant. */
37
+ buttonVariant?: ButtonVariant;
38
+ /** `variant="button"`: the trigger's `Button` size. */
39
+ buttonSize?: ButtonSize;
40
+ /** The largest file taken, in bytes. A larger one goes to `onfilereject` with `"size"`. */
41
+ maxSize?: number;
42
+ /**
43
+ * The chooser and a drop take several files, handed over together
44
+ * through `onfiles`, in the order they were chosen. The component
45
+ * then keeps none of them: no preview, `value` is not set and
46
+ * `onfileselect` is not called. The page shows them where it wants
47
+ * (a `PhotoGrid`, say).
48
+ */
49
+ multiple?: boolean;
50
+ /** With `multiple`: the most files taken in one go. The rest go to `onfilereject` with `"count"`. */
51
+ maxFiles?: number;
52
+ /** `variant="button"`: the trigger's content, in place of `label`. */
53
+ children?: Snippet;
21
54
  /** Id of the control the label points at; generated when omitted. */
22
55
  id?: string;
23
- /** Visible label, associated with the dropzone. */
56
+ /** Visible label, associated with the dropzone. With `variant="button"` it is the button's text. */
24
57
  label?: string;
25
58
  /** Preview URL; `bind:value` to read it. */
26
59
  value?: string | null;
@@ -63,8 +96,10 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "onchange" | "onclick" | "id">
63
96
  onbrowse?: (event: Event) => void;
64
97
  /** Selected file and preview URL; `{ file: null, url: null }` on remove. */
65
98
  onfileselect?: (detail: ImageUploadFileDetail) => void;
66
- /** A dropped file that `accept` does not allow. */
67
- onfilereject?: (file: File) => void;
99
+ /** A dropped file that `accept` does not allow, or a dropped or chosen file over `maxSize`. */
100
+ onfilereject?: (file: File, reason: ImageUploadRejectReason) => void;
101
+ /** With `multiple`: every file that was taken, in order. Not called with none. */
102
+ onfiles?: (files: File[]) => void;
68
103
  };
69
104
  declare const ImageUpload: import("svelte").Component<Props, {}, "value">;
70
105
  type ImageUpload = ReturnType<typeof ImageUpload>;