zabi-components 8.0.0 → 8.1.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 (364) hide show
  1. package/CHANGELOG.md +1924 -0
  2. package/README.md +243 -10
  3. package/THEME.md +149 -332
  4. package/THEMING.md +1192 -266
  5. package/dist/atoms/ActionPanel.svelte +15 -5
  6. package/dist/atoms/Avatar.svelte +104 -0
  7. package/dist/atoms/Avatar.svelte.d.ts +34 -0
  8. package/dist/atoms/Badge.svelte +36 -7
  9. package/dist/atoms/Badge.svelte.d.ts +2 -2
  10. package/dist/atoms/Button.svelte +177 -27
  11. package/dist/atoms/Button.svelte.d.ts +30 -2
  12. package/dist/atoms/Card.svelte +4 -4
  13. package/dist/atoms/CardHeader.svelte +16 -4
  14. package/dist/atoms/Checkbox.svelte +48 -21
  15. package/dist/atoms/Checkbox.svelte.d.ts +4 -2
  16. package/dist/atoms/CodeBlock.svelte +20 -2
  17. package/dist/atoms/CodeBlock.svelte.d.ts +4 -0
  18. package/dist/atoms/ColorPicker.svelte +259 -49
  19. package/dist/atoms/ColorPicker.svelte.d.ts +3 -0
  20. package/dist/atoms/Container.svelte +16 -8
  21. package/dist/atoms/DateField.svelte +107 -0
  22. package/dist/atoms/DateField.svelte.d.ts +53 -0
  23. package/dist/atoms/FieldMessages.svelte +69 -0
  24. package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
  25. package/dist/atoms/FloatingActionButton.svelte +174 -0
  26. package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
  27. package/dist/atoms/Heading.svelte +37 -5
  28. package/dist/atoms/Heading.svelte.d.ts +7 -0
  29. package/dist/atoms/IconButton.svelte +220 -24
  30. package/dist/atoms/IconButton.svelte.d.ts +49 -4
  31. package/dist/atoms/Input.svelte +253 -52
  32. package/dist/atoms/Input.svelte.d.ts +32 -2
  33. package/dist/atoms/List.svelte +4 -1
  34. package/dist/atoms/ListItem.svelte +44 -11
  35. package/dist/atoms/ListItem.svelte.d.ts +5 -0
  36. package/dist/atoms/Progress.svelte +27 -8
  37. package/dist/atoms/Progress.svelte.d.ts +4 -0
  38. package/dist/atoms/Radio.svelte +19 -3
  39. package/dist/atoms/Radio.svelte.d.ts +4 -2
  40. package/dist/atoms/Rating.svelte +494 -0
  41. package/dist/atoms/Rating.svelte.d.ts +56 -0
  42. package/dist/atoms/Select.svelte +433 -91
  43. package/dist/atoms/Select.svelte.d.ts +49 -3
  44. package/dist/atoms/SelectionControl.svelte +94 -17
  45. package/dist/atoms/SelectionControl.svelte.d.ts +4 -3
  46. package/dist/atoms/Skeleton.svelte +1 -1
  47. package/dist/atoms/Slider.svelte +309 -0
  48. package/dist/atoms/Slider.svelte.d.ts +40 -0
  49. package/dist/atoms/Spinner.svelte +57 -0
  50. package/dist/atoms/Spinner.svelte.d.ts +19 -0
  51. package/dist/atoms/Table.svelte +151 -6
  52. package/dist/atoms/Table.svelte.d.ts +15 -3
  53. package/dist/atoms/TemporalField.svelte +140 -0
  54. package/dist/atoms/TemporalField.svelte.d.ts +32 -0
  55. package/dist/atoms/Text.svelte +23 -11
  56. package/dist/atoms/Text.svelte.d.ts +6 -3
  57. package/dist/atoms/Textarea.svelte +55 -44
  58. package/dist/atoms/Textarea.svelte.d.ts +14 -2
  59. package/dist/atoms/ThemeToggle.svelte +223 -44
  60. package/dist/atoms/ThemeToggle.svelte.d.ts +26 -3
  61. package/dist/atoms/TimeField.svelte +108 -0
  62. package/dist/atoms/TimeField.svelte.d.ts +54 -0
  63. package/dist/atoms/Toast.svelte +17 -4
  64. package/dist/atoms/Toast.svelte.d.ts +2 -0
  65. package/dist/atoms/Toggle.svelte +41 -8
  66. package/dist/atoms/Toggle.svelte.d.ts +11 -2
  67. package/dist/atoms/Tooltip.svelte +508 -23
  68. package/dist/atoms/Tooltip.svelte.d.ts +11 -0
  69. package/dist/atoms/ZabiStringsProvider.svelte +45 -0
  70. package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
  71. package/dist/atoms/index.d.ts +17 -0
  72. package/dist/atoms/index.js +12 -0
  73. package/dist/atoms/selection-control.styles.d.ts +36 -5
  74. package/dist/atoms/selection-control.styles.js +40 -9
  75. package/dist/components/atoms/index.d.ts +17 -0
  76. package/dist/components/atoms/index.d.ts.map +1 -1
  77. package/dist/components/atoms/selection-control.styles.d.ts +36 -5
  78. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  79. package/dist/components/index.d.ts +80 -2
  80. package/dist/components/index.d.ts.map +1 -1
  81. package/dist/components/molecules/index.d.ts +46 -0
  82. package/dist/components/molecules/index.d.ts.map +1 -1
  83. package/dist/components/molecules/toast-store.d.ts +53 -7
  84. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  85. package/dist/components/organisms/index.d.ts +5 -0
  86. package/dist/components/organisms/index.d.ts.map +1 -1
  87. package/dist/components/types/variants.d.ts +15 -1
  88. package/dist/components/types/variants.d.ts.map +1 -1
  89. package/dist/components/util/app-shell.d.ts +28 -0
  90. package/dist/components/util/app-shell.d.ts.map +1 -0
  91. package/dist/components/util/avatar.d.ts +36 -0
  92. package/dist/components/util/avatar.d.ts.map +1 -0
  93. package/dist/components/util/bottom-sheet.d.ts +30 -0
  94. package/dist/components/util/bottom-sheet.d.ts.map +1 -0
  95. package/dist/components/util/bottom-tab-bar.d.ts +35 -0
  96. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
  97. package/dist/components/util/calendar.d.ts +56 -0
  98. package/dist/components/util/calendar.d.ts.map +1 -0
  99. package/dist/components/util/collapsible.d.ts +58 -0
  100. package/dist/components/util/collapsible.d.ts.map +1 -0
  101. package/dist/components/util/confirm-dialog.d.ts +12 -0
  102. package/dist/components/util/confirm-dialog.d.ts.map +1 -0
  103. package/dist/components/util/date.d.ts +96 -0
  104. package/dist/components/util/date.d.ts.map +1 -0
  105. package/dist/components/util/drawer.d.ts +13 -0
  106. package/dist/components/util/drawer.d.ts.map +1 -0
  107. package/dist/components/util/dropdown.d.ts +47 -0
  108. package/dist/components/util/dropdown.d.ts.map +1 -0
  109. package/dist/components/util/field.d.ts +39 -0
  110. package/dist/components/util/field.d.ts.map +1 -0
  111. package/dist/components/util/fit-in-viewport.d.ts +201 -0
  112. package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
  113. package/dist/components/util/focus-utils.d.ts +75 -2
  114. package/dist/components/util/focus-utils.d.ts.map +1 -1
  115. package/dist/components/util/hydration.d.ts +45 -0
  116. package/dist/components/util/hydration.d.ts.map +1 -0
  117. package/dist/components/util/keyboard-inset.d.ts +18 -0
  118. package/dist/components/util/keyboard-inset.d.ts.map +1 -0
  119. package/dist/components/util/media-grid.d.ts +51 -0
  120. package/dist/components/util/media-grid.d.ts.map +1 -0
  121. package/dist/components/util/media.d.ts +6 -0
  122. package/dist/components/util/media.d.ts.map +1 -0
  123. package/dist/components/util/overlay-chrome.d.ts +53 -0
  124. package/dist/components/util/overlay-chrome.d.ts.map +1 -0
  125. package/dist/components/util/overlay.d.ts +72 -0
  126. package/dist/components/util/overlay.d.ts.map +1 -0
  127. package/dist/components/util/photo.d.ts +215 -0
  128. package/dist/components/util/photo.d.ts.map +1 -0
  129. package/dist/components/util/portal.d.ts +23 -0
  130. package/dist/components/util/portal.d.ts.map +1 -0
  131. package/dist/components/util/pull-to-refresh.d.ts +27 -0
  132. package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
  133. package/dist/components/util/radio-keys.d.ts +25 -0
  134. package/dist/components/util/radio-keys.d.ts.map +1 -0
  135. package/dist/components/util/rating.d.ts +23 -0
  136. package/dist/components/util/rating.d.ts.map +1 -0
  137. package/dist/components/util/ready-made-strings.d.ts +77 -0
  138. package/dist/components/util/ready-made-strings.d.ts.map +1 -0
  139. package/dist/components/util/scroll-padding.d.ts +20 -0
  140. package/dist/components/util/scroll-padding.d.ts.map +1 -0
  141. package/dist/components/util/segmented-control.d.ts +16 -0
  142. package/dist/components/util/segmented-control.d.ts.map +1 -0
  143. package/dist/components/util/select.d.ts +41 -0
  144. package/dist/components/util/select.d.ts.map +1 -0
  145. package/dist/components/util/sheet-drag.d.ts +60 -0
  146. package/dist/components/util/sheet-drag.d.ts.map +1 -0
  147. package/dist/components/util/sidebar.d.ts +63 -0
  148. package/dist/components/util/sidebar.d.ts.map +1 -0
  149. package/dist/components/util/sortable-list.d.ts +65 -0
  150. package/dist/components/util/sortable-list.d.ts.map +1 -0
  151. package/dist/components/util/stepper.d.ts +40 -0
  152. package/dist/components/util/stepper.d.ts.map +1 -0
  153. package/dist/components/util/swipeable-list-item.d.ts +36 -0
  154. package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
  155. package/dist/components/util/theme-mode.d.ts +80 -0
  156. package/dist/components/util/theme-mode.d.ts.map +1 -0
  157. package/dist/components/util/toaster.d.ts +93 -0
  158. package/dist/components/util/toaster.d.ts.map +1 -0
  159. package/dist/components/util/tooltip.d.ts +10 -0
  160. package/dist/components/util/tooltip.d.ts.map +1 -0
  161. package/dist/components/util/top-navbar.d.ts +11 -0
  162. package/dist/components/util/top-navbar.d.ts.map +1 -0
  163. package/dist/components/util/touch-target.d.ts +31 -0
  164. package/dist/components/util/touch-target.d.ts.map +1 -0
  165. package/dist/components/util/zabi-strings.d.ts +133 -0
  166. package/dist/components/util/zabi-strings.d.ts.map +1 -0
  167. package/dist/create-theme/cli.js +169 -0
  168. package/dist/create-theme/index.d.ts +176 -0
  169. package/dist/create-theme/index.js +817 -0
  170. package/dist/create-theme/lib/ramp-math.js +128 -0
  171. package/dist/create-theme/lib/resolve.js +141 -0
  172. package/dist/create-theme/theme-data.js +1116 -0
  173. package/dist/index.d.ts +80 -2
  174. package/dist/index.js +57 -2
  175. package/dist/molecules/Alert.svelte +33 -5
  176. package/dist/molecules/Alert.svelte.d.ts +2 -0
  177. package/dist/molecules/AppBar.svelte +415 -0
  178. package/dist/molecules/AppBar.svelte.d.ts +69 -0
  179. package/dist/molecules/AvatarGroup.svelte +116 -0
  180. package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
  181. package/dist/molecules/BottomSheet.svelte +540 -0
  182. package/dist/molecules/BottomSheet.svelte.d.ts +93 -0
  183. package/dist/molecules/BottomTabBar.svelte +297 -0
  184. package/dist/molecules/BottomTabBar.svelte.d.ts +64 -0
  185. package/dist/molecules/Calendar.svelte +597 -0
  186. package/dist/molecules/Calendar.svelte.d.ts +72 -0
  187. package/dist/molecules/Collapsible.svelte +243 -0
  188. package/dist/molecules/Collapsible.svelte.d.ts +44 -0
  189. package/dist/molecules/CollapsibleGroup.svelte +126 -0
  190. package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
  191. package/dist/molecules/ComponentDemo.svelte +16 -4
  192. package/dist/molecules/ComponentDemo.svelte.d.ts +3 -0
  193. package/dist/molecules/ConfirmDialog.svelte +253 -0
  194. package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
  195. package/dist/molecules/ContactForm.svelte +34 -17
  196. package/dist/molecules/ContactForm.svelte.d.ts +8 -0
  197. package/dist/molecules/Drawer.svelte +394 -0
  198. package/dist/molecules/Drawer.svelte.d.ts +55 -0
  199. package/dist/molecules/Dropdown.svelte +509 -48
  200. package/dist/molecules/Dropdown.svelte.d.ts +45 -6
  201. package/dist/molecules/DropdownItem.svelte +122 -0
  202. package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
  203. package/dist/molecules/EmptyState.svelte +55 -10
  204. package/dist/molecules/EmptyState.svelte.d.ts +8 -0
  205. package/dist/molecules/FormField.svelte +9 -1
  206. package/dist/molecules/FormField.svelte.d.ts +2 -0
  207. package/dist/molecules/Header.svelte +1 -1
  208. package/dist/molecules/ImageUpload.svelte +383 -70
  209. package/dist/molecules/ImageUpload.svelte.d.ts +47 -2
  210. package/dist/molecules/MediaGrid.svelte +527 -0
  211. package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
  212. package/dist/molecules/Modal.svelte +288 -72
  213. package/dist/molecules/Modal.svelte.d.ts +51 -2
  214. package/dist/molecules/NavigationMenuContent.svelte +58 -1
  215. package/dist/molecules/NavigationMenuItem.svelte +0 -1
  216. package/dist/molecules/NavigationMenuLink.svelte +1 -1
  217. package/dist/molecules/NavigationMenuList.svelte +10 -3
  218. package/dist/molecules/NavigationMenuTrigger.svelte +2 -2
  219. package/dist/molecules/Page.svelte +32 -2
  220. package/dist/molecules/Page.svelte.d.ts +11 -0
  221. package/dist/molecules/PhotoGrid.svelte +433 -0
  222. package/dist/molecules/PhotoGrid.svelte.d.ts +62 -0
  223. package/dist/molecules/PhotoViewer.svelte +1116 -0
  224. package/dist/molecules/PhotoViewer.svelte.d.ts +56 -0
  225. package/dist/molecules/PropsTable.svelte +22 -10
  226. package/dist/molecules/PropsTable.svelte.d.ts +3 -0
  227. package/dist/molecules/PullToRefresh.svelte +263 -0
  228. package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
  229. package/dist/molecules/RadioGroup.svelte +52 -14
  230. package/dist/molecules/RadioGroup.svelte.d.ts +14 -3
  231. package/dist/molecules/Section.svelte +1 -1
  232. package/dist/molecules/SegmentedControl.svelte +323 -0
  233. package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
  234. package/dist/molecules/SidebarBrandHeader.svelte +11 -1
  235. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +3 -0
  236. package/dist/molecules/SidebarFooter.svelte +29 -6
  237. package/dist/molecules/SidebarFooter.svelte.d.ts +3 -0
  238. package/dist/molecules/SlideUp.svelte +220 -61
  239. package/dist/molecules/SlideUp.svelte.d.ts +26 -0
  240. package/dist/molecules/SortableList.svelte +632 -0
  241. package/dist/molecules/SortableList.svelte.d.ts +69 -0
  242. package/dist/molecules/Stepper.svelte +548 -0
  243. package/dist/molecules/Stepper.svelte.d.ts +39 -0
  244. package/dist/molecules/StickyActionBar.svelte +191 -0
  245. package/dist/molecules/StickyActionBar.svelte.d.ts +65 -0
  246. package/dist/molecules/SwipeableListItem.svelte +334 -0
  247. package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
  248. package/dist/molecules/Tabs.svelte +233 -9
  249. package/dist/molecules/Tabs.svelte.d.ts +7 -0
  250. package/dist/molecules/Toaster.svelte +350 -5
  251. package/dist/molecules/Toaster.svelte.d.ts +95 -2
  252. package/dist/molecules/ToasterToast.svelte +253 -67
  253. package/dist/molecules/ToasterToast.svelte.d.ts +9 -0
  254. package/dist/molecules/UnsavedChangesBar.svelte +212 -0
  255. package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
  256. package/dist/molecules/index.d.ts +46 -0
  257. package/dist/molecules/index.js +27 -0
  258. package/dist/molecules/toast-store.d.ts +53 -7
  259. package/dist/molecules/toast-store.js +21 -0
  260. package/dist/organisms/AppShell.svelte +196 -0
  261. package/dist/organisms/AppShell.svelte.d.ts +61 -0
  262. package/dist/organisms/SidebarAccountPanel.svelte +107 -12
  263. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +19 -0
  264. package/dist/organisms/SidebarNavigation.svelte +96 -15
  265. package/dist/organisms/SidebarNavigation.svelte.d.ts +29 -1
  266. package/dist/organisms/SidebarPanel.svelte +31 -10
  267. package/dist/organisms/SidebarShell.svelte +162 -11
  268. package/dist/organisms/SidebarShell.svelte.d.ts +56 -2
  269. package/dist/organisms/TopNavbar.svelte +256 -31
  270. package/dist/organisms/TopNavbar.svelte.d.ts +26 -0
  271. package/dist/organisms/index.d.ts +5 -0
  272. package/dist/organisms/index.js +3 -0
  273. package/dist/types/components.d.ts +4 -0
  274. package/dist/types/components.d.ts.map +1 -1
  275. package/dist/types/index.d.ts +605 -25
  276. package/dist/types/index.d.ts.map +1 -1
  277. package/dist/types/variants.ts +17 -1
  278. package/dist/util/app-shell.d.ts +27 -0
  279. package/dist/util/app-shell.js +72 -0
  280. package/dist/util/avatar.d.ts +35 -0
  281. package/dist/util/avatar.js +52 -0
  282. package/dist/util/bottom-sheet.d.ts +29 -0
  283. package/dist/util/bottom-sheet.js +43 -0
  284. package/dist/util/bottom-tab-bar.d.ts +34 -0
  285. package/dist/util/bottom-tab-bar.js +54 -0
  286. package/dist/util/calendar.d.ts +55 -0
  287. package/dist/util/calendar.js +74 -0
  288. package/dist/util/collapsible.d.ts +57 -0
  289. package/dist/util/collapsible.js +23 -0
  290. package/dist/util/confirm-dialog.d.ts +11 -0
  291. package/dist/util/confirm-dialog.js +2 -0
  292. package/dist/util/date.d.ts +95 -0
  293. package/dist/util/date.js +237 -0
  294. package/dist/util/drawer.d.ts +12 -0
  295. package/dist/util/drawer.js +7 -0
  296. package/dist/util/dropdown.d.ts +46 -0
  297. package/dist/util/dropdown.js +21 -0
  298. package/dist/util/field.d.ts +38 -0
  299. package/dist/util/field.js +38 -0
  300. package/dist/util/fit-in-viewport.d.ts +200 -0
  301. package/dist/util/fit-in-viewport.js +399 -0
  302. package/dist/util/focus-utils.d.ts +75 -2
  303. package/dist/util/focus-utils.js +182 -9
  304. package/dist/util/hydration.d.ts +44 -0
  305. package/dist/util/hydration.js +58 -0
  306. package/dist/util/keyboard-inset.d.ts +17 -0
  307. package/dist/util/keyboard-inset.js +53 -0
  308. package/dist/util/media-grid.d.ts +50 -0
  309. package/dist/util/media-grid.js +81 -0
  310. package/dist/util/media.d.ts +5 -0
  311. package/dist/util/media.js +10 -0
  312. package/dist/util/overlay-chrome.d.ts +52 -0
  313. package/dist/util/overlay-chrome.js +56 -0
  314. package/dist/util/overlay.d.ts +71 -0
  315. package/dist/util/overlay.js +240 -0
  316. package/dist/util/photo.d.ts +214 -0
  317. package/dist/util/photo.js +206 -0
  318. package/dist/util/portal.d.ts +22 -0
  319. package/dist/util/portal.js +50 -0
  320. package/dist/util/pull-to-refresh.d.ts +26 -0
  321. package/dist/util/pull-to-refresh.js +30 -0
  322. package/dist/util/radio-keys.d.ts +24 -0
  323. package/dist/util/radio-keys.js +49 -0
  324. package/dist/util/rating.d.ts +22 -0
  325. package/dist/util/rating.js +26 -0
  326. package/dist/util/ready-made-strings.d.ts +76 -0
  327. package/dist/util/ready-made-strings.js +67 -0
  328. package/dist/util/scroll-padding.d.ts +19 -0
  329. package/dist/util/scroll-padding.js +44 -0
  330. package/dist/util/segmented-control.d.ts +15 -0
  331. package/dist/util/segmented-control.js +1 -0
  332. package/dist/util/select.d.ts +40 -0
  333. package/dist/util/select.js +63 -0
  334. package/dist/util/sheet-drag.d.ts +59 -0
  335. package/dist/util/sheet-drag.js +241 -0
  336. package/dist/util/sidebar.d.ts +62 -0
  337. package/dist/util/sidebar.js +38 -0
  338. package/dist/util/sortable-list.d.ts +64 -0
  339. package/dist/util/sortable-list.js +53 -0
  340. package/dist/util/stepper.d.ts +39 -0
  341. package/dist/util/stepper.js +38 -0
  342. package/dist/util/swipeable-list-item.d.ts +35 -0
  343. package/dist/util/swipeable-list-item.js +32 -0
  344. package/dist/util/theme-mode.d.ts +79 -0
  345. package/dist/util/theme-mode.js +121 -0
  346. package/dist/util/toaster.d.ts +92 -0
  347. package/dist/util/toaster.js +75 -0
  348. package/dist/util/tooltip.d.ts +9 -0
  349. package/dist/util/tooltip.js +18 -0
  350. package/dist/util/top-navbar.d.ts +10 -0
  351. package/dist/util/top-navbar.js +5 -0
  352. package/dist/util/touch-target.d.ts +30 -0
  353. package/dist/util/touch-target.js +30 -0
  354. package/dist/util/zabi-strings.d.ts +132 -0
  355. package/dist/util/zabi-strings.js +105 -0
  356. package/dist/zabi-components-colors.css +5 -2
  357. package/dist/zabi-components-theme-dark-only.css +543 -241
  358. package/dist/zabi-components-theme-dark.css +543 -241
  359. package/dist/zabi-components-theme-only.css +1584 -367
  360. package/dist/zabi-components-theme.css +1584 -367
  361. package/dist/zabi-components.css +3885 -518
  362. package/docs/lucide-icons.md +40 -17
  363. package/docs/theme-imports.md +81 -11
  364. package/package.json +35 -8
@@ -14,6 +14,13 @@
14
14
  /** Selected tab id; supports `bind:activeTab`. */
15
15
  activeTab?: string;
16
16
  variant?: "default" | "pills";
17
+ /**
18
+ * The tabs share the row equally. Meant for two or three tabs on a
19
+ * phone. A label too long for its share wraps between words; when a
20
+ * word does not fit, the tabs keep their words whole and the row
21
+ * scrolls sideways instead.
22
+ */
23
+ fullWidth?: boolean;
17
24
  onclick?: (event: Event) => void;
18
25
  onkeydown?: (event: Event) => void;
19
26
  children?: Snippet<[{ activeTab: string }]>;
@@ -22,12 +29,24 @@
22
29
  let {
23
30
  class: className = "",
24
31
  tabs = [],
25
- activeTab = $bindable(""),
32
+ activeTab = $bindable<Exclude<Props["activeTab"], undefined>>(),
26
33
  variant = "default",
34
+ fullWidth = false,
27
35
  children,
28
36
  ...restProps
29
37
  }: Props = $props();
30
38
 
39
+ // No fallback on a bindable prop: Svelte refuses `bind:…={undefined}` on
40
+ // one that has a fallback (`props_invalid_value`), and a page that throws
41
+ // while it hydrates never becomes interactive. The default is applied
42
+ // here instead: at once, for the server and the first render, and again
43
+ // whenever a parent hands back `undefined`.
44
+ const applyDefaults = () => {
45
+ if (activeTab === undefined) activeTab = "";
46
+ };
47
+ applyDefaults();
48
+ $effect.pre(applyDefaults);
49
+
31
50
  const tabsBaseId = generateId("tabs");
32
51
 
33
52
  /** Button refs keyed by tab id — roving tabindex needs focus to follow selection. */
@@ -36,8 +55,107 @@
36
55
  function selectTab(tabId: string, moveFocus = false) {
37
56
  activeTab = tabId;
38
57
  if (moveFocus) {
39
- tabElements[tabId]?.focus();
58
+ // The list brings the tab into view itself (`handleFocusIn`), with
59
+ // room for the fade; the browser's own jump would come first.
60
+ tabElements[tabId]?.focus({ preventScroll: true });
61
+ }
62
+ }
63
+
64
+ /**
65
+ * When the tabs do not fit, the list scrolls sideways. It used to do
66
+ * neither that nor wrap: in a 160px card the third tab ended 94px outside
67
+ * it. Each edge with more tabs beyond it fades out, so a cut-off label
68
+ * reads as "there is more" and not as a mistake.
69
+ */
70
+ let listElement = $state<HTMLDivElement | null>(null);
71
+ /** Physical sides, since a mask is drawn in physical directions. */
72
+ let fadeLeft = $state(false);
73
+ let fadeRight = $state(false);
74
+
75
+ /** Room kept between a revealed tab and the edge: the fade, and the tab's focus ring. */
76
+ const REVEAL_MARGIN = 32;
77
+
78
+ function updateFades() {
79
+ const list = listElement;
80
+ if (!list) return;
81
+ const hidden = list.scrollWidth - list.clientWidth;
82
+ if (hidden <= 1) {
83
+ fadeLeft = false;
84
+ fadeRight = false;
85
+ return;
86
+ }
87
+ // Right to left, `scrollLeft` runs from 0 down to minus the hidden width.
88
+ const rtl = getComputedStyle(list).direction === "rtl";
89
+ const fromLeft = rtl ? hidden + list.scrollLeft : list.scrollLeft;
90
+ fadeLeft = fromLeft > 1;
91
+ fadeRight = fromLeft < hidden - 1;
92
+ }
93
+
94
+ /** Scrolls the list, and only the list, until `tab` is clear of both edges. */
95
+ function reveal(tab: HTMLElement | undefined, animate: boolean) {
96
+ const list = listElement;
97
+ if (!list || !tab || list.scrollWidth - list.clientWidth <= 1) return;
98
+ const box = list.getBoundingClientRect();
99
+ const item = tab.getBoundingClientRect();
100
+ let delta = 0;
101
+ if (item.left < box.left + REVEAL_MARGIN) {
102
+ delta = item.left - box.left - REVEAL_MARGIN;
103
+ } else if (item.right > box.right - REVEAL_MARGIN) {
104
+ delta = item.right - box.right + REVEAL_MARGIN;
105
+ }
106
+ if (delta === 0) return;
107
+ const still =
108
+ !animate ||
109
+ (typeof window.matchMedia === "function" &&
110
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches);
111
+ // `scrollBy` with a behaviour is missing from older engines and from jsdom.
112
+ if (typeof list.scrollBy === "function") {
113
+ list.scrollBy({ left: delta, behavior: still ? "instant" : "smooth" });
114
+ } else {
115
+ list.scrollLeft += delta;
116
+ }
117
+ }
118
+
119
+ // The selected tab is brought into view when the list mounts and when the
120
+ // selection changes: at once the first time, so the page does not open on
121
+ // a moving list.
122
+ let revealedOnce = false;
123
+ $effect(() => {
124
+ const tab = tabElements[activeTab];
125
+ if (!listElement) return;
126
+ reveal(tab, revealedOnce);
127
+ revealedOnce = true;
128
+ updateFades();
129
+ });
130
+
131
+ // The fades follow the list's own size and the number of tabs.
132
+ $effect(() => {
133
+ const list = listElement;
134
+ void tabs.length;
135
+ if (!list) return;
136
+ updateFades();
137
+ if (typeof ResizeObserver === "undefined") return;
138
+ const observer = new ResizeObserver(updateFades);
139
+ observer.observe(list);
140
+ return () => observer.disconnect();
141
+ });
142
+
143
+ /**
144
+ * Arrow keys and Tab move focus to a tab that may be out of view. Keyboard
145
+ * focus only: a press focuses the tab on the way down, and moving the list
146
+ * then would take the tab from under the pointer before the click lands.
147
+ * A pressed tab is revealed once it is selected, by the effect above.
148
+ */
149
+ function handleFocusIn(event: FocusEvent) {
150
+ const tab = (event.target as HTMLElement | null)?.closest<HTMLElement>('[role="tab"]');
151
+ if (!tab) return;
152
+ let fromKeyboard = true;
153
+ try {
154
+ fromKeyboard = tab.matches(":focus-visible");
155
+ } catch {
156
+ // An engine without `:focus-visible`: treat every focus as the keyboard's.
40
157
  }
158
+ if (fromKeyboard) reveal(tab, true);
41
159
  }
42
160
 
43
161
  function getEnabledTabs() {
@@ -91,14 +209,57 @@
91
209
  }
92
210
  // Enter/Space: native <button> click already selects the tab.
93
211
  }
212
+
213
+ const TAB_BASE =
214
+ "focus-ring cursor-pointer border-b-2 px-4 py-2 pointer-coarse:min-h-11 text-sm font-medium transition-colors focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed";
215
+ // A tab keeps its width and the list scrolls; sharing the row, it may shrink and its label wrap.
216
+ const TAB_NATURAL = "shrink-0 whitespace-nowrap";
217
+ // `min-w-min` is the tab's longest word: a label wraps between words, never
218
+ // inside one, and when the words do not fit the shares the row scrolls like
219
+ // any other.
220
+ const TAB_SHARED = "min-w-min flex-1 basis-0 text-center";
221
+ // A disabled tab is never the selected one, so only this arm carries the disabled state.
222
+ const TAB_IDLE =
223
+ "border-transparent text-description hover:border-border-medium hover:text-body active:bg-surface-active disabled:opacity-50 disabled:hover:border-transparent disabled:hover:text-description disabled:active:bg-transparent";
224
+ const TAB_SELECTED = "border-brand-500 text-body active:bg-surface-active";
225
+ // The pill has a fill of its own, so its pressed state is a further step of
226
+ // that fill; a translucent tint in its place would be a weaker fill, not a
227
+ // stronger one. It is the pressed role, not the hover step: the hover step
228
+ // is 1.24:1 from the resting fill in light, under the 1.25:1 a pressed fill
229
+ // is held to. The label darkens with it (`.text-link:active` in app.css)
230
+ // and keeps 4.89:1 on the pressed fill.
231
+ const TAB_SELECTED_PILL =
232
+ "border-brand-500 bg-action-primary-subtle text-link active:bg-action-primary-subtle-active";
233
+
234
+ function tabClasses(tabId: string): string {
235
+ const base = `${TAB_BASE} ${fullWidth ? TAB_SHARED : TAB_NATURAL}`;
236
+ if (activeTab !== tabId) return `${base} ${TAB_IDLE}`;
237
+ return `${base} ${variant === "pills" ? TAB_SELECTED_PILL : TAB_SELECTED}`;
238
+ }
94
239
  </script>
95
240
 
96
241
  <div class={cn("tabs-container", className)}>
242
+ <!-- The tablist keeps the line under the tabs, and the tabs scroll in a
243
+ box of their own inside it, so the line stays put. That box is
244
+ presentational: the tabs are still the tablist's tabs. -->
245
+ <!-- `flow-root`: the inner box's negative margin stays inside this one,
246
+ so the row is as tall as its tabs and its line, as it was. -->
97
247
  <div
98
- class="flex border-b border-base-200"
248
+ class="flow-root border-b border-border"
99
249
  role="tablist"
100
250
  tabindex="-1"
101
251
  onkeydown={handleKeydown}
252
+ >
253
+ <!-- `tabindex="-1"`: a scrolling box is otherwise a Tab stop of its own. -->
254
+ <div
255
+ bind:this={listElement}
256
+ class="tabs-list flex"
257
+ role="presentation"
258
+ tabindex="-1"
259
+ data-fade-left={fadeLeft ? "" : undefined}
260
+ data-fade-right={fadeRight ? "" : undefined}
261
+ onfocusin={handleFocusIn}
262
+ onscroll={updateFades}
102
263
  >
103
264
  {#each tabs as tab (tab.id)}
104
265
  <button
@@ -106,12 +267,7 @@
106
267
  type="button"
107
268
  role="tab"
108
269
  id={getTabId(tab.id)}
109
- class="focus-ring cursor-pointer border-b-2 px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:outline-none active:bg-surface-active disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-transparent disabled:hover:text-description {activeTab ===
110
- tab.id
111
- ? variant === 'pills'
112
- ? 'border-brand-500 bg-brand-100 text-brand-700'
113
- : 'border-brand-500 text-body'
114
- : 'border-transparent text-description hover:border-border-medium hover:text-body'}"
270
+ class={tabClasses(tab.id)}
115
271
  onclick={() => selectTab(tab.id)}
116
272
  disabled={tab.disabled}
117
273
  aria-selected={activeTab === tab.id}
@@ -122,6 +278,7 @@
122
278
  </button>
123
279
  {/each}
124
280
  </div>
281
+ </div>
125
282
 
126
283
  <div
127
284
  class="mt-4"
@@ -132,3 +289,70 @@
132
289
  {@render children?.({ activeTab })}
133
290
  </div>
134
291
  </div>
292
+
293
+ <style>
294
+ /*
295
+ * The list scrolls sideways when its tabs do not fit, with no scrollbar:
296
+ * the fade says there is more. A scroll container clips what is outside
297
+ * it, and a tab's focus ring is 4px outside the tab, so the list is 4px
298
+ * larger on every side than the row it fills and pulled back by the same
299
+ * amount. A list that fits looks exactly as it did.
300
+ */
301
+ .tabs-list {
302
+ --tabs-ring-room: 4px;
303
+ margin: calc(var(--tabs-ring-room) * -1);
304
+ padding: var(--tabs-ring-room);
305
+ overflow-x: auto;
306
+ overflow-y: hidden;
307
+ overscroll-behavior-x: contain;
308
+ scrollbar-width: none;
309
+ }
310
+
311
+ .tabs-list::-webkit-scrollbar {
312
+ display: none;
313
+ }
314
+
315
+ /*
316
+ * A mask, not a gradient laid over the tabs: it fades them into whatever
317
+ * surface is behind, and nothing sits on top of a tab or its focus ring.
318
+ * Only an edge with more tabs beyond it fades, and a list that fits has no
319
+ * mask at all. Left and right are physical here; the script works out
320
+ * which is which in a right-to-left layout.
321
+ */
322
+ .tabs-list[data-fade-left],
323
+ .tabs-list[data-fade-right] {
324
+ --tabs-fade-left: 0px;
325
+ --tabs-fade-right: 0px;
326
+ -webkit-mask-image: linear-gradient(
327
+ to right,
328
+ transparent,
329
+ black var(--tabs-fade-left),
330
+ black calc(100% - var(--tabs-fade-right)),
331
+ transparent
332
+ );
333
+ mask-image: linear-gradient(
334
+ to right,
335
+ transparent,
336
+ black var(--tabs-fade-left),
337
+ black calc(100% - var(--tabs-fade-right)),
338
+ transparent
339
+ );
340
+ }
341
+
342
+ .tabs-list[data-fade-left] {
343
+ --tabs-fade-left: 1.5rem;
344
+ }
345
+
346
+ .tabs-list[data-fade-right] {
347
+ --tabs-fade-right: 1.5rem;
348
+ }
349
+
350
+ /* Forced colours: a half-transparent label is harder to read than a cut-off one. */
351
+ @media (forced-colors: active) {
352
+ .tabs-list[data-fade-left],
353
+ .tabs-list[data-fade-right] {
354
+ -webkit-mask-image: none;
355
+ mask-image: none;
356
+ }
357
+ }
358
+ </style>
@@ -10,6 +10,13 @@ interface Props {
10
10
  /** Selected tab id; supports `bind:activeTab`. */
11
11
  activeTab?: string;
12
12
  variant?: "default" | "pills";
13
+ /**
14
+ * The tabs share the row equally. Meant for two or three tabs on a
15
+ * phone. A label too long for its share wraps between words; when a
16
+ * word does not fit, the tabs keep their words whole and the row
17
+ * scrolls sideways instead.
18
+ */
19
+ fullWidth?: boolean;
13
20
  onclick?: (event: Event) => void;
14
21
  onkeydown?: (event: Event) => void;
15
22
  children?: Snippet<[{
@@ -1,22 +1,367 @@
1
1
  <script lang="ts">
2
+ import { mergeStrings } from "../util/ready-made-strings.js";
3
+ import { zabiStringsFor } from "../util/zabi-strings.js";
2
4
  import ToasterToast from './ToasterToast.svelte';
3
5
  import { toastStore } from './toast-store.js';
4
6
  import { cn } from "../util/cn.js";
7
+ import { readKeyboardInset } from "../util/keyboard-inset.js";
8
+ import { topOverlayFooter, topOverlayHeader, watchOverlayFooters } from "../util/overlay.js";
9
+ import {
10
+ DEFAULT_TOASTER_STRINGS,
11
+ type ToastDuration,
12
+ type ToasterStrings,
13
+ type ToastPauseChange,
14
+ } from "../util/toaster.js";
15
+ import type { HTMLAttributes } from "svelte/elements";
5
16
 
6
- interface Props {
17
+ /**
18
+ * The stack of toasts, fixed to the bottom of the screen: in the corner
19
+ * from 640px up, across the width with 16px on each side below that.
20
+ *
21
+ * It keeps clear of what is at the bottom of a phone screen. The home
22
+ * indicator and a notch at the side are read from the safe-area insets
23
+ * (the page has to ask for them with `viewport-fit=cover`). Inside an
24
+ * `AppShell` the stack sits above the shell's tab bar, which the shell
25
+ * reports in `--app-shell-bottom-inset`. For anything else fixed to the
26
+ * bottom (a `BottomTabBar` on its own, a `StickyActionBar`, a
27
+ * `FloatingActionButton`), say how tall it is with
28
+ * `--toaster-bottom-offset` on the Toaster or any ancestor:
29
+ *
30
+ * ```svelte
31
+ * <Toaster style="--toaster-bottom-offset: 65px" />
32
+ * ```
33
+ *
34
+ * A `FloatingActionButton` is 56px with 16px under it: `72px` puts the
35
+ * stack above it.
36
+ *
37
+ * That offset is for what is on the page. Over a modal overlay (Modal,
38
+ * BottomSheet, SlideUp, Drawer) the stack minds the overlay instead: it
39
+ * never lies on the header, which holds the close button, nor on a
40
+ * footer pinned to the bottom, and the offset is not added on top. It
41
+ * sits above the panel when there is more room there, and otherwise
42
+ * between the header and the footer. Where the on-screen keyboard covers
43
+ * the page, the stack sits above the keyboard. It is never taller than
44
+ * the room it has: with more toasts than fit, it scrolls. With an
45
+ * overlay open, Tab goes from its last control into the toasts and back,
46
+ * and Escape in a toast returns focus to where it came from.
47
+ *
48
+ * A toast shows what it was pushed with: its `title`, its `message`
49
+ * under it, or the message alone.
50
+ *
51
+ * How long it stays is said in words, per toast (`pushToast({ duration:
52
+ * "short" })`) or for all of them here (`defaultDuration`): `"short"` is
53
+ * 3 seconds, for a few words that need no reading time ("Saved");
54
+ * anything a person has to read is `"medium"` (7 seconds) or `"long"`
55
+ * (14); what they have to act on, and an error, should stay:
56
+ * `"persistent"`. A number is milliseconds. A toast given no duration
57
+ * gets medium up to 120 characters of text, long up to 240, and stays
58
+ * beyond that; an error and a toast with an action stay until they are
59
+ * dismissed.
60
+ *
61
+ * Its timer stops while a mouse is over it, a finger is on it or focus
62
+ * is inside it, which is what lets a timed toast be read in the reader's
63
+ * own time (WCAG 2.2.1). A finger that lifts leaves it at least three
64
+ * seconds, so holding a short toast starts it again.
65
+ * `data-paused` on the toast says so, and `onpausechange` is called.
66
+ * Every word the toaster says by itself is in `strings`:
67
+ *
68
+ * ```svelte
69
+ * <Toaster
70
+ * strings={{
71
+ * regionLabel: "Aviseringar",
72
+ * dismiss: "Stäng aviseringen",
73
+ * closesIn: (seconds) => `Stängs om ${seconds} sekunder.`,
74
+ * }}
75
+ * />
76
+ * ```
77
+ */
78
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, "class" | "role" | "aria-label"> & {
79
+ /**
80
+ * The words the toaster says by itself, for an app in another
81
+ * language: the region's name, the buttons' names, the sentence about
82
+ * the time left. What is left out keeps its English default.
83
+ */
84
+ strings?: Partial<ToasterStrings>;
85
+ /** Accessible name of the region. Wins over `strings.regionLabel`. */
86
+ "aria-label"?: string;
87
+ /**
88
+ * Shows the time a toast has left as a sentence under it, with a
89
+ * button that stops the timer. Without it the bar along the bottom of
90
+ * the toast shows the time, and the sentence is there for a screen
91
+ * reader only.
92
+ */
93
+ showCountdown?: boolean;
94
+ /**
95
+ * How long a toast stays when it was pushed without a `duration`, in
96
+ * place of `"medium"`: `"short"`, `"medium"`, `"long"`,
97
+ * `"persistent"` or milliseconds. An error and a toast with an action
98
+ * still stay until dismissed, a text of 121 to 240 characters still
99
+ * gets at least `"long"`, and a longer one still stays. A `duration`
100
+ * on the toast wins.
101
+ */
102
+ defaultDuration?: ToastDuration;
103
+ /**
104
+ * Called when a pointer, a finger or focus starts or stops holding a
105
+ * toast's timer, with the toast's id. The same state is `data-paused`
106
+ * on the toast.
107
+ */
108
+ onpausechange?: (detail: ToastPauseChange) => void;
7
109
  class?: string;
110
+ };
111
+
112
+ let {
113
+ strings,
114
+ "aria-label": ariaLabel,
115
+ showCountdown = false,
116
+ defaultDuration,
117
+ onpausechange,
118
+ class: className = '',
119
+ ...restProps
120
+ }: Props = $props();
121
+
122
+ /** The app-wide words for the toaster, from the `ZabiStringsProvider` it is mounted under, if there is one. */
123
+ const provided = zabiStringsFor("toaster");
124
+ const words = $derived<ToasterStrings>(mergeStrings(DEFAULT_TOASTER_STRINGS, provided(), strings));
125
+
126
+ /**
127
+ * One utility per property, so a class from the caller still replaces it
128
+ * at every width; what differs below 640px is carried by the three
129
+ * variables. From 640px up this is what it always was: 1rem from the
130
+ * bottom and the right, 24rem wide, 1rem of padding.
131
+ */
132
+ const placement = [
133
+ "[--toaster-edge:0px] sm:[--toaster-edge:1rem]",
134
+ "[--toaster-width:100vw] sm:[--toaster-width:24rem]",
135
+ // In px below 640px: a gutter that grew with the text would take the room the text needs.
136
+ "[--toaster-padding:16px] sm:[--toaster-padding:1rem]",
137
+ // `--toaster-overlay-inset` is set below, while a footer of an open overlay is under the stack.
138
+ "bottom-[calc(max(max(var(--app-shell-bottom-inset,0px),env(safe-area-inset-bottom,0px))_+_var(--toaster-bottom-offset,0px),var(--toaster-overlay-inset,0px))_+_var(--toaster-edge))]",
139
+ "right-[calc(env(safe-area-inset-right,0px)_+_var(--toaster-edge))]",
140
+ "w-[min(var(--toaster-width),calc(100vw_-_env(safe-area-inset-left,0px)_-_env(safe-area-inset-right,0px)_-_2_*_var(--toaster-edge)))]",
141
+ "p-(--toaster-padding)",
142
+ ].join(" ");
143
+
144
+ let region = $state<HTMLDivElement>();
145
+
146
+ /** The highest the stack may reach: below the status bar or an AppShell's top bar. */
147
+ const TOP = "calc(max(var(--app-shell-top-inset, 0px), env(safe-area-inset-top, 0px)) + var(--toaster-edge))";
148
+ /**
149
+ * Places the stack for what is on screen now. At rest it is where the
150
+ * classes put it, raised above the on-screen keyboard where that covers
151
+ * the page. The header (with its close button) and the pinned footer of
152
+ * the overlay on top are never covered: when the stack would lie on
153
+ * either, it takes the larger of the two free stretches, above the panel
154
+ * or between its header and its footer. Where it would be taller than
155
+ * the room it has, it gets that height and scrolls, with the newest toast
156
+ * in view.
157
+ * Written straight to the element: it is measured between the steps, in
158
+ * one go, before anything is painted.
159
+ */
160
+ function layout(): void {
161
+ const stack = region;
162
+ if (!stack) return;
163
+ // Taking the height off puts a stack that scrolls back at its start.
164
+ const scrolledTo = stack.style.overflowY === "auto" ? stack.scrollTop : null;
165
+ stack.style.removeProperty("--toaster-overlay-inset");
166
+ stack.style.top = "";
167
+ stack.style.bottom = "";
168
+ stack.style.maxHeight = "";
169
+ stack.style.overflowY = "";
170
+ stack.style.paddingBlock = "";
171
+ if (stack.childElementCount === 0) return;
172
+
173
+ const viewportHeight = document.documentElement.clientHeight;
174
+ // Nothing where the page shrinks by itself: the classes already end above the keyboard there.
175
+ const keyboard = readKeyboardInset();
176
+ const raise = (px: number) => stack.style.setProperty("--toaster-overlay-inset", `${px}px`);
177
+ if (keyboard > 0) raise(keyboard);
178
+ const rest = stack.getBoundingClientRect();
179
+ // No layout (a test DOM): nothing to place.
180
+ if (rest.width === 0 && rest.height === 0) return;
181
+
182
+ stack.style.top = TOP;
183
+ stack.style.bottom = "auto";
184
+ const topLimit = stack.getBoundingClientRect().top;
185
+ stack.style.top = "";
186
+ stack.style.bottom = "";
187
+
188
+ // Only what is in the same columns as the stack can be under it.
189
+ const beside = (part: HTMLElement | null) => {
190
+ const box = part?.getBoundingClientRect();
191
+ return box && box.height > 0 && box.left < rest.right && box.right > rest.left ? box : null;
192
+ };
193
+ const header = beside(topOverlayHeader());
194
+ const footer = beside(topOverlayFooter());
195
+ const under = (box: DOMRect | null) => !!box && box.top < rest.bottom && box.bottom > rest.top;
196
+
197
+ let ceiling = topLimit;
198
+ let bottomEdge = rest.bottom;
199
+ if (under(header) || under(footer)) {
200
+ // Between the header and the footer (or the stack's own resting edge)...
201
+ const insideTop = header ? Math.max(topLimit, header.bottom) : topLimit;
202
+ const insideBottom = footer && footer.top > insideTop ? footer.top : rest.bottom;
203
+ // ...or above the panel, over the backdrop.
204
+ const aboveBottom = header ? header.top : topLimit;
205
+ const above = aboveBottom - topLimit > insideBottom - insideTop;
206
+ ceiling = above ? topLimit : insideTop;
207
+ const floor = above ? aboveBottom : insideBottom;
208
+ if (floor < rest.bottom) raise(Math.max(keyboard, Math.ceil(viewportHeight - floor)));
209
+ bottomEdge = stack.getBoundingClientRect().bottom;
210
+ } else if (header && header.bottom <= rest.top) {
211
+ // Clear of it now; it does not grow over it either.
212
+ ceiling = Math.max(topLimit, header.bottom);
213
+ }
214
+
215
+ const room = Math.max(0, Math.floor(bottomEdge - ceiling));
216
+ if (stack.scrollHeight > room + 1) {
217
+ stack.style.maxHeight = `${room}px`;
218
+ stack.style.overflowY = "auto";
219
+ // A box is never shorter than its own padding. In a slit between
220
+ // a header and a footer (a small dialog over a keyboard, on a
221
+ // phone held sideways) the padding goes, or the stack would reach
222
+ // over the header after all.
223
+ const padding = parseFloat(getComputedStyle(stack).paddingTop) || 0;
224
+ if (room < 2 * padding + 44) stack.style.paddingBlock = "0px";
225
+ // Where it was, or at its end when it has only now begun to scroll:
226
+ // the newest toast is the last one.
227
+ stack.scrollTop = scrolledTo ?? stack.scrollHeight;
228
+ }
8
229
  }
9
230
 
10
- let { class: className = '' }: Props = $props();
231
+ /** How many toasts were shown at the last layout: a new one is scrolled to. */
232
+ let shownCount = 0;
233
+
234
+ $effect(() => {
235
+ const count = $toastStore.length;
236
+ layout();
237
+ const stack = region;
238
+ // The newest is at the end: in a stack that scrolls, that is what to show.
239
+ if (stack && count > shownCount && stack.style.overflowY === "auto") {
240
+ stack.scrollTop = stack.scrollHeight;
241
+ }
242
+ shownCount = count;
243
+ });
244
+
245
+ $effect(() => {
246
+ const stack = region;
247
+ if (!stack) return;
248
+ const again = () => layout();
249
+ const stopFooters = watchOverlayFooters(again);
250
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(again) : undefined;
251
+ observer?.observe(stack);
252
+ // Its children too: a toast that opens its details makes the stack taller inside a fixed height.
253
+ const children = new MutationObserver(() => {
254
+ observer?.disconnect();
255
+ observer?.observe(stack);
256
+ for (const child of stack.children) observer?.observe(child);
257
+ });
258
+ children.observe(stack, { childList: true });
259
+ window.addEventListener("resize", again);
260
+ window.visualViewport?.addEventListener("resize", again);
261
+ window.visualViewport?.addEventListener("scroll", again);
262
+ return () => {
263
+ window.visualViewport?.removeEventListener("scroll", again);
264
+ stopFooters();
265
+ observer?.disconnect();
266
+ children.disconnect();
267
+ window.removeEventListener("resize", again);
268
+ window.visualViewport?.removeEventListener("resize", again);
269
+ };
270
+ });
271
+
272
+ /**
273
+ * Escape in a toast gives focus back to where it came from (a control in
274
+ * the page, or in the modal that is open), and nothing else: the toast
275
+ * stays, and the modal under it does not take the key as its own.
276
+ */
277
+ function handleKeydown(event: KeyboardEvent) {
278
+ if (event.key !== "Escape" || event.defaultPrevented) return;
279
+ const back = returnTarget();
280
+ if (!back) return;
281
+ event.preventDefault();
282
+ back.focus();
283
+ }
284
+
285
+ /** What had focus before it entered the region; where it goes back to. */
286
+ let cameFrom: HTMLElement | null = null;
287
+
288
+ /**
289
+ * Where focus goes back to, if that is still a place for it. A control of
290
+ * the page that focus came from before a modal overlay opened is behind
291
+ * that overlay now: focus is not sent there, out of the overlay's trap.
292
+ */
293
+ function returnTarget(): HTMLElement | null {
294
+ if (!cameFrom?.isConnected) return null;
295
+ const modal = '[aria-modal="true"]';
296
+ if (document.querySelector(modal) && !cameFrom.closest(modal)) return null;
297
+ return cameFrom;
298
+ }
299
+
300
+ function handleFocusIn(event: FocusEvent) {
301
+ const from = event.relatedTarget;
302
+ if (from instanceof HTMLElement && !region?.contains(from)) cameFrom = from;
303
+ }
304
+
305
+ /**
306
+ * Dismissing the toast that holds focus (its action, or Dismiss) removes
307
+ * the focused control, and the browser would drop focus on `<body>`. It
308
+ * goes to the neighbouring toast instead, or back to where it came from.
309
+ * Which toast held focus has to be read before the list is re-rendered.
310
+ */
311
+ let shownIds: string[] = [];
312
+ let focusAfterDismiss: { neighbours: string[] } | null = null;
313
+
314
+ $effect.pre(() => {
315
+ const ids = $toastStore.map((toast) => toast.id);
316
+ const holder = region
317
+ ? (document.activeElement?.closest<HTMLElement>('[data-toast-id]') ?? null)
318
+ : null;
319
+ const held = holder && region?.contains(holder) ? holder.dataset.toastId : undefined;
320
+ if (held !== undefined && !ids.includes(held)) {
321
+ const at = shownIds.indexOf(held);
322
+ // The next toast first, then the ones before it, nearest first.
323
+ focusAfterDismiss = {
324
+ neighbours: [...shownIds.slice(at + 1), ...shownIds.slice(0, at).reverse()],
325
+ };
326
+ }
327
+ shownIds = ids;
328
+ });
329
+
330
+ $effect(() => {
331
+ void $toastStore;
332
+ const pending = focusAfterDismiss;
333
+ focusAfterDismiss = null;
334
+ if (!pending || !region) return;
335
+ // Something else took focus in the meantime (the action opened a dialog): leave it.
336
+ const active = document.activeElement;
337
+ if (active && active !== document.body && !region.contains(active)) return;
338
+
339
+ const toasts = [...region.querySelectorAll<HTMLElement>('[data-toast-id]')];
340
+ for (const id of pending.neighbours) {
341
+ const next = toasts
342
+ .find((toast) => toast.dataset.toastId === id)
343
+ ?.querySelector<HTMLElement>('button');
344
+ if (next) {
345
+ next.focus();
346
+ return;
347
+ }
348
+ }
349
+ returnTarget()?.focus();
350
+ });
11
351
  </script>
12
352
 
13
353
  <div
14
- class={cn("pointer-events-none fixed bottom-4 right-4 z-toast flex min-w-0 w-[min(24rem,calc(100vw-2rem))] flex-col gap-2 p-4", className)}
354
+ {...restProps}
355
+ class={cn("pointer-events-none fixed z-toast flex min-w-0 flex-col gap-2 overscroll-contain", placement, className)}
15
356
  role="region"
16
- aria-label="Notifications"
357
+ aria-label={ariaLabel ?? words.regionLabel}
358
+ data-zabi-toaster
359
+ bind:this={region}
360
+ onfocusin={handleFocusIn}
361
+ onkeydown={handleKeydown}
17
362
  >
18
363
  <!-- Each toast owns its role="status"/"alert"; a region-level aria-live would announce twice. -->
19
364
  {#each $toastStore as toast (toast.id)}
20
- <ToasterToast {toast} />
365
+ <ToasterToast {toast} strings={words} {showCountdown} {defaultDuration} {onpausechange} />
21
366
  {/each}
22
367
  </div>