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,7 +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';
4
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';
5
23
 
6
24
  export type DropdownTriggerProps = {
7
25
  'aria-expanded': boolean;
@@ -13,16 +31,53 @@
13
31
  /** Extra classes for the host element. */
14
32
  class?: string;
15
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
+ */
16
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;
17
69
  ariaLabel?: string;
70
+ /** Id of an element that names the list; wins over `ariaLabel` for the pop-over list. */
71
+ ariaLabelledby?: string;
18
72
  /** `listbox` for Select-style; `menu` for actions. */
19
73
  menuRole?: 'menu' | 'listbox';
20
74
  selectedValue?: string | number | null;
21
- options?: Array<{
22
- value: string | number;
23
- label: string;
24
- disabled?: boolean;
25
- }>;
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[];
26
81
  onOptionClick?: (value: string | number) => void;
27
82
  trigger: Snippet<[DropdownTriggerProps]>;
28
83
  /** Rendered in the popup above the menu/listbox, outside its role (e.g. a search field). */
@@ -32,9 +87,17 @@
32
87
 
33
88
  let {
34
89
  class: className = "",
35
- isOpen = $bindable(false),
90
+ isOpen = $bindable<Exclude<Props["isOpen"], undefined>>(),
36
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,
37
99
  ariaLabel = 'Menu',
100
+ ariaLabelledby = undefined,
38
101
  menuRole = 'menu',
39
102
  selectedValue = null,
40
103
  options = [],
@@ -45,9 +108,48 @@
45
108
  ...restProps
46
109
  }: Props = $props();
47
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
+
48
135
  const menuId = generateId('dropdown-menu');
49
136
  let rootEl = $state<HTMLDivElement | null>(null);
50
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
+
51
153
  let openedViaKeyboard = $state(false);
52
154
 
53
155
  const triggerAria = $derived({
@@ -84,6 +186,12 @@
84
186
  return;
85
187
  }
86
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
+
87
195
  switch (event.key) {
88
196
  case 'Escape':
89
197
  event.preventDefault();
@@ -108,15 +216,128 @@
108
216
  event.preventDefault();
109
217
  focusLastItem();
110
218
  break;
111
- case 'Tab':
112
- 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
+ }
113
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;
114
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;
115
295
  }
116
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
+
117
336
  let menuElement = $state<HTMLElement | null>(null);
337
+ const popoverElement = () => menuElement;
118
338
 
119
339
  function getMenuItems(): HTMLElement[] {
340
+ const menuElement = sheetElement ?? popoverElement();
120
341
  if (!menuElement) return [];
121
342
  // `menuitemradio` and `menuitemcheckbox` are menu items too. Matching
122
343
  // only `menuitem` left a menu built from them with nothing to focus:
@@ -171,10 +392,81 @@
171
392
  }
172
393
  });
173
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;
415
+ $effect(() => {
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
+
174
464
  $effect(() => {
175
- if (!isOpen) return;
465
+ // A sheet has a backdrop of its own, and is not inside `rootEl`.
466
+ if (!isOpen || asSheet) return;
176
467
  function onDocMouseDown(e: MouseEvent) {
177
468
  const t = e.target as Node;
469
+ if (isInsideToastRegion(t)) return;
178
470
  if (rootEl && !rootEl.contains(t)) {
179
471
  isOpen = false;
180
472
  }
@@ -183,15 +475,71 @@
183
475
  return () => document.removeEventListener('mousedown', onDocMouseDown);
184
476
  });
185
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
+
186
533
  const placementClasses = $derived(() => {
187
534
  const base = 'absolute z-dropdown min-w-[12rem]';
188
535
  const positioning: Record<typeof placement, string> = {
189
- 'bottom-start': 'top-full left-0 mt-2',
190
- 'bottom-end': 'top-full right-0 mt-2',
191
- 'top-start': 'bottom-full left-0 mb-2',
192
- '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',
193
541
  };
194
- return `${base} ${positioning[placement]}`;
542
+ return `${base} ${positioning[resolvedPlacement]}`;
195
543
  });
196
544
 
197
545
  const transformClasses = $derived(() => {
@@ -210,61 +558,174 @@
210
558
  const dropdownContentClasses = $derived(() => {
211
559
  return [
212
560
  placementClasses(),
213
- 'rounded-control border border-border bg-surface-overlay 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',
214
566
  transformClasses(),
215
567
  ]
216
568
  .join(' ')
217
569
  .replace(/\s+/g, ' ')
218
570
  .trim();
219
571
  });
220
-
221
- const itemRole = $derived(menuRole === 'listbox' ? 'option' : 'menuitem');
222
-
223
- const optionClasses =
224
- 'focus-ring flex w-full items-center justify-start rounded-control px-3 py-2 text-left text-sm text-body transition-colors hover:bg-surface-overlay-hover focus:outline-none disabled:cursor-not-allowed disabled:opacity-50';
225
572
  </script>
226
573
 
227
574
  <div
228
575
  bind:this={rootEl}
229
- class={cn("relative inline-block", className)}
576
+ class={cn("relative", fullWidth ? "block w-full min-w-0" : "inline-block", className)}
230
577
  data-placement={placement}
231
578
  onkeydown={handleKeydown}
232
579
  {...restProps}
233
580
  >
234
581
  {@render trigger(triggerAria)}
235
582
 
236
- {#if isOpen}
237
- <div bind:this={menuElement} class={dropdownContentClasses()}>
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. -->
630
+ <div
631
+ bind:this={menuElement}
632
+ class={dropdownContentClasses()}
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}
655
+ >
238
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. -->
239
661
  <div
240
662
  id={menuId}
241
663
  role={menuRole === 'listbox' ? 'listbox' : 'menu'}
242
- aria-label={ariaLabel}
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}
243
670
  >
244
- {#if options.length > 0}
245
- <div class="px-2 py-1">
246
- {#each options as option (option.value)}
247
- <button
248
- type="button"
249
- role={itemRole}
250
- aria-selected={menuRole === 'listbox' &&
251
- selectedValue !== null &&
252
- String(selectedValue) === String(option.value)
253
- ? true
254
- : undefined}
255
- data-value={String(option.value)}
256
- disabled={option.disabled}
257
- class={optionClasses}
258
- onclick={() => onOptionClick?.(option.value)}
259
- >
260
- {option.label}
261
- </button>
262
- {/each}
263
- </div>
264
- {:else if children}
265
- {@render children?.()}
266
- {/if}
671
+ {@render items()}
267
672
  </div>
268
673
  </div>
269
674
  {/if}
270
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>