@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,1026 @@
1
+ import { Fragment, useId, useMemo, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, RefObject } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { ChevronDown, X } from "lucide-react";
5
+ import { FieldLabel, FIELD_BASE, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
6
+ import { cn } from "../lib/cn";
7
+ import { useDropdown } from "./dropdown";
8
+ import { useAnchoredPanel } from "../hooks/use-anchored-panel";
9
+ import { useMediaQuery } from "../hooks/use-media-query";
10
+ import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
11
+ import { useActiveOptionScroll, type ComboOption } from "./combobox-core";
12
+ import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
13
+
14
+ // One look for both combobox flavors below — the suggestion list and its rows
15
+ // must stay pixel-identical between the free-text and the id-keyed variant.
16
+ const LIST_CLASS =
17
+ "max-h-64 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--bg-surface)] py-1 shadow-lg";
18
+
19
+ /**
20
+ * "This focus came from a mouse button that is not the left one" — for the two
21
+ * fields below, which open their list ON FOCUS.
22
+ *
23
+ * Keksdose live #309 rework: *"When clicking the mouse back button now while hovering
24
+ * one of the selects it opens the select as long as I am holding the button down and
25
+ * closes select after releasing."* A mouse's BACK button focuses whatever it is
26
+ * pressed over, exactly as the left one does — the browser only reserves the
27
+ * NAVIGATION for itself — so a field that opens on focus opened a list for a gesture
28
+ * that means "go back", and the release then navigated out from under it. Measured on
29
+ * his own budget: `mousePressed button=back` over the transfer form's account field
30
+ * left `aria-expanded=true`, and the release took the whole add card with it.
31
+ *
32
+ * The press is recorded and read by the focus that the SAME press causes: focus is
33
+ * mousedown's default action, dispatched inside the same task, so the flag is always
34
+ * read before the `setTimeout` below can drop it. And it is always dropped — a press
35
+ * that focuses nothing (the pointer was over a disabled field, the button was
36
+ * released elsewhere) must not leave a latch that swallows the next Tab.
37
+ *
38
+ * Only the OPEN is suppressed, never the navigation: Back still goes back, which is
39
+ * the whole of what the button was pressed for.
40
+ */
41
+ function usePrimaryPressOnly() {
42
+ const auxPress = useRef(false);
43
+ return {
44
+ onMouseDown: (e: { button: number }) => {
45
+ if (e.button === 0) return;
46
+ auxPress.current = true;
47
+ setTimeout(() => {
48
+ auxPress.current = false;
49
+ }, 0);
50
+ },
51
+ /** True while handling the focus a non-primary press just caused. */
52
+ fromAuxButton: () => auxPress.current,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The desktop suggestion list, PORTALLED and anchored to the field.
58
+ *
59
+ * It used to be an `absolute` `<ul>` inside the field's own wrapper, and that is
60
+ * Keksdose live #295: *"Category select inside the list is not readable. Some sort
61
+ * of z indexes issue?"*. It was not z-index — a stacking context can be out-ranked,
62
+ * but `overflow` cannot be argued with. The receipt's line table sits in a card
63
+ * carrying `overflow-clip`, so a list opened from the last visible row was cut off
64
+ * at the card's edge: measured at 256px tall with 150px of it painted.
65
+ *
66
+ * Every other panel in this package already learned this — `DropdownPanel`'s
67
+ * `anchorRef` form, the calculator popover, the tooltip — so this reuses the same
68
+ * hook rather than inventing a second placement. What that buys beyond the clip:
69
+ * the list flips above the field when there is no room below, and it caps its own
70
+ * height against the VISIBLE viewport, which on a phone means the on-screen
71
+ * keyboard (see {@link useAnchoredPanel}).
72
+ *
73
+ * `panelRef` is not optional plumbing. Portalled, the list is no longer a
74
+ * descendant of the wrapper, so {@link useDropdown}'s outside-click test answers
75
+ * "outside" for a click on the list itself — the first option a user picked would
76
+ * close the dropdown having picked nothing.
77
+ */
78
+ function SuggestionList({
79
+ id,
80
+ anchorRef,
81
+ panelRef,
82
+ children,
83
+ }: {
84
+ /** What the field's `aria-controls` names. */
85
+ id: string;
86
+ anchorRef: RefObject<HTMLElement | null>;
87
+ panelRef: RefObject<HTMLDivElement | null>;
88
+ children: ReactNode;
89
+ }) {
90
+ const { rect, top, maxHeight } = useAnchoredPanel(anchorRef, true, { preferredHeight: 256 });
91
+ if (!rect) return null;
92
+ return createPortal(
93
+ <div
94
+ ref={panelRef}
95
+ // z-50, not the old z-30: the list is a child of <body> now, so it is
96
+ // competing with the app's own overlays rather than with its own siblings.
97
+ className="fixed z-50"
98
+ style={{ top, left: rect.left, width: rect.width }}
99
+ >
100
+ <ul id={id} role="listbox" className={LIST_CLASS} style={{ maxHeight }}>
101
+ {children}
102
+ </ul>
103
+ </div>,
104
+ document.body,
105
+ );
106
+ }
107
+
108
+ const rowClass = (isActive: boolean) =>
109
+ cn(
110
+ "block w-full truncate px-3 py-1.5 text-left text-sm text-[var(--text-primary)]",
111
+ isActive ? "bg-[var(--bg-active)]" : "hover:bg-[var(--bg-hover)]",
112
+ );
113
+
114
+ /**
115
+ * Four of the div's own attributes are omitted because this component already owns
116
+ * the name, with a different meaning: `id` is the INPUT's (a caller labels or
117
+ * automates the field, not the box around it), and `onChange`/`onBlur`/`onSubmit`
118
+ * are the field's — a value, a departure, and "I mean what I typed" — rather than
119
+ * the DOM events of the wrapper. Everything else a wrapper needs reaches the root.
120
+ */
121
+ export interface ComboboxProps
122
+ extends Omit<ComponentPropsWithoutRef<"div">, "id" | "onChange" | "onBlur" | "onSubmit"> {
123
+ value: string;
124
+ onChange: (v: string) => void;
125
+ /** Suggestion pool (e.g. existing payee names). */
126
+ options: string[];
127
+ label?: string;
128
+ id?: string;
129
+ placeholder?: string;
130
+ className?: string;
131
+ /** Required and unanswered — see {@link Input}'s `invalid`. Set on the `<input>`
132
+ * itself rather than on the wrapper, which is what let Keksdose's account picker
133
+ * delete the `[&_input]:…` copy of {@link FIELD_INVALID} it had been carrying
134
+ * because this component had no `invalid` of its own. */
135
+ invalid?: boolean;
136
+ /** Heading an option belongs under. Supplying it makes this list read exactly
137
+ * like {@link InlineEntityCombobox}'s — one heading per group with its rows
138
+ * indented beneath — instead of a flat list (feedback #136 rework: the payee
139
+ * field sat next to the newly-grouped category field and no longer matched).
140
+ * Omit for a plain list. */
141
+ groupBy?: (option: string) => string;
142
+ /**
143
+ * Something to show at the far end of an option's row — a badge saying what the
144
+ * option IS, as distinct from what it is called.
145
+ *
146
+ * Keksdose's category-name field is the case: its proposals carry per-locale
147
+ * names, so picking one stores a category that follows the UI language, and
148
+ * picking a look-alike custom name does not. That difference is invisible in the
149
+ * label and decides what the row DOES, so the row has to show it. Returning
150
+ * `null` for an option renders nothing and costs no layout.
151
+ *
152
+ * Not rendered in the create row, which by definition names nothing in the pool.
153
+ */
154
+ optionAdornment?: (option: string) => ReactNode;
155
+ /** Focus on mount, the way `<input autoFocus>` does — and, through `onFocus`, open
156
+ * the list with it. A click-the-value inline editor needs it: the cell the user
157
+ * clicked names the field the caret should land in. {@link InlineEntityCombobox}
158
+ * has had it since live #218. */
159
+ autoFocus?: boolean;
160
+ /** Focus genuinely LEFT the field. Not fired by picking a row — the rows suppress
161
+ * `mousedown`, so the input never blurs — which is what makes it usable as a
162
+ * "close the inline editor" signal. */
163
+ onBlur?: () => void;
164
+ /** Enter pressed with no row highlighted, i.e. "I mean what I typed". The list
165
+ * closes either way; this is for a caller whose Enter also submits a row editor. */
166
+ onSubmit?: () => void;
167
+ /** Rows to offer when the field is empty. 8 on a dropdown, where that is all
168
+ * that fits; the phone sheet asks for more because it has a screen. */
169
+ maxSuggestions?: number;
170
+ searchPlaceholder?: string;
171
+ closeLabel?: string;
172
+ /**
173
+ * Label for the row that COMMITS a value the list does not contain — e.g.
174
+ * `(v) => \`Add "${v}" as payee\``. Supplying it turns the free text into
175
+ * something you confirm rather than something you leave behind.
176
+ *
177
+ * Keksdose live #212: *"For the payee input have an apply button, when input is
178
+ * placed like 'add xxx as payee' to confirm. Now it can be put in and closed with
179
+ * the right top X, which seems not intuitive."* On the phone sheet the search box
180
+ * IS the value, so typing a brand-new name and dismissing the sheet did commit it —
181
+ * but the only control on offer was the close X, which reads as "discard". A named
182
+ * affordance says what the typing did.
183
+ *
184
+ * Omit and the row is not rendered, which is the right default for a list whose
185
+ * values are all supposed to already exist.
186
+ */
187
+ createLabel?: (value: string) => string;
188
+ }
189
+
190
+ /**
191
+ * Free-text combobox that looks like the shared {@link Select} (same FIELD_BASE
192
+ * styling, chevron and static floating label) but lets the user type a value
193
+ * that isn't in the list — e.g. naming a brand-new payee. Picking from the
194
+ * filtered suggestion list fills the value; typing keeps whatever was entered.
195
+ *
196
+ * Use this instead of an `<Input list="…">` + `<datalist>` so the control is
197
+ * visually and behaviourally consistent with the other dropdowns (feedback
198
+ * #230 — the payee field looked/behaved differently from every other select).
199
+ */
200
+ export function Combobox({
201
+ value,
202
+ onChange,
203
+ options,
204
+ label,
205
+ id,
206
+ placeholder,
207
+ className,
208
+ groupBy,
209
+ maxSuggestions,
210
+ searchPlaceholder,
211
+ closeLabel,
212
+ createLabel,
213
+ invalid,
214
+ optionAdornment,
215
+ autoFocus,
216
+ onBlur,
217
+ onSubmit,
218
+ "aria-label": ariaLabel,
219
+ ...rest
220
+ }: ComboboxProps) {
221
+ const generated = useId();
222
+ const fieldId = id ?? generated;
223
+ // Derived from the GENERATED id, never from `id`: a caller's id is theirs to
224
+ // collide with, and `aria-controls` has to resolve.
225
+ const listboxId = `${generated}-listbox`;
226
+ const optionId = (index: number) => `${generated}-option-${index}`;
227
+ // `backCloses` is the desktop half of live #309: the phone's list IS a
228
+ // {@link PickerSheet}, which registers its own history entry, so registering a
229
+ // second one here would cost two Back presses to dismiss one sheet.
230
+ const isPhone = useMediaQuery(PHONE_QUERY, false);
231
+ const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone });
232
+ const primaryOnly = usePrimaryPressOnly();
233
+ const [active, setActive] = useState(-1);
234
+ // A phone opens the list as a full-screen sheet with its own input, the way a
235
+ // native <select> does — live #200: "Paid as full screen dialog with input.
236
+ // Similar to the account select that already appears as full screen." The
237
+ // anchored list stays for pointer devices, where it is the better shape.
238
+ const sheetInputRef = useRef<HTMLInputElement | null>(null);
239
+ // The box the portalled list hangs off — the inner wrapper, which hugs the input,
240
+ // not the outer one (a grid item that can be taller than the field).
241
+ const fieldRef = useRef<HTMLDivElement>(null);
242
+
243
+ /**
244
+ * Is the text in the field a QUERY, or just what was picked last time?
245
+ *
246
+ * Keksdose dev#549, filed against the category-name field: *"Currently it acts kind
247
+ * of a filter and clicking again does not open anything since the previous text is
248
+ * still there likely filtering all other options out."* A combobox whose filter is
249
+ * its own value can only ever re-offer the answer it already has — after picking
250
+ * "REWE" the list reopens holding one row, the one you are looking at, and changing
251
+ * your mind means clearing the field by hand first.
252
+ *
253
+ * So the text filters only while it is being TYPED; opening the list, by focus or
254
+ * by clicking the field again, puts the whole pool back. {@link InlineEntityCombobox}
255
+ * below has had this since it was written (see its `typedQuery`) and the app's
256
+ * category-name field grew its own copy to get it — this one, the payee field, was
257
+ * the one left with the bug.
258
+ *
259
+ * Not derived from "does the text exactly match an option": a custom value that
260
+ * happens to collide with one would then behave differently from every other, and
261
+ * what is being tracked is what the user just did, which is not a property of the
262
+ * string.
263
+ */
264
+ const [typing, setTyping] = useState(false);
265
+
266
+ const query = typing ? value.trim().toLowerCase() : "";
267
+ const matches = useMemo(() => {
268
+ const seen = new Set<string>();
269
+ const uniq = options.filter((o) => o && !seen.has(o) && seen.add(o));
270
+ let ranked: string[];
271
+ const cap = maxSuggestions ?? (isPhone ? 50 : 8);
272
+ if (!query) {
273
+ ranked = uniq.slice(0, cap);
274
+ } else {
275
+ // Prefix matches first, then substring matches — most relevant on top.
276
+ const starts = uniq.filter((o) => o.toLowerCase().startsWith(query));
277
+ const contains = uniq.filter(
278
+ (o) => !o.toLowerCase().startsWith(query) && o.toLowerCase().includes(query),
279
+ );
280
+ ranked = [...starts, ...contains].slice(0, cap);
281
+ }
282
+ if (!groupBy) return ranked;
283
+ // Keep each group contiguous so a heading appears once instead of every time
284
+ // the ranking interleaves two groups — while preserving "best match first":
285
+ // a Map keeps insertion order, so groups come out ordered by their best-ranked
286
+ // member and members keep their rank order inside the group.
287
+ const byGroup = new Map<string, string[]>();
288
+ for (const o of ranked) {
289
+ const key = groupBy(o);
290
+ if (!byGroup.has(key)) byGroup.set(key, []);
291
+ byGroup.get(key)!.push(o);
292
+ }
293
+ return [...byGroup.values()].flat();
294
+ }, [options, query, groupBy, maxSuggestions, isPhone]);
295
+
296
+ /**
297
+ * Which rows OPEN a group, and with what heading — computed once for both lists.
298
+ *
299
+ * The desktop list had this as `matches[i - 1]`, which is free; the sheet had it as
300
+ * `matches[matches.indexOf(o) - 1]`, which is a scan of the list per row and another
301
+ * per heading test. That is quadratic in the sheet's own cap of 50, on the branch
302
+ * that runs on the slowest hardware we ship to — and it answers the wrong question
303
+ * besides: `indexOf` finds the FIRST row with that string, so a duplicated option
304
+ * would have compared against a neighbour it is not next to. (`matches` de-duplicates
305
+ * today; that is a property of the memo above, not of the row being rendered.)
306
+ */
307
+ const groupHeadings = useMemo(() => {
308
+ if (!groupBy) return null;
309
+ const heads = new Map<number, string>();
310
+ let prev: string | undefined;
311
+ matches.forEach((o, i) => {
312
+ const group = groupBy(o);
313
+ if (group != null && group !== prev) heads.set(i, group);
314
+ prev = group;
315
+ });
316
+ return heads;
317
+ }, [matches, groupBy]);
318
+
319
+ const commit = (v: string) => {
320
+ onChange(v);
321
+ setOpen(false);
322
+ setActive(-1);
323
+ // What is in the field is now an ANSWER, not a query — so reopening offers the
324
+ // whole pool again rather than a one-row filter of the value just chosen.
325
+ setTyping(false);
326
+ };
327
+
328
+ // Offered only when the typed text is genuinely NOT in the pool: an exact
329
+ // (case-insensitive) match is an existing entry the list is already showing, and a
330
+ // second way to pick it would be noise. Compared against `options`, not `matches` —
331
+ // `matches` is capped, so a pool of 300 payees would otherwise offer to "add" one
332
+ // that exists but fell off the end of the list.
333
+ // What the field claims the keyboard is on. Only on a pointer device: the phone
334
+ // renders its rows in a {@link PickerSheet} whose own search box owns focus, and a
335
+ // field pointing at an id that is not there is worse than one pointing nowhere.
336
+ const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;
337
+ useActiveOptionScroll(activeId);
338
+
339
+ const typed = value.trim();
340
+ const createRow =
341
+ createLabel && typed.length > 0 && !options.some((o) => o.toLowerCase() === typed.toLowerCase())
342
+ ? createLabel(typed)
343
+ : null;
344
+
345
+ return (
346
+ // `rest` dresses the outer box — a `data-tour` anchor, a test id, a form-level
347
+ // `aria-describedby`. Not the NAME: that belongs on the <input> below, which is
348
+ // the combobox a reader meets. Spread FIRST, so the field's ARIA and the
349
+ // handlers carrying live #309 and dev#549 cannot be replaced from outside.
350
+ <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
351
+ {label !== undefined && <FieldLabel>{label}</FieldLabel>}
352
+ {/* The chevron centers against this inner wrapper, which hugs the input.
353
+ The outer div can be taller than the input (as a grid item it
354
+ stretches to the row height, e.g. next to the editor's category cell
355
+ with its split button), which used to drag a top-1/2 chevron down to
356
+ the input's bottom edge (feedback #248). */}
357
+ <div ref={fieldRef} className="relative">
358
+ <input
359
+ id={fieldId}
360
+ value={value}
361
+ placeholder={placeholder}
362
+ // The visual label is a floating <span>, not a <label for>, so fall back to
363
+ // it for the accessible name — otherwise the field announces only what is
364
+ // typed in it (dev#477, applied to all three entity fields of a transaction
365
+ // form at once).
366
+ aria-label={ariaLabel ?? label}
367
+ role="combobox"
368
+ aria-expanded={open}
369
+ // The list this field is the mouth of. Required by the role, and the half
370
+ // that was missing: the field said it was expanded and never said what it
371
+ // had expanded, so a reader had no way from the box to the options
372
+ // (ESLint's `role-has-required-aria-props`, the audit's §a11y).
373
+ aria-controls={listboxId}
374
+ aria-activedescendant={activeId}
375
+ aria-autocomplete="list"
376
+ aria-invalid={invalid || undefined}
377
+ autoComplete="off"
378
+ autoFocus={autoFocus}
379
+ onBlur={onBlur}
380
+ onMouseDown={primaryOnly.onMouseDown}
381
+ onFocus={() => {
382
+ // A back/forward mouse button focuses this field on its way to
383
+ // navigating; it is not a request to open anything (live #309 rework).
384
+ if (primaryOnly.fromAuxButton()) return;
385
+ setOpen(true);
386
+ setTyping(false);
387
+ // The sheet carries its own input, so the field behind it must not also
388
+ // pull up the keyboard and scroll the page under the dialog.
389
+ if (isPhone) sheetInputRef.current?.focus();
390
+ }}
391
+ // A CLICK as well as focus — the other half of dev#549. Picking a suggestion
392
+ // closes the list without moving focus (the rows suppress `mousedown` on
393
+ // purpose, so the input never blurred), which means clicking the field again
394
+ // fires no `focus` event at all and the list stayed shut. "Does not open
395
+ // anything" was literally true.
396
+ onClick={() => {
397
+ setOpen(true);
398
+ setTyping(false);
399
+ }}
400
+ // `inputMode="none"` rather than readOnly: the field must not look
401
+ // uneditable (FIELD_BASE greys a read-only field since dev#468) and must
402
+ // still take focus — it just has no keyboard of its own, the same trick
403
+ // the amount field uses for the numpad.
404
+ inputMode={isPhone ? "none" : undefined}
405
+ onChange={(e) => {
406
+ onChange(e.target.value);
407
+ setOpen(true);
408
+ setActive(-1);
409
+ setTyping(true);
410
+ }}
411
+ onKeyDown={(e) => {
412
+ if (e.key === "ArrowDown") {
413
+ e.preventDefault();
414
+ setOpen(true);
415
+ setActive((i) => Math.min(i + 1, matches.length - 1));
416
+ } else if (e.key === "ArrowUp") {
417
+ e.preventDefault();
418
+ setActive((i) => Math.max(i - 1, 0));
419
+ } else if (e.key === "Enter") {
420
+ if (open && active >= 0 && active < matches.length) {
421
+ e.preventDefault();
422
+ commit(matches[active]);
423
+ } else {
424
+ // No row highlighted: the typed text is the answer. `preventDefault`
425
+ // only when a caller is taking Enter, so a plain form submit is
426
+ // otherwise left alone.
427
+ if (onSubmit) e.preventDefault();
428
+ setOpen(false);
429
+ onSubmit?.();
430
+ }
431
+ } else if (e.key === "Escape") {
432
+ setOpen(false);
433
+ setActive(-1);
434
+ } else if (e.key === "Tab") {
435
+ // Closes, and lets the browser take the Tab: focus is in THIS input,
436
+ // which is staying, so there is nothing to catch — unlike the pickers
437
+ // whose focus sits inside a portalled panel.
438
+ setOpen(false);
439
+ setActive(-1);
440
+ }
441
+ // Home/End are deliberately absent. The APG gives them to the list only
442
+ // where the combobox is not editable; here the text IS the value, a
443
+ // payee name is long enough to want the caret moved to its start, and
444
+ // the desktop list is capped at 8 rows — so jumping it would be worth
445
+ // almost nothing and would cost the one gesture that field is used with.
446
+ }}
447
+ className={cn(FIELD_BASE, label !== undefined && FIELD_FLOATING_PAD, "pr-9", invalid && FIELD_INVALID)}
448
+ />
449
+ <ChevronDown
450
+ aria-hidden
451
+ onMouseDown={(e) => {
452
+ // Toggle on the chevron without stealing focus from the input.
453
+ e.preventDefault();
454
+ setOpen((o) => !o);
455
+ }}
456
+ className="absolute right-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer text-[var(--text-placeholder)]"
457
+ />
458
+ </div>
459
+ {isPhone && (
460
+ <PickerSheet
461
+ open={open}
462
+ onClose={() => setOpen(false)}
463
+ title={label}
464
+ // The sheet's input IS the field: this is a free-text control, so what is
465
+ // typed here is the value (a brand-new payee is just a name nothing
466
+ // matches), and the list below narrows as it changes.
467
+ query={value}
468
+ onQueryChange={(v) => {
469
+ onChange(v);
470
+ setTyping(true);
471
+ }}
472
+ searchPlaceholder={searchPlaceholder ?? placeholder}
473
+ inputRef={sheetInputRef}
474
+ closeLabel={closeLabel}
475
+ >
476
+ <ul id={listboxId} role="listbox">
477
+ {createRow && (
478
+ // `role="option"` rides the BUTTON, not the <li> around it: an option
479
+ // may not contain a separately focusable control, and the button is
480
+ // what a pointer presses. Same shape `CommandPalette` has always had.
481
+ <li role="presentation">
482
+ <button
483
+ type="button"
484
+ role="option"
485
+ aria-selected={false}
486
+ onClick={() => commit(typed)}
487
+ className={cn(SHEET_ROW_CLASS, "font-medium text-[var(--brand)]")}
488
+ >
489
+ {createRow}
490
+ </button>
491
+ </li>
492
+ )}
493
+ {matches.map((o, i) => {
494
+ const group = groupHeadings?.get(i);
495
+ return (
496
+ <Fragment key={o}>
497
+ {group !== undefined && (
498
+ <li
499
+ role="presentation"
500
+ className="px-4 pb-0.5 pt-3 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]"
501
+ >
502
+ {group}
503
+ </li>
504
+ )}
505
+ <li role="presentation">
506
+ <button
507
+ type="button"
508
+ role="option"
509
+ aria-selected={o === value}
510
+ onClick={() => commit(o)}
511
+ className={cn(
512
+ SHEET_ROW_CLASS,
513
+ optionAdornment && "flex items-center justify-between gap-2",
514
+ )}
515
+ >
516
+ {optionAdornment ? <span className="truncate">{o}</span> : o}
517
+ {optionAdornment?.(o)}
518
+ </button>
519
+ </li>
520
+ </Fragment>
521
+ );
522
+ })}
523
+ </ul>
524
+ </PickerSheet>
525
+ )}
526
+ {!isPhone && open && (matches.length > 0 || createRow) && (
527
+ <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
528
+ {createRow && (
529
+ <li role="presentation">
530
+ <button
531
+ type="button"
532
+ role="option"
533
+ aria-selected={false}
534
+ tabIndex={-1}
535
+ onMouseDown={(e) => {
536
+ // mousedown, like the rows below: the input's blur would otherwise
537
+ // close the list before the click landed.
538
+ e.preventDefault();
539
+ commit(typed);
540
+ }}
541
+ className={cn(rowClass(false), "font-medium text-[var(--brand)]")}
542
+ >
543
+ {createRow}
544
+ </button>
545
+ </li>
546
+ )}
547
+ {matches.map((o, i) => {
548
+ // Heading at each group boundary only — `matches` is group-contiguous, so
549
+ // the boundaries are a property of the list rather than of the branch
550
+ // drawing it. See `groupHeadings`.
551
+ const group = groupHeadings?.get(i);
552
+ return (
553
+ <Fragment key={o}>
554
+ {group !== undefined && (
555
+ <li
556
+ role="presentation"
557
+ className="px-3 pb-0.5 pt-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] first:pt-1"
558
+ >
559
+ {group}
560
+ </li>
561
+ )}
562
+ <li role="presentation">
563
+ <button
564
+ type="button"
565
+ id={optionId(i)}
566
+ role="option"
567
+ // CHOSEN, not highlighted. The row the arrows are on is named by
568
+ // the field's `aria-activedescendant`; saying "selected" for it
569
+ // would tell a reader that the row they are passing over is
570
+ // already the answer.
571
+ aria-selected={o === value}
572
+ // Focus stays in the field — that is what
573
+ // `aria-activedescendant` is for — so the rows are not tab stops.
574
+ tabIndex={-1}
575
+ onMouseDown={(e) => {
576
+ // mousedown (not click) so the blur from the input firing first
577
+ // doesn't close the list before the selection registers.
578
+ e.preventDefault();
579
+ commit(o);
580
+ }}
581
+ onMouseEnter={() => setActive(i)}
582
+ className={cn(
583
+ rowClass(i === active),
584
+ group != null && "pl-6",
585
+ // A row with a badge is a flex row so the label truncates and the
586
+ // badge keeps its width; without one it stays the plain block the
587
+ // other lists render, so nothing shifts for callers that pass none.
588
+ optionAdornment && "flex items-center justify-between gap-2",
589
+ )}
590
+ >
591
+ {optionAdornment ? <span className="truncate">{o}</span> : o}
592
+ {optionAdornment?.(o)}
593
+ </button>
594
+ </li>
595
+ </Fragment>
596
+ );
597
+ })}
598
+ </SuggestionList>
599
+ )}
600
+ </div>
601
+ );
602
+ }
603
+
604
+ /** `id` is the INPUT's and `onChange` is the field's — see {@link ComboboxProps}
605
+ * for why the div's spellings of them are omitted. */
606
+ export interface InlineEntityComboboxProps<V extends string | number>
607
+ extends Omit<ComponentPropsWithoutRef<"div">, "id" | "onChange"> {
608
+ /** Selected option id, or null when nothing is selected. */
609
+ value: V | null;
610
+ /** Required and unanswered — see {@link Combobox}'s `invalid`. */
611
+ invalid?: boolean;
612
+ /** A picked/typed option emits its id; emptying the field emits `null`. */
613
+ onChange: (v: V | null) => void;
614
+ options: ComboOption<V>[];
615
+ label?: string;
616
+ id?: string;
617
+ placeholder?: string;
618
+ className?: string;
619
+ disabled?: boolean;
620
+ /** Focus on mount, the way `<input autoFocus>` does — and, through `onFocus`
621
+ * below, open the list with it. A click-the-value editor needs it: the cell
622
+ * the user clicked names the field the editor should land the caret in, and
623
+ * without a passthrough this component's fixed prop list puts the `<input>`
624
+ * out of a caller's reach entirely (Keksdose feedback live #218). */
625
+ autoFocus?: boolean;
626
+ /** Phone sheet only — the anchored list has the field itself to type in. Falls
627
+ * back to `placeholder`, then to `combobox.search` from the {@link UiKitProvider}. */
628
+ searchPlaceholder?: string;
629
+ /** Phone sheet only: a full screen showing nothing has to say why. The anchored
630
+ * list simply does not open. Default: `combobox.noResults` from the provider. */
631
+ emptyLabel?: string;
632
+ closeLabel?: string;
633
+ /** Offer a clear "×" in place of the chevron whenever something is selected.
634
+ *
635
+ * Emptying the text already clears (see {@link reconcile}), and on a desktop that
636
+ * is the fast way. It is not a way at all on a PHONE: touching the field opens the
637
+ * full-screen sheet, which covers the very input the text would have been deleted
638
+ * from — so a field that had been answered could not be UNanswered by any gesture
639
+ * the screen offered (Keksdose live #236, filed from a phone). The "×" is the one
640
+ * affordance both shells share, and it clears without opening anything. */
641
+ clearable?: boolean;
642
+ clearLabel?: string;
643
+ }
644
+
645
+ /**
646
+ * The id-keyed sibling of {@link Combobox}: identical anatomy (a real text input
647
+ * with the shared field styling, chevron and inline as-you-type filtering) but
648
+ * the value is an option id, not free text. Built so an entity picker can sit
649
+ * next to a free-text combobox without any visible difference — a button-trigger
650
+ * panel picker ({@link EntityCombobox}) never matches an input field exactly.
651
+ *
652
+ * Text-vs-value reconciliation: the input's text is transient. Picking a row or
653
+ * typing an exact (unique) label commits that option; emptying the text commits
654
+ * a clear; anything else reverts to the selected option's label on blur/Escape.
655
+ *
656
+ * **On a phone the list opens as a full-screen sheet**, exactly like {@link Combobox}
657
+ * beside it (Keksdose live #200, extended by dev#477 — the account picker moved onto
658
+ * this component and must not lose its sheet). The sheet carries its OWN query state
659
+ * rather than reusing `text`: `text` is what {@link reconcile} judges on blur, so a
660
+ * sheet that emptied it to show the full list would read as "the user cleared the
661
+ * field" the moment it closed.
662
+ */
663
+ export function InlineEntityCombobox<V extends string | number>({
664
+ value,
665
+ onChange,
666
+ options,
667
+ label,
668
+ id,
669
+ placeholder,
670
+ className,
671
+ disabled,
672
+ autoFocus,
673
+ searchPlaceholder,
674
+ emptyLabel,
675
+ closeLabel,
676
+ clearable,
677
+ clearLabel,
678
+ invalid,
679
+ "aria-label": ariaLabel,
680
+ ...rest
681
+ }: InlineEntityComboboxProps<V>) {
682
+ const generated = useId();
683
+ const fieldId = id ?? generated;
684
+ // See the twin above on why these hang off the generated id.
685
+ const listboxId = `${generated}-listbox`;
686
+ const optionId = (index: number) => `${generated}-option-${index}`;
687
+ const isPhone = useMediaQuery(PHONE_QUERY, false);
688
+ // See the twin above: the phone sheet owns its own Back entry (live #309).
689
+ const { open, setOpen, wrapperRef, panelRef } = useDropdown({ backCloses: !isPhone });
690
+ const primaryOnly = usePrimaryPressOnly();
691
+ const [active, setActive] = useState(-1);
692
+ // null = not editing → the input shows the selected option's label.
693
+ const [text, setText] = useState<string | null>(null);
694
+ const sheetInputRef = useRef<HTMLInputElement | null>(null);
695
+ // The box the portalled list hangs off — see {@link SuggestionList}.
696
+ const fieldRef = useRef<HTMLDivElement>(null);
697
+ // The sheet's search box. Starts empty on every open, so a field that already
698
+ // holds a value still offers the whole list — the shape a native <select> has
699
+ // on a phone, and what the anchored panel gets from `query` below.
700
+ const [sheetQuery, setSheetQuery] = useState("");
701
+ // Props first, provider second — see `EntityCombobox`. `emptyLabel` used to render
702
+ // nothing when omitted, which left a phone user staring at a blank full-screen
703
+ // sheet; now an omitted one says "No results" in the provider's language.
704
+ const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
705
+ search: searchPlaceholder,
706
+ noResults: emptyLabel,
707
+ clear: clearLabel,
708
+ });
709
+
710
+ const selected = useMemo(
711
+ () => (value == null ? null : (options.find((o) => o.value === value) ?? null)),
712
+ [options, value],
713
+ );
714
+ const shown = text ?? selected?.label ?? "";
715
+ // Nothing selected has nothing to clear, and the chevron comes back — the field
716
+ // keeps exactly one trailing control, so the "×" never crowds the value it sits on.
717
+ const showClear = Boolean(clearable && value != null && !disabled);
718
+
719
+ // Focusing select-alls the current label; filtering only kicks in once the
720
+ // text actually differs from it, so an already-filled field still opens on
721
+ // the FULL list instead of a single-row "filter" of its own value.
722
+ const typedQuery =
723
+ text !== null && text !== (selected?.label ?? "") ? text.trim().toLowerCase() : "";
724
+ const query = isPhone ? sheetQuery.trim().toLowerCase() : typedQuery;
725
+ const matches = useMemo(() => {
726
+ let ranked = options;
727
+ if (query) {
728
+ const hit = (s: string | undefined) => s?.toLowerCase().includes(query) ?? false;
729
+ const starts = options.filter((o) => o.label.toLowerCase().startsWith(query));
730
+ const rest = options.filter(
731
+ (o) =>
732
+ !o.label.toLowerCase().startsWith(query) &&
733
+ (hit(o.label) || hit(o.sublabel) || hit(o.group)),
734
+ );
735
+ ranked = [...starts, ...rest];
736
+ }
737
+ if (!ranked.some((o) => o.group)) return ranked;
738
+ // Keep each group contiguous so the headings below appear once instead of
739
+ // re-appearing every time the ranking interleaves two groups — while preserving
740
+ // "best match first": a Map keeps insertion order, so groups come out ordered by
741
+ // their best-ranked member and members keep their rank order inside the group.
742
+ const blocks = new Map<string, ComboOption<V>[]>();
743
+ for (const o of ranked) {
744
+ const key = o.group ?? "";
745
+ if (!blocks.has(key)) blocks.set(key, []);
746
+ blocks.get(key)!.push(o);
747
+ }
748
+ return [...blocks.values()].flat();
749
+ }, [options, query]);
750
+
751
+ // Pointer-device only: the phone's rows live in a {@link PickerSheet} whose own
752
+ // search box holds focus, so the field behind it must not claim to be pointing at
753
+ // one of them.
754
+ const activeId = !isPhone && active >= 0 && active < matches.length ? optionId(active) : undefined;
755
+ useActiveOptionScroll(activeId);
756
+
757
+ const close = () => {
758
+ setOpen(false);
759
+ setActive(-1);
760
+ setSheetQuery("");
761
+ };
762
+ const commit = (o: ComboOption<V>) => {
763
+ if (o.value !== value) onChange(o.value);
764
+ setText(null);
765
+ close();
766
+ };
767
+ /** Turn loose text into a decision: empty clears, an exact label match that names
768
+ * ONE entity commits, anything else reverts to the selected label.
769
+ *
770
+ * Unique by VALUE, not by row. An option may deliberately appear twice — Keksdose
771
+ * repeats recently-used categories in a "Recent" group at the top (live #203) — and
772
+ * counting rows made every such option uncommittable: two hits, so nothing fired and
773
+ * the field reverted to whatever was selected before. Typing a category you had just
774
+ * used, then tabbing away, silently discarded it, and the more categories you used
775
+ * the more of them stopped working. Two rows naming the same id are not an ambiguity;
776
+ * two ids sharing a label are. */
777
+ const reconcile = () => {
778
+ if (text !== null) {
779
+ const q = text.trim();
780
+ if (!q) {
781
+ if (value != null) onChange(null);
782
+ } else {
783
+ const hits = options.filter((o) => o.label.toLowerCase() === q.toLowerCase());
784
+ const ids = new Set(hits.map((h) => h.value));
785
+ if (ids.size === 1 && hits[0].value !== value) onChange(hits[0].value);
786
+ }
787
+ setText(null);
788
+ }
789
+ close();
790
+ };
791
+
792
+ return (
793
+ // `rest` dresses the outer box — a `data-tour` anchor, a test id, a form-level
794
+ // `aria-describedby`. Not the NAME: that belongs on the <input> below, which is
795
+ // the combobox a reader meets. Spread FIRST, so the field's ARIA and the
796
+ // handlers carrying live #309 and dev#549 cannot be replaced from outside.
797
+ <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
798
+ {label !== undefined && <FieldLabel>{label}</FieldLabel>}
799
+ {/* Inner wrapper for chevron centering — same reasoning as Combobox above.
800
+ It is also what the portalled list anchors to. */}
801
+ <div ref={fieldRef} className="relative">
802
+ <input
803
+ id={fieldId}
804
+ value={shown}
805
+ placeholder={placeholder}
806
+ // The visual label is a floating <span>, not a <label for>, so without this
807
+ // the field has NO accessible name — it announces its value and nothing
808
+ // else. Just the label, never "label: value" the way a trigger button has
809
+ // to compose it: an input already exposes its value separately.
810
+ aria-label={ariaLabel ?? (typeof label === "string" ? label : undefined)}
811
+ role="combobox"
812
+ aria-expanded={open}
813
+ // Required by the role, and the half that was missing: the field said it
814
+ // was expanded and never said what it had expanded (ESLint's
815
+ // `role-has-required-aria-props`, the audit's §a11y).
816
+ aria-controls={listboxId}
817
+ aria-activedescendant={activeId}
818
+ aria-autocomplete="list"
819
+ aria-invalid={invalid || undefined}
820
+ autoComplete="off"
821
+ disabled={disabled}
822
+ autoFocus={autoFocus}
823
+ // `inputMode="none"` rather than readOnly, for the same reason Combobox
824
+ // above gives: the sheet carries the keyboard, and a readOnly field would
825
+ // take FIELD_BASE's settled look on a field that is perfectly editable.
826
+ inputMode={isPhone ? "none" : undefined}
827
+ onMouseDown={primaryOnly.onMouseDown}
828
+ onFocus={(e) => {
829
+ // See {@link usePrimaryPressOnly}: a back/forward button lands here on
830
+ // its way to navigating, and neither the list nor the select-all is
831
+ // anything it asked for (live #309 rework).
832
+ if (primaryOnly.fromAuxButton()) return;
833
+ setText(shown);
834
+ e.currentTarget.select();
835
+ setOpen(true);
836
+ // The sheet has its own input; the field behind it must not also pull up
837
+ // the keyboard and scroll the page under the dialog.
838
+ if (isPhone) sheetInputRef.current?.focus();
839
+ }}
840
+ onBlur={() => {
841
+ // On a phone the blur is the SHEET taking focus, not the user leaving the
842
+ // field — reconciling there would close the sheet the instant it opened.
843
+ if (!isPhone) reconcile();
844
+ }}
845
+ onChange={(e) => {
846
+ setText(e.target.value);
847
+ setOpen(true);
848
+ setActive(-1);
849
+ }}
850
+ onKeyDown={(e) => {
851
+ if (e.key === "ArrowDown") {
852
+ e.preventDefault();
853
+ setOpen(true);
854
+ setActive((i) => Math.min(i + 1, matches.length - 1));
855
+ } else if (e.key === "ArrowUp") {
856
+ e.preventDefault();
857
+ setActive((i) => Math.max(i - 1, 0));
858
+ } else if (e.key === "Enter") {
859
+ if (open && active >= 0 && active < matches.length) {
860
+ e.preventDefault();
861
+ commit(matches[active]);
862
+ } else {
863
+ reconcile();
864
+ }
865
+ } else if (e.key === "Escape") {
866
+ setText(null);
867
+ close();
868
+ } else if (e.key === "Tab") {
869
+ // Focus is in THIS input and stays there, so the browser's own Tab is
870
+ // left alone; all that is needed is that the list stop covering what
871
+ // the user is tabbing to. `reconcile` rather than `close`, because
872
+ // leaving the field is exactly when loose text has to be judged.
873
+ reconcile();
874
+ }
875
+ // Home/End stay with the caret — see the note in {@link Combobox}: this
876
+ // field is editable, and its text is what `reconcile` judges.
877
+ }}
878
+ className={cn(FIELD_BASE, label !== undefined && FIELD_FLOATING_PAD, "pr-9", invalid && FIELD_INVALID)}
879
+ />
880
+ {showClear ? (
881
+ <button
882
+ type="button"
883
+ // Out of the tab order, like the clear on `EntityCombobox`: the keyboard
884
+ // already clears this field by selecting its text and deleting, and a
885
+ // second stop between every picker and the next field is a worse trade
886
+ // than the one gesture it saves.
887
+ tabIndex={-1}
888
+ aria-label={labels.clear}
889
+ // preventDefault, exactly as the chevron does: without it the press
890
+ // focuses the input, which on a phone opens the sheet over the field the
891
+ // press was clearing.
892
+ onMouseDown={(e) => e.preventDefault()}
893
+ onClick={() => {
894
+ setText(null);
895
+ close();
896
+ onChange(null);
897
+ }}
898
+ className={cn(
899
+ "absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-[var(--text-placeholder)]",
900
+ "hover:text-[var(--text-secondary)]",
901
+ )}
902
+ >
903
+ <X aria-hidden className="size-4" />
904
+ </button>
905
+ ) : (
906
+ <ChevronDown
907
+ aria-hidden
908
+ onMouseDown={(e) => {
909
+ // Toggle on the chevron without stealing focus from the input.
910
+ e.preventDefault();
911
+ setOpen((o) => !o);
912
+ }}
913
+ className={cn(
914
+ "absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--text-placeholder)]",
915
+ disabled ? "opacity-50" : "cursor-pointer",
916
+ )}
917
+ />
918
+ )}
919
+ </div>
920
+ {isPhone && (
921
+ <PickerSheet
922
+ open={open}
923
+ // Closing without choosing keeps the value: `text` was never emptied, so
924
+ // reconcile has nothing to undo — it just puts the label back.
925
+ onClose={reconcile}
926
+ title={label}
927
+ query={sheetQuery}
928
+ onQueryChange={(v) => {
929
+ setSheetQuery(v);
930
+ setActive(-1);
931
+ }}
932
+ searchPlaceholder={searchPlaceholder ?? placeholder ?? labels.search}
933
+ inputRef={sheetInputRef}
934
+ closeLabel={closeLabel}
935
+ >
936
+ <ul id={listboxId} role="listbox">
937
+ {matches.map((o, i) => (
938
+ // Keyed by group AND value, like combobox-core.tsx: an option may
939
+ // deliberately appear twice (see `reconcile`), and a bare value key
940
+ // would collide.
941
+ <Fragment key={`${o.group ?? ""}|${String(o.value)}`}>
942
+ {o.group && o.group !== matches[i - 1]?.group && (
943
+ <li
944
+ role="presentation"
945
+ className="px-4 pb-0.5 pt-3 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]"
946
+ >
947
+ {o.group}
948
+ </li>
949
+ )}
950
+ <li role="presentation">
951
+ <button
952
+ type="button"
953
+ role="option"
954
+ aria-selected={o.value === value}
955
+ onClick={() => commit(o)}
956
+ className={cn(SHEET_ROW_CLASS, o.value === value && "font-medium")}
957
+ >
958
+ {o.label}
959
+ </button>
960
+ </li>
961
+ </Fragment>
962
+ ))}
963
+ {matches.length === 0 && (
964
+ <li className="px-4 py-3 text-sm text-[var(--text-muted)]">{labels.noResults}</li>
965
+ )}
966
+ </ul>
967
+ </PickerSheet>
968
+ )}
969
+ {!isPhone && open && matches.length > 0 && (
970
+ <SuggestionList id={listboxId} anchorRef={fieldRef} panelRef={panelRef}>
971
+ {matches.map((o, i) => (
972
+ // A group heading is emitted at each group boundary rather than repeating
973
+ // the group on every row (feedback #136). `matches` is group-contiguous,
974
+ // so comparing with the previous row is enough. Fragment key sits here;
975
+ // the heading and the option carry their own list semantics — keyed by
976
+ // group AND value, because an option may deliberately appear twice (see
977
+ // `reconcile`).
978
+ <Fragment key={`${o.group ?? ""}|${String(o.value)}`}>
979
+ {o.group && o.group !== matches[i - 1]?.group && (
980
+ <li
981
+ role="presentation"
982
+ className="px-3 pb-0.5 pt-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] first:pt-1"
983
+ >
984
+ {o.group}
985
+ </li>
986
+ )}
987
+ <li role="presentation">
988
+ <button
989
+ type="button"
990
+ id={optionId(i)}
991
+ // See {@link Combobox}: the role rides the button, `aria-selected`
992
+ // is the CHOSEN row, and the keyboard's row is the field's
993
+ // `aria-activedescendant`.
994
+ role="option"
995
+ aria-selected={o.value === value}
996
+ tabIndex={-1}
997
+ onMouseDown={(e) => {
998
+ // mousedown (not click) so the input's blur can't close the
999
+ // list before the selection registers.
1000
+ e.preventDefault();
1001
+ commit(o);
1002
+ }}
1003
+ onMouseEnter={() => setActive(i)}
1004
+ className={cn(
1005
+ rowClass(i === active),
1006
+ // Indented under its heading, so the hierarchy is readable at a
1007
+ // glance instead of inferred from grey trailing text.
1008
+ o.group && "pl-6",
1009
+ o.value === value && "font-medium",
1010
+ )}
1011
+ >
1012
+ {o.label}
1013
+ {o.sublabel && (
1014
+ <span className="ml-2 text-xs text-[var(--text-placeholder)]">
1015
+ {o.sublabel}
1016
+ </span>
1017
+ )}
1018
+ </button>
1019
+ </li>
1020
+ </Fragment>
1021
+ ))}
1022
+ </SuggestionList>
1023
+ )}
1024
+ </div>
1025
+ );
1026
+ }