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
@@ -1,27 +1,97 @@
1
1
  <script lang="ts">
2
+ import { zabiCommonStrings } from "../util/zabi-strings.js";
2
3
  import type { Snippet } from 'svelte';
4
+ import type { HTMLAttributes } from 'svelte/elements';
3
5
  import {
4
- getFocusableElements,
5
6
  saveFocus,
6
7
  returnFocus,
7
8
  focusFirstElement,
9
+ joinOverlayStack,
10
+ recoverStrayFocus,
8
11
  } from '../util/focus-utils.js';
12
+ import {
13
+ followKeyboard,
14
+ lockBodyScroll,
15
+ registerOverlayFooter,
16
+ registerOverlayHeader,
17
+ trapTabKey,
18
+ watchScrollerNeedsFocus,
19
+ } from '../util/overlay.js';
20
+ import { TOUCH_HIT_AREA } from '../util/touch-target.js';
9
21
  import { generateId } from "../util/ssr-safe.js";
22
+ import { portal as portalTo } from "../util/portal.js";
10
23
  import Card from '../atoms/Card.svelte';
11
24
  import CardHeader from '../atoms/CardHeader.svelte';
12
25
  import CardContent from '../atoms/CardContent.svelte';
13
26
  import CardFooter from '../atoms/CardFooter.svelte';
14
27
 
15
28
  import { cn } from "../util/cn.js";
29
+ import {
30
+ OVERLAY_CHROME,
31
+ OVERLAY_CLOSE_GLYPH,
32
+ OVERLAY_TITLE_2XL,
33
+ OVERLAY_TITLE_HYPHENS,
34
+ OVERLAY_TITLE_WRAP,
35
+ textIsEnlarged,
36
+ } from "../util/overlay-chrome.js";
16
37
  type Size = 'sm' | 'md' | 'lg';
38
+ type CloseReason = 'escape' | 'backdrop' | 'close-button';
17
39
 
18
- interface Props {
40
+ /**
41
+ * Anything else is an attribute of the dialog panel (`aria-describedby`,
42
+ * `aria-busy`, `data-*`, `id`), which is where it is spread.
43
+ */
44
+ type Props = Omit<
45
+ HTMLAttributes<HTMLDivElement>,
46
+ 'class' | 'title' | 'role' | 'onclick' | 'onkeydown' | 'onclose' | 'children'
47
+ > & {
19
48
  isOpen?: boolean;
20
49
  title?: string;
21
50
  description?: string;
22
51
  size?: Size;
52
+ /**
53
+ * `alertdialog` for a dialog that interrupts to ask for a response,
54
+ * such as a confirmation. Focus handling is the same for both.
55
+ */
56
+ role?: 'dialog' | 'alertdialog';
23
57
  /** Render the close button even when there is no `title`. */
24
58
  showClose?: boolean;
59
+ /** Accessible name of the close button. */
60
+ closeLabel?: string;
61
+ /**
62
+ * CSS selector, looked up inside the panel, of the control that takes
63
+ * focus when the modal opens (a search field, or the safe choice in a
64
+ * confirmation). Without it, or with no match, the first control does.
65
+ */
66
+ initialFocus?: string;
67
+ /**
68
+ * Render the overlay in `document.body` instead of in place, so an
69
+ * ancestor with a transform, filter or clipped overflow cannot trap it.
70
+ * The theme class belongs on `<html>` or `<body>`; set lower, it does
71
+ * not reach a portalled modal.
72
+ */
73
+ portal?: boolean;
74
+ /**
75
+ * When false, Escape, a backdrop click and the close button do not
76
+ * close the modal (a pending confirm, say). Focus stays trapped, and
77
+ * setting `isOpen` yourself still closes it.
78
+ */
79
+ dismissible?: boolean;
80
+ /**
81
+ * Fills the screen instead of floating over it: for a long form on a
82
+ * phone. `true` at every width; `"mobile"` below 768px only, with the
83
+ * usual dialog from there up. The title and the close button stay at
84
+ * the top, the footer at the bottom, both inside the safe areas, and
85
+ * the content scrolls between them. Everything else is the same
86
+ * dialog: focus, Escape, `dismissible`, `portal`.
87
+ */
88
+ fullScreen?: boolean | 'mobile';
89
+ /** Fired when the modal closes itself, with what the user did. */
90
+ onclose?: (detail: { reason: CloseReason }) => void;
91
+ /**
92
+ * @deprecated for close reporting: use `onclose`. Still called with the
93
+ * event on Escape, a backdrop click and the close button.
94
+ */
25
95
  onclick?: (event: Event) => void;
26
96
  onkeydown?: (event: Event) => void;
27
97
  /** On the `role="dialog"` panel (testing, analytics). */
@@ -30,15 +100,22 @@
30
100
  class?: string;
31
101
  children?: Snippet;
32
102
  footer?: Snippet;
33
- }
103
+ };
34
104
 
35
105
  let {
36
- isOpen = $bindable(false),
106
+ isOpen = $bindable<Exclude<Props["isOpen"], undefined>>(),
37
107
  title = '',
38
108
  description = '',
39
109
  size = 'md',
110
+ role = 'dialog',
40
111
  showClose = true,
112
+ closeLabel: closeLabelGiven,
113
+ initialFocus,
114
+ portal = false,
115
+ dismissible = true,
116
+ fullScreen = false,
41
117
  class: className = "",
118
+ onclose,
42
119
  onclick,
43
120
  onkeydown,
44
121
  "data-testid": dataTestId = undefined,
@@ -47,11 +124,28 @@
47
124
  ...restProps
48
125
  }: Props = $props();
49
126
 
127
+ /** Words many components share: a `ZabiStringsProvider` above this one may give them; else English. */
128
+ const common = zabiCommonStrings();
129
+ const closeLabel = $derived(closeLabelGiven ?? common().close);
130
+
131
+ // No fallback on a bindable prop: Svelte refuses `bind:…={undefined}` on
132
+ // one that has a fallback (`props_invalid_value`), and a page that throws
133
+ // while it hydrates never becomes interactive. The default is applied
134
+ // here instead: at once, for the server and the first render, and again
135
+ // whenever a parent hands back `undefined`.
136
+ const applyDefaults = () => {
137
+ if (isOpen === undefined) isOpen = false;
138
+ };
139
+ applyDefaults();
140
+ $effect.pre(applyDefaults);
141
+
50
142
  const modalTitleId = generateId('modal-title');
51
143
  const modalDescriptionId = generateId('modal-description');
52
144
 
53
145
  let modalContainer = $state<HTMLDivElement>();
54
146
  let focusActive = false;
147
+ /** Position among the open overlays; lifts a modal opened later above the earlier ones. */
148
+ let depth = $state(0);
55
149
 
56
150
  const sizeClasses = $derived(
57
151
  {
@@ -61,28 +155,94 @@
61
155
  }[size] || 'w-full md:w-[28rem]',
62
156
  );
63
157
 
64
- /** Ref-counted on `<body>` so nested Modal/SlideUp overlays share one lock. */
65
- function lockBodyScroll(): () => void {
66
- const body = document.body;
67
- const count = Number(body.dataset.zabiScrollLock ?? '0');
68
- if (count === 0) {
69
- body.dataset.zabiScrollLockOverflow = body.style.overflow;
70
- body.style.overflow = 'hidden';
158
+ /**
159
+ * What `fullScreen` adds to each part: `all` at every width, `mobile`
160
+ * below `md` only. Both are written out, because a class name put
161
+ * together at run time is one Tailwind never sees.
162
+ *
163
+ * The panel is as tall as the visible screen (`dvh`: the browser's own
164
+ * bars are not counted) and the content scrolls inside it, so the header
165
+ * and the footer stay where they are. The content keeps 120px at least:
166
+ * when the header and the footer leave less (a long title in very large
167
+ * text on a small screen), the panel scrolls as a whole instead, and the
168
+ * footer is reached by scrolling rather than not at all. The gutters are
169
+ * in px: at 320px with enlarged text, a gutter that grew with the text
170
+ * would leave a form no room. Each is at least the safe-area inset on its
171
+ * side.
172
+ */
173
+ const FULL_SCREEN = {
174
+ backdrop: { all: 'md:p-0', mobile: '' },
175
+ panel: {
176
+ all: 'h-dvh max-h-none rounded-none border-0 md:w-full md:rounded-none',
177
+ mobile: 'max-md:h-dvh max-md:max-h-none max-md:rounded-none max-md:border-0',
178
+ },
179
+ card: {
180
+ all: 'flex min-h-0 flex-1 flex-col rounded-none md:rounded-none',
181
+ mobile: 'max-md:flex max-md:min-h-0 max-md:flex-1 max-md:flex-col max-md:rounded-none',
182
+ },
183
+ header: {
184
+ all: 'shrink-0 border-b border-border pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))] pr-[max(24px,env(safe-area-inset-right,0px))] pl-[max(24px,env(safe-area-inset-left,0px))]',
185
+ mobile: 'max-md:shrink-0 max-md:border-b max-md:border-border max-md:pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))] max-md:pr-[max(24px,env(safe-area-inset-right,0px))] max-md:pl-[max(24px,env(safe-area-inset-left,0px))]',
186
+ },
187
+ content: {
188
+ all: 'flex min-h-[120px] flex-col',
189
+ mobile: 'max-md:flex max-md:min-h-[120px] max-md:flex-col',
190
+ },
191
+ /** From `md` up, `mobile` restates the padding of the usual dialog. */
192
+ scroller: {
193
+ all: 'min-h-0 flex-1 overflow-y-auto overscroll-contain pr-[max(24px,env(safe-area-inset-right,0px))] pl-[max(24px,env(safe-area-inset-left,0px))]',
194
+ mobile: 'max-md:min-h-0 max-md:flex-1 max-md:overflow-y-auto max-md:overscroll-contain max-md:pr-[max(24px,env(safe-area-inset-right,0px))] max-md:pl-[max(24px,env(safe-area-inset-left,0px))] md:px-[24px]',
195
+ },
196
+ scrollerBelowHeader: { all: 'pt-[16px]', mobile: 'max-md:pt-[16px]' },
197
+ scrollerAtTop: {
198
+ all: 'pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))]',
199
+ mobile: 'max-md:pt-[max(24px,calc(env(safe-area-inset-top,0px)_+_8px))] md:pt-[24px]',
200
+ },
201
+ scrollerAboveFooter: { all: 'pb-[16px]', mobile: 'max-md:pb-[16px]' },
202
+ scrollerAtBottom: {
203
+ all: 'pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))]',
204
+ mobile: 'max-md:pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))] md:pb-[24px]',
205
+ },
206
+ footer: {
207
+ all: 'shrink-0 flex-wrap border-t border-border pr-[max(24px,env(safe-area-inset-right,0px))] pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))] pl-[max(24px,env(safe-area-inset-left,0px))] group-data-keyboard-open/overlay:pb-[24px]',
208
+ mobile: 'max-md:shrink-0 max-md:flex-wrap max-md:border-t max-md:border-border max-md:pr-[max(24px,env(safe-area-inset-right,0px))] max-md:pb-[max(24px,calc(env(safe-area-inset-bottom,0px)_+_8px))] max-md:pl-[max(24px,env(safe-area-inset-left,0px))] max-md:group-data-keyboard-open/overlay:pb-[24px]',
209
+ },
210
+ } as const;
211
+
212
+ const fullScreenMode = $derived(
213
+ fullScreen === 'mobile' ? 'mobile' : fullScreen ? 'all' : null,
214
+ );
215
+ const full = (part: keyof typeof FULL_SCREEN) =>
216
+ fullScreenMode ? FULL_SCREEN[part][fullScreenMode] : '';
217
+
218
+ /**
219
+ * The scrolling content of a full-screen modal. With nothing in it that
220
+ * takes focus (a long text), the keyboard could not scroll it, so it is a
221
+ * Tab stop itself for as long as that is true.
222
+ */
223
+ let scroller = $state<HTMLDivElement>();
224
+ let scrollerNeedsFocus = $state(false);
225
+
226
+ /**
227
+ * Read as the overlay opens: with the text enlarged, a long word in the
228
+ * title is hyphenated before it is cut (util/overlay-chrome.ts).
229
+ */
230
+ let enlargedText = $state(false);
231
+ $effect(() => {
232
+ if (isOpen) enlargedText = textIsEnlarged();
233
+ });
234
+
235
+ $effect(() => {
236
+ const box = scroller;
237
+ if (!isOpen || !box) {
238
+ scrollerNeedsFocus = false;
239
+ return;
71
240
  }
72
- body.dataset.zabiScrollLock = String(count + 1);
73
- return () => {
74
- const next = Number(body.dataset.zabiScrollLock ?? '1') - 1;
75
- if (next <= 0) {
76
- body.style.overflow = body.dataset.zabiScrollLockOverflow ?? '';
77
- delete body.dataset.zabiScrollLock;
78
- delete body.dataset.zabiScrollLockOverflow;
79
- } else {
80
- body.dataset.zabiScrollLock = String(next);
81
- }
82
- };
83
- }
241
+ return watchScrollerNeedsFocus(box, (needsFocus) => (scrollerNeedsFocus = needsFocus));
242
+ });
84
243
 
85
- function closeModal(event?: Event) {
244
+ function closeModal(reason: CloseReason, event?: Event) {
245
+ if (!dismissible) return;
86
246
  isOpen = false;
87
247
  if (focusActive) {
88
248
  focusActive = false;
@@ -91,6 +251,7 @@
91
251
  if (onclick && event) {
92
252
  onclick(event);
93
253
  }
254
+ onclose?.({ reason });
94
255
  }
95
256
 
96
257
  $effect(() => {
@@ -99,11 +260,44 @@
99
260
  saveFocus();
100
261
  focusActive = true;
101
262
  const unlockScroll = lockBodyScroll();
263
+ const overlay = joinOverlayStack(container);
264
+ depth = overlay.depth;
102
265
  const t = setTimeout(() => {
103
- focusFirstElement(container);
266
+ // Something inside has taken focus already (a field that
267
+ // focuses itself as it mounts): leave it there. `initialFocus`
268
+ // is the consumer saying where focus goes, so it still decides.
269
+ const active = document.activeElement;
270
+ const inside = !!active && active !== container && container.contains(active);
271
+ if (inside && !(initialFocus && container.querySelector(initialFocus))) return;
272
+ focusFirstElement(container, initialFocus);
104
273
  }, 0);
274
+ // Focus can leave the dialog without a Tab (a confirm that starts
275
+ // loading disables the focused button); the topmost dialog takes
276
+ // Tab and Escape back from `<body>`.
277
+ const stopRecovery = recoverStrayFocus(container, {
278
+ onEscape: (event) => closeModal('escape', event),
279
+ });
280
+ // Where the on-screen keyboard covers the page, the overlay
281
+ // keeps to what is left above it.
282
+ const stopKeyboard = container.parentElement
283
+ ? followKeyboard(container.parentElement)
284
+ : undefined;
285
+ // The footer, for the toast stack to stay off it. The panel holds
286
+ // the Card, and the Card the footer: nothing in the content matches.
287
+ const pinned = footer
288
+ ? container.querySelector<HTMLElement>(':scope > div > footer')
289
+ : null;
290
+ const forgetFooter = pinned ? registerOverlayFooter(pinned) : undefined;
291
+ // And the header, which holds the close button: toasts start below it.
292
+ const top = container.querySelector<HTMLElement>(':scope > div > header');
293
+ const forgetHeader = top ? registerOverlayHeader(top) : undefined;
105
294
  return () => {
106
295
  clearTimeout(t);
296
+ stopRecovery();
297
+ stopKeyboard?.();
298
+ forgetFooter?.();
299
+ forgetHeader?.();
300
+ overlay.leave();
107
301
  unlockScroll();
108
302
  if (focusActive) {
109
303
  focusActive = false;
@@ -113,48 +307,19 @@
113
307
  }
114
308
  });
115
309
 
116
- /** Re-queries focusables on every Tab so content added while open stays inside the trap. */
117
- function handleTrapKeydown(event: KeyboardEvent) {
118
- const container = modalContainer;
119
- if (event.key !== 'Tab' || !container) return;
120
- // A nested dialog handles its own Tab cycle.
121
- const owner = (event.target as Element | null)?.closest?.('[role="dialog"]');
122
- if (owner && owner !== container) return;
123
-
124
- const focusable = getFocusableElements(container);
125
- if (focusable.length === 0) {
126
- event.preventDefault();
127
- container.focus();
128
- return;
129
- }
130
- const first = focusable[0];
131
- const last = focusable[focusable.length - 1];
132
- const active = document.activeElement as HTMLElement | null;
133
-
134
- if (!active || !focusable.includes(active)) {
135
- event.preventDefault();
136
- first.focus();
137
- } else if (event.shiftKey && active === first) {
138
- event.preventDefault();
139
- last.focus();
140
- } else if (!event.shiftKey && active === last) {
141
- event.preventDefault();
142
- first.focus();
143
- }
144
- }
145
-
146
310
  function handleBackdropClick(event: Event) {
147
311
  if (event.target === event.currentTarget) {
148
- closeModal(event);
312
+ closeModal('backdrop', event);
149
313
  }
150
314
  }
151
315
 
152
316
  function handleKeydown(event: Event) {
153
317
  const keyboardEvent = event as KeyboardEvent;
154
- // `defaultPrevented` means a nested dialog already closed itself.
318
+ // `defaultPrevented` means a nested dialog already handled the key:
319
+ // it closed itself, or it is not dismissible and must keep its parent open.
155
320
  if (keyboardEvent.key === 'Escape' && !keyboardEvent.defaultPrevented) {
156
321
  keyboardEvent.preventDefault();
157
- closeModal(event);
322
+ closeModal('escape', event);
158
323
  }
159
324
  onkeydown?.(event);
160
325
  }
@@ -163,7 +328,14 @@
163
328
  {#if isOpen}
164
329
  <!-- svelte-ignore a11y_click_events_have_key_events -->
165
330
  <div
166
- class="fixed inset-0 z-modal flex cursor-pointer items-end justify-center bg-overlay p-0 md:items-center md:p-4"
331
+ class={cn(
332
+ "group/overlay fixed inset-0 z-modal flex items-end justify-center bg-overlay p-0 md:items-center md:p-4",
333
+ dismissible ? 'cursor-pointer' : 'cursor-default',
334
+ full('backdrop'),
335
+ )}
336
+ style:z-index={depth > 0 ? `calc(var(--z-modal) + ${depth})` : undefined}
337
+ data-overlay-depth={depth}
338
+ use:portalTo={portal}
167
339
  onclick={handleBackdropClick}
168
340
  onkeydown={handleKeydown}
169
341
  role="presentation"
@@ -171,42 +343,65 @@
171
343
  <div
172
344
  bind:this={modalContainer}
173
345
  class={cn(
174
- "flex max-h-[90vh] min-w-[320px] cursor-default flex-col overflow-y-auto rounded-t-overlay border border-border-overlay bg-surface-overlay p-0 shadow-lg animate-[slideUp_0.3s_ease-out] md:animate-none md:rounded-overlay",
346
+ // With the keyboard up the root is what is left of the screen: never taller than that.
347
+ "group-data-keyboard-open/overlay:max-h-full",
348
+ "flex max-h-[90dvh] min-w-[320px] cursor-default flex-col overflow-y-auto rounded-t-overlay border border-border-overlay bg-surface-overlay p-0 shadow-lg animate-[slideUp_0.3s_ease-out] motion-reduce:animate-none md:animate-none md:rounded-overlay",
175
349
  sizeClasses,
350
+ full('panel'),
176
351
  className,
177
352
  )}
178
- role="dialog"
353
+ {role}
179
354
  aria-modal="true"
180
355
  aria-labelledby={title ? modalTitleId : undefined}
181
356
  aria-describedby={description ? modalDescriptionId : undefined}
182
357
  tabindex="-1"
183
358
  data-testid={dataTestId}
184
- onkeydown={handleTrapKeydown}
359
+ data-full-screen={fullScreenMode === 'all' ? 'true' : (fullScreenMode ?? undefined)}
360
+ onkeydown={(event) => trapTabKey(modalContainer, event)}
185
361
  {...restProps}
186
362
  >
187
363
  <!-- The panel owns padding: Card's own p-6 would stack with the header's px-6/pt-6 and indent the title 24px past the body. -->
188
- <Card variant="flat" fullWidth={false} className="bg-transparent! p-0!">
364
+ <!-- And the corners: the Card sits 1px inside the panel (its
365
+ border), so its radius is the panel's less that pixel, not a
366
+ Card's own 12px. Below `md` the panel is a sheet with square
367
+ bottom corners, and so is the Card. -->
368
+ <Card
369
+ variant="flat"
370
+ fullWidth={false}
371
+ className={cn(
372
+ "bg-transparent! p-0! rounded-b-none rounded-t-[calc(var(--radius-overlay)-1px)] md:rounded-[calc(var(--radius-overlay)-1px)]",
373
+ full('card'),
374
+ )}
375
+ >
189
376
  {#if title || description || showClose}
377
+ <!-- Chrome: laid out in px, with a title that stops at 1.3
378
+ times its size (util/overlay-chrome.ts). The padding of the
379
+ content and the footer is px with it, so they stay in line. -->
190
380
  <CardHeader
191
381
  {description}
192
382
  descriptionId={description ? modalDescriptionId : undefined}
193
- className="px-6 pt-6 pb-4"
383
+ className={cn("px-6 pt-6 pb-4", OVERLAY_CHROME, full('header'))}
194
384
  >
195
- <div class="flex items-center {title ? 'justify-between' : 'justify-end'}">
385
+ <!-- 12px between a long title and the button: the title wraps, the button keeps its size. -->
386
+ <div class="flex items-center gap-[12px] {title ? 'justify-between' : 'justify-end'}">
196
387
  {#if title}
197
388
  <h2
198
389
  id={modalTitleId}
199
- class="text-2xl font-normal leading-8 tracking-normal text-headline"
390
+ class="min-w-0 {OVERLAY_TITLE_2XL} font-normal tracking-normal text-headline {OVERLAY_TITLE_WRAP} {enlargedText ? OVERLAY_TITLE_HYPHENS : ''}"
200
391
  >
201
392
  {title}
202
393
  </h2>
203
394
  {/if}
204
395
  {#if showClose}
396
+ <!-- `aria-disabled`, not `disabled` or removed: the button may hold focus when `dismissible` turns false, and a disabled or missing element would drop focus out of the trap. -->
205
397
  <button
206
398
  type="button"
207
- onclick={closeModal}
208
- class="focus-ring flex size-8 cursor-pointer items-center justify-center rounded-control text-2xl text-description transition-colors hover:bg-surface-overlay-hover hover:text-headline"
209
- aria-label="Close"
399
+ onclick={(event) => closeModal('close-button', event)}
400
+ class="focus-ring pointer-coarse:relative flex size-8 shrink-0 items-center justify-center rounded-control {OVERLAY_CLOSE_GLYPH} text-description transition-colors {TOUCH_HIT_AREA} {dismissible
401
+ ? 'cursor-pointer hover:bg-surface-overlay-hover hover:text-headline active:bg-surface-active'
402
+ : 'cursor-not-allowed opacity-50'}"
403
+ aria-label={closeLabel}
404
+ aria-disabled={dismissible ? undefined : "true"}
210
405
  >
211
406
  ×
212
407
  </button>
@@ -215,16 +410,37 @@
215
410
  </CardHeader>
216
411
  {/if}
217
412
 
218
- {#if children}
413
+ {#if children && fullScreenMode}
414
+ <!-- The padding is on the box that scrolls, so its scrollbar is at the edge of the screen. -->
415
+ <CardContent className={cn("flex-1", full('content'))}>
416
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
417
+ <div
418
+ bind:this={scroller}
419
+ data-modal-content
420
+ class={cn(
421
+ full('scroller'),
422
+ full(title || description || showClose ? 'scrollerBelowHeader' : 'scrollerAtTop'),
423
+ full(footer ? 'scrollerAboveFooter' : 'scrollerAtBottom'),
424
+ scrollerNeedsFocus &&
425
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus-ring",
426
+ )}
427
+ tabindex={scrollerNeedsFocus ? 0 : undefined}
428
+ role={scrollerNeedsFocus && title ? "group" : undefined}
429
+ aria-labelledby={scrollerNeedsFocus && title ? modalTitleId : undefined}
430
+ >
431
+ {@render children?.()}
432
+ </div>
433
+ </CardContent>
434
+ {:else if children}
219
435
  <CardContent
220
- className="flex-1 px-6 {title || description || showClose ? '' : 'pt-6'} {footer ? '' : 'pb-6'}"
436
+ className="flex-1 px-[24px] {title || description || showClose ? '' : 'pt-[24px]'} {footer ? '' : 'pb-[24px]'}"
221
437
  >
222
438
  {@render children?.()}
223
439
  </CardContent>
224
440
  {/if}
225
441
 
226
442
  {#if footer}
227
- <CardFooter className="flex justify-end gap-3 pt-4">
443
+ <CardFooter className={cn("flex justify-end gap-[12px] p-[24px] pt-[16px]", full('footer'))}>
228
444
  {@render footer?.()}
229
445
  </CardFooter>
230
446
  {/if}
@@ -1,12 +1,61 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
2
3
  type Size = 'sm' | 'md' | 'lg';
3
- interface Props {
4
+ type CloseReason = 'escape' | 'backdrop' | 'close-button';
5
+ /**
6
+ * Anything else is an attribute of the dialog panel (`aria-describedby`,
7
+ * `aria-busy`, `data-*`, `id`), which is where it is spread.
8
+ */
9
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'title' | 'role' | 'onclick' | 'onkeydown' | 'onclose' | 'children'> & {
4
10
  isOpen?: boolean;
5
11
  title?: string;
6
12
  description?: string;
7
13
  size?: Size;
14
+ /**
15
+ * `alertdialog` for a dialog that interrupts to ask for a response,
16
+ * such as a confirmation. Focus handling is the same for both.
17
+ */
18
+ role?: 'dialog' | 'alertdialog';
8
19
  /** Render the close button even when there is no `title`. */
9
20
  showClose?: boolean;
21
+ /** Accessible name of the close button. */
22
+ closeLabel?: string;
23
+ /**
24
+ * CSS selector, looked up inside the panel, of the control that takes
25
+ * focus when the modal opens (a search field, or the safe choice in a
26
+ * confirmation). Without it, or with no match, the first control does.
27
+ */
28
+ initialFocus?: string;
29
+ /**
30
+ * Render the overlay in `document.body` instead of in place, so an
31
+ * ancestor with a transform, filter or clipped overflow cannot trap it.
32
+ * The theme class belongs on `<html>` or `<body>`; set lower, it does
33
+ * not reach a portalled modal.
34
+ */
35
+ portal?: boolean;
36
+ /**
37
+ * When false, Escape, a backdrop click and the close button do not
38
+ * close the modal (a pending confirm, say). Focus stays trapped, and
39
+ * setting `isOpen` yourself still closes it.
40
+ */
41
+ dismissible?: boolean;
42
+ /**
43
+ * Fills the screen instead of floating over it: for a long form on a
44
+ * phone. `true` at every width; `"mobile"` below 768px only, with the
45
+ * usual dialog from there up. The title and the close button stay at
46
+ * the top, the footer at the bottom, both inside the safe areas, and
47
+ * the content scrolls between them. Everything else is the same
48
+ * dialog: focus, Escape, `dismissible`, `portal`.
49
+ */
50
+ fullScreen?: boolean | 'mobile';
51
+ /** Fired when the modal closes itself, with what the user did. */
52
+ onclose?: (detail: {
53
+ reason: CloseReason;
54
+ }) => void;
55
+ /**
56
+ * @deprecated for close reporting: use `onclose`. Still called with the
57
+ * event on Escape, a backdrop click and the close button.
58
+ */
10
59
  onclick?: (event: Event) => void;
11
60
  onkeydown?: (event: Event) => void;
12
61
  /** On the `role="dialog"` panel (testing, analytics). */
@@ -15,7 +64,7 @@ interface Props {
15
64
  class?: string;
16
65
  children?: Snippet;
17
66
  footer?: Snippet;
18
- }
67
+ };
19
68
  declare const Modal: import("svelte").Component<Props, {}, "isOpen">;
20
69
  type Modal = ReturnType<typeof Modal>;
21
70
  export default Modal;
@@ -2,6 +2,8 @@
2
2
  import type { Snippet } from "svelte";
3
3
  import { getContext } from "svelte";
4
4
  import { cn } from "../util/cn.js";
5
+ import { isInsideToastRegion } from "../util/focus-utils.js";
6
+ import { measurePlacement, watchViewport } from "../util/fit-in-viewport.js";
5
7
  import {
6
8
  NAVIGATION_MENU_CONTEXT_KEY,
7
9
  navigationMenuPanelId,
@@ -42,6 +44,52 @@
42
44
  : "",
43
45
  );
44
46
 
47
+ /** Room kept between the panel and each side of the screen: its width is the viewport less 16px at most. */
48
+ const VIEWPORT_MARGIN = 8;
49
+
50
+ /**
51
+ * How far the panel is moved from under the left edge of its item to stay on
52
+ * screen, and its width limit when it is wider than the screen. The panel
53
+ * of the second trigger used to span x 182 to 382 in a 375px viewport.
54
+ */
55
+ let leftOffset = $state(0);
56
+ let maxWidth = $state<number | null>(null);
57
+ /**
58
+ * Set when a box that scrolls around the menu would cut the panel off: it
59
+ * is then placed against the viewport with `position: fixed`, which such
60
+ * a box does not clip, at these coordinates. It stays where it is in the
61
+ * DOM, so Escape, the outside press and focus work as before.
62
+ */
63
+ let fixedAt = $state<{ left: number; top: number; width: number } | null>(null);
64
+
65
+ // After the panel is in the DOM and before it is painted, then whenever the screen changes.
66
+ $effect(() => {
67
+ if (!isActive || !contentElement) {
68
+ leftOffset = 0;
69
+ maxWidth = null;
70
+ fixedAt = null;
71
+ return;
72
+ }
73
+ const panel: HTMLElement = contentElement;
74
+ const anchor = panel.closest<HTMLElement>("[data-navigation-menu-item]") ?? panel;
75
+ const update = () => {
76
+ // The panel always opens below, from the left edge of its item
77
+ // (`left-0`, in either writing direction, as it always has), and
78
+ // is slid along that edge only as far as it takes to fit.
79
+ const fit = measurePlacement(
80
+ anchor,
81
+ panel,
82
+ { block: "bottom", inline: "start" },
83
+ { margin: VIEWPORT_MARGIN, flipBlock: false, flipInline: false, rtl: false },
84
+ );
85
+ leftOffset = fit.inlineOffset;
86
+ maxWidth = fit.maxWidth;
87
+ fixedAt = fit.fixed;
88
+ };
89
+ update();
90
+ return watchViewport(update, panel);
91
+ });
92
+
45
93
  /** Disclosure panel: one document listener while open, removed on close/unmount. */
46
94
  $effect(() => {
47
95
  if (!isActive || !contentElement) return;
@@ -53,6 +101,8 @@
53
101
 
54
102
  function handleClickOutside(event: MouseEvent) {
55
103
  const target = event.target as Node;
104
+ // A toast lies over the panel; a press on it is for the toast.
105
+ if (isInsideToastRegion(target)) return;
56
106
  if (!panel.contains(target) && !parentItem?.contains(target)) {
57
107
  context?.setActiveItem(null);
58
108
  }
@@ -80,7 +130,14 @@
80
130
  <div
81
131
  bind:this={contentElement}
82
132
  id={panelId || undefined}
83
- class={cn("absolute left-0 top-full mt-2 bg-surface-overlay rounded-control shadow-lg border border-border p-4 z-dropdown min-w-[200px] transition-all duration-200 ease-in-out", className)}
133
+ class={cn("absolute left-0 top-full mt-2 bg-surface-overlay rounded-overlay shadow-lg border border-border-overlay p-4 z-dropdown min-w-[200px] transition-[opacity,translate] duration-200 ease-in-out", className)}
134
+ style:position={fixedAt ? "fixed" : undefined}
135
+ style:top={fixedAt ? `${fixedAt.top}px` : undefined}
136
+ style:left={fixedAt ? `${fixedAt.left}px` : leftOffset !== 0 ? `${leftOffset}px` : undefined}
137
+ style:margin={fixedAt ? "0" : undefined}
138
+ style:width={fixedAt ? `${fixedAt.width}px` : undefined}
139
+ style:max-width={maxWidth !== null ? `${maxWidth}px` : undefined}
140
+ style:min-width={maxWidth !== null ? "0" : undefined}
84
141
  data-navigation-menu-content
85
142
  {...restProps}
86
143
  >
@@ -36,7 +36,6 @@
36
36
 
37
37
  <li
38
38
  class={cn("relative", className)}
39
- role="none"
40
39
  data-navigation-menu-item
41
40
  data-active={isActive}
42
41
  {...restProps}