zabi-components 8.0.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 (456) hide show
  1. package/CHANGELOG.md +2935 -0
  2. package/README.md +243 -10
  3. package/THEME.md +162 -332
  4. package/THEMING.md +1419 -259
  5. package/dist/atoms/ActionPanel.svelte +15 -5
  6. package/dist/atoms/Avatar.svelte +120 -0
  7. package/dist/atoms/Avatar.svelte.d.ts +46 -0
  8. package/dist/atoms/Badge.svelte +71 -13
  9. package/dist/atoms/Badge.svelte.d.ts +8 -2
  10. package/dist/atoms/Block.svelte +74 -0
  11. package/dist/atoms/Block.svelte.d.ts +29 -0
  12. package/dist/atoms/Button.svelte +217 -30
  13. package/dist/atoms/Button.svelte.d.ts +33 -4
  14. package/dist/atoms/Card.svelte +182 -26
  15. package/dist/atoms/Card.svelte.d.ts +55 -3
  16. package/dist/atoms/CardHeader.svelte +16 -4
  17. package/dist/atoms/Checkbox.svelte +81 -21
  18. package/dist/atoms/Checkbox.svelte.d.ts +31 -2
  19. package/dist/atoms/Chip.svelte +351 -0
  20. package/dist/atoms/Chip.svelte.d.ts +57 -0
  21. package/dist/atoms/CodeBlock.svelte +20 -2
  22. package/dist/atoms/CodeBlock.svelte.d.ts +4 -0
  23. package/dist/atoms/CodeField.svelte +385 -0
  24. package/dist/atoms/CodeField.svelte.d.ts +61 -0
  25. package/dist/atoms/ColorPicker.svelte +260 -49
  26. package/dist/atoms/ColorPicker.svelte.d.ts +3 -0
  27. package/dist/atoms/Container.svelte +16 -8
  28. package/dist/atoms/DateField.svelte +145 -0
  29. package/dist/atoms/DateField.svelte.d.ts +81 -0
  30. package/dist/atoms/FieldMessages.svelte +69 -0
  31. package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
  32. package/dist/atoms/FloatingActionButton.svelte +176 -0
  33. package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
  34. package/dist/atoms/Heading.svelte +63 -5
  35. package/dist/atoms/Heading.svelte.d.ts +14 -0
  36. package/dist/atoms/IconButton.svelte +294 -26
  37. package/dist/atoms/IconButton.svelte.d.ts +62 -4
  38. package/dist/atoms/Input.svelte +259 -53
  39. package/dist/atoms/Input.svelte.d.ts +33 -2
  40. package/dist/atoms/List.svelte +5 -1
  41. package/dist/atoms/ListItem.svelte +70 -13
  42. package/dist/atoms/ListItem.svelte.d.ts +11 -0
  43. package/dist/atoms/PickerField.svelte +239 -0
  44. package/dist/atoms/PickerField.svelte.d.ts +67 -0
  45. package/dist/atoms/Progress.svelte +99 -19
  46. package/dist/atoms/Progress.svelte.d.ts +15 -1
  47. package/dist/atoms/Radio.svelte +51 -3
  48. package/dist/atoms/Radio.svelte.d.ts +30 -2
  49. package/dist/atoms/Rating.svelte +620 -0
  50. package/dist/atoms/Rating.svelte.d.ts +77 -0
  51. package/dist/atoms/Select.svelte +438 -98
  52. package/dist/atoms/Select.svelte.d.ts +61 -3
  53. package/dist/atoms/SelectionControl.svelte +193 -23
  54. package/dist/atoms/SelectionControl.svelte.d.ts +29 -3
  55. package/dist/atoms/Skeleton.svelte +114 -18
  56. package/dist/atoms/Skeleton.svelte.d.ts +30 -3
  57. package/dist/atoms/SkeletonGroup.svelte +43 -0
  58. package/dist/atoms/SkeletonGroup.svelte.d.ts +20 -0
  59. package/dist/atoms/Slider.svelte +344 -0
  60. package/dist/atoms/Slider.svelte.d.ts +40 -0
  61. package/dist/atoms/Sparkline.svelte +109 -0
  62. package/dist/atoms/Sparkline.svelte.d.ts +27 -0
  63. package/dist/atoms/SpeechBubble.svelte +187 -0
  64. package/dist/atoms/SpeechBubble.svelte.d.ts +33 -0
  65. package/dist/atoms/Spinner.svelte +57 -0
  66. package/dist/atoms/Spinner.svelte.d.ts +19 -0
  67. package/dist/atoms/Stat.svelte +72 -0
  68. package/dist/atoms/Stat.svelte.d.ts +19 -0
  69. package/dist/atoms/Table.svelte +151 -6
  70. package/dist/atoms/Table.svelte.d.ts +15 -3
  71. package/dist/atoms/TemporalField.svelte +472 -0
  72. package/dist/atoms/TemporalField.svelte.d.ts +48 -0
  73. package/dist/atoms/Text.svelte +23 -11
  74. package/dist/atoms/Text.svelte.d.ts +6 -3
  75. package/dist/atoms/Textarea.svelte +150 -44
  76. package/dist/atoms/Textarea.svelte.d.ts +35 -2
  77. package/dist/atoms/ThemeToggle.svelte +223 -44
  78. package/dist/atoms/ThemeToggle.svelte.d.ts +26 -3
  79. package/dist/atoms/TimeColumn.svelte +150 -0
  80. package/dist/atoms/TimeColumn.svelte.d.ts +25 -0
  81. package/dist/atoms/TimeField.svelte +151 -0
  82. package/dist/atoms/TimeField.svelte.d.ts +85 -0
  83. package/dist/atoms/Toast.svelte +22 -8
  84. package/dist/atoms/Toast.svelte.d.ts +2 -0
  85. package/dist/atoms/Toggle.svelte +98 -14
  86. package/dist/atoms/Toggle.svelte.d.ts +26 -2
  87. package/dist/atoms/Tooltip.svelte +528 -24
  88. package/dist/atoms/Tooltip.svelte.d.ts +18 -0
  89. package/dist/atoms/ZabiStringsProvider.svelte +45 -0
  90. package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
  91. package/dist/atoms/index.d.ts +38 -0
  92. package/dist/atoms/index.js +29 -0
  93. package/dist/atoms/selection-control.styles.d.ts +47 -5
  94. package/dist/atoms/selection-control.styles.js +52 -10
  95. package/dist/components/atoms/index.d.ts +38 -0
  96. package/dist/components/atoms/index.d.ts.map +1 -1
  97. package/dist/components/atoms/selection-control.styles.d.ts +47 -5
  98. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  99. package/dist/components/index.d.ts +104 -2
  100. package/dist/components/index.d.ts.map +1 -1
  101. package/dist/components/map.d.ts +11 -0
  102. package/dist/components/map.d.ts.map +1 -0
  103. package/dist/components/molecules/index.d.ts +51 -0
  104. package/dist/components/molecules/index.d.ts.map +1 -1
  105. package/dist/components/molecules/toast-store.d.ts +83 -7
  106. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  107. package/dist/components/organisms/index.d.ts +10 -0
  108. package/dist/components/organisms/index.d.ts.map +1 -1
  109. package/dist/components/types/variants.d.ts +24 -1
  110. package/dist/components/types/variants.d.ts.map +1 -1
  111. package/dist/components/util/app-shell.d.ts +79 -0
  112. package/dist/components/util/app-shell.d.ts.map +1 -0
  113. package/dist/components/util/avatar.d.ts +39 -0
  114. package/dist/components/util/avatar.d.ts.map +1 -0
  115. package/dist/components/util/bottom-sheet.d.ts +30 -0
  116. package/dist/components/util/bottom-sheet.d.ts.map +1 -0
  117. package/dist/components/util/bottom-tab-bar.d.ts +41 -0
  118. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
  119. package/dist/components/util/calendar.d.ts +56 -0
  120. package/dist/components/util/calendar.d.ts.map +1 -0
  121. package/dist/components/util/chart.d.ts +140 -0
  122. package/dist/components/util/chart.d.ts.map +1 -0
  123. package/dist/components/util/chip.d.ts +66 -0
  124. package/dist/components/util/chip.d.ts.map +1 -0
  125. package/dist/components/util/cn.d.ts.map +1 -1
  126. package/dist/components/util/code-field.d.ts +63 -0
  127. package/dist/components/util/code-field.d.ts.map +1 -0
  128. package/dist/components/util/collapsible.d.ts +64 -0
  129. package/dist/components/util/collapsible.d.ts.map +1 -0
  130. package/dist/components/util/confirm-dialog.d.ts +12 -0
  131. package/dist/components/util/confirm-dialog.d.ts.map +1 -0
  132. package/dist/components/util/date.d.ts +96 -0
  133. package/dist/components/util/date.d.ts.map +1 -0
  134. package/dist/components/util/display-voice.d.ts +10 -0
  135. package/dist/components/util/display-voice.d.ts.map +1 -0
  136. package/dist/components/util/drawer.d.ts +13 -0
  137. package/dist/components/util/drawer.d.ts.map +1 -0
  138. package/dist/components/util/dropdown.d.ts +47 -0
  139. package/dist/components/util/dropdown.d.ts.map +1 -0
  140. package/dist/components/util/field-control.d.ts +30 -0
  141. package/dist/components/util/field-control.d.ts.map +1 -0
  142. package/dist/components/util/field.d.ts +45 -0
  143. package/dist/components/util/field.d.ts.map +1 -0
  144. package/dist/components/util/fit-in-viewport.d.ts +201 -0
  145. package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
  146. package/dist/components/util/focus-utils.d.ts +77 -2
  147. package/dist/components/util/focus-utils.d.ts.map +1 -1
  148. package/dist/components/util/hydration.d.ts +45 -0
  149. package/dist/components/util/hydration.d.ts.map +1 -0
  150. package/dist/components/util/keyboard-inset.d.ts +18 -0
  151. package/dist/components/util/keyboard-inset.d.ts.map +1 -0
  152. package/dist/components/util/map-view.d.ts +144 -0
  153. package/dist/components/util/map-view.d.ts.map +1 -0
  154. package/dist/components/util/media-grid.d.ts +51 -0
  155. package/dist/components/util/media-grid.d.ts.map +1 -0
  156. package/dist/components/util/media.d.ts +6 -0
  157. package/dist/components/util/media.d.ts.map +1 -0
  158. package/dist/components/util/overlay-chrome.d.ts +53 -0
  159. package/dist/components/util/overlay-chrome.d.ts.map +1 -0
  160. package/dist/components/util/overlay.d.ts +72 -0
  161. package/dist/components/util/overlay.d.ts.map +1 -0
  162. package/dist/components/util/photo.d.ts +234 -0
  163. package/dist/components/util/photo.d.ts.map +1 -0
  164. package/dist/components/util/portal.d.ts +23 -0
  165. package/dist/components/util/portal.d.ts.map +1 -0
  166. package/dist/components/util/progress.d.ts +19 -0
  167. package/dist/components/util/progress.d.ts.map +1 -0
  168. package/dist/components/util/pull-to-refresh.d.ts +27 -0
  169. package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
  170. package/dist/components/util/radio-keys.d.ts +25 -0
  171. package/dist/components/util/radio-keys.d.ts.map +1 -0
  172. package/dist/components/util/rating.d.ts +23 -0
  173. package/dist/components/util/rating.d.ts.map +1 -0
  174. package/dist/components/util/ready-made-strings.d.ts +77 -0
  175. package/dist/components/util/ready-made-strings.d.ts.map +1 -0
  176. package/dist/components/util/scroll-edge.d.ts +33 -0
  177. package/dist/components/util/scroll-edge.d.ts.map +1 -0
  178. package/dist/components/util/scroll-padding.d.ts +20 -0
  179. package/dist/components/util/scroll-padding.d.ts.map +1 -0
  180. package/dist/components/util/segmented-control.d.ts +16 -0
  181. package/dist/components/util/segmented-control.d.ts.map +1 -0
  182. package/dist/components/util/select.d.ts +41 -0
  183. package/dist/components/util/select.d.ts.map +1 -0
  184. package/dist/components/util/sheet-drag.d.ts +84 -0
  185. package/dist/components/util/sheet-drag.d.ts.map +1 -0
  186. package/dist/components/util/sidebar.d.ts +63 -0
  187. package/dist/components/util/sidebar.d.ts.map +1 -0
  188. package/dist/components/util/skeleton.d.ts +15 -0
  189. package/dist/components/util/skeleton.d.ts.map +1 -0
  190. package/dist/components/util/sliding-indicator.d.ts +25 -0
  191. package/dist/components/util/sliding-indicator.d.ts.map +1 -0
  192. package/dist/components/util/sortable-list.d.ts +65 -0
  193. package/dist/components/util/sortable-list.d.ts.map +1 -0
  194. package/dist/components/util/stepper.d.ts +40 -0
  195. package/dist/components/util/stepper.d.ts.map +1 -0
  196. package/dist/components/util/swipeable-list-item.d.ts +36 -0
  197. package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
  198. package/dist/components/util/temporal-field.d.ts +89 -0
  199. package/dist/components/util/temporal-field.d.ts.map +1 -0
  200. package/dist/components/util/theme-mode.d.ts +80 -0
  201. package/dist/components/util/theme-mode.d.ts.map +1 -0
  202. package/dist/components/util/toaster.d.ts +133 -0
  203. package/dist/components/util/toaster.d.ts.map +1 -0
  204. package/dist/components/util/tone-surface.d.ts +34 -0
  205. package/dist/components/util/tone-surface.d.ts.map +1 -0
  206. package/dist/components/util/tooltip.d.ts +10 -0
  207. package/dist/components/util/tooltip.d.ts.map +1 -0
  208. package/dist/components/util/top-navbar.d.ts +35 -0
  209. package/dist/components/util/top-navbar.d.ts.map +1 -0
  210. package/dist/components/util/touch-target.d.ts +31 -0
  211. package/dist/components/util/touch-target.d.ts.map +1 -0
  212. package/dist/components/util/zabi-strings.d.ts +142 -0
  213. package/dist/components/util/zabi-strings.d.ts.map +1 -0
  214. package/dist/create-theme/cli.js +173 -0
  215. package/dist/create-theme/index.d.ts +190 -0
  216. package/dist/create-theme/index.js +1034 -0
  217. package/dist/create-theme/lib/ramp-math.js +128 -0
  218. package/dist/create-theme/lib/resolve.js +238 -0
  219. package/dist/create-theme/theme-data.js +2644 -0
  220. package/dist/index.d.ts +104 -2
  221. package/dist/index.js +75 -2
  222. package/dist/map.d.ts +10 -0
  223. package/dist/map.js +9 -0
  224. package/dist/molecules/Alert.svelte +63 -16
  225. package/dist/molecules/Alert.svelte.d.ts +14 -1
  226. package/dist/molecules/AppBar.svelte +674 -0
  227. package/dist/molecules/AppBar.svelte.d.ts +131 -0
  228. package/dist/molecules/AvatarGroup.svelte +116 -0
  229. package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
  230. package/dist/molecules/BarChart.svelte +381 -0
  231. package/dist/molecules/BarChart.svelte.d.ts +45 -0
  232. package/dist/molecules/BottomSheet.svelte +608 -0
  233. package/dist/molecules/BottomSheet.svelte.d.ts +109 -0
  234. package/dist/molecules/BottomTabBar.svelte +403 -0
  235. package/dist/molecules/BottomTabBar.svelte.d.ts +98 -0
  236. package/dist/molecules/Calendar.svelte +597 -0
  237. package/dist/molecules/Calendar.svelte.d.ts +72 -0
  238. package/dist/molecules/ChipGroup.svelte +325 -0
  239. package/dist/molecules/ChipGroup.svelte.d.ts +41 -0
  240. package/dist/molecules/Collapsible.svelte +347 -0
  241. package/dist/molecules/Collapsible.svelte.d.ts +61 -0
  242. package/dist/molecules/CollapsibleGroup.svelte +136 -0
  243. package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
  244. package/dist/molecules/ComponentDemo.svelte +16 -4
  245. package/dist/molecules/ComponentDemo.svelte.d.ts +3 -0
  246. package/dist/molecules/ConfirmDialog.svelte +257 -0
  247. package/dist/molecules/ConfirmDialog.svelte.d.ts +58 -0
  248. package/dist/molecules/ContactForm.svelte +34 -17
  249. package/dist/molecules/ContactForm.svelte.d.ts +8 -0
  250. package/dist/molecules/Drawer.svelte +415 -0
  251. package/dist/molecules/Drawer.svelte.d.ts +55 -0
  252. package/dist/molecules/Dropdown.svelte +517 -48
  253. package/dist/molecules/Dropdown.svelte.d.ts +47 -6
  254. package/dist/molecules/DropdownItem.svelte +122 -0
  255. package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
  256. package/dist/molecules/EmptyState.svelte +55 -10
  257. package/dist/molecules/EmptyState.svelte.d.ts +8 -0
  258. package/dist/molecules/FormField.svelte +9 -1
  259. package/dist/molecules/FormField.svelte.d.ts +2 -0
  260. package/dist/molecules/Header.svelte +1 -1
  261. package/dist/molecules/ImageUpload.svelte +492 -69
  262. package/dist/molecules/ImageUpload.svelte.d.ts +82 -2
  263. package/dist/molecules/LineChart.svelte +459 -0
  264. package/dist/molecules/LineChart.svelte.d.ts +53 -0
  265. package/dist/molecules/MapView.svelte +565 -0
  266. package/dist/molecules/MapView.svelte.d.ts +88 -0
  267. package/dist/molecules/MediaGrid.svelte +527 -0
  268. package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
  269. package/dist/molecules/Modal.svelte +293 -72
  270. package/dist/molecules/Modal.svelte.d.ts +51 -2
  271. package/dist/molecules/NavigationMenuContent.svelte +58 -1
  272. package/dist/molecules/NavigationMenuItem.svelte +0 -1
  273. package/dist/molecules/NavigationMenuLink.svelte +1 -1
  274. package/dist/molecules/NavigationMenuList.svelte +10 -3
  275. package/dist/molecules/NavigationMenuTrigger.svelte +2 -2
  276. package/dist/molecules/Page.svelte +32 -2
  277. package/dist/molecules/Page.svelte.d.ts +11 -0
  278. package/dist/molecules/PhotoGrid.svelte +523 -0
  279. package/dist/molecules/PhotoGrid.svelte.d.ts +73 -0
  280. package/dist/molecules/PhotoViewer.svelte +1213 -0
  281. package/dist/molecules/PhotoViewer.svelte.d.ts +74 -0
  282. package/dist/molecules/PieChart.svelte +421 -0
  283. package/dist/molecules/PieChart.svelte.d.ts +51 -0
  284. package/dist/molecules/PropsTable.svelte +22 -10
  285. package/dist/molecules/PropsTable.svelte.d.ts +3 -0
  286. package/dist/molecules/PullToRefresh.svelte +263 -0
  287. package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
  288. package/dist/molecules/RadioGroup.svelte +72 -18
  289. package/dist/molecules/RadioGroup.svelte.d.ts +19 -3
  290. package/dist/molecules/Section.svelte +1 -1
  291. package/dist/molecules/SegmentedControl.svelte +456 -0
  292. package/dist/molecules/SegmentedControl.svelte.d.ts +40 -0
  293. package/dist/molecules/SidebarBrandHeader.svelte +11 -1
  294. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +3 -0
  295. package/dist/molecules/SidebarFooter.svelte +30 -7
  296. package/dist/molecules/SidebarFooter.svelte.d.ts +3 -0
  297. package/dist/molecules/SidebarNavSection.svelte +1 -1
  298. package/dist/molecules/SlideUp.svelte +220 -61
  299. package/dist/molecules/SlideUp.svelte.d.ts +26 -0
  300. package/dist/molecules/SortableList.svelte +633 -0
  301. package/dist/molecules/SortableList.svelte.d.ts +69 -0
  302. package/dist/molecules/Stepper.svelte +562 -0
  303. package/dist/molecules/Stepper.svelte.d.ts +39 -0
  304. package/dist/molecules/StickyActionBar.svelte +250 -0
  305. package/dist/molecules/StickyActionBar.svelte.d.ts +78 -0
  306. package/dist/molecules/SwipeableListItem.svelte +334 -0
  307. package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
  308. package/dist/molecules/Tabs.svelte +310 -9
  309. package/dist/molecules/Tabs.svelte.d.ts +7 -0
  310. package/dist/molecules/Toaster.svelte +514 -5
  311. package/dist/molecules/Toaster.svelte.d.ts +153 -2
  312. package/dist/molecules/ToasterToast.svelte +495 -77
  313. package/dist/molecules/ToasterToast.svelte.d.ts +15 -0
  314. package/dist/molecules/UnsavedChangesBar.svelte +223 -0
  315. package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
  316. package/dist/molecules/index.d.ts +51 -0
  317. package/dist/molecules/index.js +31 -0
  318. package/dist/molecules/toast-store.d.ts +83 -7
  319. package/dist/molecules/toast-store.js +25 -1
  320. package/dist/organisms/AppNavigation.svelte +440 -0
  321. package/dist/organisms/AppNavigation.svelte.d.ts +72 -0
  322. package/dist/organisms/AppShell.svelte +696 -0
  323. package/dist/organisms/AppShell.svelte.d.ts +157 -0
  324. package/dist/organisms/AppShellHeaderPart.svelte +163 -0
  325. package/dist/organisms/AppShellHeaderPart.svelte.d.ts +57 -0
  326. package/dist/organisms/SidebarAccountPanel.svelte +107 -12
  327. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +19 -0
  328. package/dist/organisms/SidebarNavigation.svelte +111 -43
  329. package/dist/organisms/SidebarNavigation.svelte.d.ts +29 -1
  330. package/dist/organisms/SidebarPanel.svelte +34 -17
  331. package/dist/organisms/SidebarPanel.svelte.d.ts +1 -0
  332. package/dist/organisms/SidebarShell.svelte +164 -14
  333. package/dist/organisms/SidebarShell.svelte.d.ts +56 -2
  334. package/dist/organisms/TopNavbar.svelte +353 -39
  335. package/dist/organisms/TopNavbar.svelte.d.ts +47 -0
  336. package/dist/organisms/TopNavbarSheetContent.svelte +145 -0
  337. package/dist/organisms/TopNavbarSheetContent.svelte.d.ts +24 -0
  338. package/dist/organisms/index.d.ts +10 -0
  339. package/dist/organisms/index.js +7 -0
  340. package/dist/types/components.d.ts +21 -1
  341. package/dist/types/components.d.ts.map +1 -1
  342. package/dist/types/index.d.ts +1093 -26
  343. package/dist/types/index.d.ts.map +1 -1
  344. package/dist/types/variants.ts +28 -1
  345. package/dist/util/app-shell.d.ts +78 -0
  346. package/dist/util/app-shell.js +97 -0
  347. package/dist/util/avatar.d.ts +38 -0
  348. package/dist/util/avatar.js +52 -0
  349. package/dist/util/bottom-sheet.d.ts +29 -0
  350. package/dist/util/bottom-sheet.js +43 -0
  351. package/dist/util/bottom-tab-bar.d.ts +40 -0
  352. package/dist/util/bottom-tab-bar.js +54 -0
  353. package/dist/util/calendar.d.ts +55 -0
  354. package/dist/util/calendar.js +74 -0
  355. package/dist/util/chart.d.ts +139 -0
  356. package/dist/util/chart.js +305 -0
  357. package/dist/util/chip.d.ts +65 -0
  358. package/dist/util/chip.js +58 -0
  359. package/dist/util/cn.js +28 -2
  360. package/dist/util/code-field.d.ts +62 -0
  361. package/dist/util/code-field.js +112 -0
  362. package/dist/util/collapsible.d.ts +63 -0
  363. package/dist/util/collapsible.js +23 -0
  364. package/dist/util/confirm-dialog.d.ts +11 -0
  365. package/dist/util/confirm-dialog.js +2 -0
  366. package/dist/util/date.d.ts +95 -0
  367. package/dist/util/date.js +237 -0
  368. package/dist/util/display-voice.d.ts +9 -0
  369. package/dist/util/display-voice.js +9 -0
  370. package/dist/util/drawer.d.ts +12 -0
  371. package/dist/util/drawer.js +7 -0
  372. package/dist/util/dropdown.d.ts +46 -0
  373. package/dist/util/dropdown.js +21 -0
  374. package/dist/util/field-control.d.ts +29 -0
  375. package/dist/util/field-control.js +32 -0
  376. package/dist/util/field.d.ts +44 -0
  377. package/dist/util/field.js +38 -0
  378. package/dist/util/fit-in-viewport.d.ts +200 -0
  379. package/dist/util/fit-in-viewport.js +399 -0
  380. package/dist/util/focus-utils.d.ts +77 -2
  381. package/dist/util/focus-utils.js +186 -9
  382. package/dist/util/hydration.d.ts +44 -0
  383. package/dist/util/hydration.js +58 -0
  384. package/dist/util/keyboard-inset.d.ts +17 -0
  385. package/dist/util/keyboard-inset.js +53 -0
  386. package/dist/util/map-view.d.ts +143 -0
  387. package/dist/util/map-view.js +121 -0
  388. package/dist/util/media-grid.d.ts +50 -0
  389. package/dist/util/media-grid.js +81 -0
  390. package/dist/util/media.d.ts +5 -0
  391. package/dist/util/media.js +10 -0
  392. package/dist/util/overlay-chrome.d.ts +52 -0
  393. package/dist/util/overlay-chrome.js +56 -0
  394. package/dist/util/overlay.d.ts +71 -0
  395. package/dist/util/overlay.js +240 -0
  396. package/dist/util/photo.d.ts +233 -0
  397. package/dist/util/photo.js +209 -0
  398. package/dist/util/portal.d.ts +22 -0
  399. package/dist/util/portal.js +50 -0
  400. package/dist/util/progress.d.ts +18 -0
  401. package/dist/util/progress.js +17 -0
  402. package/dist/util/pull-to-refresh.d.ts +26 -0
  403. package/dist/util/pull-to-refresh.js +30 -0
  404. package/dist/util/radio-keys.d.ts +24 -0
  405. package/dist/util/radio-keys.js +49 -0
  406. package/dist/util/rating.d.ts +22 -0
  407. package/dist/util/rating.js +26 -0
  408. package/dist/util/ready-made-strings.d.ts +76 -0
  409. package/dist/util/ready-made-strings.js +67 -0
  410. package/dist/util/scroll-edge.d.ts +32 -0
  411. package/dist/util/scroll-edge.js +87 -0
  412. package/dist/util/scroll-padding.d.ts +19 -0
  413. package/dist/util/scroll-padding.js +44 -0
  414. package/dist/util/segmented-control.d.ts +15 -0
  415. package/dist/util/segmented-control.js +1 -0
  416. package/dist/util/select.d.ts +40 -0
  417. package/dist/util/select.js +63 -0
  418. package/dist/util/sheet-drag.d.ts +83 -0
  419. package/dist/util/sheet-drag.js +311 -0
  420. package/dist/util/sidebar.d.ts +62 -0
  421. package/dist/util/sidebar.js +38 -0
  422. package/dist/util/skeleton.d.ts +14 -0
  423. package/dist/util/skeleton.js +22 -0
  424. package/dist/util/sliding-indicator.d.ts +24 -0
  425. package/dist/util/sliding-indicator.js +44 -0
  426. package/dist/util/sortable-list.d.ts +64 -0
  427. package/dist/util/sortable-list.js +53 -0
  428. package/dist/util/stepper.d.ts +39 -0
  429. package/dist/util/stepper.js +38 -0
  430. package/dist/util/swipeable-list-item.d.ts +35 -0
  431. package/dist/util/swipeable-list-item.js +32 -0
  432. package/dist/util/temporal-field.d.ts +88 -0
  433. package/dist/util/temporal-field.js +136 -0
  434. package/dist/util/theme-mode.d.ts +79 -0
  435. package/dist/util/theme-mode.js +121 -0
  436. package/dist/util/toaster.d.ts +132 -0
  437. package/dist/util/toaster.js +130 -0
  438. package/dist/util/tone-surface.d.ts +33 -0
  439. package/dist/util/tone-surface.js +35 -0
  440. package/dist/util/tooltip.d.ts +9 -0
  441. package/dist/util/tooltip.js +18 -0
  442. package/dist/util/top-navbar.d.ts +34 -0
  443. package/dist/util/top-navbar.js +28 -0
  444. package/dist/util/touch-target.d.ts +30 -0
  445. package/dist/util/touch-target.js +30 -0
  446. package/dist/util/zabi-strings.d.ts +141 -0
  447. package/dist/util/zabi-strings.js +105 -0
  448. package/dist/zabi-components-colors.css +11 -2
  449. package/dist/zabi-components-theme-dark-only.css +870 -258
  450. package/dist/zabi-components-theme-dark.css +870 -258
  451. package/dist/zabi-components-theme-only.css +2419 -368
  452. package/dist/zabi-components-theme.css +2419 -368
  453. package/dist/zabi-components.css +6032 -711
  454. package/docs/lucide-icons.md +40 -17
  455. package/docs/theme-imports.md +81 -11
  456. package/package.json +45 -8
@@ -0,0 +1,1213 @@
1
+ <script lang="ts">
2
+ import { mergeStrings } from "../util/ready-made-strings.js";
3
+ import { zabiStringsFor } from "../util/zabi-strings.js";
4
+ import { tick, untrack } from "svelte";
5
+ import type { HTMLAttributes } from "svelte/elements";
6
+ import ChevronLeft from "@lucide/svelte/icons/chevron-left";
7
+ import ChevronRight from "@lucide/svelte/icons/chevron-right";
8
+ import Ellipsis from "@lucide/svelte/icons/ellipsis";
9
+ import ImageOff from "@lucide/svelte/icons/image-off";
10
+ import X from "@lucide/svelte/icons/x";
11
+ import Button from "../atoms/Button.svelte";
12
+ import IconButton from "../atoms/IconButton.svelte";
13
+ import Spinner from "../atoms/Spinner.svelte";
14
+ import Dropdown from "./Dropdown.svelte";
15
+ import { isDevBuild } from "../util/app-shell.js";
16
+ import { cn } from "../util/cn.js";
17
+ import {
18
+ focusFirstElement,
19
+ getFocusableElements,
20
+ isTopOverlay,
21
+ joinOverlayStack,
22
+ recoverStrayFocus,
23
+ returnFocus,
24
+ saveFocus,
25
+ } from "../util/focus-utils.js";
26
+ import { lockBodyScroll, trapTabKey } from "../util/overlay.js";
27
+ import {
28
+ clampIndex,
29
+ doubleTapTransform,
30
+ dragAxis,
31
+ isDoubleTap,
32
+ isZoomed,
33
+ panBy,
34
+ PHOTO_VIEWER_STRINGS,
35
+ photoKey,
36
+ pinchSample,
37
+ pinchTransform,
38
+ resist,
39
+ REST,
40
+ settleZoom,
41
+ splitActions,
42
+ swipeCloses,
43
+ swipeTurn,
44
+ TAP_MS,
45
+ TAP_SLOP,
46
+ ZOOM_STEP,
47
+ zoomAbout,
48
+ type Photo,
49
+ type PhotoTransform,
50
+ type PhotoViewerAction,
51
+ type PhotoViewerCloseReason,
52
+ type PhotoViewerStrings,
53
+ type PinchSample,
54
+ type Point,
55
+ type Size,
56
+ } from "../util/photo.js";
57
+ import { portal as portalTo } from "../util/portal.js";
58
+ import { generateId } from "../util/ssr-safe.js";
59
+ import { isRtl } from "../util/radio-keys.js";
60
+ import { createVelocityTracker, FLICK_VELOCITY, timeOf } from "../util/sheet-drag.js";
61
+
62
+ /**
63
+ * Photos one at a time, on the whole screen: swipe or use the arrows to
64
+ * go between them, pinch or double tap to look closer, swipe down or
65
+ * press Escape to leave.
66
+ *
67
+ * ```svelte
68
+ * <PhotoGrid {photos} onopen={(at) => { index = at; open = true; }} />
69
+ * <PhotoViewer {photos} bind:index bind:isOpen={open} actions={[share, remove]} />
70
+ * ```
71
+ *
72
+ * It is a modal dialog, on the same footing as Modal, Drawer and
73
+ * BottomSheet. When it closes, focus goes to the tile of the photo that
74
+ * was showing, if the `PhotoGrid` it was opened from shows it; otherwise
75
+ * to whatever opened it.
76
+ *
77
+ * It is dark in both themes: the photos are on black. Nothing in it is
78
+ * written on that black: every control, the counter and the caption sit
79
+ * on plates in the theme's own surface colour, so they read over any
80
+ * photo in either theme.
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
+ *
88
+ * Other attributes (`id`, `data-*`, ...) land on the `role="dialog"` panel.
89
+ */
90
+ type Props = Omit<
91
+ HTMLAttributes<HTMLDivElement>,
92
+ // `onclose` is also a DOM event handler; ours carries a reason instead.
93
+ "class" | "onclose" | "aria-label"
94
+ > & {
95
+ photos: Photo[];
96
+ /** Place in `photos` of the photo that is showing. Supports `bind:index`. */
97
+ index?: number;
98
+ isOpen?: boolean;
99
+ /**
100
+ * Things to do with the photo that is showing. Up to three are
101
+ * buttons in a bar under the photo; with more, two are, and the
102
+ * rest are in a menu. The viewer does nothing by itself: each action
103
+ * is your own function.
104
+ */
105
+ actions?: PhotoViewerAction[];
106
+ /**
107
+ * Render the overlay in `document.body`, so an ancestor with a
108
+ * transform, filter or clipped overflow cannot trap it; pass `false`
109
+ * to render in place.
110
+ */
111
+ portal?: boolean;
112
+ /** Fired when the viewer closes itself, with what the user did. */
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>;
126
+ /** Accessible name of the dialog. */
127
+ label?: string;
128
+ /** Overrides for the built-in strings. */
129
+ strings?: Partial<PhotoViewerStrings>;
130
+ /** Extra classes for the dialog panel. */
131
+ class?: string;
132
+ };
133
+
134
+ let {
135
+ photos,
136
+ index = $bindable<Exclude<Props["index"], undefined>>(),
137
+ isOpen = $bindable<Exclude<Props["isOpen"], undefined>>(),
138
+ actions = [],
139
+ portal = true,
140
+ onclose,
141
+ onloaderror,
142
+ onretry,
143
+ label = "Photo viewer",
144
+ strings,
145
+ class: className = "",
146
+ ...restProps
147
+ }: Props = $props();
148
+
149
+ // No fallback on a bindable prop: Svelte refuses `bind:…={undefined}` on
150
+ // one that has a fallback (`props_invalid_value`), and a page that throws
151
+ // while it hydrates never becomes interactive. The default is applied
152
+ // here instead: at once, for the server and the first render, and again
153
+ // whenever a parent hands back `undefined`.
154
+ const applyDefaults = () => {
155
+ if (index === undefined) index = 0;
156
+ if (isOpen === undefined) isOpen = false;
157
+ };
158
+ applyDefaults();
159
+ $effect.pre(applyDefaults);
160
+
161
+ /** Room between two photos while one is swiped to the next, in px. */
162
+ const GAP = 16;
163
+ /** How far an arrow key pans a zoomed photo, in px. */
164
+ const KEY_PAN = 64;
165
+ /** A photo that loads faster than this never shows a spinner. */
166
+ const SLOW_MS = 400;
167
+
168
+ /** The app-wide words for this component, from a `ZabiStringsProvider` above it, if there is one. */
169
+ const provided = zabiStringsFor("photoViewer");
170
+ const text = $derived(mergeStrings(PHOTO_VIEWER_STRINGS, provided(), strings));
171
+ const count = $derived(photos.length);
172
+ const current = $derived(clampIndex(index, count));
173
+ const photo = $derived<Photo | undefined>(photos[current]);
174
+ const nameOf = (item: Photo, at: number) =>
175
+ item.alt?.trim() || text.photoName(at + 1, photos.length);
176
+
177
+ /** The photo showing and the one on either side of it, each with where it stands. */
178
+ const slides = $derived(
179
+ [-1, 0, 1]
180
+ .map((slot) => ({ slot, at: current + slot, item: photos[current + slot] }))
181
+ .filter((slide): slide is { slot: number; at: number; item: Photo } => !!slide.item),
182
+ );
183
+
184
+ const split = $derived(splitActions(actions));
185
+
186
+ let root = $state<HTMLDivElement>();
187
+ let panel = $state<HTMLDivElement>();
188
+ let stage = $state<HTMLDivElement>();
189
+ let captionText = $state<HTMLParagraphElement>();
190
+ let focusActive = false;
191
+ /** Position among the open overlays; lifts a viewer opened later above the earlier ones. */
192
+ let depth = $state(0);
193
+ /** What had focus when the viewer opened. */
194
+ let opener: Element | null = null;
195
+ /**
196
+ * The grid it was in. Kept apart from the opener: when the photo that was
197
+ * opened from is deleted, its tile leaves the page and no longer leads to
198
+ * the grid.
199
+ */
200
+ let openerGrid: Element | null = null;
201
+ const statusId = generateId("photo-viewer-status");
202
+ const captionId = generateId("photo-viewer-caption");
203
+ const creditId = generateId("photo-viewer-credit");
204
+ let rtl = $state(false);
205
+
206
+ // --- Loading -----------------------------------------------------------
207
+
208
+ /** Full images that have loaded, and those that failed. */
209
+ let loaded = $state<string[]>([]);
210
+ let failed = $state<string[]>([]);
211
+ /** Bumped by Retry, so the image element is made again and asks again. */
212
+ let attempts = $state<Record<string, number>>({});
213
+ /** The photo showing has been loading for a while. */
214
+ let slow = $state(false);
215
+
216
+ function markLoaded(source: string) {
217
+ if (!loaded.includes(source)) loaded = [...loaded, source];
218
+ }
219
+
220
+ /** The page is told once for each address that fails. */
221
+ function markFailed(source: string) {
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);
227
+ }
228
+
229
+ /** An image that was complete before anything was listening. */
230
+ function settled(image: HTMLImageElement) {
231
+ if (!image.complete) return;
232
+ const source = image.getAttribute("src") ?? "";
233
+ // Untracked: this runs as an attachment, which would otherwise run
234
+ // again for every image each time one of the lists changes.
235
+ untrack(() => {
236
+ if (image.naturalWidth > 0) markLoaded(source);
237
+ else markFailed(source);
238
+ });
239
+ }
240
+
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
+ }
257
+ failed = failed.filter((candidate) => candidate !== source);
258
+ attempts = { ...attempts, [source]: (attempts[source] ?? 0) + 1 };
259
+ // The button that was pressed goes with the message it was in: the
260
+ // dialog takes its focus, so it is not dropped onto the page.
261
+ panel?.focus({ preventScroll: true });
262
+ }
263
+
264
+ $effect(() => {
265
+ const source = photo?.src;
266
+ slow = false;
267
+ if (!isOpen || !source || loaded.includes(source) || failed.includes(source)) return;
268
+ const timer = setTimeout(() => (slow = true), SLOW_MS);
269
+ return () => clearTimeout(timer);
270
+ });
271
+
272
+ // --- Where the photo is ------------------------------------------------
273
+
274
+ let zoom = $state<PhotoTransform>(REST);
275
+ /** Sideways travel of a swipe between photos, and downward travel of a swipe to close, in px. */
276
+ let dragX = $state(0);
277
+ let dragY = $state(0);
278
+ /** Whether the next change of position is eased. Off while a finger is moving it. */
279
+ let animating = $state(false);
280
+
281
+ const zoomed = $derived(isZoomed(zoom));
282
+
283
+ function viewSize(): Size {
284
+ return { width: stage?.clientWidth ?? 0, height: stage?.clientHeight ?? 0 };
285
+ }
286
+
287
+ /** The size of the photo showing at scale 1: its laid-out box, which transforms do not change. */
288
+ function fit(): Size {
289
+ const box = stage?.querySelector<HTMLElement>("[data-photo-viewer-current] [data-photo-viewer-box]");
290
+ return { width: box?.offsetWidth ?? 0, height: box?.offsetHeight ?? 0 };
291
+ }
292
+
293
+ /** A point on the screen, measured from the centre of the stage. */
294
+ function fromCentre(clientX: number, clientY: number): Point {
295
+ const rect = stage?.getBoundingClientRect();
296
+ if (!rect) return { x: 0, y: 0 };
297
+ return { x: clientX - rect.left - rect.width / 2, y: clientY - rect.top - rect.height / 2 };
298
+ }
299
+
300
+ function setZoom(next: PhotoTransform, eased: boolean) {
301
+ animating = eased;
302
+ zoom = next;
303
+ }
304
+
305
+ /**
306
+ * Shows the photo `turn` places on (1 is next, -1 previous). The photo
307
+ * that comes in starts from where it stood beside the one showing, plus
308
+ * however far a swipe had already pulled it, and is eased to the middle.
309
+ */
310
+ async function go(turn: number) {
311
+ const target = current + turn;
312
+ if (turn === 0 || target < 0 || target >= count) {
313
+ animating = true;
314
+ dragX = 0;
315
+ return;
316
+ }
317
+ const step = (viewSize().width + GAP) * (rtl ? -1 : 1) * Math.sign(turn);
318
+ animating = false;
319
+ zoom = REST;
320
+ // However the photo was changed (a key, a swipe, a button): the next caption starts folded.
321
+ captionOpen = false;
322
+ index = target;
323
+ dragX = dragX + step;
324
+ await tick();
325
+ requestAnimationFrame(() => {
326
+ animating = true;
327
+ dragX = 0;
328
+ });
329
+ }
330
+
331
+ function goTo(target: number) {
332
+ const next = clampIndex(target, count);
333
+ if (next === current || next === -1) return;
334
+ // Further than one photo away there is nothing to slide past.
335
+ if (Math.abs(next - current) === 1) void go(next - current);
336
+ else {
337
+ animating = false;
338
+ zoom = REST;
339
+ dragX = 0;
340
+ captionOpen = false;
341
+ index = next;
342
+ }
343
+ }
344
+
345
+ // --- Pointers ------------------------------------------------------------
346
+
347
+ type Gesture =
348
+ | { kind: "pending"; start: Point; at: Moment; base: PhotoTransform }
349
+ | { kind: "pan"; start: Point; base: PhotoTransform }
350
+ | { kind: "swipe"; start: Point }
351
+ | { kind: "close"; start: Point }
352
+ | { kind: "pinch"; from: PinchSample; base: PhotoTransform }
353
+ | { kind: "spent" };
354
+
355
+ const pointers = new Map<number, Point>();
356
+ let gesture: Gesture | null = null;
357
+ let lastTap: { at: Moment; point: Point } | null = null;
358
+ const speedX = createVelocityTracker();
359
+ const speedY = createVelocityTracker();
360
+
361
+ /**
362
+ * When a pointer event happened. `time` is the event's own, which is
363
+ * when the finger touched or lifted; `clock` is when it was handled,
364
+ * later by however busy the page is. A tap is timed by the first, as a
365
+ * flick is: handled late, two quick taps were two single taps.
366
+ */
367
+ interface Moment {
368
+ time: number;
369
+ clock: number;
370
+ }
371
+ const momentOf = (event: Event): Moment => ({ time: timeOf(event), clock: performance.now() });
372
+ /** Some browsers hand every event of one frame the same stamp: then the clock says how long. */
373
+ const elapsed = (from: Moment, to: Moment) =>
374
+ to.time !== from.time ? to.time - from.time : to.clock - from.clock;
375
+
376
+ function pinchNow(): PinchSample {
377
+ const [first, second] = [...pointers.values()];
378
+ return pinchSample(fromCentre(first.x, first.y), fromCentre(second.x, second.y));
379
+ }
380
+
381
+ function handlePointerDown(event: PointerEvent) {
382
+ if (event.pointerType === "mouse" && event.button !== 0) return;
383
+ if (pointers.size >= 2) return;
384
+ try {
385
+ stage?.setPointerCapture(event.pointerId);
386
+ } catch {
387
+ /* a test DOM, or a pointer that is already gone */
388
+ }
389
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
390
+ animating = false;
391
+ if (pointers.size === 1) {
392
+ gesture = {
393
+ kind: "pending",
394
+ start: { x: event.clientX, y: event.clientY },
395
+ at: momentOf(event),
396
+ base: zoom,
397
+ };
398
+ } else {
399
+ // A second finger: whatever the first was doing, this is a pinch.
400
+ dragX = 0;
401
+ dragY = 0;
402
+ gesture = { kind: "pinch", from: pinchNow(), base: zoom };
403
+ }
404
+ }
405
+
406
+ function handlePointerMove(event: PointerEvent) {
407
+ if (!pointers.has(event.pointerId) || !gesture) return;
408
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
409
+
410
+ if (gesture.kind === "pinch") {
411
+ if (pointers.size >= 2) {
412
+ zoom = pinchTransform(gesture.base, gesture.from, pinchNow(), fit(), viewSize());
413
+ }
414
+ return;
415
+ }
416
+ if (gesture.kind === "spent" || pointers.size !== 1) return;
417
+
418
+ const point = { x: event.clientX, y: event.clientY };
419
+ if (gesture.kind === "pending") {
420
+ const moved = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
421
+ if (isZoomed(gesture.base)) {
422
+ // Zoomed in, any drag moves the photo: it does not turn the page or close.
423
+ if (Math.hypot(moved.x, moved.y) < TAP_SLOP) return;
424
+ gesture = { kind: "pan", start: point, base: zoom };
425
+ } else {
426
+ const axis = dragAxis(moved);
427
+ if (axis === null) return;
428
+ // Measured from where the drag was recognised, so nothing jumps by the slop.
429
+ gesture = { kind: axis === "x" ? "swipe" : "close", start: point };
430
+ speedX.reset(point.x, timeOf(event));
431
+ speedY.reset(point.y, timeOf(event));
432
+ }
433
+ return;
434
+ }
435
+
436
+ const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
437
+ if (gesture.kind === "pan") {
438
+ zoom = panBy(gesture.base, delta, fit(), viewSize());
439
+ } else if (gesture.kind === "swipe") {
440
+ speedX.add(point.x, timeOf(event));
441
+ // Towards a photo that is not there, the photo only gives a little.
442
+ const towards = (delta.x < 0 ? 1 : -1) * (rtl ? -1 : 1);
443
+ const exists = current + towards >= 0 && current + towards < count;
444
+ dragX = exists ? delta.x : resist(delta.x);
445
+ } else if (gesture.kind === "close") {
446
+ speedY.add(point.y, timeOf(event));
447
+ dragY = Math.max(0, delta.y);
448
+ }
449
+ }
450
+
451
+ function handlePointerUp(event: PointerEvent) {
452
+ if (!pointers.has(event.pointerId)) return;
453
+ const lifted = pointers.get(event.pointerId)!;
454
+ pointers.delete(event.pointerId);
455
+ try {
456
+ stage?.releasePointerCapture(event.pointerId);
457
+ } catch {
458
+ /* never captured */
459
+ }
460
+ const ended = gesture;
461
+ if (!ended) return;
462
+
463
+ if (ended.kind === "pinch") {
464
+ setZoom(settleZoom(zoom, fit(), viewSize()), true);
465
+ // The finger that stays does nothing more until it too has lifted.
466
+ gesture = pointers.size > 0 ? { kind: "spent" } : null;
467
+ return;
468
+ }
469
+ if (pointers.size > 0) return;
470
+ gesture = null;
471
+
472
+ switch (ended.kind) {
473
+ case "pending": {
474
+ const at = momentOf(event);
475
+ if (elapsed(ended.at, at) > TAP_MS) break;
476
+ const tap = { at, point: lifted };
477
+ const before = lastTap && { time: 0, point: lastTap.point };
478
+ if (isDoubleTap(before, { time: lastTap ? elapsed(lastTap.at, at) : 0, point: lifted })) {
479
+ lastTap = null;
480
+ setZoom(
481
+ doubleTapTransform(zoom, fromCentre(lifted.x, lifted.y), fit(), viewSize()),
482
+ true,
483
+ );
484
+ } else {
485
+ lastTap = tap;
486
+ }
487
+ break;
488
+ }
489
+ case "swipe": {
490
+ speedX.add(lifted.x, timeOf(event));
491
+ const turn = swipeTurn({
492
+ distance: lifted.x - ended.start.x,
493
+ velocity: speedX.velocity(),
494
+ width: viewSize().width,
495
+ index: current,
496
+ count,
497
+ rtl,
498
+ flick: FLICK_VELOCITY,
499
+ });
500
+ void go(turn);
501
+ break;
502
+ }
503
+ case "close": {
504
+ speedY.add(lifted.y, timeOf(event));
505
+ const closes = swipeCloses({
506
+ distance: lifted.y - ended.start.y,
507
+ velocity: speedY.velocity(),
508
+ height: viewSize().height,
509
+ flick: FLICK_VELOCITY,
510
+ });
511
+ if (closes) close("swipe");
512
+ else {
513
+ animating = true;
514
+ dragY = 0;
515
+ }
516
+ break;
517
+ }
518
+ default:
519
+ break;
520
+ }
521
+ }
522
+
523
+ function handlePointerCancel(event: PointerEvent) {
524
+ if (!pointers.has(event.pointerId)) return;
525
+ pointers.delete(event.pointerId);
526
+ if (pointers.size > 0) return;
527
+ gesture = null;
528
+ animating = true;
529
+ dragX = 0;
530
+ dragY = 0;
531
+ zoom = settleZoom(zoom, fit(), viewSize());
532
+ }
533
+
534
+ /** Ctrl or Cmd with the wheel (and a pinch on a trackpad, which arrives as one) zooms about the pointer. */
535
+ function handleWheel(event: WheelEvent) {
536
+ if (!event.ctrlKey && !event.metaKey) return;
537
+ event.preventDefault();
538
+ const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
539
+ setZoom(
540
+ settleZoom(
541
+ zoomAbout(zoom, zoom.scale * factor, fromCentre(event.clientX, event.clientY), fit(), viewSize()),
542
+ fit(),
543
+ viewSize(),
544
+ ),
545
+ false,
546
+ );
547
+ }
548
+
549
+ $effect(() => {
550
+ const element = stage;
551
+ if (!element) return;
552
+ // Not passive: the browser must not zoom the page as well.
553
+ element.addEventListener("wheel", handleWheel, { passive: false });
554
+ return () => element.removeEventListener("wheel", handleWheel);
555
+ });
556
+
557
+ // --- Opening and closing -------------------------------------------------
558
+
559
+ /** The tile of the photo now showing, in the grid the viewer was opened from. */
560
+ function tileOfCurrent(): HTMLElement | null {
561
+ const grid = openerGrid?.isConnected ? openerGrid : null;
562
+ const key = photo ? String(photoKey(photo)) : null;
563
+ if (!grid || key === null) return null;
564
+ return (
565
+ Array.from(grid.querySelectorAll<HTMLElement>("[data-photo-key]")).find(
566
+ (tile) => tile.dataset.photoKey === key,
567
+ ) ?? null
568
+ );
569
+ }
570
+
571
+ function giveFocusBack() {
572
+ if (!focusActive) return;
573
+ focusActive = false;
574
+ const tile = tileOfCurrent();
575
+ returnFocus();
576
+ if (tile) {
577
+ tile.focus();
578
+ return;
579
+ }
580
+ // The tile it was opened from is gone (its photo was deleted) and the
581
+ // photo showing has none: the grid's own Tab stop, or its first tile,
582
+ // so focus is not dropped onto the page.
583
+ if (opener?.isConnected || !openerGrid?.isConnected) return;
584
+ (
585
+ openerGrid.querySelector<HTMLElement>('[data-photo-grid-item][tabindex="0"]') ??
586
+ openerGrid.querySelector<HTMLElement>("[data-photo-grid-item]")
587
+ )?.focus();
588
+ }
589
+
590
+ function close(reason: PhotoViewerCloseReason) {
591
+ isOpen = false;
592
+ giveFocusBack();
593
+ onclose?.({ reason });
594
+ }
595
+
596
+ $effect(() => {
597
+ const container = panel;
598
+ if (isOpen && container) {
599
+ opener = document.activeElement;
600
+ openerGrid = opener?.closest?.("[data-photo-grid]") ?? null;
601
+ saveFocus();
602
+ focusActive = true;
603
+ rtl = isRtl(container);
604
+ const unlockScroll = lockBodyScroll();
605
+ const overlay = joinOverlayStack(container);
606
+ depth = overlay.depth;
607
+ const t = setTimeout(() => {
608
+ focusFirstElement(container);
609
+ }, 0);
610
+ // If the focused control is disabled or removed, focus lands on
611
+ // `<body>`; take Tab and Escape back from there.
612
+ const stopRecovery = recoverStrayFocus(container, {
613
+ onEscape: () => close("escape"),
614
+ });
615
+ return () => {
616
+ clearTimeout(t);
617
+ stopRecovery();
618
+ overlay.leave();
619
+ unlockScroll();
620
+ pointers.clear();
621
+ gesture = null;
622
+ lastTap = null;
623
+ zoom = REST;
624
+ dragX = 0;
625
+ dragY = 0;
626
+ animating = false;
627
+ captionOpen = false;
628
+ menuOpen = false;
629
+ giveFocusBack();
630
+ };
631
+ }
632
+ });
633
+
634
+ /**
635
+ * The list can change under an open viewer: a photo is deleted. Stay on a
636
+ * photo that exists, and with none left there is nothing to show.
637
+ */
638
+ $effect(() => {
639
+ const total = count;
640
+ untrack(() => {
641
+ if (!isOpen) return;
642
+ if (total === 0) {
643
+ isOpen = false;
644
+ return;
645
+ }
646
+ if (index !== clampIndex(index, total)) index = clampIndex(index, total);
647
+ });
648
+ });
649
+
650
+ $effect(() => {
651
+ if (!isDevBuild() || !isOpen) return;
652
+ const unnamed = photos.filter((item) => !item.alt?.trim()).length;
653
+ if (unnamed > 0) {
654
+ console.warn(
655
+ `[zabi-components] PhotoViewer: ${unnamed} of ${photos.length} photos have no alt text. ` +
656
+ 'Each is read out as "Photo 3 of 12" until it has one.',
657
+ );
658
+ }
659
+ });
660
+
661
+ // --- Keys ----------------------------------------------------------------
662
+
663
+ function handleKeydown(event: KeyboardEvent) {
664
+ trapTabKey(panel, event);
665
+ // A menu inside the viewer has the keys while it is open.
666
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
667
+ if ((event.target as Element | null)?.closest?.('[role="menu"], input, textarea, select')) return;
668
+
669
+ const view = viewSize();
670
+ const size = fit();
671
+ const pan = (x: number, y: number) => setZoom(panBy(zoom, { x, y }, size, view), true);
672
+ const centre = { x: 0, y: 0 };
673
+ // Left and Right follow what is on screen, which is mirrored in RTL.
674
+ const forward = rtl ? "ArrowLeft" : "ArrowRight";
675
+ const backward = rtl ? "ArrowRight" : "ArrowLeft";
676
+
677
+ switch (event.key) {
678
+ case "Escape":
679
+ // Out of the zoom first: one press never does two things.
680
+ if (zoomed) setZoom(REST, true);
681
+ else close("escape");
682
+ break;
683
+ case forward:
684
+ case backward:
685
+ // The photo moves the way the finger would push it: Right shows what is to the right.
686
+ if (zoomed) pan(event.key === "ArrowRight" ? -KEY_PAN : KEY_PAN, 0);
687
+ else void go(event.key === forward ? 1 : -1);
688
+ break;
689
+ case "ArrowUp":
690
+ case "ArrowDown":
691
+ if (!zoomed) return;
692
+ pan(0, event.key === "ArrowDown" ? -KEY_PAN : KEY_PAN);
693
+ break;
694
+ case "PageDown":
695
+ void go(1);
696
+ break;
697
+ case "PageUp":
698
+ void go(-1);
699
+ break;
700
+ case "Home":
701
+ goTo(0);
702
+ break;
703
+ case "End":
704
+ goTo(count - 1);
705
+ break;
706
+ case "+":
707
+ case "=":
708
+ setZoom(zoomAbout(zoom, zoom.scale * ZOOM_STEP, centre, size, view), true);
709
+ break;
710
+ case "-":
711
+ case "_":
712
+ setZoom(settleZoom(zoomAbout(zoom, zoom.scale / ZOOM_STEP, centre, size, view), size, view), true);
713
+ break;
714
+ case "0":
715
+ setZoom(REST, true);
716
+ break;
717
+ default:
718
+ return;
719
+ }
720
+ event.preventDefault();
721
+ }
722
+
723
+ // --- Caption ---------------------------------------------------------------
724
+
725
+ /** The menu of the actions that do not fit in the bar. */
726
+ let menuOpen = $state(false);
727
+
728
+ let captionOpen = $state(false);
729
+ /** The caption is longer than the two lines it is given. */
730
+ let captionLong = $state(false);
731
+
732
+ $effect(() => {
733
+ const element = captionText;
734
+ // Read again for each photo, and when the screen turns.
735
+ void photo?.caption;
736
+ if (!element) {
737
+ captionLong = false;
738
+ return;
739
+ }
740
+ const measure = () => {
741
+ if (!captionOpen) captionLong = element.scrollHeight > element.clientHeight + 1;
742
+ };
743
+ measure();
744
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : undefined;
745
+ observer?.observe(element);
746
+ return () => observer?.disconnect();
747
+ });
748
+
749
+ /**
750
+ * More and Less come and go with the caption, and Try again with its
751
+ * message. One of them may be holding the focus as it goes (an arrow key
752
+ * pressed on More shows a photo with a short caption): the dialog takes
753
+ * the focus then, so the keys go on working and it is not left on the page.
754
+ */
755
+ $effect(() => {
756
+ void current;
757
+ void captionLong;
758
+ void captionOpen;
759
+ void failed;
760
+ const container = panel;
761
+ if (!isOpen || !container || !focusActive) return;
762
+ const active = document.activeElement;
763
+ if (!active || active === document.body) container.focus({ preventScroll: true });
764
+ });
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
+
797
+ function showPhoto(turn: number) {
798
+ captionOpen = false;
799
+ void go(turn);
800
+ }
801
+
802
+ function run(action: PhotoViewerAction) {
803
+ if (action.disabled || !photo) return;
804
+ action.onclick(photo, current);
805
+ }
806
+
807
+ /**
808
+ * A plate: the theme's own overlay surface and text, opaque, with an edge
809
+ * that shows on black and on any photo. Everything in the viewer that is
810
+ * read or pressed sits on one.
811
+ */
812
+ const plate = "border border-control-border bg-surface-overlay text-body";
813
+ const plateButton = cn(plate, "hover:bg-surface-overlay-hover active:bg-surface-overlay-hover");
814
+ /** Clear of the notch and the rounded corners, whichever side they are on. */
815
+ const sides =
816
+ "px-[max(8px,env(safe-area-inset-left),env(safe-area-inset-right))]";
817
+
818
+ const atFirst = $derived(current <= 0);
819
+ const atLast = $derived(current >= count - 1);
820
+ /**
821
+ * `aria-disabled`, not `disabled` or removed: the button may hold focus
822
+ * when the end is reached. Only the arrow is dimmed, to the colour a
823
+ * control's edge has (3:1 on the plate). The plate and its edge stay
824
+ * opaque: `opacity` on the whole button let the photo through and left
825
+ * the arrow at 1:1 to 2.5:1 over one. No hover or pressed fill either:
826
+ * nothing happens there.
827
+ */
828
+ const endedButton =
829
+ "border border-control-border bg-surface-overlay text-control-border hover:bg-surface-overlay active:bg-surface-overlay cursor-not-allowed active:scale-100";
830
+ </script>
831
+
832
+ {#snippet slide(item: Photo, at: number, isCurrent: boolean)}
833
+ {@const source = item.src}
834
+ {@const ready = loaded.includes(source)}
835
+ {@const broken = failed.includes(source)}
836
+ <!-- The box has the photo's own proportions and is as large as fits, from
837
+ its width and height alone: the small image and the full one fill the same
838
+ box, so nothing jumps when one replaces the other. -->
839
+ <div
840
+ class="photo-box"
841
+ style:aspect-ratio="{item.width} / {item.height}"
842
+ style:--photo-ratio={item.width / item.height}
843
+ style:transform={isCurrent && (zoomed || zoom.x !== 0 || zoom.y !== 0)
844
+ ? `translate3d(${zoom.x}px, ${zoom.y}px, 0) scale(${zoom.scale})`
845
+ : undefined}
846
+ style:transition={isCurrent && animating ? undefined : "none"}
847
+ data-photo-viewer-box
848
+ >
849
+ {#if broken}
850
+ {#if isCurrent}
851
+ <div class="flex size-full items-center justify-center p-4">
852
+ <div
853
+ class={cn(plate, "flex flex-col items-center gap-3 rounded-container p-4 text-center text-sm")}
854
+ role="alert"
855
+ >
856
+ <ImageOff size={24} aria-hidden="true" />
857
+ <p>{text.loadError}</p>
858
+ <Button
859
+ variant="secondary"
860
+ size="lg"
861
+ loading={retrying.includes(source)}
862
+ onclick={() => retry(item, at)}
863
+ >
864
+ {text.retry}
865
+ </Button>
866
+ </div>
867
+ </div>
868
+ {/if}
869
+ {:else}
870
+ {#if !ready && item.thumbSrc}
871
+ <!-- The small image, blurred, until the full one is there. -->
872
+ <img
873
+ src={item.thumbSrc}
874
+ alt=""
875
+ aria-hidden="true"
876
+ draggable="false"
877
+ class="absolute inset-0 size-full scale-105 object-cover blur-md"
878
+ data-photo-viewer-thumb
879
+ />
880
+ {/if}
881
+ {#key attempts[source] ?? 0}
882
+ <img
883
+ src={source}
884
+ alt={isCurrent ? nameOf(item, at) : ""}
885
+ aria-hidden={isCurrent ? undefined : "true"}
886
+ width={item.width}
887
+ height={item.height}
888
+ decoding="async"
889
+ draggable="false"
890
+ class={cn(
891
+ "absolute inset-0 size-full object-contain transition-opacity duration-200 motion-reduce:transition-none",
892
+ ready ? "opacity-100" : "opacity-0",
893
+ )}
894
+ onload={() => markLoaded(source)}
895
+ onerror={() => markFailed(source)}
896
+ data-photo-viewer-image
897
+ {@attach settled}
898
+ />
899
+ {/key}
900
+ {/if}
901
+ </div>
902
+ {/snippet}
903
+
904
+ {#if isOpen && photo}
905
+ <div
906
+ bind:this={root}
907
+ class="fixed inset-x-0 top-0 z-modal h-dvh overflow-hidden"
908
+ style:z-index={depth > 0 ? `calc(var(--z-modal) + ${depth})` : undefined}
909
+ data-overlay-depth={depth}
910
+ use:portalTo={portal}
911
+ role="presentation"
912
+ >
913
+ <!-- Black, and opaque: the overlay colour alone lets the page show
914
+ through, and a photo over a page cannot be read. It thins while a swipe
915
+ down pulls the photo away, to show what is coming back. -->
916
+ <div
917
+ class="photo-backdrop absolute inset-0"
918
+ style:opacity={dragY > 0 ? Math.max(0.3, 1 - dragY / Math.max(1, viewSize().height)) : undefined}
919
+ style:transition={animating ? undefined : "none"}
920
+ aria-hidden="true"
921
+ ></div>
922
+ <!-- `touch-none` on the whole dialog, not only the stage: two fingers
923
+ that land on the counter or a button would otherwise zoom the page
924
+ under the viewer, and leave it zoomed. Taps are not affected. -->
925
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
926
+ <div
927
+ bind:this={panel}
928
+ class={cn("absolute inset-0 touch-none outline-none", className)}
929
+ role="dialog"
930
+ aria-modal="true"
931
+ aria-label={label}
932
+ aria-describedby={[statusId, photo.caption ? captionId : "", photo.credit ? creditId : ""]
933
+ .filter(Boolean)
934
+ .join(" ")}
935
+ tabindex="-1"
936
+ data-zoomed={zoomed ? "true" : undefined}
937
+ {...restProps}
938
+ onkeydown={handleKeydown}
939
+ >
940
+ <!-- The stage takes every touch on the photo and the black around
941
+ it, and decides what it is: `touch-none`, or the browser would
942
+ scroll or zoom the page with it. A tap beside the photo does not
943
+ close: it is half of a double tap, and a near miss of the photo. -->
944
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
945
+ <div
946
+ bind:this={stage}
947
+ class="photo-stage absolute inset-0 touch-none overflow-hidden select-none"
948
+ class:cursor-grab={zoomed}
949
+ data-photo-viewer-stage
950
+ onpointerdown={handlePointerDown}
951
+ onpointermove={handlePointerMove}
952
+ onpointerup={handlePointerUp}
953
+ onpointercancel={handlePointerCancel}
954
+ >
955
+ {#each slides as entry (photoKey(entry.item))}
956
+ {@const isCurrent = entry.slot === 0}
957
+ <div
958
+ class="photo-slide absolute inset-0 flex items-center justify-center"
959
+ style:transform="translate3d(calc({entry.slot * (rtl ? -1 : 1)} * (100% + {GAP}px) + {dragX}px), {isCurrent ? dragY : 0}px, 0)"
960
+ style:transition={animating ? undefined : "none"}
961
+ data-photo-viewer-current={isCurrent ? "" : undefined}
962
+ aria-hidden={isCurrent ? undefined : "true"}
963
+ >
964
+ {@render slide(entry.item, entry.at, isCurrent)}
965
+ </div>
966
+ {/each}
967
+ {#if slow && !loaded.includes(photo.src) && !failed.includes(photo.src)}
968
+ <div class="pointer-events-none absolute inset-0 flex items-center justify-center">
969
+ <div class={cn(plate, "flex size-11 items-center justify-center rounded-pill")}>
970
+ <Spinner size="lg" label={text.loading} />
971
+ </div>
972
+ </div>
973
+ {/if}
974
+ </div>
975
+
976
+ <!-- Top: where you are, and the way out. Close is the first
977
+ control, so it takes the focus when the viewer opens. -->
978
+ <div
979
+ class={cn(
980
+ "pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-[8px] pt-[max(8px,env(safe-area-inset-top))]",
981
+ sides,
982
+ )}
983
+ >
984
+ <p
985
+ class={cn(plate, "pointer-events-auto flex min-h-11 items-center rounded-pill px-4 text-sm font-medium tabular-nums")}
986
+ aria-hidden="true"
987
+ data-photo-viewer-counter
988
+ >
989
+ {text.counter(current + 1, count)}
990
+ </p>
991
+ <IconButton
992
+ variant="outline"
993
+ size="lg"
994
+ label={text.close}
995
+ class={cn(plateButton, "pointer-events-auto")}
996
+ onclick={() => close("close-button")}
997
+ >
998
+ <X size={20} aria-hidden="true" />
999
+ </IconButton>
1000
+ </div>
1001
+
1002
+ {#if count > 1}
1003
+ <!-- At either end the button stays, dimmed and aria-disabled:
1004
+ taken away, it would drop the focus it may be holding. -->
1005
+ <div
1006
+ class={cn(
1007
+ "pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-between",
1008
+ sides,
1009
+ )}
1010
+ >
1011
+ <IconButton
1012
+ variant="outline"
1013
+ size="lg"
1014
+ label={text.previous}
1015
+ class={cn(atFirst ? endedButton : plateButton, "pointer-events-auto")}
1016
+ aria-disabled={atFirst ? "true" : undefined}
1017
+ onclick={() => !atFirst && showPhoto(-1)}
1018
+ >
1019
+ <ChevronLeft size={20} class="rtl:rotate-180" aria-hidden="true" />
1020
+ </IconButton>
1021
+ <IconButton
1022
+ variant="outline"
1023
+ size="lg"
1024
+ label={text.next}
1025
+ class={cn(atLast ? endedButton : plateButton, "pointer-events-auto")}
1026
+ aria-disabled={atLast ? "true" : undefined}
1027
+ onclick={() => !atLast && showPhoto(1)}
1028
+ >
1029
+ <ChevronRight size={20} class="rtl:rotate-180" aria-hidden="true" />
1030
+ </IconButton>
1031
+ </div>
1032
+ {/if}
1033
+
1034
+ <!-- Bottom: the caption, then what can be done with the photo. -->
1035
+ {#if photo.caption || photo.credit || actions.length > 0}
1036
+ <div
1037
+ class={cn(
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))]",
1039
+ sides,
1040
+ )}
1041
+ >
1042
+ {#if photo.caption || photo.credit}
1043
+ <div
1044
+ class={cn(
1045
+ plate,
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",
1049
+ )}
1050
+ data-photo-viewer-caption
1051
+ >
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
+ )}
1061
+ >
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>
1084
+ {/if}
1085
+ </div>
1086
+ {/if}
1087
+ {#if actions.length > 0}
1088
+ <div
1089
+ class="pointer-events-auto flex flex-wrap items-center justify-center gap-[8px]"
1090
+ data-photo-viewer-actions
1091
+ >
1092
+ {#each split.bar as action (action.id)}
1093
+ {@const Icon = action.icon}
1094
+ <!-- `aria-disabled`: an action that turns unavailable under the finger keeps the focus. -->
1095
+ <button
1096
+ type="button"
1097
+ class={cn(
1098
+ "focus-ring inline-flex min-h-12 cursor-pointer items-center gap-2 rounded-control px-4 text-sm font-medium transition-colors duration-150 motion-reduce:transition-none",
1099
+ plateButton,
1100
+ action.tone === "danger" && "text-error-text",
1101
+ action.disabled && "cursor-not-allowed opacity-50",
1102
+ )}
1103
+ aria-disabled={action.disabled ? "true" : undefined}
1104
+ data-action={action.id}
1105
+ onclick={() => run(action)}
1106
+ >
1107
+ {#if Icon}
1108
+ <span class="flex shrink-0" aria-hidden="true"><Icon size={20} /></span>
1109
+ {/if}
1110
+ {action.label}
1111
+ </button>
1112
+ {/each}
1113
+ {#if split.menu.length > 0}
1114
+ <Dropdown
1115
+ bind:isOpen={menuOpen}
1116
+ placement="top-end"
1117
+ ariaLabel={text.moreActions}
1118
+ options={split.menu.map((action) => ({
1119
+ value: action.id,
1120
+ label: action.label,
1121
+ icon: action.icon,
1122
+ tone: action.tone,
1123
+ disabled: action.disabled,
1124
+ }))}
1125
+ onOptionClick={(value) => {
1126
+ const action = split.menu.find((candidate) => candidate.id === value);
1127
+ if (!action || action.disabled) return;
1128
+ menuOpen = false;
1129
+ run(action);
1130
+ }}
1131
+ >
1132
+ {#snippet trigger(aria)}
1133
+ <IconButton
1134
+ variant="outline"
1135
+ size="lg"
1136
+ label={text.moreActions}
1137
+ class={plateButton}
1138
+ onclick={() => (menuOpen = !menuOpen)}
1139
+ {...aria}
1140
+ >
1141
+ <Ellipsis size={20} aria-hidden="true" />
1142
+ </IconButton>
1143
+ {/snippet}
1144
+ </Dropdown>
1145
+ {/if}
1146
+ </div>
1147
+ {/if}
1148
+ </div>
1149
+ {/if}
1150
+
1151
+ <!-- Read out when the photo changes: what it shows, and where it is
1152
+ in the set. A live region says nothing of what it holds when it
1153
+ arrives, so it is also the dialog's description: that is read with
1154
+ the dialog's name when the viewer opens. -->
1155
+ <div id={statusId} class="sr-only" aria-live="polite" aria-atomic="true" data-photo-viewer-status>
1156
+ {nameOf(photo, current)}, {text.position(current + 1, count)}
1157
+ </div>
1158
+ </div>
1159
+ </div>
1160
+ {/if}
1161
+
1162
+ <style>
1163
+ /*
1164
+ * The viewer is dark whatever the theme. The overlay colour is black with
1165
+ * some of it let through; here it is taken whole, so nothing of the page
1166
+ * shows behind a photo. Where `rgb(from ...)` is not known, the overlay
1167
+ * colour itself is the fallback.
1168
+ */
1169
+ .photo-backdrop {
1170
+ background-color: var(--color-overlay);
1171
+ background-color: rgb(from var(--color-overlay) r g b / 1);
1172
+ transition: opacity 200ms ease-out;
1173
+ }
1174
+
1175
+ /* The stage is the container the photo's box is sized against. */
1176
+ .photo-stage {
1177
+ container-type: size;
1178
+ }
1179
+
1180
+ .photo-slide {
1181
+ transition: transform 250ms ease-out;
1182
+ will-change: transform;
1183
+ }
1184
+
1185
+ /*
1186
+ * As wide as the stage, or as wide as its height allows at the photo's
1187
+ * proportions, whichever is less. `aspect-ratio` on the element gives the
1188
+ * height.
1189
+ */
1190
+ .photo-box {
1191
+ position: relative;
1192
+ flex: none;
1193
+ overflow: hidden;
1194
+ width: min(100cqw, calc(100cqh * var(--photo-ratio)));
1195
+ transform-origin: center;
1196
+ transition: transform 200ms ease-out;
1197
+ }
1198
+
1199
+ @media (prefers-reduced-motion: reduce) {
1200
+ .photo-backdrop,
1201
+ .photo-slide,
1202
+ .photo-box {
1203
+ transition: none;
1204
+ }
1205
+ }
1206
+
1207
+ /* Where colours are forced the page's own background is the dark. */
1208
+ @media (forced-colors: active) {
1209
+ .photo-backdrop {
1210
+ background-color: Canvas;
1211
+ }
1212
+ }
1213
+ </style>