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,18 +1,54 @@
1
1
  <script lang="ts">
2
+ import { mergeStrings } from "../util/ready-made-strings.js";
3
+ import { zabiCommonStrings, zabiStringsFor } from "../util/zabi-strings.js";
2
4
  import Dropdown from "../molecules/Dropdown.svelte";
3
5
  import Input from "./Input.svelte";
6
+ import Check from "@lucide/svelte/icons/check";
7
+ import ChevronDown from "@lucide/svelte/icons/chevron-down";
8
+ import { onMount, tick, untrack } from "svelte";
9
+ import type { HTMLButtonAttributes } from "svelte/elements";
4
10
  import {
5
- ChevronDown,
6
- CheckCircle,
7
- AlertTriangle,
8
- AlertCircle,
9
- } from "@lucide/svelte";
11
+ DEFAULT_SELECT_STRINGS,
12
+ NATIVE_SELECT_ATTRIBUTE,
13
+ choiceBeforeMount,
14
+ type SelectPresentation,
15
+ type SelectStrings,
16
+ } from "../util/select.js";
10
17
  import { generateId } from "../util/ssr-safe.js";
18
+ import { isInsideToastRegion } from "../util/focus-utils.js";
11
19
  import { cn } from "../util/cn.js";
20
+ import { fieldDescribedBy, fieldMessageState } from "../util/field.js";
21
+ import FieldMessages from "./FieldMessages.svelte";
12
22
 
13
- interface Props {
23
+ /**
24
+ * Other attributes (`data-*`, `aria-*`, `onfocus`, ...) land on the
25
+ * trigger, which is a `<button>`: it is the control that takes focus and
26
+ * that the label names.
27
+ *
28
+ * The server's HTML also carries a native `<select>` with the same
29
+ * options. Until the component has mounted, and for good where there are
30
+ * no scripts, that is the control: it is what is seen, pressed and
31
+ * submitted. Once mounted the trigger and the list take over, and the
32
+ * native select stays, out of sight and out of reach, as the form control
33
+ * (`name`, `required`), kept at the same value. With
34
+ * `presentation="native"` it is the control throughout.
35
+ */
36
+ type Props = Omit<
37
+ HTMLButtonAttributes,
38
+ | "class"
39
+ | "value"
40
+ | "name"
41
+ | "disabled"
42
+ | "type"
43
+ | "onchange"
44
+ | "onclick"
45
+ | "aria-describedby"
46
+ | "children"
47
+ > & {
14
48
  /** Extra classes for the host element. */
15
49
  class?: string;
50
+ /** Id of the trigger. Omit to auto-generate. */
51
+ id?: string;
16
52
  value?: string | number | undefined;
17
53
  options?: Array<{
18
54
  value: string | number;
@@ -20,9 +56,26 @@
20
56
  disabled?: boolean;
21
57
  }>;
22
58
  searchable?: boolean;
59
+ /**
60
+ * The component's own texts, for another language. Whatever is left
61
+ * out keeps its English default. The six older props for single texts
62
+ * (`placeholder`, `searchPlaceholder`, `noResultsText`,
63
+ * `loadingText`, `emptyStateTitle`, `emptyStateDescription`) win
64
+ * over it.
65
+ */
66
+ strings?: Partial<SelectStrings>;
23
67
  searchPlaceholder?: string;
24
68
  maxMenuHeight?: string;
25
69
  menuWidth?: string;
70
+ /**
71
+ * How the list is shown. `auto`: in a BottomSheet on a phone (a touch
72
+ * screen narrower than 640px), under the trigger everywhere else.
73
+ * `popover` and `sheet` are always the one or the other. `native`:
74
+ * only the browser's own `<select>`, styled as the field, with the
75
+ * platform's picker. It shows the options' labels and nothing else:
76
+ * no search field, and none of the loading or empty states.
77
+ */
78
+ presentation?: SelectPresentation;
26
79
  noResultsText?: string;
27
80
  isLoading?: boolean;
28
81
  loadingText?: string;
@@ -31,32 +84,47 @@
31
84
  emptyStateActionLabel?: string;
32
85
  placeholder?: string;
33
86
  label?: string;
34
- /** When set, a hidden input submits the selected value with native forms. */
87
+ /** The name the chosen value is submitted under, by the native `<select>`. */
35
88
  name?: string;
89
+ /**
90
+ * The form is not sent while nothing is chosen. Checked by the
91
+ * browser on the native `<select>`; once mounted, the browser's own
92
+ * message is shown under the field and focus goes to the trigger.
93
+ */
36
94
  required?: boolean;
37
95
  disabled?: boolean;
38
96
  size?: "sm" | "md" | "lg";
39
97
  variant?: "default" | "success" | "warning" | "error";
98
+ /** Status text under the field. Shown with a `success`, `warning` or `error` variant; for neutral help, use `hint`. */
40
99
  message?: string;
100
+ /** Help text under the field, read out with it. */
101
+ hint?: string;
102
+ /** An error under the field. It marks the field invalid and is announced; it wins over `variant` and `message`. */
103
+ error?: string;
41
104
  onchange?: (event: Event) => void;
42
105
  onEmptyStateAction?: () => void;
43
- }
106
+ /** Ids of other elements that describe the field. The hint and the message are added after them. */
107
+ "aria-describedby"?: string | null;
108
+ };
44
109
 
45
110
  let {
46
111
  class: className = "",
112
+ id: idProp,
47
113
  value = $bindable(undefined),
48
114
  options = [],
49
115
  searchable = true,
50
- searchPlaceholder = "Search options",
51
- maxMenuHeight = "60vh",
116
+ strings,
117
+ searchPlaceholder,
118
+ maxMenuHeight = "60dvh",
52
119
  menuWidth = "100%",
53
- noResultsText = "No results found",
120
+ presentation = "auto",
121
+ noResultsText,
54
122
  isLoading = false,
55
- loadingText = "Loading options...",
56
- emptyStateTitle = "No options available",
57
- emptyStateDescription = "Add an option to start making selections.",
123
+ loadingText,
124
+ emptyStateTitle,
125
+ emptyStateDescription,
58
126
  emptyStateActionLabel = "",
59
- placeholder = "Select an option",
127
+ placeholder,
60
128
  label = "",
61
129
  name = "",
62
130
  required = false,
@@ -64,63 +132,120 @@
64
132
  size = "md",
65
133
  variant = "default",
66
134
  message = "",
135
+ hint = "",
136
+ error = "",
67
137
  onchange,
68
138
  onEmptyStateAction,
139
+ "aria-describedby": describedBy,
69
140
  ...restProps
70
141
  }: Props = $props();
71
142
 
143
+ /** The defaults, then a provider's words, then `strings`, then the older props where they are set. */
144
+ const provided = zabiStringsFor("select");
145
+ const common = zabiCommonStrings();
146
+ const text = $derived<SelectStrings>({
147
+ ...mergeStrings(
148
+ {
149
+ ...DEFAULT_SELECT_STRINGS,
150
+ // The sheet's three buttons say what every sheet's do.
151
+ closeLabel: common().close,
152
+ expandLabel: common().expand,
153
+ collapseLabel: common().collapse,
154
+ },
155
+ provided(),
156
+ strings,
157
+ ),
158
+ ...(placeholder !== undefined && { placeholder }),
159
+ ...(searchPlaceholder !== undefined && { searchPlaceholder }),
160
+ ...(noResultsText !== undefined && { noResults: noResultsText }),
161
+ ...(loadingText !== undefined && { loading: loadingText }),
162
+ ...(emptyStateTitle !== undefined && { emptyTitle: emptyStateTitle }),
163
+ ...(emptyStateDescription !== undefined && { emptyDescription: emptyStateDescription }),
164
+ });
165
+
166
+ const isNative = $derived(presentation === "native");
167
+ /**
168
+ * False on the server and until the component has mounted: the native
169
+ * select is the control. True after: the trigger is. The two have the
170
+ * same box, so nothing moves when one replaces the other.
171
+ */
172
+ let mounted = $state(false);
173
+ /** Whether the native select is what is seen and pressed. */
174
+ const nativeShown = $derived(isNative || !mounted);
175
+
176
+ let nativeElement = $state<HTMLSelectElement | null>(null);
177
+ /** What the browser said was wrong with the hidden native select (`required`), in its own words. */
178
+ let nativeError = $state("");
179
+
180
+ const status = $derived(
181
+ fieldMessageState({ variant, message, hint, error: error || nativeError }),
182
+ );
183
+
72
184
  let isOpen = $state(false);
73
185
  let selectContainer: HTMLDivElement;
74
186
  let searchQuery = $state("");
75
187
 
76
- // Same fixed height scale as Button, IconButton and Input (32 / 40 / 48).
188
+ // Same fixed height scale as Button, IconButton and Input (32 / 40 / 48),
189
+ // and the same 16px text below `sm`, so a Select beside an Input matches it.
77
190
  const sizeClass = $derived(() => {
78
- if (size === "sm") return { box: "h-8 px-3", text: "text-sm" };
79
- if (size === "lg") return { box: "h-12 px-4", text: "text-base" };
80
- return { box: "h-10 px-3", text: "text-sm" };
191
+ // A minimum height, so a label that wraps makes the trigger taller
192
+ // instead of being cut. One line is exactly 32, 40 or 48px: the
193
+ // padding leaves room for the line and the border inside it, and is
194
+ // a step smaller below `sm`, where the text is 16px on a 24px line. `native` is the same box for the native
195
+ // select, which is always one line.
196
+ if (size === "sm") return { box: "min-h-8 px-3 py-1 max-sm:py-0 pointer-coarse:min-h-11", text: "text-sm max-sm:text-base", native: "h-8 ps-3 pe-9 pointer-coarse:h-11", chevron: "end-3" };
197
+ if (size === "lg") return { box: "min-h-12 px-4 py-2", text: "text-base", native: "h-12 ps-4 pe-10", chevron: "end-4" };
198
+ return { box: "min-h-10 px-3 py-2 max-sm:py-1 pointer-coarse:min-h-11", text: "text-sm max-sm:text-base", native: "h-10 ps-3 pe-9 pointer-coarse:h-11", chevron: "end-3" };
81
199
  });
82
200
 
83
201
  const variantClass = $derived(() => {
84
- return variant === "success"
202
+ return status.variant === "success"
85
203
  ? "border-success focus-visible:border-success"
86
- : variant === "warning"
204
+ : status.variant === "warning"
87
205
  ? "border-warning focus-visible:border-warning"
88
- : variant === "error"
206
+ : status.variant === "error"
89
207
  ? "border-error focus-visible:border-error"
90
- : "border-input-border";
208
+ : "border-input-border enabled:hover:border-input-border-hover";
91
209
  });
92
210
 
211
+ /** What the trigger and the visible native select share: the field's surface, edge and states. */
212
+ const fieldSurface =
213
+ "focus-ring w-full min-w-0 cursor-pointer rounded-control border bg-input text-body transition-colors duration-150 hover:bg-input-hover active:bg-input-active focus-visible:bg-input-focus focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-input-disabled disabled:text-action-disabled-text";
214
+
93
215
  const triggerClasses = $derived(() => {
94
216
  const sizeStyles = sizeClass();
95
- const baseClasses =
96
- "focus-ring flex w-full cursor-pointer items-center justify-between gap-2 rounded-control border bg-input text-body transition-colors duration-150 hover:bg-input-hover active:bg-input-focus focus-visible:bg-input-focus focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-input-disabled disabled:text-action-disabled-text";
217
+ const baseClasses = `${fieldSurface} flex items-center justify-between gap-2`;
97
218
 
98
- return cn(`${baseClasses} ${sizeStyles.box} ${sizeStyles.text} ${variantClass()}`);
219
+ return cn(
220
+ `${baseClasses} ${sizeStyles.box} ${sizeStyles.text} ${variantClass()}`,
221
+ // Until it has mounted the native select stands in its place.
222
+ !mounted && "hidden",
223
+ );
99
224
  });
100
225
 
101
- const labelClasses = $derived(
102
- () => "block text-sm font-medium text-label mb-2",
103
- );
104
-
105
- const messageClasses = $derived(() => {
106
- if (variant === "error") {
107
- return "text-error text-sm mt-1 flex items-center gap-2 w-full";
108
- } else if (variant === "success") {
109
- return "text-success text-sm mt-1 flex items-center gap-2 w-full";
110
- } else if (variant === "warning") {
111
- return "text-warning text-sm mt-1 flex items-center gap-2 w-full";
226
+ const nativeClasses = $derived.by(() => {
227
+ const sizeStyles = sizeClass();
228
+ if (!nativeShown) {
229
+ // Mounted, with a list of our own: still the form control, laid
230
+ // over the trigger so the browser's validation has a place to
231
+ // point at, but not seen, pressed or reached. `invisible`
232
+ // (`visibility: hidden`) is what takes it out of the tab order
233
+ // and the accessibility tree for certain; a form still sends and
234
+ // validates it.
235
+ return "pointer-events-none invisible absolute inset-0 h-full w-full";
112
236
  }
113
- return "text-description text-sm mt-1 flex items-center gap-2 w-full";
237
+ return cn(
238
+ `${fieldSurface} block appearance-none truncate ${sizeStyles.native} ${sizeStyles.text} ${variantClass()}`,
239
+ !disabled && isEmpty() && "text-input-placeholder",
240
+ );
114
241
  });
115
242
 
116
- const getIcon = $derived(() => {
117
- if (variant === "error") return AlertCircle;
118
- if (variant === "success") return CheckCircle;
119
- if (variant === "warning") return AlertTriangle;
120
- return null;
121
- });
243
+ const labelClasses = $derived(
244
+ () => "block text-sm font-medium text-label mb-2",
245
+ );
122
246
 
123
- const selectId = generateId("select");
247
+ const fallbackId = generateId("select");
248
+ const selectId = $derived(idProp ?? fallbackId);
124
249
 
125
250
  const isEmpty = $derived(() => {
126
251
  return value === undefined || value === null || value === "";
@@ -164,8 +289,14 @@
164
289
 
165
290
  function handleOptionClick(optionValue: string | number) {
166
291
  if (disabled || isLoading) return;
167
- value = optionValue;
292
+ if (options.find((option) => option.value === optionValue)?.disabled) return;
168
293
  isOpen = false;
294
+ choose(optionValue);
295
+ }
296
+
297
+ /** Sets the value and tells the app, as a change of a form control does. */
298
+ function choose(optionValue: string | number | undefined) {
299
+ value = optionValue;
169
300
 
170
301
  if (onchange) {
171
302
  const syntheticEvent = new Event("change", { bubbles: true });
@@ -177,6 +308,124 @@
177
308
  }
178
309
  }
179
310
 
311
+ /**
312
+ * The native select's value as one of the options' own: a number stays a
313
+ * number. The empty first option is "nothing chosen".
314
+ */
315
+ function fromNative(raw: string): string | number | undefined {
316
+ if (raw === "") return undefined;
317
+ return options.find((option) => String(option.value) === raw)?.value ?? raw;
318
+ }
319
+
320
+ /** The native select was changed: by the visitor, in `native`, or by the browser restoring a form. */
321
+ function handleNativeChange(event: Event) {
322
+ const raw = (event.currentTarget as HTMLSelectElement).value;
323
+ if (raw === (isEmpty() ? "" : String(value))) return;
324
+ choose(fromNative(raw));
325
+ }
326
+
327
+ /**
328
+ * The browser found the hidden native select invalid (`required`, and
329
+ * nothing chosen). Its own bubble would point at a control nobody can
330
+ * see, and focus would go into it: the message is shown under the field
331
+ * instead, in the browser's words, and focus goes to the trigger.
332
+ */
333
+ function handleNativeInvalid(event: Event) {
334
+ if (nativeShown) return;
335
+ event.preventDefault();
336
+ const element = event.currentTarget as HTMLSelectElement;
337
+ nativeError = element.validationMessage;
338
+ // The browser tells every invalid control, and would focus the first.
339
+ // So does this: each Select shows its message, and only the first
340
+ // invalid control of the form takes focus.
341
+ const controls = [...(element.form?.elements ?? [])] as (Element & Partial<HTMLSelectElement>)[];
342
+ const first = controls.find((control) => control.willValidate && control.validity?.valid === false);
343
+ if (!first || first === element) document.getElementById(selectId)?.focus();
344
+ }
345
+
346
+ $effect(() => {
347
+ // A choice answers it.
348
+ void value;
349
+ nativeError = "";
350
+ });
351
+
352
+ /**
353
+ * The trigger takes over from the native select. If the keyboard was in
354
+ * the native select, which now leaves the tab order, focus goes to the
355
+ * trigger and does not fall to the page.
356
+ */
357
+ function handOver() {
358
+ const hadFocus = !isNative && nativeElement !== null && document.activeElement === nativeElement;
359
+ mounted = true;
360
+ if (hadFocus) void tick().then(() => document.getElementById(selectId)?.focus());
361
+ }
362
+
363
+ /** The value the Select was rendered with: what a form reset goes back to. */
364
+ const initialValue = untrack(() => value);
365
+
366
+ /**
367
+ * The form around the Select was reset. The native select goes back to
368
+ * what its markup says by itself, which the component would not know of:
369
+ * the value is put back to the one it was rendered with, and that is
370
+ * reported once if it is a change. After the browser has done its own
371
+ * resetting, which is after the event.
372
+ */
373
+ function handleFormReset(event: Event) {
374
+ setTimeout(() => {
375
+ if (event.defaultPrevented) return;
376
+ const initiallyEmpty = initialValue === undefined || initialValue === null || initialValue === "";
377
+ const unchanged = initiallyEmpty ? isEmpty() : isSameValue(value, initialValue);
378
+ if (!unchanged) choose(initiallyEmpty ? undefined : initialValue);
379
+ void tick().then(() => {
380
+ if (nativeElement) nativeElement.value = initiallyEmpty ? "" : String(initialValue);
381
+ });
382
+ });
383
+ }
384
+
385
+ /** Whether the browser's own list of the native select is open, where the browser can say (`:open`). */
386
+ function nativePickerOpen(element: HTMLSelectElement): boolean {
387
+ try {
388
+ return element.matches(":open");
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ onMount(() => {
395
+ // A choice made in the native select before the scripts arrived is
396
+ // the visitor's: it becomes the value, and is reported once. Only a
397
+ // choice: a select nobody touched keeps the value it was given.
398
+ const before = nativeElement ? choiceBeforeMount(nativeElement) : undefined;
399
+ if (before !== undefined && before !== (isEmpty() ? "" : String(value))) {
400
+ choose(fromNative(before));
401
+ }
402
+
403
+ const element = nativeElement;
404
+ const form = element?.form ?? null;
405
+ form?.addEventListener("reset", handleFormReset);
406
+ const stopListening = () => form?.removeEventListener("reset", handleFormReset);
407
+
408
+ // Someone is choosing in the browser's own list right now: taking the
409
+ // select away would close it under their finger. They keep it until
410
+ // they have chosen or left, and the trigger takes over then.
411
+ if (!isNative && element && document.activeElement === element && nativePickerOpen(element)) {
412
+ const finish = () => {
413
+ element.removeEventListener("change", finish);
414
+ element.removeEventListener("blur", finish);
415
+ handOver();
416
+ };
417
+ element.addEventListener("change", finish);
418
+ element.addEventListener("blur", finish);
419
+ return () => {
420
+ element.removeEventListener("change", finish);
421
+ element.removeEventListener("blur", finish);
422
+ stopListening();
423
+ };
424
+ }
425
+ handOver();
426
+ return stopListening;
427
+ });
428
+
180
429
  function handleTriggerClick(event: MouseEvent) {
181
430
  if (disabled || isLoading) return;
182
431
  event.stopPropagation();
@@ -187,6 +436,10 @@
187
436
  if (!isOpen) return;
188
437
 
189
438
  const target = event.target as HTMLElement;
439
+ if (isInsideToastRegion(target)) return;
440
+ // The list in a sheet is in `<body>`, not in this container: a press
441
+ // in that sheet (an option, the grip, the search field) is not outside.
442
+ if (target.closest('[role="dialog"]')?.querySelector("[data-dropdown-sheet]")) return;
190
443
  const clickedSelectContainer = target.closest(".select-container");
191
444
 
192
445
  if (
@@ -206,47 +459,134 @@
206
459
 
207
460
  <svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
208
461
 
209
- <div bind:this={selectContainer} class={cn("w-full select-container", className)}>
462
+ <div bind:this={selectContainer} class={cn("w-full min-w-0 select-container", className)}>
210
463
  {#if label}
211
464
  <label for={selectId} class={labelClasses()}>{label}</label>
212
465
  {/if}
213
466
 
467
+ <div class="relative w-full min-w-0">
468
+ <!-- The native select. While it is the control it has the field's id, so
469
+ the label and the messages are its; once the trigger has taken over it is
470
+ hidden from assistive technology and out of the tab order, and only the
471
+ form talks to it. A disabled Select has always sent its value, so the
472
+ hidden one is not disabled; what is not sent is its `required`. -->
473
+ <select
474
+ bind:this={nativeElement}
475
+ id={nativeShown ? selectId : `${selectId}-native`}
476
+ name={name || undefined}
477
+ class={nativeClasses}
478
+ required={required && (nativeShown || !(disabled || isLoading))}
479
+ disabled={nativeShown && (disabled || isLoading)}
480
+ tabindex={nativeShown ? undefined : -1}
481
+ aria-hidden={nativeShown ? undefined : "true"}
482
+ aria-invalid={nativeShown && status.variant === "error" ? "true" : undefined}
483
+ aria-describedby={nativeShown ? fieldDescribedBy(selectId, status, describedBy) : undefined}
484
+ onchange={handleNativeChange}
485
+ oninvalid={handleNativeInvalid}
486
+ aria-label={nativeShown ? restProps["aria-label"] : undefined}
487
+ aria-labelledby={nativeShown ? restProps["aria-labelledby"] : undefined}
488
+ {...{ [NATIVE_SELECT_ATTRIBUTE]: "" }}
489
+ {...isNative ? (restProps as Record<string, unknown>) : {}}
490
+ >
491
+ {#if isEmpty()}
492
+ <option value="" selected>{isLoading ? text.loading : text.placeholder}</option>
493
+ {:else if !selectedOption}
494
+ <!-- The value has no option of its own: its options are still on
495
+ their way, or it is not among them. It is still the value, so the
496
+ select holds it and a form sends it; it is not offered in the list
497
+ where the browser lets an option be hidden. -->
498
+ <option value={String(value)} selected hidden>{isLoading ? text.loading : String(value)}</option>
499
+ {/if}
500
+ {#each options as option (option.value)}
501
+ <option
502
+ value={String(option.value)}
503
+ disabled={option.disabled}
504
+ selected={isSameValue(value, option.value)}
505
+ >
506
+ {option.label}
507
+ </option>
508
+ {/each}
509
+ </select>
510
+ {#if nativeShown}
511
+ <!-- `appearance-none` takes the browser's arrow away with its chrome. -->
512
+ <span
513
+ class={cn(
514
+ "pointer-events-none absolute top-1/2 me-px flex -translate-y-1/2 text-description",
515
+ sizeClass().chevron,
516
+ )}
517
+ aria-hidden="true"
518
+ >
519
+ <ChevronDown size={20} />
520
+ </span>
521
+ {/if}
522
+
523
+ {#if !isNative}
214
524
  <Dropdown
215
525
  bind:isOpen={isOpen}
216
526
  placement="bottom-start"
217
527
  selectedValue={value}
218
528
  onOptionClick={handleOptionClick}
219
- ariaLabel="Select options"
529
+ ariaLabel={strings?.listLabel || label || (restProps["aria-label"] as string | undefined) || text.listLabel}
530
+ ariaLabelledby={strings?.listLabel || label ? undefined : (restProps["aria-labelledby"] as string | undefined)}
220
531
  menuRole="listbox"
532
+ fullWidth
533
+ presentation={presentation === "native" ? "popover" : presentation}
534
+ sheetTitle={label || text.placeholder}
535
+ sheetCloseLabel={text.closeLabel}
536
+ sheetExpandLabel={text.expandLabel}
537
+ sheetCollapseLabel={text.collapseLabel}
538
+ sheetSnap={searchable && options.length > 6 ? "full" : "half"}
221
539
  >
222
540
  {#snippet trigger(aria)}
541
+ <!-- A select-only combobox, as the ARIA practices describe it:
542
+ a button could not say that it is invalid or required
543
+ (`aria-invalid` and `aria-required` are not a button's), so a
544
+ Select with an error was announced like one without. The keys,
545
+ `aria-haspopup="listbox"`, `aria-expanded` and `aria-controls` are
546
+ as they were. -->
223
547
  <button
224
548
  type="button"
225
- id={selectId}
549
+ id={mounted ? selectId : `${selectId}-trigger`}
226
550
  class={triggerClasses()}
227
551
  {disabled}
228
552
  onclick={handleTriggerClick}
553
+ {...restProps}
229
554
  {...aria}
230
- aria-describedby={message ? `${selectId}-message` : undefined}
555
+ role="combobox"
556
+ aria-invalid={status.variant === "error" ? "true" : undefined}
557
+ aria-required={required ? "true" : undefined}
558
+ aria-busy={isLoading ? "true" : undefined}
559
+ aria-describedby={fieldDescribedBy(selectId, status, describedBy)}
231
560
  >
561
+ <!-- An empty trigger shows its placeholder in the placeholder
562
+ colour, as Input does. A value has no colour class of its own,
563
+ and a disabled trigger none at all: the button's `text-body`
564
+ and its disabled colour are inherited. A colour class here
565
+ beat the button's `disabled:` one, and a disabled Select read
566
+ as enabled. -->
567
+ <!-- The label wraps: cut off, there was nowhere on the closed
568
+ control to read the rest. The chevron stays in the middle of
569
+ the box, however many lines there are. -->
232
570
  <span
233
- class="text-left flex-1 truncate {isLoading
234
- ? 'text-description'
235
- : isEmpty()
236
- ? 'text-description'
237
- : 'text-body'}"
571
+ class="min-w-0 flex-1 text-start [overflow-wrap:anywhere] {disabled
572
+ ? ''
573
+ : isLoading
574
+ ? 'text-description'
575
+ : isEmpty()
576
+ ? 'text-input-placeholder'
577
+ : ''}"
238
578
  >
239
579
  {#if isLoading}
240
- {loadingText}
580
+ {text.loading}
241
581
  {:else}
242
582
  {isEmpty()
243
- ? placeholder
244
- : selectedOption?.label || placeholder}
583
+ ? text.placeholder
584
+ : selectedOption?.label || text.placeholder}
245
585
  {/if}
246
586
  </span>
247
587
  <ChevronDown
248
588
  size={20}
249
- class="text-description transition-transform duration-200 {isOpen
589
+ class="shrink-0 text-description transition-transform duration-200 motion-reduce:transition-none {isOpen
250
590
  ? 'rotate-180'
251
591
  : ''}"
252
592
  />
@@ -255,30 +595,37 @@
255
595
  {#snippet header()}
256
596
  {#if searchable && !isLoading}
257
597
  <!-- Outside role="listbox": a textbox is not a valid listbox child. -->
258
- <div class="px-3 pb-2 pt-1" style:width={menuWidth}>
598
+ <!-- 0.5rem and the panel's 1px border from the corner, as the
599
+ options below and a Dropdown's items are: an 8px field 9px
600
+ inside the panel's 16px corner is concentric with it. -->
601
+ <div class="px-2 pb-2 pt-1" style:width={menuWidth} data-menu-width>
259
602
  <Input
260
603
  type="text"
261
604
  size="sm"
262
605
  class="min-w-0"
263
- placeholder={searchPlaceholder}
606
+ placeholder={text.searchPlaceholder}
264
607
  bind:value={searchQuery}
265
- aria-label={searchPlaceholder}
608
+ aria-label={text.searchPlaceholder}
266
609
  {disabled}
267
610
  />
268
611
  </div>
269
612
  {/if}
270
613
  {/snippet}
271
614
  {#snippet children()}
272
- <div class="px-2 pb-2 pt-1" style:width={menuWidth}>
615
+ <!-- `px-1` twice: the inner one is room for an option's focus
616
+ ring inside the scrolling box, and together with the panel's
617
+ border they put the options 9px from its corner. -->
618
+ <div class="px-1 pb-2 pt-1" style:width={menuWidth} data-menu-width>
273
619
  <div
274
620
  class="overflow-y-auto px-1"
621
+ data-menu-scroll
275
622
  style:max-height={maxMenuHeight}
276
623
  >
277
624
  {#if isLoading}
278
625
  <div class="space-y-2 px-2 py-2" role="status" aria-live="polite">
279
626
  <div class="h-8 w-full animate-pulse rounded-control bg-base-200"></div>
280
627
  <div class="h-8 w-full animate-pulse rounded-control bg-base-200"></div>
281
- <p class="text-xs text-description">{loadingText}</p>
628
+ <p class="text-xs text-description">{text.loading}</p>
282
629
  </div>
283
630
  {:else if filteredOptions().length > 0}
284
631
  {#each filteredOptions() as option (option.value)}
@@ -286,35 +633,44 @@
286
633
  "data-value": String(option.value),
287
634
  } as Record<string, string>}
288
635
  <div class="w-full my-1">
636
+ <!-- `aria-disabled`, not `disabled`: a natively disabled button cannot take focus, so the arrow keys stopped at the option before it. The click is refused in `handleOptionClick`. -->
289
637
  <button
290
638
  type="button"
291
639
  role="option"
292
- aria-selected={isSameValue(value, option.value)
293
- ? true
294
- : undefined}
295
- class="focus-ring flex w-full items-center justify-start rounded-control border-2 px-3 py-2 text-left text-sm font-medium transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 {isSameValue(
640
+ aria-selected={isSameValue(value, option.value)}
641
+ class="focus-ring flex w-full items-center justify-start gap-2 rounded-control border-2 px-3 py-2 pointer-coarse:py-3 text-start text-sm font-medium transition-colors focus:outline-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 {isSameValue(
296
642
  value,
297
643
  option.value,
298
644
  )
299
- ? 'border-action-primary bg-transparent text-headline'
300
- : 'border-transparent bg-transparent text-body hover:bg-surface-overlay-hover'}"
301
- disabled={option.disabled}
645
+ ? 'border-transparent bg-surface-overlay-hover text-headline active:bg-surface-active'
646
+ : option.disabled
647
+ ? 'border-transparent bg-transparent text-body'
648
+ : 'border-transparent bg-transparent text-body hover:bg-surface-overlay-hover active:bg-surface-active'}"
649
+ aria-disabled={option.disabled ? "true" : undefined}
302
650
  {...buttonRestProps}
303
651
  onclick={() =>
304
652
  handleOptionClick(option.value)}
305
653
  >
306
- {option.label}
654
+ <span class="min-w-0 flex-1">{option.label}</span>
655
+ <!-- The chosen option's mark: a check and a
656
+ fill. It used to be an edge in the action
657
+ colour, which is what the focus ring draws:
658
+ the chosen and the focused option could
659
+ not be told apart. -->
660
+ {#if isSameValue(value, option.value)}
661
+ <Check size={16} class="shrink-0" aria-hidden="true" />
662
+ {/if}
307
663
  </button>
308
664
  </div>
309
665
  {/each}
310
666
  {:else if hasSearchQuery()}
311
667
  <div class="px-3 py-2 text-sm text-description">
312
- {noResultsText}
668
+ {text.noResults}
313
669
  </div>
314
670
  {:else}
315
671
  <div class="rounded-control border border-border bg-surface-overlay-hover px-3 py-3 text-sm">
316
- <p class="font-medium text-headline">{emptyStateTitle}</p>
317
- <p class="mt-1 text-description">{emptyStateDescription}</p>
672
+ <p class="font-medium text-headline">{text.emptyTitle}</p>
673
+ <p class="mt-1 text-description">{text.emptyDescription}</p>
318
674
  {#if emptyStateActionLabel && onEmptyStateAction}
319
675
  <button
320
676
  type="button"
@@ -330,21 +686,7 @@
330
686
  </div>
331
687
  {/snippet}
332
688
  </Dropdown>
333
- {#if name}
334
- <input type="hidden" {name} value={isEmpty() ? "" : String(value)} />
335
- {/if}
336
- {#if message && variant !== "default"}
337
- <p
338
- id={`${selectId}-message`}
339
- class={messageClasses()}
340
- role={variant === "error" ? "alert" : "status"}
341
- aria-live={variant === "error" ? "assertive" : "polite"}
342
- >
343
- {#if getIcon()}
344
- {@const Icon = getIcon()}
345
- <Icon size={14} class="shrink-0" />
346
- {/if}
347
- <span>{message}</span>
348
- </p>
349
689
  {/if}
690
+ </div>
691
+ <FieldMessages fieldId={selectId} {status} {hint} gap="mt-1" messageClass="w-full" />
350
692
  </div>