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
@@ -4,9 +4,11 @@
4
4
  import { untrack } from "svelte";
5
5
  import type { HTMLAttributes } from "svelte/elements";
6
6
  import Check from "@lucide/svelte/icons/check";
7
+ import CircleAlert from "@lucide/svelte/icons/circle-alert";
7
8
  import ImageOff from "@lucide/svelte/icons/image-off";
8
9
  import Maximize2 from "@lucide/svelte/icons/maximize-2";
9
10
  import Plus from "@lucide/svelte/icons/plus";
11
+ import RotateCw from "@lucide/svelte/icons/rotate-cw";
10
12
  import Skeleton from "../atoms/Skeleton.svelte";
11
13
  import { isDevBuild } from "../util/app-shell.js";
12
14
  import { cn } from "../util/cn.js";
@@ -77,6 +79,17 @@
77
79
  * Open your own file picker or an ImageUpload from it.
78
80
  */
79
81
  onadd?: () => void;
82
+ /**
83
+ * Called once when a thumbnail cannot be loaded, so the page can answer
84
+ * with a new address (a signed one that has run out). A new `thumbSrc`
85
+ * or `src` for the same photo is tried at once.
86
+ */
87
+ onloaderror?: (photo: Photo, index: number) => void;
88
+ /**
89
+ * Puts a "Try again" button on each photo whose `status` is `error`
90
+ * and is called when it is pressed.
91
+ */
92
+ onretry?: (photo: Photo, index: number) => void;
80
93
  /** Overrides for the built-in strings. */
81
94
  strings?: Partial<PhotoGridStrings>;
82
95
  /** Extra classes for the host element. */
@@ -95,6 +108,8 @@
95
108
  selectedKeys = $bindable<Exclude<Props["selectedKeys"], undefined>>(),
96
109
  onselect,
97
110
  onadd,
111
+ onloaderror,
112
+ onretry,
98
113
  strings,
99
114
  class: className = "",
100
115
  "aria-label": ariaLabel,
@@ -162,16 +177,38 @@
162
177
  // again for every image each time one of the lists changes.
163
178
  untrack(() => {
164
179
  if (image.naturalWidth > 0) markLoaded(address);
165
- else markFailed(address);
180
+ else if (markFailed(address)) reportFailed(address);
166
181
  });
167
182
  }
168
183
 
184
+ /** Tells the page which photo an address that failed belongs to. */
185
+ function reportFailed(address: string) {
186
+ if (!onloaderror) return;
187
+ const index = photos.findIndex((photo) => (photo.thumbSrc ?? photo.src) === address);
188
+ if (index !== -1) onloaderror(photos[index], index);
189
+ }
190
+
169
191
  function markLoaded(address: string) {
170
192
  if (!loaded.includes(address)) loaded = [...loaded, address];
171
193
  }
172
194
 
173
- function markFailed(address: string) {
174
- if (!failed.includes(address)) failed = [...failed, address];
195
+ /** True the first time an address fails, so the page is told once. */
196
+ function markFailed(address: string): boolean {
197
+ if (failed.includes(address)) return false;
198
+ failed = [...failed, address];
199
+ return true;
200
+ }
201
+
202
+ function nameWithStatus(photo: Photo, name: string): string {
203
+ if (photo.status === "uploading") return `${name}, ${text.uploading}`;
204
+ if (photo.status === "error") return `${name}, ${text.uploadFailed}`;
205
+ return name;
206
+ }
207
+
208
+ /** "40%" for an upload that says how far it has come; nothing for one that does not. */
209
+ function percent(progress: number | undefined): string {
210
+ if (progress === undefined || !Number.isFinite(progress)) return "";
211
+ return `${Math.round(Math.min(Math.max(progress, 0), 1) * 100)}%`;
175
212
  }
176
213
 
177
214
  /**
@@ -295,20 +332,29 @@
295
332
  onkeydown={handleKeydown}
296
333
  >
297
334
  {#if onadd}
298
- <li class="relative aspect-square" data-photo-grid-tile onfocusin={() => handleTileFocus(ADD_KEY)}>
335
+ <li
336
+ class="@container relative aspect-square"
337
+ data-photo-grid-tile
338
+ onfocusin={() => handleTileFocus(ADD_KEY)}
339
+ >
299
340
  <!-- A dashed edge the tile depends on to be seen, so in the
300
- control-border colour; the label is shown, not only read out. -->
341
+ control-border colour; the label is shown, not only read out.
342
+ Nothing leaves the tile: the label is cut at two lines (the
343
+ name keeps the whole text), and the plus is drawn only where
344
+ the tile has room for it above two lines, which at 200% text
345
+ on a phone it has not (Z-136). -->
301
346
  <button
302
347
  type="button"
303
- class="focus-ring flex size-full cursor-pointer flex-col items-center justify-center gap-1 rounded-container border border-dashed border-control-border bg-transparent p-1 text-center text-xs font-medium text-body transition-colors duration-150 hover:bg-surface-hover active:bg-surface-active motion-reduce:transition-none"
348
+ class="focus-ring flex size-full cursor-pointer flex-col items-center justify-center gap-1 overflow-hidden rounded-container border border-dashed border-control-border bg-transparent p-1 text-center text-xs font-medium text-body transition-colors duration-150 hover:bg-surface-hover active:bg-surface-active motion-reduce:transition-none"
349
+ aria-label={text.addPhoto}
304
350
  aria-describedby={describedKey === ADD_KEY ? hintId : undefined}
305
351
  tabindex={tabbableKey === ADD_KEY ? 0 : -1}
306
352
  data-photo-grid-item
307
353
  data-photo-grid-add
308
354
  onclick={() => onadd?.()}
309
355
  >
310
- <Plus size={24} class="shrink-0" aria-hidden="true" />
311
- <span class="[overflow-wrap:anywhere]">{text.addPhoto}</span>
356
+ <Plus size={24} class="hidden shrink-0 @min-[calc(24px+3.5rem)]:block" aria-hidden="true" />
357
+ <span class="line-clamp-2 [overflow-wrap:anywhere]">{text.addPhoto}</span>
312
358
  </button>
313
359
  </li>
314
360
  {/if}
@@ -337,7 +383,7 @@
337
383
  ? "outline-2 -outline-offset-2 outline-(--color-action-primary)"
338
384
  : "outline-1 -outline-offset-1 outline-(--color-border) hover:outline-(--color-border-medium) active:scale-[0.98] motion-reduce:active:scale-100",
339
385
  )}
340
- aria-label={more ? `${name}, ${text.morePhotos(counts.hidden)}` : name}
386
+ aria-label={more ? `${name}, ${text.morePhotos(counts.hidden)}` : nameWithStatus(photo, name)}
341
387
  aria-pressed={selects ? on : undefined}
342
388
  aria-describedby={key === describedKey ? hintId : undefined}
343
389
  {tabindex}
@@ -370,7 +416,9 @@
370
416
  loaded.includes(source) ? "opacity-100" : "opacity-0",
371
417
  )}
372
418
  onload={() => markLoaded(source)}
373
- onerror={() => markFailed(source)}
419
+ onerror={() => {
420
+ if (markFailed(source)) onloaderror?.(photo, index);
421
+ }}
374
422
  {@attach settled}
375
423
  />
376
424
  {/if}
@@ -382,6 +430,30 @@
382
430
  <Check size={14} aria-hidden="true" />
383
431
  </span>
384
432
  {/if}
433
+ {#if photo.status && !more}
434
+ <!-- On its way, or not arrived: the photo is dimmed and
435
+ the mark sits on an opaque plate, as the "+12" does. The
436
+ name says it in words. -->
437
+ <span
438
+ class="absolute inset-0 flex items-center justify-center bg-overlay"
439
+ aria-hidden="true"
440
+ data-photo-grid-status={photo.status}
441
+ >
442
+ <span
443
+ class="flex min-h-8 min-w-8 items-center justify-center rounded-control border border-control-border bg-surface-overlay px-1 text-xs font-semibold text-headline tabular-nums"
444
+ >
445
+ {#if photo.status === "error"}
446
+ <CircleAlert size={18} class="text-error-text" />
447
+ {:else if percent(photo.progress)}
448
+ {percent(photo.progress)}
449
+ {:else}
450
+ <span
451
+ class="inline-block size-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-pulse"
452
+ ></span>
453
+ {/if}
454
+ </span>
455
+ </span>
456
+ {/if}
385
457
  {#if more}
386
458
  <!-- The photo is dimmed and the number sits on an opaque
387
459
  plate, so it reads on any photo in either theme. The
@@ -399,6 +471,24 @@
399
471
  </span>
400
472
  {/if}
401
473
  </button>
474
+ {#if photo.status === "error" && onretry && !more}
475
+ <!-- Beside the tile's button, in the other bottom corner
476
+ from the one that opens. -->
477
+ <button
478
+ type="button"
479
+ class={cn(
480
+ cornerMark,
481
+ TOUCH_HIT_AREA,
482
+ "focus-ring bottom-[4px] start-[4px] size-7 cursor-pointer border border-control-border bg-surface-overlay text-body transition-colors duration-150 hover:text-headline motion-reduce:transition-none",
483
+ )}
484
+ aria-label={text.retryUpload(name)}
485
+ {tabindex}
486
+ data-photo-grid-retry
487
+ onclick={() => onretry?.(photo, index)}
488
+ >
489
+ <RotateCw size={14} aria-hidden="true" />
490
+ </button>
491
+ {/if}
402
492
  {#if selects && onopen}
403
493
  <!-- Beside the tile's button, not inside it. An opaque
404
494
  plate, so it reads over any photo; on a coarse pointer an
@@ -50,6 +50,17 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "aria-label" | "aria
50
50
  * Open your own file picker or an ImageUpload from it.
51
51
  */
52
52
  onadd?: () => void;
53
+ /**
54
+ * Called once when a thumbnail cannot be loaded, so the page can answer
55
+ * with a new address (a signed one that has run out). A new `thumbSrc`
56
+ * or `src` for the same photo is tried at once.
57
+ */
58
+ onloaderror?: (photo: Photo, index: number) => void;
59
+ /**
60
+ * Puts a "Try again" button on each photo whose `status` is `error`
61
+ * and is called when it is pressed.
62
+ */
63
+ onretry?: (photo: Photo, index: number) => void;
53
64
  /** Overrides for the built-in strings. */
54
65
  strings?: Partial<PhotoGridStrings>;
55
66
  /** Extra classes for the host element. */
@@ -16,6 +16,8 @@
16
16
  import { cn } from "../util/cn.js";
17
17
  import {
18
18
  focusFirstElement,
19
+ getFocusableElements,
20
+ isTopOverlay,
19
21
  joinOverlayStack,
20
22
  recoverStrayFocus,
21
23
  returnFocus,
@@ -77,6 +79,12 @@
77
79
  * on plates in the theme's own surface colour, so they read over any
78
80
  * photo in either theme.
79
81
  *
82
+ * It does not take the browser's Back. A history entry made by a
83
+ * component conflicts with the app's router (SvelteKit warns against
84
+ * `history.pushState` outside `$app/navigation`), so open the viewer from
85
+ * the router's own state: push a state on open, read `isOpen` from it,
86
+ * and go back to close (Z-131).
87
+ *
80
88
  * Other attributes (`id`, `data-*`, ...) land on the `role="dialog"` panel.
81
89
  */
82
90
  type Props = Omit<
@@ -103,6 +111,18 @@
103
111
  portal?: boolean;
104
112
  /** Fired when the viewer closes itself, with what the user did. */
105
113
  onclose?: (detail: { reason: PhotoViewerCloseReason }) => void;
114
+ /**
115
+ * Called once when a full image cannot be loaded, so the page can
116
+ * answer with a new address (a signed one that has run out). A new
117
+ * `src` for the same photo is tried at once.
118
+ */
119
+ onloaderror?: (photo: Photo, index: number) => void;
120
+ /**
121
+ * Called when "Try again" is pressed on a photo that failed, before
122
+ * the image is asked for again. Return a promise and the button waits
123
+ * for it, busy, so the page has time to fetch a fresh address.
124
+ */
125
+ onretry?: (photo: Photo, index: number) => void | Promise<void>;
106
126
  /** Accessible name of the dialog. */
107
127
  label?: string;
108
128
  /** Overrides for the built-in strings. */
@@ -118,6 +138,8 @@
118
138
  actions = [],
119
139
  portal = true,
120
140
  onclose,
141
+ onloaderror,
142
+ onretry,
121
143
  label = "Photo viewer",
122
144
  strings,
123
145
  class: className = "",
@@ -177,6 +199,8 @@
177
199
  */
178
200
  let openerGrid: Element | null = null;
179
201
  const statusId = generateId("photo-viewer-status");
202
+ const captionId = generateId("photo-viewer-caption");
203
+ const creditId = generateId("photo-viewer-credit");
180
204
  let rtl = $state(false);
181
205
 
182
206
  // --- Loading -----------------------------------------------------------
@@ -193,8 +217,13 @@
193
217
  if (!loaded.includes(source)) loaded = [...loaded, source];
194
218
  }
195
219
 
220
+ /** The page is told once for each address that fails. */
196
221
  function markFailed(source: string) {
197
- if (!failed.includes(source)) failed = [...failed, source];
222
+ if (failed.includes(source)) return;
223
+ failed = [...failed, source];
224
+ if (!onloaderror) return;
225
+ const at = photos.findIndex((item) => item.src === source);
226
+ if (at !== -1) onloaderror(photos[at], at);
198
227
  }
199
228
 
200
229
  /** An image that was complete before anything was listening. */
@@ -209,7 +238,22 @@
209
238
  });
210
239
  }
211
240
 
212
- function retry(source: string) {
241
+ /** Addresses whose "Try again" is waiting for the page's `onretry`. */
242
+ let retrying = $state<string[]>([]);
243
+
244
+ async function retry(item: Photo, at: number) {
245
+ const source = item.src;
246
+ if (retrying.includes(source)) return;
247
+ if (onretry) {
248
+ retrying = [...retrying, source];
249
+ try {
250
+ await onretry(item, at);
251
+ } catch {
252
+ // The page's own failure: the image is asked for again all the same.
253
+ } finally {
254
+ retrying = retrying.filter((candidate) => candidate !== source);
255
+ }
256
+ }
213
257
  failed = failed.filter((candidate) => candidate !== source);
214
258
  attempts = { ...attempts, [source]: (attempts[source] ?? 0) + 1 };
215
259
  // The button that was pressed goes with the message it was in: the
@@ -719,6 +763,37 @@
719
763
  if (!active || active === document.body) container.focus({ preventScroll: true });
720
764
  });
721
765
 
766
+ /**
767
+ * An action's button can go while it holds the focus: "Remove" is pressed,
768
+ * the photo leaves `photos`, and the one that shows next has no "Remove".
769
+ * Nothing tells the page when a focused element is taken out, and a
770
+ * confirm dialog between the press and the removal hands the focus back
771
+ * to a button that is gone. So the page is watched while the viewer is
772
+ * open: when something is taken out or put in and the focus is found on
773
+ * `<body>` with this viewer on top, its first control (Close) takes it.
774
+ */
775
+ $effect(() => {
776
+ const container = panel;
777
+ if (!isOpen || !container || typeof MutationObserver !== "function") return;
778
+ let timer: ReturnType<typeof setTimeout> | undefined;
779
+ const check = () => {
780
+ timer = undefined;
781
+ if (!focusActive || !container.isConnected || !isTopOverlay(container)) return;
782
+ const active = document.activeElement;
783
+ if (active && active !== document.body) return;
784
+ (getFocusableElements(container)[0] ?? container).focus({ preventScroll: true });
785
+ };
786
+ // After the change has settled: a dialog that is closing gives the focus back first.
787
+ const observer = new MutationObserver(() => {
788
+ if (timer === undefined) timer = setTimeout(check, 0);
789
+ });
790
+ observer.observe(document.body, { childList: true, subtree: true });
791
+ return () => {
792
+ observer.disconnect();
793
+ if (timer !== undefined) clearTimeout(timer);
794
+ };
795
+ });
796
+
722
797
  function showPhoto(turn: number) {
723
798
  captionOpen = false;
724
799
  void go(turn);
@@ -780,7 +855,12 @@
780
855
  >
781
856
  <ImageOff size={24} aria-hidden="true" />
782
857
  <p>{text.loadError}</p>
783
- <Button variant="secondary" size="lg" onclick={() => retry(source)}>
858
+ <Button
859
+ variant="secondary"
860
+ size="lg"
861
+ loading={retrying.includes(source)}
862
+ onclick={() => retry(item, at)}
863
+ >
784
864
  {text.retry}
785
865
  </Button>
786
866
  </div>
@@ -849,7 +929,9 @@
849
929
  role="dialog"
850
930
  aria-modal="true"
851
931
  aria-label={label}
852
- aria-describedby={statusId}
932
+ aria-describedby={[statusId, photo.caption ? captionId : "", photo.credit ? creditId : ""]
933
+ .filter(Boolean)
934
+ .join(" ")}
853
935
  tabindex="-1"
854
936
  data-zoomed={zoomed ? "true" : undefined}
855
937
  {...restProps}
@@ -950,40 +1032,55 @@
950
1032
  {/if}
951
1033
 
952
1034
  <!-- Bottom: the caption, then what can be done with the photo. -->
953
- {#if photo.caption || actions.length > 0}
1035
+ {#if photo.caption || photo.credit || actions.length > 0}
954
1036
  <div
955
1037
  class={cn(
956
1038
  "pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-center gap-[8px] pb-[max(8px,env(safe-area-inset-bottom))]",
957
1039
  sides,
958
1040
  )}
959
1041
  >
960
- {#if photo.caption}
1042
+ {#if photo.caption || photo.credit}
961
1043
  <div
962
1044
  class={cn(
963
1045
  plate,
964
1046
  "pointer-events-auto flex w-full items-end gap-[8px] rounded-container px-3 py-2 text-sm sm:w-[min(100%,40rem)]",
1047
+ // The credit takes a line of its own under the caption.
1048
+ photo.credit && "flex-wrap gap-y-1",
965
1049
  )}
966
1050
  data-photo-viewer-caption
967
1051
  >
968
- <p
969
- bind:this={captionText}
970
- class={cn(
971
- "min-w-0 flex-1 [overflow-wrap:anywhere]",
972
- // The one thing in the viewer that scrolls under a finger.
973
- captionOpen ? "max-h-[40dvh] touch-pan-y overflow-y-auto" : "line-clamp-2",
974
- )}
975
- >
976
- {photo.caption}
977
- </p>
978
- {#if captionLong || captionOpen}
979
- <button
980
- type="button"
981
- class="focus-ring -my-1 -me-1 min-h-11 shrink-0 cursor-pointer rounded-[calc(var(--radius-container)-4px)] px-2 text-sm font-medium text-link"
982
- aria-expanded={captionOpen}
983
- onclick={() => (captionOpen = !captionOpen)}
1052
+ {#if photo.caption}
1053
+ <p
1054
+ id={captionId}
1055
+ bind:this={captionText}
1056
+ class={cn(
1057
+ "min-w-0 flex-1 [overflow-wrap:anywhere]",
1058
+ // The one thing in the viewer that scrolls under a finger.
1059
+ captionOpen ? "max-h-[40dvh] touch-pan-y overflow-y-auto" : "line-clamp-2",
1060
+ )}
984
1061
  >
985
- {captionOpen ? text.showLess : text.showMore}
986
- </button>
1062
+ {photo.caption}
1063
+ </p>
1064
+ {#if captionLong || captionOpen}
1065
+ <button
1066
+ type="button"
1067
+ class="focus-ring -my-1 -me-1 min-h-11 shrink-0 cursor-pointer rounded-[calc(var(--radius-container)-4px)] px-2 text-sm font-medium text-link"
1068
+ aria-expanded={captionOpen}
1069
+ onclick={() => (captionOpen = !captionOpen)}
1070
+ >
1071
+ {captionOpen ? text.showLess : text.showMore}
1072
+ </button>
1073
+ {/if}
1074
+ {/if}
1075
+ {#if photo.credit}
1076
+ <!-- Quieter than the caption, and outside what More and Less fold. -->
1077
+ <p
1078
+ id={creditId}
1079
+ class="min-w-0 basis-full text-description [overflow-wrap:anywhere]"
1080
+ data-photo-viewer-credit
1081
+ >
1082
+ {photo.credit}
1083
+ </p>
987
1084
  {/if}
988
1085
  </div>
989
1086
  {/if}
@@ -20,6 +20,12 @@ import { type Photo, type PhotoViewerAction, type PhotoViewerCloseReason, type P
20
20
  * on plates in the theme's own surface colour, so they read over any
21
21
  * photo in either theme.
22
22
  *
23
+ * It does not take the browser's Back. A history entry made by a
24
+ * component conflicts with the app's router (SvelteKit warns against
25
+ * `history.pushState` outside `$app/navigation`), so open the viewer from
26
+ * the router's own state: push a state on open, read `isOpen` from it,
27
+ * and go back to close (Z-131).
28
+ *
23
29
  * Other attributes (`id`, `data-*`, ...) land on the `role="dialog"` panel.
24
30
  */
25
31
  type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onclose" | "aria-label"> & {
@@ -44,6 +50,18 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "onclose" | "aria-la
44
50
  onclose?: (detail: {
45
51
  reason: PhotoViewerCloseReason;
46
52
  }) => void;
53
+ /**
54
+ * Called once when a full image cannot be loaded, so the page can
55
+ * answer with a new address (a signed one that has run out). A new
56
+ * `src` for the same photo is tried at once.
57
+ */
58
+ onloaderror?: (photo: Photo, index: number) => void;
59
+ /**
60
+ * Called when "Try again" is pressed on a photo that failed, before
61
+ * the image is asked for again. Return a promise and the button waits
62
+ * for it, busy, so the page has time to fetch a fresh address.
63
+ */
64
+ onretry?: (photo: Photo, index: number) => void | Promise<void>;
47
65
  /** Accessible name of the dialog. */
48
66
  label?: string;
49
67
  /** Overrides for the built-in strings. */