@eifi1/ui-kit 0.4.1 → 0.5.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 (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +89 -19
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +74 -19
  63. package/dist/components/dropdown.js +72 -22
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +21 -7
  75. package/dist/components/file-dropzone.js +28 -7
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +21 -2
  105. package/dist/components/number-input.js +2 -1
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +25 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +5 -3
  176. package/dist/components/user-avatar.js +4 -2
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +14 -1
  270. package/dist/theme/palette-presets.js +231 -3
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +66 -13
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,258 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { ComponentPropsWithoutRef } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useAnnounce } from "../hooks/use-announce";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+
7
+ /**
8
+ * The bar, the word and the advisory checklist under a field where someone is
9
+ * CHOOSING a secret.
10
+ *
11
+ * Ported from Keksdose (`features/auth/password-strength-meter.tsx`), where one meter
12
+ * sits under the sign-in password and the encryption passphrase alike — a copy per
13
+ * form is what had let the passphrase dialogs ship with no meter at all. Three things
14
+ * changed on the way in:
15
+ *
16
+ * 1. **Tokens, not palette.** The four fills were red/amber/lime/emerald utilities;
17
+ * they are `--danger` → `--warning` → `--success` now, so a consumer re-pointing
18
+ * those families re-skins the meter with them.
19
+ * 2. **Not colour alone.** The level is always written out beside the bar, and each
20
+ * checklist item says "met" / "not met" to a screen reader — the ✓/○ glyphs are
21
+ * decoration and hidden.
22
+ * 3. **Announced.** A level change is spoken through a polite live region
23
+ * ({@link useAnnounce}); the bar itself is `aria-hidden`, because four coloured
24
+ * strips read out as four empty groups.
25
+ *
26
+ * Advisory only, as in Keksdose: nothing here blocks a submit. The caller decides what
27
+ * is required — {@link passwordRules} marks `length` as the one rule that usually is,
28
+ * so a form can gate on it with the same function the checklist draws from.
29
+ *
30
+ * Dependency-free on purpose. zxcvbn is ~400 kB of dictionaries, and neither consumer
31
+ * ships it; a host that does can pass its scorer as `score` and keep the rest.
32
+ */
33
+
34
+ /* ── The scorer ──────────────────────────────────────────────────────────── */
35
+
36
+ /** 0 = too short (or empty), 4 = strong. */
37
+ export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4;
38
+
39
+ export type PasswordRuleId = "length" | "case" | "digit" | "symbol";
40
+
41
+ export interface PasswordRule {
42
+ id: PasswordRuleId;
43
+ met: boolean;
44
+ /** Only `length` is required; the rest are suggestions. */
45
+ required: boolean;
46
+ }
47
+
48
+ export interface PasswordScoreOptions {
49
+ /** The length below which a secret is "too short", whatever else it has. Keep it
50
+ * in step with the server's own minimum. Default 8. */
51
+ minLength?: number;
52
+ }
53
+
54
+ const DEFAULT_MIN_LENGTH = 8;
55
+
56
+ /** The checklist's rules, evaluated. Pure; the order is the order they are shown in. */
57
+ export function passwordRules(value: string, { minLength = DEFAULT_MIN_LENGTH }: PasswordScoreOptions = {}): PasswordRule[] {
58
+ return [
59
+ // Code points, not UTF-16 units: an emoji is one character to the person typing it.
60
+ { id: "length", met: [...value].length >= minLength, required: true },
61
+ { id: "case", met: /\p{Ll}/u.test(value) && /\p{Lu}/u.test(value), required: false },
62
+ { id: "digit", met: /\p{Nd}/u.test(value), required: false },
63
+ { id: "symbol", met: /[^\p{L}\p{N}\s]/u.test(value), required: false },
64
+ ];
65
+ }
66
+
67
+ /**
68
+ * A 0–4 strength bucket. Length dominates, because it genuinely dominates real-world
69
+ * guessing cost: one step each at `minLength`, `minLength + 4` and `minLength + 8`,
70
+ * and one more for at least two of the three variety rules.
71
+ *
72
+ * One addition over Keksdose's curve: a secret with fewer than four DISTINCT
73
+ * characters caps at 1. Sixteen `a`s are long, and nothing about them is strong.
74
+ */
75
+ export function scorePassword(value: string, options: PasswordScoreOptions = {}): PasswordStrengthScore {
76
+ const minLength = options.minLength ?? DEFAULT_MIN_LENGTH;
77
+ const chars = [...value];
78
+ // Under the minimum is never anything but "too short", however varied.
79
+ if (chars.length === 0 || chars.length < minLength) return 0;
80
+ let score = 1;
81
+ if (chars.length >= minLength + 4) score += 1;
82
+ if (chars.length >= minLength + 8) score += 1;
83
+ const variety = passwordRules(value, { minLength }).filter((r) => !r.required && r.met).length;
84
+ if (variety >= 2) score += 1;
85
+ if (new Set(chars).size < 4) score = 1;
86
+ return Math.min(score, 4) as PasswordStrengthScore;
87
+ }
88
+
89
+ /** UTF-8 byte length — what a bcrypt-style ceiling counts. "ü" is two, an emoji four. */
90
+ export function passwordByteLength(value: string): number {
91
+ return new TextEncoder().encode(value).length;
92
+ }
93
+
94
+ /* ── Labels ──────────────────────────────────────────────────────────────── */
95
+
96
+ /** Every string the meter renders or speaks. */
97
+ export interface PasswordStrengthLabels {
98
+ /** The five level names, shown beside the bar. Score 0 … 4. */
99
+ tooShort: string;
100
+ weak: string;
101
+ fair: string;
102
+ good: string;
103
+ strong: string;
104
+ /** What the live region says when the level changes, given the level's name. */
105
+ announcement: (level: string) => string;
106
+ /** The length rule, given the minimum. */
107
+ ruleLength: (minLength: number) => string;
108
+ ruleCase: string;
109
+ ruleDigit: string;
110
+ ruleSymbol: string;
111
+ /** A suggestion rather than a requirement, given the rule's text. */
112
+ optional: (rule: string) => string;
113
+ /** Screen-reader-only state before each checklist item — the ✓/○ are hidden. */
114
+ met: string;
115
+ notMet: string;
116
+ /** Shown when the value is over `maxBytes`, given that ceiling. */
117
+ tooLong: (maxBytes: number) => string;
118
+ }
119
+
120
+ export const DEFAULT_PASSWORD_STRENGTH_LABELS: PasswordStrengthLabels = {
121
+ tooShort: "Too short",
122
+ weak: "Weak",
123
+ fair: "Fair",
124
+ good: "Good",
125
+ strong: "Strong",
126
+ announcement: (level) => `Password strength: ${level}`,
127
+ ruleLength: (minLength) => `At least ${minLength} characters`,
128
+ ruleCase: "Upper and lower case",
129
+ ruleDigit: "A number",
130
+ ruleSymbol: "A symbol",
131
+ optional: (rule) => `${rule} (optional)`,
132
+ met: "Met:",
133
+ notMet: "Not met:",
134
+ tooLong: (maxBytes) =>
135
+ `At most ${maxBytes} characters (accents and emoji count for more than one).`,
136
+ };
137
+
138
+ const LEVEL_KEYS = ["tooShort", "weak", "fair", "good", "strong"] as const;
139
+
140
+ /* ── The meter ───────────────────────────────────────────────────────────── */
141
+
142
+ // The filled segments' colour per score. "Good" sits between the warning and the
143
+ // success hue rather than borrowing either, so 3 and 4 differ in colour as well as in
144
+ // segment count. Index 0 is never drawn — a too-short secret fills no segment.
145
+ const FILL: Record<PasswordStrengthScore, string> = {
146
+ 0: "",
147
+ 1: "bg-[var(--danger)]",
148
+ 2: "bg-[var(--warning)]",
149
+ 3: "bg-[color-mix(in_oklab,var(--success)_60%,var(--warning))]",
150
+ 4: "bg-[var(--success)]",
151
+ };
152
+
153
+ export interface PasswordStrengthMeterProps extends ComponentPropsWithoutRef<"div"> {
154
+ /** The secret being chosen. Nothing visible renders while it is empty. */
155
+ value: string;
156
+ /** Replace the built-in scorer (e.g. with zxcvbn's `score`). Must be pure. */
157
+ score?: (value: string) => PasswordStrengthScore;
158
+ /** See {@link PasswordScoreOptions.minLength}. Drives the built-in scorer and the
159
+ * length rule's text. */
160
+ minLength?: number;
161
+ /** Show the checklist under the bar. Default `true`. */
162
+ showRequirements?: boolean;
163
+ /**
164
+ * A byte ceiling to warn about, if the secret has one — bcrypt reads at most 72
165
+ * BYTES. Leave it unset for a secret that has none (a passphrase stretched in the
166
+ * browser): inventing one talks people out of the long passphrase they chose.
167
+ */
168
+ maxBytes?: number;
169
+ /** User-facing strings; see {@link PasswordStrengthLabels}. */
170
+ labels?: Partial<PasswordStrengthLabels>;
171
+ }
172
+
173
+ /**
174
+ * Render directly under the password field. To have the field itself describe its
175
+ * strength, give this an `id` and point the input's `aria-describedby` at it.
176
+ */
177
+ export function PasswordStrengthMeter({
178
+ value,
179
+ score: scoreProp,
180
+ minLength = DEFAULT_MIN_LENGTH,
181
+ showRequirements = true,
182
+ maxBytes,
183
+ labels: labelsProp,
184
+ className,
185
+ ...rest
186
+ }: PasswordStrengthMeterProps) {
187
+ // Prop > <UiKitProvider> > English, like every labelled component in the kit.
188
+ const labels = useKitLabels("passwordStrength", DEFAULT_PASSWORD_STRENGTH_LABELS, labelsProp);
189
+ const { announce, regionProps } = useAnnounce();
190
+
191
+ const empty = value.length === 0;
192
+ const score = empty ? 0 : scoreProp ? scoreProp(value) : scorePassword(value, { minLength });
193
+ const level = labels[LEVEL_KEYS[score]];
194
+
195
+ // Speak on a CHANGE of level only — not on every keystroke, and not on mount (a
196
+ // prefilled field has not changed anything). Clearing the field says nothing: the
197
+ // user did that on purpose and the meter disappearing is the whole news.
198
+ const previous = useRef(score);
199
+ useEffect(() => {
200
+ if (previous.current === score) return;
201
+ previous.current = score;
202
+ if (!empty) announce(labels.announcement(level));
203
+ }, [score, empty, level, labels, announce]);
204
+
205
+ const rules = passwordRules(value, { minLength });
206
+ const ruleText: Record<PasswordRuleId, string> = {
207
+ length: labels.ruleLength(minLength),
208
+ case: labels.ruleCase,
209
+ digit: labels.ruleDigit,
210
+ symbol: labels.ruleSymbol,
211
+ };
212
+ const tooLong = maxBytes !== undefined && passwordByteLength(value) > maxBytes;
213
+
214
+ return (
215
+ <div {...rest} className={cn("mt-1.5 space-y-1", className)}>
216
+ {/* Rendered from the start and never unmounted — see useAnnounce. */}
217
+ <div {...regionProps} />
218
+ {!empty && (
219
+ <>
220
+ <div className="flex items-center gap-2">
221
+ <div className="flex flex-1 gap-1" aria-hidden="true" data-score={score}>
222
+ {[1, 2, 3, 4].map((step) => (
223
+ <div
224
+ key={step}
225
+ className={cn(
226
+ "h-1 flex-1 rounded-full",
227
+ step <= score ? FILL[score] : "bg-[var(--border)]",
228
+ )}
229
+ />
230
+ ))}
231
+ </div>
232
+ <p className="min-w-[4.5rem] text-end text-xs text-[var(--text-secondary)]">{level}</p>
233
+ </div>
234
+ {showRequirements && (
235
+ <ul className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
236
+ {rules.map((r) => (
237
+ <li
238
+ key={r.id}
239
+ // `relative`: the sr-only state needs a local containing block.
240
+ className={cn(
241
+ "relative",
242
+ r.met ? "text-[var(--success)]" : "text-[var(--text-muted)]",
243
+ )}
244
+ data-met={r.met}
245
+ >
246
+ <span className="sr-only">{r.met ? labels.met : labels.notMet} </span>
247
+ <span aria-hidden="true">{r.met ? "✓" : "○"}</span>{" "}
248
+ {r.required ? ruleText[r.id] : labels.optional(ruleText[r.id])}
249
+ </li>
250
+ ))}
251
+ </ul>
252
+ )}
253
+ {tooLong && <p className="text-xs text-[var(--danger)]">{labels.tooLong(maxBytes)}</p>}
254
+ </>
255
+ )}
256
+ </div>
257
+ );
258
+ }
@@ -0,0 +1,251 @@
1
+ import { useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { X } from "lucide-react";
4
+ import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from "react";
5
+ import { cn } from "../lib/cn";
6
+ import { DropdownSearchHeader } from "./dropdown";
7
+ import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
8
+ import { useOverlayHistory } from "../hooks/use-overlay-history";
9
+ import { useFocusTrap } from "../hooks/use-focus-trap";
10
+ import { useVisualViewport } from "../hooks/use-anchored-panel";
11
+ import { DEFAULT_PICKER_SHEET_LABELS, useKitLabels } from "../i18n/kit-labels";
12
+
13
+ /**
14
+ * Two of the div's own attributes are omitted because this component already owns
15
+ * the name: `title` here is the sheet's HEADING (and a ReactNode), not the browser's
16
+ * tooltip string, and `onClose` is "the sheet was dismissed", not the DOM's
17
+ * `<dialog>` close event. Everything else a caller can pass reaches the sheet.
18
+ */
19
+ export interface PickerSheetProps
20
+ extends Omit<ComponentPropsWithoutRef<"div">, "title" | "onClose"> {
21
+ open: boolean;
22
+ onClose: () => void;
23
+ /** The field's own label — a sheet that fills the screen has to say what it is
24
+ * asking for, which the anchored panel got for free by sitting under it. */
25
+ title?: ReactNode;
26
+ query: string;
27
+ onQueryChange: (value: string) => void;
28
+ searchPlaceholder?: string;
29
+ inputRef?: RefObject<HTMLInputElement | null>;
30
+ /** Default: `pickerSheet.close` from the {@link UiKitProvider}, else "Close". */
31
+ closeLabel?: string;
32
+ children: ReactNode;
33
+ }
34
+
35
+ /**
36
+ * The PHONE presentation of a picker: a full-screen dialog with a search box at
37
+ * the top and the list filling everything below it (Keksdose live #200).
38
+ *
39
+ * *"Paid as full screen dialog with input. Similar to the account select that
40
+ * already appears as full screen."* The account field is a native `<select>`, and
41
+ * a phone browser renders that as a full-screen list — so the app's own pickers,
42
+ * which are anchored dropdown panels, were the odd ones out: a 320px-tall panel
43
+ * squeezed between the field and the keyboard, showing three or four rows of a
44
+ * list that might have two hundred entries.
45
+ *
46
+ * What this fixes beyond size: the panel had to be *placed* (above/below, tracking
47
+ * the visual viewport as the keyboard opened — see `useAnchoredPanel`), and every
48
+ * one of those decisions is a chance to be wrong on a screen this small. A sheet
49
+ * has no placement. It also gives the search input somewhere unambiguous to live,
50
+ * which is the other half of the request: *"add filler possibility to the account
51
+ * select or even better right to the hoc the account select is derived from"* —
52
+ * so this lives in the shared picker, not in one form's copy of it.
53
+ *
54
+ * Rows are `min-h-11` (44px): the touch target the rest of the app uses. The list
55
+ * scrolls, the header does not, and the body behind it is scroll-locked so a drag
56
+ * that overshoots the list does not move the page underneath.
57
+ */
58
+ export function PickerSheet({
59
+ open,
60
+ onClose,
61
+ title,
62
+ query,
63
+ onQueryChange,
64
+ searchPlaceholder,
65
+ inputRef,
66
+ closeLabel,
67
+ children,
68
+ "aria-label": ariaLabel,
69
+ className,
70
+ style,
71
+ ...rest
72
+ }: PickerSheetProps) {
73
+ // Every picker in the kit opens one of these on a phone, and most of them pass
74
+ // their own `closeLabel` through as `undefined` — so this lookup is what makes the
75
+ // one way out of a full-screen sheet speak the app's language without each of
76
+ // them having to (see the prop's note in `EntityCombobox`).
77
+ const labels = useKitLabels("pickerSheet", DEFAULT_PICKER_SHEET_LABELS, { close: closeLabel });
78
+ useBodyScrollLock(open);
79
+ /**
80
+ * Back closes THE SHEET, not the dialog it was opened from (Keksdose live #309:
81
+ * *"Mouse Back does not only close the select but also the whole edit or create
82
+ * dialog which is cumbersome."*).
83
+ *
84
+ * The sheet is a full-screen overlay over whatever opened it, and on a phone that
85
+ * is almost always the row editor — which DOES push a history entry
86
+ * (`data-table.tsx`). So the sheet was invisible to Back: one press popped the
87
+ * editor's entry and took the whole dialog with it, discarding an edit in progress
88
+ * in order to dismiss a list.
89
+ *
90
+ * `useOverlayHistory` was built for exactly this nesting — its stack unwinds
91
+ * last-in-first-out, and the U-12 regression test in its own suite is written
92
+ * around "a Modal and a PickerSheet inside it". The sheet simply never called it.
93
+ *
94
+ * Nothing else has to change: the hook is a no-op while `open` is false, and it
95
+ * pops its own entry when the sheet closes some other way (the X, a row, the
96
+ * backdrop) so history does not accumulate husks.
97
+ */
98
+ useOverlayHistory(open, onClose);
99
+ // The visible region, while the sheet is up — see the `style` below for why a
100
+ // full-screen sheet cannot simply be `inset-0` on a phone (live #328).
101
+ const vv = useVisualViewport(open);
102
+ // A fallback ref so a caller that does not need the handle still gets the opening
103
+ // focus, rather than the behaviour depending on whether a prop was passed. What that
104
+ // focus is FOR is on the trap below.
105
+ const ownRef = useRef<HTMLInputElement | null>(null);
106
+ const searchRef = inputRef ?? ownRef;
107
+
108
+ const sheetRef = useRef<HTMLDivElement>(null);
109
+ /**
110
+ * The sheet said `aria-modal="true"` and then left focus on the field behind it, so
111
+ * a screen reader was told to hide the page the user's cursor was still standing in
112
+ * (the audit's *"Only Modal manages focus"*). Through the shared hook rather than
113
+ * the bare `.focus()` that used to live here: the hook re-reads the tabbable list on
114
+ * every Tab, which this panel needs more than most — its list is filtered as you
115
+ * type, so the rows a trap captured at open time are gone by the second keystroke.
116
+ *
117
+ * **`initialFocus` is the search box**, not the container, and this is the one place
118
+ * in the package that overrides `Modal`'s rule. The sheet exists in order to be typed
119
+ * into (live #212: *"make the text input filter active on select click"*), and the
120
+ * callers cannot do it themselves — `Combobox`'s field runs
121
+ * `setOpen(true); sheetInputRef.current?.focus()` in a single `onFocus`, so the focus
122
+ * call happens while this sheet is still unmounted and the ref is null. Only the
123
+ * sheet knows when its own input exists. Popping the keyboard is the POINT here,
124
+ * where in a form dialog it is the cost.
125
+ *
126
+ * **`restoreFocus` is off**, which is the uncomfortable half. Every caller in this
127
+ * kit opens the sheet from the field's own `onFocus` handler, so handing focus back
128
+ * to that field on close re-opens the sheet the user has just dismissed — a row tap
129
+ * would select a value and immediately put the list back over it. The field that
130
+ * opened it is the only thing that knows whether re-focusing it means "open"; a sheet
131
+ * that cannot tell must not guess. The cost is that focus lands on `<body>` after a
132
+ * dismissal, which on the phone this shape is for costs nothing, and on a pointer
133
+ * device is what happened before this change too.
134
+ */
135
+ useFocusTrap(sheetRef, {
136
+ active: open,
137
+ restoreFocus: false,
138
+ initialFocus: () => searchRef.current,
139
+ });
140
+
141
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
142
+ if (e.key !== "Escape") return;
143
+ // The sheet is almost always the INNERMOST overlay — it opens from a field inside a
144
+ // `Modal` or a `FullBleedDialog`, both of which take Escape on their own panel. A
145
+ // React portal's events still travel up the React tree, so without stopping it here
146
+ // one press would dismiss this list and the dialog underneath with it: Keksdose
147
+ // live #309 (*"Mouse Back does not only close the select but also the whole edit or
148
+ // create dialog which is cumbersome"*), which the Back gesture already learned, on
149
+ // the key a desktop user reaches for instead.
150
+ e.stopPropagation();
151
+ onClose();
152
+ };
153
+
154
+ if (!open || typeof document === "undefined") return null;
155
+ return createPortal(
156
+ <div
157
+ // Spread FIRST, and every attribute below it deliberately wins: `style`,
158
+ // `onMouseDown` and `onKeyDown` here are not styling choices, they are live
159
+ // #328, dev#477 and live #309 respectively, and a caller who passed one by
160
+ // accident would re-open a bug three apps have already paid for. `style` is
161
+ // MERGED rather than won outright (see below), which is the one case where a
162
+ // caller has something to add.
163
+ {...rest}
164
+ ref={sheetRef}
165
+ role="dialog"
166
+ aria-modal="true"
167
+ // Without it the container cannot take focus, and `useFocusTrap` has nothing to
168
+ // fall back to when the list is empty and the search box is the only thing in
169
+ // here — see the hook's "nothing tabbable" branch.
170
+ tabIndex={-1}
171
+ onKeyDown={handleKeyDown}
172
+ // The caller's own name wins over the heading: a sheet titled "Account" in a
173
+ // form with two of them needs to say WHICH, and `title` is what the user reads
174
+ // rather than what a reader announces.
175
+ aria-label={ariaLabel ?? (typeof title === "string" ? title : undefined)}
176
+ // The sheet is PORTALLED to <body>, so it is not inside the field's own
177
+ // wrapper — and `useDropdown` closes on any document mousedown landing
178
+ // outside that wrapper. Tapping a row therefore unmounted the row (open →
179
+ // false) before its CLICK could fire, so the tap selected nothing at all and
180
+ // the sheet just vanished (Keksdose dev#477).
181
+ //
182
+ // It bit whichever sheet commits on `onClick` — the payee field's, since live
183
+ // #200, where it hid behind the free-text value the search box was already
184
+ // setting; and the account field's the moment dev#477 moved it onto the inline
185
+ // picker. `ComboboxPanel`'s rows happen to commit on `onMouseDown`, so they
186
+ // beat the document listener and were never affected — which is luck, not a
187
+ // design, and exactly why the fix belongs to the sheet.
188
+ //
189
+ // Stopping the mousedown here rather than teaching every caller about the
190
+ // portal: the sheet is modal and full-screen, so "a click in here is not an
191
+ // outside click" is a property of the sheet, not of whoever opened it.
192
+ onMouseDown={(e) => e.stopPropagation()}
193
+ // ⚠️ `inset-0` was the whole of Keksdose live #328: *"When the keyboard overlays
194
+ // the entries I cannot scroll past them … to see also the last entries. Happens
195
+ // when browsing the account select."*
196
+ //
197
+ // This sheet FOCUSES ITS OWN SEARCH BOX on open (see above), so the keyboard is
198
+ // up every single time it is used on a phone. On Android that shrinks the visual
199
+ // viewport and leaves the LAYOUT viewport alone — `inset-0` is the layout
200
+ // viewport — so the sheet kept its full height with its bottom third behind the
201
+ // keyboard. The list below is `flex-1 overflow-y-auto`, so it sized itself to
202
+ // that hidden height too: it scrolled to ITS end while the last rows were still
203
+ // under the keys, and no gesture could bring them out. Nothing was clipped and
204
+ // nothing looked broken, which is why it reads as "I cannot scroll past them".
205
+ //
206
+ // `visualViewport` is the API that answers rather than hints (the live #292
207
+ // lesson): it reports the region actually on screen, keyboard and pinch-pan
208
+ // included, and it fires on the keyboard opening and closing. Pinned to that box,
209
+ // the sheet ends where the keys begin and the list's own scroll covers the rest.
210
+ // `useVisualViewport` returns null when there is nothing to correct, and then
211
+ // this is character-for-character the sheet that was here before.
212
+ // A caller's `style` is kept and the correction laid OVER it: the three values
213
+ // below are the whole of live #328, so they are not negotiable, but a z-index
214
+ // or a transition from outside has nothing to do with them. Without a visual
215
+ // viewport to correct for this is character-for-character what the caller passed.
216
+ style={vv ? { ...style, top: vv.top, height: vv.height, bottom: "auto" } : style}
217
+ className={cn("fixed inset-0 z-50 flex flex-col bg-[var(--bg-surface)] outline-none", className)}
218
+ >
219
+ <div className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2">
220
+ <span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--text-primary)]">
221
+ {title}
222
+ </span>
223
+ <button
224
+ type="button"
225
+ aria-label={labels.close}
226
+ onClick={onClose}
227
+ className="rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
228
+ >
229
+ <X className="size-5" />
230
+ </button>
231
+ </div>
232
+ <DropdownSearchHeader
233
+ query={query}
234
+ onQueryChange={onQueryChange}
235
+ inputRef={searchRef}
236
+ placeholder={searchPlaceholder}
237
+ />
238
+ {/* min-h-0 so the LIST scrolls rather than the dialog growing past the
239
+ viewport — a flex child defaults to min-height:auto. */}
240
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">{children}</div>
241
+ </div>,
242
+ document.body,
243
+ );
244
+ }
245
+
246
+ /** A row inside a {@link PickerSheet} — the phone-sized version of a dropdown row,
247
+ * so a list is comfortable to hit with a thumb rather than merely legible. */
248
+ export const SHEET_ROW_CLASS = cn(
249
+ "flex min-h-11 w-full items-center gap-2 px-4 py-2 text-left text-base",
250
+ "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
251
+ );