zabi-components 7.0.2 → 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 (433) hide show
  1. package/CHANGELOG.md +1924 -0
  2. package/README.md +287 -11
  3. package/THEME.md +149 -332
  4. package/THEMING.md +1329 -163
  5. package/dist/atoms/ActionPanel.svelte +24 -9
  6. package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
  7. package/dist/atoms/Avatar.svelte +104 -0
  8. package/dist/atoms/Avatar.svelte.d.ts +34 -0
  9. package/dist/atoms/Badge.svelte +124 -58
  10. package/dist/atoms/Badge.svelte.d.ts +17 -5
  11. package/dist/atoms/Button.svelte +212 -62
  12. package/dist/atoms/Button.svelte.d.ts +33 -2
  13. package/dist/atoms/Card.svelte +31 -28
  14. package/dist/atoms/Card.svelte.d.ts +5 -2
  15. package/dist/atoms/CardContent.svelte +10 -2
  16. package/dist/atoms/CardContent.svelte.d.ts +2 -0
  17. package/dist/atoms/CardFooter.svelte +10 -2
  18. package/dist/atoms/CardFooter.svelte.d.ts +2 -0
  19. package/dist/atoms/CardHeader.svelte +26 -6
  20. package/dist/atoms/CardHeader.svelte.d.ts +2 -0
  21. package/dist/atoms/Checkbox.svelte +52 -21
  22. package/dist/atoms/Checkbox.svelte.d.ts +6 -2
  23. package/dist/atoms/CodeBlock.svelte +35 -9
  24. package/dist/atoms/CodeBlock.svelte.d.ts +13 -3
  25. package/dist/atoms/ColorPicker.svelte +272 -52
  26. package/dist/atoms/ColorPicker.svelte.d.ts +6 -1
  27. package/dist/atoms/Container.svelte +17 -10
  28. package/dist/atoms/DateField.svelte +107 -0
  29. package/dist/atoms/DateField.svelte.d.ts +53 -0
  30. package/dist/atoms/Divider.svelte +4 -4
  31. package/dist/atoms/FeatureCard.svelte +4 -3
  32. package/dist/atoms/FieldMessages.svelte +69 -0
  33. package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
  34. package/dist/atoms/FloatingActionButton.svelte +174 -0
  35. package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
  36. package/dist/atoms/Heading.svelte +93 -32
  37. package/dist/atoms/Heading.svelte.d.ts +24 -4
  38. package/dist/atoms/IconButton.svelte +242 -53
  39. package/dist/atoms/IconButton.svelte.d.ts +49 -4
  40. package/dist/atoms/Input.svelte +267 -74
  41. package/dist/atoms/Input.svelte.d.ts +32 -2
  42. package/dist/atoms/List.svelte +13 -2
  43. package/dist/atoms/List.svelte.d.ts +2 -0
  44. package/dist/atoms/ListItem.svelte +44 -11
  45. package/dist/atoms/ListItem.svelte.d.ts +5 -0
  46. package/dist/atoms/ListItemLeading.svelte +4 -2
  47. package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
  48. package/dist/atoms/OptimizedImage.svelte +11 -2
  49. package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
  50. package/dist/atoms/Progress.svelte +31 -9
  51. package/dist/atoms/Progress.svelte.d.ts +6 -0
  52. package/dist/atoms/Radio.svelte +23 -3
  53. package/dist/atoms/Radio.svelte.d.ts +6 -2
  54. package/dist/atoms/Rating.svelte +494 -0
  55. package/dist/atoms/Rating.svelte.d.ts +56 -0
  56. package/dist/atoms/Select.svelte +475 -135
  57. package/dist/atoms/Select.svelte.d.ts +52 -2
  58. package/dist/atoms/SelectionControl.svelte +99 -18
  59. package/dist/atoms/SelectionControl.svelte.d.ts +6 -3
  60. package/dist/atoms/Skeleton.svelte +5 -3
  61. package/dist/atoms/Slider.svelte +309 -0
  62. package/dist/atoms/Slider.svelte.d.ts +40 -0
  63. package/dist/atoms/Spinner.svelte +57 -0
  64. package/dist/atoms/Spinner.svelte.d.ts +19 -0
  65. package/dist/atoms/Table.svelte +152 -6
  66. package/dist/atoms/Table.svelte.d.ts +15 -3
  67. package/dist/atoms/TemporalField.svelte +140 -0
  68. package/dist/atoms/TemporalField.svelte.d.ts +32 -0
  69. package/dist/atoms/Text.svelte +56 -15
  70. package/dist/atoms/Text.svelte.d.ts +13 -4
  71. package/dist/atoms/Textarea.svelte +57 -45
  72. package/dist/atoms/Textarea.svelte.d.ts +14 -2
  73. package/dist/atoms/ThemeToggle.svelte +238 -56
  74. package/dist/atoms/ThemeToggle.svelte.d.ts +28 -3
  75. package/dist/atoms/TimeField.svelte +108 -0
  76. package/dist/atoms/TimeField.svelte.d.ts +54 -0
  77. package/dist/atoms/Toast.svelte +25 -10
  78. package/dist/atoms/Toast.svelte.d.ts +2 -0
  79. package/dist/atoms/Toggle.svelte +60 -19
  80. package/dist/atoms/Toggle.svelte.d.ts +18 -2
  81. package/dist/atoms/Tooltip.svelte +583 -40
  82. package/dist/atoms/Tooltip.svelte.d.ts +26 -0
  83. package/dist/atoms/ZabiStringsProvider.svelte +45 -0
  84. package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
  85. package/dist/atoms/index.d.ts +17 -0
  86. package/dist/atoms/index.js +12 -0
  87. package/dist/atoms/selection-control.styles.d.ts +36 -5
  88. package/dist/atoms/selection-control.styles.js +40 -9
  89. package/dist/components/atoms/index.d.ts +17 -0
  90. package/dist/components/atoms/index.d.ts.map +1 -1
  91. package/dist/components/atoms/selection-control.styles.d.ts +36 -5
  92. package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
  93. package/dist/components/index.d.ts +83 -3
  94. package/dist/components/index.d.ts.map +1 -1
  95. package/dist/components/molecules/index.d.ts +46 -0
  96. package/dist/components/molecules/index.d.ts.map +1 -1
  97. package/dist/components/molecules/toast-store.d.ts +53 -7
  98. package/dist/components/molecules/toast-store.d.ts.map +1 -1
  99. package/dist/components/organisms/index.d.ts +6 -0
  100. package/dist/components/organisms/index.d.ts.map +1 -1
  101. package/dist/components/types/variants.d.ts +15 -1
  102. package/dist/components/types/variants.d.ts.map +1 -1
  103. package/dist/components/util/app-shell.d.ts +28 -0
  104. package/dist/components/util/app-shell.d.ts.map +1 -0
  105. package/dist/components/util/avatar.d.ts +36 -0
  106. package/dist/components/util/avatar.d.ts.map +1 -0
  107. package/dist/components/util/bottom-sheet.d.ts +30 -0
  108. package/dist/components/util/bottom-sheet.d.ts.map +1 -0
  109. package/dist/components/util/bottom-tab-bar.d.ts +35 -0
  110. package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
  111. package/dist/components/util/calendar.d.ts +56 -0
  112. package/dist/components/util/calendar.d.ts.map +1 -0
  113. package/dist/components/util/cn.d.ts +8 -0
  114. package/dist/components/util/cn.d.ts.map +1 -0
  115. package/dist/components/util/collapsible.d.ts +58 -0
  116. package/dist/components/util/collapsible.d.ts.map +1 -0
  117. package/dist/components/util/confirm-dialog.d.ts +12 -0
  118. package/dist/components/util/confirm-dialog.d.ts.map +1 -0
  119. package/dist/components/util/date.d.ts +96 -0
  120. package/dist/components/util/date.d.ts.map +1 -0
  121. package/dist/components/util/drawer.d.ts +13 -0
  122. package/dist/components/util/drawer.d.ts.map +1 -0
  123. package/dist/components/util/dropdown.d.ts +47 -0
  124. package/dist/components/util/dropdown.d.ts.map +1 -0
  125. package/dist/components/util/field.d.ts +39 -0
  126. package/dist/components/util/field.d.ts.map +1 -0
  127. package/dist/components/util/fit-in-viewport.d.ts +201 -0
  128. package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
  129. package/dist/components/util/focus-utils.d.ts +75 -2
  130. package/dist/components/util/focus-utils.d.ts.map +1 -1
  131. package/dist/components/util/hydration.d.ts +45 -0
  132. package/dist/components/util/hydration.d.ts.map +1 -0
  133. package/dist/components/util/keyboard-inset.d.ts +18 -0
  134. package/dist/components/util/keyboard-inset.d.ts.map +1 -0
  135. package/dist/components/util/media-grid.d.ts +51 -0
  136. package/dist/components/util/media-grid.d.ts.map +1 -0
  137. package/dist/components/util/media.d.ts +6 -0
  138. package/dist/components/util/media.d.ts.map +1 -0
  139. package/dist/components/util/overlay-chrome.d.ts +53 -0
  140. package/dist/components/util/overlay-chrome.d.ts.map +1 -0
  141. package/dist/components/util/overlay.d.ts +72 -0
  142. package/dist/components/util/overlay.d.ts.map +1 -0
  143. package/dist/components/util/photo.d.ts +215 -0
  144. package/dist/components/util/photo.d.ts.map +1 -0
  145. package/dist/components/util/portal.d.ts +23 -0
  146. package/dist/components/util/portal.d.ts.map +1 -0
  147. package/dist/components/util/pull-to-refresh.d.ts +27 -0
  148. package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
  149. package/dist/components/util/radio-keys.d.ts +25 -0
  150. package/dist/components/util/radio-keys.d.ts.map +1 -0
  151. package/dist/components/util/rating.d.ts +23 -0
  152. package/dist/components/util/rating.d.ts.map +1 -0
  153. package/dist/components/util/ready-made-strings.d.ts +77 -0
  154. package/dist/components/util/ready-made-strings.d.ts.map +1 -0
  155. package/dist/components/util/scroll-padding.d.ts +20 -0
  156. package/dist/components/util/scroll-padding.d.ts.map +1 -0
  157. package/dist/components/util/segmented-control.d.ts +16 -0
  158. package/dist/components/util/segmented-control.d.ts.map +1 -0
  159. package/dist/components/util/select.d.ts +41 -0
  160. package/dist/components/util/select.d.ts.map +1 -0
  161. package/dist/components/util/sheet-drag.d.ts +60 -0
  162. package/dist/components/util/sheet-drag.d.ts.map +1 -0
  163. package/dist/components/util/sidebar.d.ts +63 -0
  164. package/dist/components/util/sidebar.d.ts.map +1 -0
  165. package/dist/components/util/sortable-list.d.ts +65 -0
  166. package/dist/components/util/sortable-list.d.ts.map +1 -0
  167. package/dist/components/util/ssr-safe.d.ts +4 -4
  168. package/dist/components/util/ssr-safe.d.ts.map +1 -1
  169. package/dist/components/util/stepper.d.ts +40 -0
  170. package/dist/components/util/stepper.d.ts.map +1 -0
  171. package/dist/components/util/swipeable-list-item.d.ts +36 -0
  172. package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
  173. package/dist/components/util/theme-mode.d.ts +80 -0
  174. package/dist/components/util/theme-mode.d.ts.map +1 -0
  175. package/dist/components/util/toaster.d.ts +93 -0
  176. package/dist/components/util/toaster.d.ts.map +1 -0
  177. package/dist/components/util/tooltip.d.ts +10 -0
  178. package/dist/components/util/tooltip.d.ts.map +1 -0
  179. package/dist/components/util/top-navbar.d.ts +11 -0
  180. package/dist/components/util/top-navbar.d.ts.map +1 -0
  181. package/dist/components/util/touch-target.d.ts +31 -0
  182. package/dist/components/util/touch-target.d.ts.map +1 -0
  183. package/dist/components/util/zabi-strings.d.ts +133 -0
  184. package/dist/components/util/zabi-strings.d.ts.map +1 -0
  185. package/dist/create-theme/cli.js +169 -0
  186. package/dist/create-theme/index.d.ts +176 -0
  187. package/dist/create-theme/index.js +817 -0
  188. package/dist/create-theme/lib/ramp-math.js +128 -0
  189. package/dist/create-theme/lib/resolve.js +141 -0
  190. package/dist/create-theme/theme-data.js +1116 -0
  191. package/dist/index.d.ts +83 -3
  192. package/dist/index.js +60 -8
  193. package/dist/molecules/Alert.svelte +73 -66
  194. package/dist/molecules/Alert.svelte.d.ts +9 -1
  195. package/dist/molecules/AppBar.svelte +415 -0
  196. package/dist/molecules/AppBar.svelte.d.ts +69 -0
  197. package/dist/molecules/AvatarGroup.svelte +116 -0
  198. package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
  199. package/dist/molecules/BottomSheet.svelte +540 -0
  200. package/dist/molecules/BottomSheet.svelte.d.ts +93 -0
  201. package/dist/molecules/BottomTabBar.svelte +297 -0
  202. package/dist/molecules/BottomTabBar.svelte.d.ts +64 -0
  203. package/dist/molecules/Calendar.svelte +597 -0
  204. package/dist/molecules/Calendar.svelte.d.ts +72 -0
  205. package/dist/molecules/Collapsible.svelte +243 -0
  206. package/dist/molecules/Collapsible.svelte.d.ts +44 -0
  207. package/dist/molecules/CollapsibleGroup.svelte +126 -0
  208. package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
  209. package/dist/molecules/ComponentDemo.svelte +25 -5
  210. package/dist/molecules/ComponentDemo.svelte.d.ts +5 -0
  211. package/dist/molecules/ConfirmDialog.svelte +253 -0
  212. package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
  213. package/dist/molecules/ContactForm.svelte +46 -19
  214. package/dist/molecules/ContactForm.svelte.d.ts +10 -0
  215. package/dist/molecules/Drawer.svelte +394 -0
  216. package/dist/molecules/Drawer.svelte.d.ts +55 -0
  217. package/dist/molecules/Dropdown.svelte +540 -50
  218. package/dist/molecules/Dropdown.svelte.d.ts +49 -6
  219. package/dist/molecules/DropdownItem.svelte +122 -0
  220. package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
  221. package/dist/molecules/EmptyState.svelte +56 -10
  222. package/dist/molecules/EmptyState.svelte.d.ts +8 -0
  223. package/dist/molecules/Form.svelte +25 -14
  224. package/dist/molecules/Form.svelte.d.ts +13 -5
  225. package/dist/molecules/FormField.svelte +11 -2
  226. package/dist/molecules/FormField.svelte.d.ts +2 -0
  227. package/dist/molecules/Header.svelte +13 -3
  228. package/dist/molecules/Header.svelte.d.ts +2 -0
  229. package/dist/molecules/ImageUpload.svelte +422 -70
  230. package/dist/molecules/ImageUpload.svelte.d.ts +61 -5
  231. package/dist/molecules/MediaGrid.svelte +527 -0
  232. package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
  233. package/dist/molecules/Modal.svelte +340 -45
  234. package/dist/molecules/Modal.svelte.d.ts +59 -7
  235. package/dist/molecules/NavigationMenu.svelte +15 -3
  236. package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
  237. package/dist/molecules/NavigationMenuContent.svelte +91 -36
  238. package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
  239. package/dist/molecules/NavigationMenuItem.svelte +10 -3
  240. package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
  241. package/dist/molecules/NavigationMenuLink.svelte +10 -2
  242. package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
  243. package/dist/molecules/NavigationMenuList.svelte +19 -4
  244. package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
  245. package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
  246. package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
  247. package/dist/molecules/Page.svelte +38 -2
  248. package/dist/molecules/Page.svelte.d.ts +13 -0
  249. package/dist/molecules/PhotoGrid.svelte +433 -0
  250. package/dist/molecules/PhotoGrid.svelte.d.ts +62 -0
  251. package/dist/molecules/PhotoViewer.svelte +1116 -0
  252. package/dist/molecules/PhotoViewer.svelte.d.ts +56 -0
  253. package/dist/molecules/PropsTable.svelte +26 -11
  254. package/dist/molecules/PropsTable.svelte.d.ts +5 -0
  255. package/dist/molecules/PullToRefresh.svelte +263 -0
  256. package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
  257. package/dist/molecules/RadioGroup.svelte +57 -15
  258. package/dist/molecules/RadioGroup.svelte.d.ts +16 -3
  259. package/dist/molecules/Section.svelte +13 -5
  260. package/dist/molecules/Section.svelte.d.ts +3 -1
  261. package/dist/molecules/SegmentedControl.svelte +323 -0
  262. package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
  263. package/dist/molecules/SidebarBrandHeader.svelte +24 -5
  264. package/dist/molecules/SidebarBrandHeader.svelte.d.ts +5 -0
  265. package/dist/molecules/SidebarFooter.svelte +39 -8
  266. package/dist/molecules/SidebarFooter.svelte.d.ts +5 -0
  267. package/dist/molecules/SidebarNavSection.svelte +12 -4
  268. package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
  269. package/dist/molecules/SlideUp.svelte +253 -30
  270. package/dist/molecules/SlideUp.svelte.d.ts +31 -4
  271. package/dist/molecules/SortableList.svelte +632 -0
  272. package/dist/molecules/SortableList.svelte.d.ts +69 -0
  273. package/dist/molecules/Stepper.svelte +548 -0
  274. package/dist/molecules/Stepper.svelte.d.ts +39 -0
  275. package/dist/molecules/StickyActionBar.svelte +191 -0
  276. package/dist/molecules/StickyActionBar.svelte.d.ts +65 -0
  277. package/dist/molecules/SwipeableListItem.svelte +334 -0
  278. package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
  279. package/dist/molecules/Tabs.svelte +254 -24
  280. package/dist/molecules/Tabs.svelte.d.ts +15 -4
  281. package/dist/molecules/Toaster.svelte +352 -6
  282. package/dist/molecules/Toaster.svelte.d.ts +95 -2
  283. package/dist/molecules/ToasterToast.svelte +279 -62
  284. package/dist/molecules/ToasterToast.svelte.d.ts +11 -0
  285. package/dist/molecules/UnsavedChangesBar.svelte +212 -0
  286. package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
  287. package/dist/molecules/index.d.ts +46 -0
  288. package/dist/molecules/index.js +27 -0
  289. package/dist/molecules/toast-store.d.ts +53 -7
  290. package/dist/molecules/toast-store.js +21 -0
  291. package/dist/organisms/AppShell.svelte +196 -0
  292. package/dist/organisms/AppShell.svelte.d.ts +61 -0
  293. package/dist/organisms/SidebarAccountPanel.svelte +111 -12
  294. package/dist/organisms/SidebarAccountPanel.svelte.d.ts +21 -0
  295. package/dist/organisms/SidebarNavigation.svelte +193 -69
  296. package/dist/organisms/SidebarNavigation.svelte.d.ts +31 -1
  297. package/dist/organisms/SidebarPanel.svelte +45 -16
  298. package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
  299. package/dist/organisms/SidebarShell.svelte +263 -0
  300. package/dist/organisms/SidebarShell.svelte.d.ts +106 -0
  301. package/dist/organisms/TopNavbar.svelte +329 -33
  302. package/dist/organisms/TopNavbar.svelte.d.ts +28 -0
  303. package/dist/organisms/index.d.ts +6 -0
  304. package/dist/organisms/index.js +4 -0
  305. package/dist/routes/lib/variant-utils.d.ts +26 -0
  306. package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
  307. package/dist/types/components.d.ts +4 -0
  308. package/dist/types/components.d.ts.map +1 -1
  309. package/dist/types/events.js +1 -0
  310. package/dist/types/index.d.ts +605 -25
  311. package/dist/types/index.d.ts.map +1 -1
  312. package/dist/types/index.js +3 -0
  313. package/dist/types/variants.ts +17 -1
  314. package/dist/util/app-shell.d.ts +27 -0
  315. package/dist/util/app-shell.js +72 -0
  316. package/dist/util/avatar.d.ts +35 -0
  317. package/dist/util/avatar.js +52 -0
  318. package/dist/util/bottom-sheet.d.ts +29 -0
  319. package/dist/util/bottom-sheet.js +43 -0
  320. package/dist/util/bottom-tab-bar.d.ts +34 -0
  321. package/dist/util/bottom-tab-bar.js +54 -0
  322. package/dist/util/calendar.d.ts +55 -0
  323. package/dist/util/calendar.js +74 -0
  324. package/dist/util/cn.d.ts +7 -0
  325. package/dist/util/cn.js +48 -0
  326. package/dist/util/collapsible.d.ts +57 -0
  327. package/dist/util/collapsible.js +23 -0
  328. package/dist/util/confirm-dialog.d.ts +11 -0
  329. package/dist/util/confirm-dialog.js +2 -0
  330. package/dist/util/date.d.ts +95 -0
  331. package/dist/util/date.js +237 -0
  332. package/dist/util/drawer.d.ts +12 -0
  333. package/dist/util/drawer.js +7 -0
  334. package/dist/util/dropdown.d.ts +46 -0
  335. package/dist/util/dropdown.js +21 -0
  336. package/dist/util/field.d.ts +38 -0
  337. package/dist/util/field.js +38 -0
  338. package/dist/util/fit-in-viewport.d.ts +200 -0
  339. package/dist/util/fit-in-viewport.js +399 -0
  340. package/dist/util/focus-utils.d.ts +75 -2
  341. package/dist/util/focus-utils.js +182 -9
  342. package/dist/util/hydration.d.ts +44 -0
  343. package/dist/util/hydration.js +58 -0
  344. package/dist/util/keyboard-inset.d.ts +17 -0
  345. package/dist/util/keyboard-inset.js +53 -0
  346. package/dist/util/media-grid.d.ts +50 -0
  347. package/dist/util/media-grid.js +81 -0
  348. package/dist/util/media.d.ts +5 -0
  349. package/dist/util/media.js +10 -0
  350. package/dist/util/overlay-chrome.d.ts +52 -0
  351. package/dist/util/overlay-chrome.js +56 -0
  352. package/dist/util/overlay.d.ts +71 -0
  353. package/dist/util/overlay.js +240 -0
  354. package/dist/util/photo.d.ts +214 -0
  355. package/dist/util/photo.js +206 -0
  356. package/dist/util/portal.d.ts +22 -0
  357. package/dist/util/portal.js +50 -0
  358. package/dist/util/pull-to-refresh.d.ts +26 -0
  359. package/dist/util/pull-to-refresh.js +30 -0
  360. package/dist/util/radio-keys.d.ts +24 -0
  361. package/dist/util/radio-keys.js +49 -0
  362. package/dist/util/rating.d.ts +22 -0
  363. package/dist/util/rating.js +26 -0
  364. package/dist/util/ready-made-strings.d.ts +76 -0
  365. package/dist/util/ready-made-strings.js +67 -0
  366. package/dist/util/scroll-padding.d.ts +19 -0
  367. package/dist/util/scroll-padding.js +44 -0
  368. package/dist/util/segmented-control.d.ts +15 -0
  369. package/dist/util/segmented-control.js +1 -0
  370. package/dist/util/select.d.ts +40 -0
  371. package/dist/util/select.js +63 -0
  372. package/dist/util/sheet-drag.d.ts +59 -0
  373. package/dist/util/sheet-drag.js +241 -0
  374. package/dist/util/sidebar.d.ts +62 -0
  375. package/dist/util/sidebar.js +38 -0
  376. package/dist/util/sortable-list.d.ts +64 -0
  377. package/dist/util/sortable-list.js +53 -0
  378. package/dist/util/ssr-safe.d.ts +4 -4
  379. package/dist/util/stepper.d.ts +39 -0
  380. package/dist/util/stepper.js +38 -0
  381. package/dist/util/swipeable-list-item.d.ts +35 -0
  382. package/dist/util/swipeable-list-item.js +32 -0
  383. package/dist/util/theme-mode.d.ts +79 -0
  384. package/dist/util/theme-mode.js +121 -0
  385. package/dist/util/toaster.d.ts +92 -0
  386. package/dist/util/toaster.js +75 -0
  387. package/dist/util/tooltip.d.ts +9 -0
  388. package/dist/util/tooltip.js +18 -0
  389. package/dist/util/top-navbar.d.ts +10 -0
  390. package/dist/util/top-navbar.js +5 -0
  391. package/dist/util/touch-target.d.ts +30 -0
  392. package/dist/util/touch-target.js +30 -0
  393. package/dist/util/zabi-strings.d.ts +132 -0
  394. package/dist/util/zabi-strings.js +105 -0
  395. package/dist/zabi-components-colors.css +5 -2
  396. package/dist/zabi-components-theme-dark-only.css +574 -225
  397. package/dist/zabi-components-theme-dark.css +574 -225
  398. package/dist/zabi-components-theme-only.css +1680 -317
  399. package/dist/zabi-components-theme.css +1680 -317
  400. package/dist/zabi-components.css +4473 -1158
  401. package/docs/lucide-icons.md +41 -16
  402. package/docs/theme-imports.md +81 -11
  403. package/package.json +51 -29
  404. package/dist/lib/layout-width-tokens.d.ts +0 -17
  405. package/dist/lib/layout-width-tokens.d.ts.map +0 -1
  406. package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
  407. package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
  408. package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
  409. package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
  410. package/dist/lib/showcase/component-docs/List.d.ts +0 -3
  411. package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
  412. package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
  413. package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
  414. package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
  415. package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
  416. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
  417. package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
  418. package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
  419. package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
  420. package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
  421. package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
  422. package/dist/lib/showcase/component-docs/index.d.ts +0 -4
  423. package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
  424. package/dist/lib/showcase/components-catalog.d.ts +0 -3
  425. package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
  426. package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
  427. package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
  428. package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
  429. package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
  430. package/dist/routes/lib/focus-utils.d.ts +0 -12
  431. package/dist/routes/lib/focus-utils.ts +0 -110
  432. package/dist/routes/lib/ssr-safe.ts +0 -58
  433. package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
@@ -1,6 +1,25 @@
1
1
  <script lang="ts">
2
+ import { zabiCommonStrings } from "../util/zabi-strings.js";
2
3
  import { generateId } from "../util/ssr-safe.js";
3
- import type { Snippet } from 'svelte';
4
+ import { onMount, setContext, type Snippet } from 'svelte';
5
+ import { cn } from "../util/cn.js";
6
+ import {
7
+ DROPDOWN_CONTEXT_KEY,
8
+ opensAsSheet,
9
+ type DropdownContext,
10
+ type DropdownOption,
11
+ type DropdownPresentation,
12
+ } from "../util/dropdown.js";
13
+ import type { BottomSheetSnap } from "../util/bottom-sheet.js";
14
+ import {
15
+ measurePlacement,
16
+ unfitted,
17
+ watchViewport,
18
+ type PanelPlacement,
19
+ } from "../util/fit-in-viewport.js";
20
+ import { isInsideToastRegion } from "../util/focus-utils.js";
21
+ import BottomSheet from './BottomSheet.svelte';
22
+ import DropdownItem from './DropdownItem.svelte';
4
23
 
5
24
  export type DropdownTriggerProps = {
6
25
  'aria-expanded': boolean;
@@ -9,38 +28,128 @@
9
28
  };
10
29
 
11
30
  interface Props {
31
+ /** Extra classes for the host element. */
32
+ class?: string;
12
33
  isOpen?: boolean;
34
+ /**
35
+ * The side the menu opens on. It is where the menu goes whenever it
36
+ * fits there; near an edge of the screen it flips to the other side,
37
+ * and is narrowed or given its own scroll when neither side has room.
38
+ */
13
39
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
40
+ /**
41
+ * How the menu is shown. `popover`: under its trigger, as always.
42
+ * `sheet`: in a BottomSheet, with the same items, roles and keys; this
43
+ * is the action sheet ("Edit, Share, Delete" from the bottom of the
44
+ * screen). `auto`: a sheet on a phone (a touch screen narrower than
45
+ * 640px), the pop-over everywhere else. Decided each time the menu
46
+ * opens, in the browser.
47
+ */
48
+ presentation?: DropdownPresentation;
49
+ /**
50
+ * The host fills its container instead of being as wide as its
51
+ * trigger's content, and the menu is at least as wide as the host.
52
+ * For a trigger that is a field (Select); a menu on an icon button
53
+ * stays as it is.
54
+ */
55
+ fullWidth?: boolean;
56
+ /** Heading of the sheet. Without it, `ariaLabel` is. */
57
+ sheetTitle?: string;
58
+ /**
59
+ * The height the sheet opens at. Without it: half the screen for up
60
+ * to six `options`, all of it for more. The grip moves it either way.
61
+ */
62
+ sheetSnap?: BottomSheetSnap;
63
+ /** Accessible name of the sheet's close button. */
64
+ sheetCloseLabel?: string;
65
+ /** Accessible name of the sheet's grip while its button takes the sheet up a step. */
66
+ sheetExpandLabel?: string;
67
+ /** The same, while it takes the sheet down a step. */
68
+ sheetCollapseLabel?: string;
14
69
  ariaLabel?: string;
70
+ /** Id of an element that names the list; wins over `ariaLabel` for the pop-over list. */
71
+ ariaLabelledby?: string;
15
72
  /** `listbox` for Select-style; `menu` for actions. */
16
73
  menuRole?: 'menu' | 'listbox';
17
74
  selectedValue?: string | number | null;
18
- options?: Array<{
19
- value: string | number;
20
- label: string;
21
- disabled?: boolean;
22
- }>;
75
+ /**
76
+ * Menu items, when not supplying children. Each takes an optional
77
+ * `icon`, `tone` (`danger`) and `description`; a disabled item stays
78
+ * focusable so its description can be read.
79
+ */
80
+ options?: DropdownOption[];
23
81
  onOptionClick?: (value: string | number) => void;
24
82
  trigger: Snippet<[DropdownTriggerProps]>;
83
+ /** Rendered in the popup above the menu/listbox, outside its role (e.g. a search field). */
84
+ header?: Snippet;
25
85
  children?: Snippet;
26
86
  }
27
87
 
28
88
  let {
29
- isOpen = $bindable(false),
89
+ class: className = "",
90
+ isOpen = $bindable<Exclude<Props["isOpen"], undefined>>(),
30
91
  placement = 'bottom-start',
92
+ presentation = 'popover',
93
+ fullWidth = false,
94
+ sheetTitle = undefined,
95
+ sheetSnap = undefined,
96
+ sheetCloseLabel: sheetCloseLabelGiven,
97
+ sheetExpandLabel: sheetExpandLabelGiven,
98
+ sheetCollapseLabel: sheetCollapseLabelGiven,
31
99
  ariaLabel = 'Menu',
100
+ ariaLabelledby = undefined,
32
101
  menuRole = 'menu',
33
102
  selectedValue = null,
34
103
  options = [],
35
104
  onOptionClick,
36
105
  trigger,
106
+ header,
37
107
  children,
38
108
  ...restProps
39
109
  }: Props = $props();
40
110
 
111
+ // No fallback on a bindable prop: Svelte refuses `bind:…={undefined}` on
112
+ // one that has a fallback (`props_invalid_value`), and a page that throws
113
+ // while it hydrates never becomes interactive. The default is applied
114
+ // here instead: at once, for the server and the first render, and again
115
+ // whenever a parent hands back `undefined`.
116
+ const applyDefaults = () => {
117
+ if (isOpen === undefined) isOpen = false;
118
+ };
119
+ applyDefaults();
120
+ $effect.pre(applyDefaults);
121
+
122
+ /** Words many components share: a `ZabiStringsProvider` above this one may give them; else English. */
123
+ const common = zabiCommonStrings();
124
+ const sheetCloseLabel = $derived(sheetCloseLabelGiven ?? common().close);
125
+ const sheetExpandLabel = $derived(sheetExpandLabelGiven ?? common().expand);
126
+ const sheetCollapseLabel = $derived(sheetCollapseLabelGiven ?? common().collapse);
127
+
128
+ // `DropdownItem` reads its role from here, in `options` and in `children` alike.
129
+ setContext<DropdownContext>(DROPDOWN_CONTEXT_KEY, {
130
+ get itemRole() {
131
+ return menuRole === 'listbox' ? ('option' as const) : ('menuitem' as const);
132
+ },
133
+ });
134
+
41
135
  const menuId = generateId('dropdown-menu');
42
136
  let rootEl = $state<HTMLDivElement | null>(null);
43
137
 
138
+ /**
139
+ * Whether the open menu is in a sheet. Worked out when `isOpen` turns
140
+ * true and kept while it is open, so turning the phone does not swap one
141
+ * for the other under the finger. Never before the component has mounted:
142
+ * a menu rendered open on the server is a pop-over, and stays one through
143
+ * hydration.
144
+ */
145
+ let mounted = $state(false);
146
+ onMount(() => {
147
+ mounted = true;
148
+ });
149
+ const asSheet = $derived(isOpen && mounted && opensAsSheet(presentation));
150
+ /** The sheet's content, when the menu is in one: it is outside `rootEl`, in `<body>`. */
151
+ let sheetElement = $state<HTMLElement | null>(null);
152
+
44
153
  let openedViaKeyboard = $state(false);
45
154
 
46
155
  const triggerAria = $derived({
@@ -49,8 +158,22 @@
49
158
  'aria-controls': menuId,
50
159
  } satisfies DropdownTriggerProps);
51
160
 
161
+ function isTextEntryTarget(target: EventTarget | null): boolean {
162
+ if (!(target instanceof HTMLElement)) return false;
163
+ if (target.isContentEditable || target instanceof HTMLTextAreaElement) {
164
+ return true;
165
+ }
166
+ return (
167
+ target instanceof HTMLInputElement &&
168
+ !['checkbox', 'radio', 'button', 'submit', 'reset'].includes(target.type)
169
+ );
170
+ }
171
+
52
172
  function handleKeydown(event: KeyboardEvent) {
53
173
  if (!isOpen) {
174
+ if (event.key === ' ' && isTextEntryTarget(event.target)) {
175
+ return;
176
+ }
54
177
  if (
55
178
  event.key === 'Enter' ||
56
179
  event.key === ' ' ||
@@ -63,6 +186,12 @@
63
186
  return;
64
187
  }
65
188
 
189
+ // In a sheet, Escape and Tab are the sheet's: it closes itself and
190
+ // keeps Tab inside, on its grip and close button too.
191
+ if (asSheet && (event.key === 'Escape' || event.key === 'Tab')) return;
192
+
193
+ if (typedIntoList(event)) return;
194
+
66
195
  switch (event.key) {
67
196
  case 'Escape':
68
197
  event.preventDefault();
@@ -77,26 +206,146 @@
77
206
  focusPreviousItem();
78
207
  break;
79
208
  case 'Home':
209
+ // Let text fields (e.g. Select search) keep native caret movement.
210
+ if (isTextEntryTarget(event.target)) break;
80
211
  event.preventDefault();
81
212
  focusFirstItem();
82
213
  break;
83
214
  case 'End':
215
+ if (isTextEntryTarget(event.target)) break;
84
216
  event.preventDefault();
85
217
  focusLastItem();
86
218
  break;
87
- case 'Tab':
88
- isOpen = false;
219
+ case 'Tab': {
220
+ // The list is one stop and a field above it (Select's search)
221
+ // another: Shift+Tab from an item goes to the field, Tab from
222
+ // the field to the list. Any other Tab leaves, and closes.
223
+ const field = headerField();
224
+ const onItem = getMenuItems().includes(event.target as HTMLElement);
225
+ if (field && onItem && event.shiftKey) {
226
+ event.preventDefault();
227
+ field.focus();
228
+ } else if (field && event.target === field && !event.shiftKey && getMenuItems().length > 0) {
229
+ event.preventDefault();
230
+ tabStop()?.focus();
231
+ } else {
232
+ isOpen = false;
233
+ }
89
234
  break;
235
+ }
236
+ }
237
+ }
238
+
239
+ /** The text field in the popup's header, when there is one: Select's search. */
240
+ function headerField(): HTMLElement | null {
241
+ const popup = sheetElement ?? popoverElement();
242
+ if (!popup) return null;
243
+ return (
244
+ [...popup.querySelectorAll<HTMLElement>('input, textarea')].find(
245
+ (candidate) => isTextEntryTarget(candidate) && !(candidate as HTMLInputElement).disabled,
246
+ ) ?? null
247
+ );
248
+ }
249
+
250
+ /** The item that is the list's one Tab stop. */
251
+ function tabStop(): HTMLElement | undefined {
252
+ const items = getMenuItems();
253
+ return items.find((item) => item.tabIndex === 0) ?? items[0];
254
+ }
255
+
256
+ /** How long a pause ends a word typed on the list, in ms. */
257
+ const TYPEAHEAD_PAUSE = 600;
258
+ let typed = '';
259
+ let typedAt = 0;
260
+
261
+ /**
262
+ * A printable character typed while focus is on an item. With a text
263
+ * field above the list it is meant for that field: focus moves there and
264
+ * the character is typed into it. Without one it is typeahead: focus goes
265
+ * to the next item that starts with what has been typed. Returns true
266
+ * when the key was taken.
267
+ */
268
+ function typedIntoList(event: KeyboardEvent): boolean {
269
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false;
270
+ const items = getMenuItems();
271
+ const at = items.indexOf(event.target as HTMLElement);
272
+ if (at === -1) return false;
273
+ const now = event.timeStamp || Date.now();
274
+ const word = now - typedAt > TYPEAHEAD_PAUSE ? '' : typed;
275
+ // A space activates the item, unless it is in the middle of a word.
276
+ if (event.key === ' ' && word === '') return false;
277
+
278
+ const field = headerField();
279
+ if (field) {
280
+ // Focus moves during keydown, so the character lands in the field.
281
+ field.focus();
282
+ return true;
90
283
  }
284
+
285
+ event.preventDefault();
286
+ typed = word + event.key.toLocaleLowerCase();
287
+ typedAt = now;
288
+ const starts = (item: HTMLElement) =>
289
+ (item.textContent ?? '').trim().toLocaleLowerCase().startsWith(typed);
290
+ // One letter again and again steps through the items that start with it.
291
+ const from = typed.length === 1 ? at + 1 : at;
292
+ const order = [...items.slice(from), ...items.slice(0, from)];
293
+ order.find(starts)?.focus();
294
+ return true;
91
295
  }
92
296
 
297
+ /**
298
+ * One Tab stop for the whole list, on the item that has focus, or the
299
+ * chosen one, or the first: the roving tabindex of a listbox or a menu.
300
+ * With every item a stop, a field above twelve options was twelve
301
+ * Shift+Tabs away. Kept as the items change (a search filters them).
302
+ */
303
+ $effect(() => {
304
+ const popup = sheetElement ?? menuElement;
305
+ if (!isOpen || !popup) return;
306
+ const rove = (current?: HTMLElement) => {
307
+ const items = getMenuItems();
308
+ const stop =
309
+ (current && items.includes(current) ? current : undefined) ??
310
+ items.find((item) => item === document.activeElement) ??
311
+ items.find((item) => item.tabIndex === 0 && item.hasAttribute('data-roving')) ??
312
+ items.find(
313
+ (item) =>
314
+ item.getAttribute('aria-selected') === 'true' ||
315
+ item.getAttribute('aria-checked') === 'true',
316
+ ) ??
317
+ items[0];
318
+ for (const item of items) {
319
+ item.tabIndex = item === stop ? 0 : -1;
320
+ item.setAttribute('data-roving', '');
321
+ }
322
+ };
323
+ rove();
324
+ const onFocusIn = (event: FocusEvent) => {
325
+ if (getMenuItems().includes(event.target as HTMLElement)) rove(event.target as HTMLElement);
326
+ };
327
+ popup.addEventListener('focusin', onFocusIn);
328
+ const changes = new MutationObserver(() => rove());
329
+ changes.observe(popup, { childList: true, subtree: true });
330
+ return () => {
331
+ popup.removeEventListener('focusin', onFocusIn);
332
+ changes.disconnect();
333
+ };
334
+ });
335
+
93
336
  let menuElement = $state<HTMLElement | null>(null);
337
+ const popoverElement = () => menuElement;
94
338
 
95
339
  function getMenuItems(): HTMLElement[] {
340
+ const menuElement = sheetElement ?? popoverElement();
96
341
  if (!menuElement) return [];
342
+ // `menuitemradio` and `menuitemcheckbox` are menu items too. Matching
343
+ // only `menuitem` left a menu built from them with nothing to focus:
344
+ // arrows and Home/End did nothing, opening by keyboard focused nothing,
345
+ // and Tab closed the menu, so its items could not be reached at all.
97
346
  return Array.from(
98
347
  menuElement.querySelectorAll<HTMLElement>(
99
- '[role="menuitem"], [role="option"]',
348
+ '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"]',
100
349
  ),
101
350
  );
102
351
  }
@@ -143,10 +392,81 @@
143
392
  }
144
393
  });
145
394
 
395
+ /**
396
+ * Closing removes the popup, and with it the focused item: the browser
397
+ * then drops focus on `<body>` and a keyboard user loses their place.
398
+ * Whether focus was in the popup has to be read before the DOM changes.
399
+ */
400
+ let focusWasInPopup = false;
401
+ $effect.pre(() => {
402
+ if (!isOpen) {
403
+ focusWasInPopup =
404
+ !!menuElement && menuElement.contains(document.activeElement);
405
+ }
406
+ });
407
+
408
+ /**
409
+ * In a sheet. Focus goes to the chosen item, or the first one, as soon as
410
+ * the sheet is in place: the sheet itself would start on its grip. (It
411
+ * leaves focus alone when something inside already has it.) Focusing also
412
+ * scrolls the chosen item into view.
413
+ */
414
+ let closedFromSheet = false;
146
415
  $effect(() => {
147
- if (!isOpen) return;
416
+ if (!asSheet || !sheetElement) return;
417
+ closedFromSheet = true;
418
+ queueMicrotask(() => {
419
+ const items = getMenuItems();
420
+ const chosen = items.find(
421
+ (item) =>
422
+ item.getAttribute('aria-selected') === 'true' ||
423
+ item.getAttribute('aria-checked') === 'true',
424
+ );
425
+ (chosen ?? items[0])?.focus();
426
+ });
427
+ });
428
+
429
+ // Closed from a sheet, focus goes back to the trigger. The sheet returns
430
+ // it to whatever had focus when it opened, but a tap on a button does not
431
+ // focus it in every browser, and then that is `<body>`.
432
+ $effect(() => {
433
+ if (isOpen || !closedFromSheet) return;
434
+ closedFromSheet = false;
435
+ const timer = setTimeout(() => {
436
+ const active = document.activeElement;
437
+ if (!active || active === document.body) triggerElement()?.focus();
438
+ }, 0);
439
+ return () => clearTimeout(timer);
440
+ });
441
+
442
+ /** The control that opened the menu: what `aria-controls` is spread on, else the first control. */
443
+ function triggerElement(): HTMLElement | null {
444
+ if (!rootEl) return null;
445
+ return (
446
+ rootEl.querySelector<HTMLElement>(`[aria-controls="${menuId}"]`) ??
447
+ rootEl.querySelector<HTMLElement>(
448
+ 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
449
+ )
450
+ );
451
+ }
452
+
453
+ $effect(() => {
454
+ if (isOpen || !focusWasInPopup) return;
455
+ focusWasInPopup = false;
456
+ // Only when the close left focus nowhere. If the handler that closed
457
+ // the menu moved focus itself (it opened a dialog, say), that wins.
458
+ const active = document.activeElement;
459
+ if (!active || active === document.body) {
460
+ triggerElement()?.focus();
461
+ }
462
+ });
463
+
464
+ $effect(() => {
465
+ // A sheet has a backdrop of its own, and is not inside `rootEl`.
466
+ if (!isOpen || asSheet) return;
148
467
  function onDocMouseDown(e: MouseEvent) {
149
468
  const t = e.target as Node;
469
+ if (isInsideToastRegion(t)) return;
150
470
  if (rootEl && !rootEl.contains(t)) {
151
471
  isOpen = false;
152
472
  }
@@ -155,15 +475,71 @@
155
475
  return () => document.removeEventListener('mousedown', onDocMouseDown);
156
476
  });
157
477
 
478
+ /** Room kept between the menu and the edge of the screen. */
479
+ const VIEWPORT_MARGIN = 8;
480
+
481
+ /** The room between the menu and its trigger: `mt-2` or `mb-2`, so it grows with the text size. */
482
+ function triggerGap(panel: HTMLElement): number {
483
+ // A menu placed with `position: fixed` has its margin taken away inline:
484
+ // read the one its classes give it, or the gap would be 0 from then on.
485
+ const inline = panel.style.margin;
486
+ panel.style.margin = '';
487
+ const style = getComputedStyle(panel);
488
+ const gap = Math.max(parseFloat(style.marginTop), parseFloat(style.marginBottom));
489
+ panel.style.margin = inline;
490
+ return Number.isFinite(gap) ? gap : 8;
491
+ }
492
+
493
+ const preferred = $derived({
494
+ block: placement.startsWith('top') ? ('top' as const) : ('bottom' as const),
495
+ inline: placement.endsWith('end') ? ('end' as const) : ('start' as const),
496
+ });
497
+
498
+ /**
499
+ * Where the menu actually is. It starts, and stays, on the side asked for
500
+ * whenever the menu fits there; `measurePlacement` only moves it when it
501
+ * would leave the screen, or be cut off by a box that scrolls around it
502
+ * (a Modal's body, say). In the second case it may be given
503
+ * `position: fixed` (`fit.fixed`), which such a box does not clip. It
504
+ * never leaves its place in the DOM, so the keys, focus and a dialog's
505
+ * focus trap all still see it as part of this Dropdown.
506
+ */
507
+ let measured = $state<PanelPlacement | null>(null);
508
+ const fit = $derived<PanelPlacement>(
509
+ measured ?? { ...unfitted(preferred.block, preferred.inline), fixed: null },
510
+ );
511
+ const resolvedPlacement = $derived(`${fit.block}-${fit.inline}` as typeof placement);
512
+
513
+ // Runs after the menu is in the DOM and before the browser paints it, so
514
+ // it is never seen on the wrong side; then again when the screen changes.
515
+ $effect(() => {
516
+ const side = preferred;
517
+ if (!isOpen || !menuElement || !rootEl) {
518
+ measured = null;
519
+ return;
520
+ }
521
+ const anchor = rootEl;
522
+ const panel = menuElement;
523
+ const update = () => {
524
+ measured = measurePlacement(anchor, panel, side, {
525
+ margin: VIEWPORT_MARGIN,
526
+ gap: triggerGap(panel),
527
+ });
528
+ };
529
+ update();
530
+ return watchViewport(update, panel);
531
+ });
532
+
158
533
  const placementClasses = $derived(() => {
159
534
  const base = 'absolute z-dropdown min-w-[12rem]';
160
535
  const positioning: Record<typeof placement, string> = {
161
- 'bottom-start': 'top-full left-0 mt-2',
162
- 'bottom-end': 'top-full right-0 mt-2',
163
- 'top-start': 'bottom-full left-0 mb-2',
164
- 'top-end': 'bottom-full right-0 mb-2',
536
+ // Logical: `start` is the left edge, and the right one in a right-to-left page.
537
+ 'bottom-start': 'top-full start-0 mt-2',
538
+ 'bottom-end': 'top-full end-0 mt-2',
539
+ 'top-start': 'bottom-full start-0 mb-2',
540
+ 'top-end': 'bottom-full end-0 mb-2',
165
541
  };
166
- return `${base} ${positioning[placement]}`;
542
+ return `${base} ${positioning[resolvedPlacement]}`;
167
543
  });
168
544
 
169
545
  const transformClasses = $derived(() => {
@@ -182,60 +558,174 @@
182
558
  const dropdownContentClasses = $derived(() => {
183
559
  return [
184
560
  placementClasses(),
185
- 'rounded-lg border border-border bg-card py-2 shadow-lg transition-all duration-200 ease-in-out',
561
+ // A menu is an overlay (16px). Its items are 8px, 9px inside
562
+ // the corner: within a pixel of concentric.
563
+ // A column, so that the list below can be the part that scrolls
564
+ // when the menu is limited in height.
565
+ 'flex flex-col rounded-overlay border border-border-overlay bg-surface-overlay py-2 shadow-lg transition-[opacity,translate] duration-200 ease-in-out motion-reduce:transition-none',
186
566
  transformClasses(),
187
567
  ]
188
568
  .join(' ')
189
569
  .replace(/\s+/g, ' ')
190
570
  .trim();
191
571
  });
192
-
193
- const itemRole = $derived(menuRole === 'listbox' ? 'option' : 'menuitem');
194
-
195
- const optionClasses =
196
- 'focus-ring flex w-full items-center justify-start rounded-md px-3 py-2 text-left text-sm text-body transition-colors hover:bg-base-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50';
197
572
  </script>
198
573
 
199
574
  <div
200
575
  bind:this={rootEl}
201
- class="relative inline-block"
576
+ class={cn("relative", fullWidth ? "block w-full min-w-0" : "inline-block", className)}
202
577
  data-placement={placement}
203
578
  onkeydown={handleKeydown}
204
579
  {...restProps}
205
580
  >
206
581
  {@render trigger(triggerAria)}
207
582
 
208
- {#if isOpen}
583
+ {#if asSheet}
584
+ <!-- The same menu in a BottomSheet: the element with the menu or
585
+ listbox role, its items and the header are the ones the pop-over has,
586
+ so roles, names and the arrow keys are too. The sheet brings the focus
587
+ trap, Escape, the backdrop, the swipe and its place in the stack of
588
+ overlays. It is rendered in `<body>`, outside this component's root, so
589
+ the keys are listened to here as well. -->
590
+ <BottomSheet
591
+ bind:isOpen
592
+ snap={sheetSnap ?? (options.length > 6 ? 'full' : 'half')}
593
+ title={sheetTitle || ariaLabel}
594
+ closeLabel={sheetCloseLabel}
595
+ expandLabel={sheetExpandLabel}
596
+ collapseLabel={sheetCollapseLabel}
597
+ >
598
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
599
+ <div
600
+ bind:this={sheetElement}
601
+ class="dropdown-sheet"
602
+ data-dropdown-sheet
603
+ onkeydown={handleKeydown}
604
+ >
605
+ {#if header}
606
+ <div class="dropdown-sheet-header">
607
+ {@render header()}
608
+ </div>
609
+ {/if}
610
+ <div
611
+ id={menuId}
612
+ role={menuRole === 'listbox' ? 'listbox' : 'menu'}
613
+ aria-label={ariaLabel}
614
+ >
615
+ {@render items()}
616
+ </div>
617
+ </div>
618
+ </BottomSheet>
619
+ {:else if isOpen}
620
+ <!-- The inline styles are only set when the menu would leave the screen,
621
+ or be cut off by a scrolling box around it (`fit.fixed`: placed against
622
+ the viewport instead, with the classes' own offsets switched off).
623
+ `min-width`: `min-w-[12rem]` is wider than a 320px screen once the text
624
+ is at 200%, and a minimum beats `max-width`; a narrowed menu is exactly
625
+ as wide as the room there is.
626
+ A menu limited in height does not scroll itself: the list inside it
627
+ does. A scrollbar on the panel was drawn in the panel's square box, on
628
+ and outside its 16px corners; the list is 9px inside them (the panel's
629
+ padding and border), and a header (Select's search field) stays put. -->
209
630
  <div
210
631
  bind:this={menuElement}
211
- id={menuId}
212
632
  class={dropdownContentClasses()}
213
- role={menuRole === 'listbox' ? 'listbox' : 'menu'}
214
- aria-label={ariaLabel}
633
+ data-resolved-placement={resolvedPlacement}
634
+ style:inset-inline-start={!fit.fixed && fit.inline === 'start' && fit.inlineOffset !== 0
635
+ ? `${fit.inlineOffset}px`
636
+ : undefined}
637
+ style:inset-inline-end={!fit.fixed && fit.inline === 'end' && fit.inlineOffset !== 0
638
+ ? `${fit.inlineOffset}px`
639
+ : undefined}
640
+ style:position={fit.fixed ? 'fixed' : undefined}
641
+ style:top={fit.fixed ? `${fit.fixed.top}px` : undefined}
642
+ style:left={fit.fixed ? `${fit.fixed.left}px` : undefined}
643
+ style:right={fit.fixed ? 'auto' : undefined}
644
+ style:bottom={fit.fixed ? 'auto' : undefined}
645
+ style:margin={fit.fixed ? '0' : undefined}
646
+ style:width={fit.fixed ? `${fit.fixed.width}px` : undefined}
647
+ style:max-width={fit.maxWidth !== null ? `${fit.maxWidth}px` : undefined}
648
+ style:min-width={fit.maxWidth !== null
649
+ ? `${fit.maxWidth}px`
650
+ : fullWidth
651
+ ? 'max(12rem, 100%)'
652
+ : undefined}
653
+ style:max-height={fit.maxHeight !== null ? `${fit.maxHeight}px` : undefined}
654
+ style:overflow={fit.maxWidth !== null ? 'hidden' : undefined}
215
655
  >
216
- {#if options.length > 0}
217
- <div class="px-2 py-1">
218
- {#each options as option (option.value)}
219
- <button
220
- type="button"
221
- role={itemRole}
222
- aria-selected={menuRole === 'listbox' &&
223
- selectedValue !== null &&
224
- String(selectedValue) === String(option.value)
225
- ? true
226
- : undefined}
227
- data-value={String(option.value)}
228
- disabled={option.disabled}
229
- class={optionClasses}
230
- onclick={() => onOptionClick?.(option.value)}
231
- >
232
- {option.label}
233
- </button>
234
- {/each}
235
- </div>
236
- {:else if children}
237
- {@render children?.()}
238
- {/if}
656
+ {@render header?.()}
657
+ <!-- `mx-1` and `px-1` are the 0.5rem the items always had from the
658
+ panel's edge, split so that the scrolling box is 4px in from it:
659
+ room for an item's focus ring inside the box, and a scrollbar
660
+ clear of the corners. -->
661
+ <div
662
+ id={menuId}
663
+ role={menuRole === 'listbox' ? 'listbox' : 'menu'}
664
+ aria-label={ariaLabelledby ? undefined : ariaLabel}
665
+ aria-labelledby={ariaLabelledby}
666
+ class={options.length > 0 ? 'mx-1 min-h-0' : 'min-h-0'}
667
+ data-dropdown-scroller
668
+ style:overflow={fit.maxWidth !== null || fit.maxHeight !== null ? 'auto' : undefined}
669
+ style:overscroll-behavior={fit.maxHeight !== null ? 'contain' : undefined}
670
+ >
671
+ {@render items()}
672
+ </div>
239
673
  </div>
240
674
  {/if}
241
675
  </div>
676
+
677
+ {#snippet items()}
678
+ {#if options.length > 0}
679
+ <div class="px-1 py-1">
680
+ {#each options as option (option.value)}
681
+ <DropdownItem
682
+ label={option.label}
683
+ description={option.description}
684
+ icon={option.icon}
685
+ tone={option.tone}
686
+ disabled={option.disabled}
687
+ selected={selectedValue !== null &&
688
+ String(selectedValue) === String(option.value)}
689
+ data-value={String(option.value)}
690
+ onclick={() => onOptionClick?.(option.value)}
691
+ />
692
+ {/each}
693
+ </div>
694
+ {:else if children}
695
+ {@render children?.()}
696
+ {/if}
697
+ {/snippet}
698
+
699
+ <style>
700
+ /*
701
+ * In a sheet the rows are 48px, for a thumb, and as wide as the sheet.
702
+ * px, not rem: a row that grows with the text grows anyway, by its text.
703
+ */
704
+ .dropdown-sheet :global(:is([role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"])) {
705
+ min-height: 48px;
706
+ align-items: center;
707
+ }
708
+
709
+ /* The sheet scrolls, so a list with a height limit and a scrollbar of its
710
+ own (Select's) has neither here, and a width set for the pop-over
711
+ (`menuWidth`) is the sheet's. */
712
+ .dropdown-sheet :global([data-menu-scroll]) {
713
+ max-height: none !important;
714
+ overflow: visible !important;
715
+ }
716
+ .dropdown-sheet :global([data-menu-width]) {
717
+ width: 100% !important;
718
+ }
719
+
720
+ /* A header (Select's search field) stays under the sheet's own header
721
+ while the list scrolls beneath it. Wider than the list by the room a
722
+ focus ring takes, so no ring shows beside it. */
723
+ .dropdown-sheet-header {
724
+ position: sticky;
725
+ top: 0;
726
+ z-index: 1;
727
+ margin-inline: -4px;
728
+ padding-inline: 4px;
729
+ background-color: var(--color-surface-overlay);
730
+ }
731
+ </style>