@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,540 @@
1
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type {
4
+ ComponentPropsWithoutRef,
5
+ Dispatch,
6
+ KeyboardEvent as ReactKeyboardEvent,
7
+ ReactNode,
8
+ RefObject,
9
+ SetStateAction,
10
+ } from "react";
11
+ import { Check, Plus } from "lucide-react";
12
+ import { cn } from "../lib/cn";
13
+ import { DropdownSearchHeader } from "./dropdown";
14
+ import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
15
+ import { useMediaQuery } from "../hooks/use-media-query";
16
+ import { PHONE_QUERY } from "./ui";
17
+ import { type AnchorRect } from "../hooks/use-anchored-rect";
18
+ import { useAnchoredPanel, type AnchoredPanel } from "../hooks/use-anchored-panel";
19
+ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
20
+ import { DEFAULT_COMBOBOX_LABELS, useKitLabels } from "../i18n/kit-labels";
21
+
22
+ export interface ComboOption<V extends string | number> {
23
+ value: V;
24
+ label: string;
25
+ sublabel?: string;
26
+ icon?: ReactNode;
27
+ /**
28
+ * Section this option belongs to. Where a consumer renders it as a heading with the
29
+ * members indented beneath — {@link InlineEntityCombobox} does — this is a genuine
30
+ * grouping, not decoration: the name appears once per section rather than trailing
31
+ * every row in small grey type, which is barely legible on a phone (feedback #136).
32
+ * Searchable like `sublabel`.
33
+ */
34
+ group?: string;
35
+ }
36
+
37
+ export interface ComboboxCoreOptions<V extends string | number> {
38
+ /** Already-loaded options (client-side filtered), also used to resolve labels. */
39
+ options?: ComboOption<V>[];
40
+ /** Async option source, debounced and race-safe; stale responses are ignored. */
41
+ loadOptions?: (query: string) => Promise<ComboOption<V>[]>;
42
+ /** External loading flag, OR-ed with the internal async state. */
43
+ loading?: boolean;
44
+ }
45
+
46
+ export interface ComboboxCore<V extends string | number> {
47
+ triggerRef: RefObject<HTMLButtonElement | null>;
48
+ panelRef: RefObject<HTMLDivElement | null>;
49
+ inputRef: RefObject<HTMLInputElement | null>;
50
+ open: boolean;
51
+ setOpen: Dispatch<SetStateAction<boolean>>;
52
+ query: string;
53
+ setQuery: Dispatch<SetStateAction<string>>;
54
+ active: number;
55
+ setActive: Dispatch<SetStateAction<number>>;
56
+ results: ComboOption<V>[];
57
+ busy: boolean;
58
+ rect: AnchorRect | null;
59
+ /** Where the dropdown goes, clamped to the visible viewport (feedback #135). */
60
+ placement: AnchoredPanel;
61
+ cacheRef: RefObject<Map<V, ComboOption<V>>>;
62
+ resolve: (v: V) => ComboOption<V> | null;
63
+ /** Close and hand focus back to the trigger — the keyboard paths only. See
64
+ * {@link useDropdown} for why a pointer dismissal must not do this. */
65
+ closeToTrigger: () => void;
66
+ }
67
+
68
+ /**
69
+ * Shared plumbing for the entity pickers: open/query/active state, portalled
70
+ * anchoring + outside-click/Escape dismissal, focus-on-open, a debounced
71
+ * race-safe async (or client-side) result list, and a label cache so a selected
72
+ * value still renders its label after the async list has moved on. The single-
73
+ * and multi-value comboboxes render their triggers on top of this and share
74
+ * {@link ComboboxPanel} for the dropdown.
75
+ */
76
+ export function useComboboxCore<V extends string | number>({
77
+ options,
78
+ loadOptions,
79
+ loading,
80
+ }: ComboboxCoreOptions<V>): ComboboxCore<V> {
81
+ const triggerRef = useRef<HTMLButtonElement>(null);
82
+ const panelRef = useRef<HTMLDivElement>(null);
83
+ const inputRef = useRef<HTMLInputElement>(null);
84
+ const [open, setOpen] = useState(false);
85
+ const [query, setQuery] = useState("");
86
+ const [active, setActive] = useState(0);
87
+
88
+ const isAsync = typeof loadOptions === "function";
89
+ const [asyncResults, setAsyncResults] = useState<ComboOption<V>[]>([]);
90
+ const [asyncLoading, setAsyncLoading] = useState(false);
91
+ const loadRef = useRef(loadOptions);
92
+ loadRef.current = loadOptions;
93
+ const reqId = useRef(0);
94
+
95
+ const close = () => setOpen(false);
96
+ // Escape unmounts the panel the user is typing in; without this the browser drops
97
+ // focus on <body>, so the caret disappears and the next Tab restarts at the top of
98
+ // the page. A pointer dismissal deliberately does NOT do this — the press has
99
+ // already decided where focus belongs.
100
+ const closeToTrigger = () => {
101
+ setOpen(false);
102
+ triggerRef.current?.focus();
103
+ };
104
+ // Placement, not just the anchor rect: the panel has to dodge the on-screen
105
+ // keyboard its own search box summons (feedback #135).
106
+ const placement = useAnchoredPanel(triggerRef, open);
107
+ const rect = placement.rect;
108
+ // Outside-click dismissal belongs to the ANCHORED panel, and only to it. On a
109
+ // phone `ComboboxPanel` renders a full-screen `PickerSheet` instead (live #200),
110
+ // portalled to <body> and carrying its own ways out — the close X, Escape, and
111
+ // Back through `useOverlayHistory` — so there is no outside left to press.
112
+ //
113
+ // Leaving the listener on there was not merely redundant, it was the bug:
114
+ // `panelRef` is attached to the desktop panel alone, so with the sheet up every
115
+ // press inside it answered "outside". While `useOutsideClick` listened for
116
+ // `mousedown` the rows survived by accident — they commit on `onMouseDown` too,
117
+ // and won the race — but `pointerdown` PRECEDES `mousedown`, so the sheet closed
118
+ // at finger-down and the commit landed on an unmounted tree: a tapped row kept the
119
+ // old value, and tapping the search box or the X was equally fatal. `PickerSheet`
120
+ // stops `mousedown` for exactly this reason, but that guard was written for
121
+ // `useDropdown`'s listener and never covered this hook.
122
+ const isPhone = useMediaQuery(PHONE_QUERY, false);
123
+ useOutsideClick([triggerRef, panelRef], close, open && !isPhone);
124
+ useEscapeKey(closeToTrigger, open);
125
+
126
+ // Reset the query + focus the search box each time the panel opens.
127
+ useEffect(() => {
128
+ if (!open) return;
129
+ setQuery("");
130
+ const id = requestAnimationFrame(() => inputRef.current?.focus());
131
+ return () => cancelAnimationFrame(id);
132
+ }, [open]);
133
+
134
+ // Debounced, race-safe async search (only when loadOptions is provided).
135
+ useEffect(() => {
136
+ if (!open || !isAsync) return;
137
+ const id = ++reqId.current;
138
+ setAsyncLoading(true);
139
+ const run = async () => {
140
+ try {
141
+ const r = await loadRef.current!(query);
142
+ if (reqId.current === id) setAsyncResults(r);
143
+ } finally {
144
+ if (reqId.current === id) setAsyncLoading(false);
145
+ }
146
+ };
147
+ const t = setTimeout(run, query ? 150 : 0);
148
+ return () => clearTimeout(t);
149
+ }, [query, open, isAsync]);
150
+
151
+ const clientResults = useMemo(() => {
152
+ if (isAsync) return [];
153
+ const src = options ?? [];
154
+ const q = query.trim().toLowerCase();
155
+ if (!q) return src;
156
+ return src.filter(
157
+ (o) => o.label.toLowerCase().includes(q) || (o.sublabel ?? "").toLowerCase().includes(q),
158
+ );
159
+ }, [isAsync, options, query]);
160
+
161
+ const results = isAsync ? asyncResults : clientResults;
162
+ const busy = Boolean(loading) || (isAsync && asyncLoading);
163
+
164
+ // Accumulate every option we've seen so a selected value can render its label
165
+ // even after the async list has moved on to other query results.
166
+ const cacheRef = useRef(new Map<V, ComboOption<V>>());
167
+ useEffect(() => {
168
+ for (const o of options ?? []) cacheRef.current.set(o.value, o);
169
+ for (const o of results) cacheRef.current.set(o.value, o);
170
+ }, [options, results]);
171
+ const resolve = (v: V): ComboOption<V> | null =>
172
+ options?.find((o) => o.value === v) ?? cacheRef.current.get(v) ?? null;
173
+
174
+ // Reset the highlighted row whenever the visible set changes.
175
+ useEffect(() => {
176
+ setActive(0);
177
+ }, [query, open]);
178
+
179
+ return {
180
+ triggerRef,
181
+ panelRef,
182
+ inputRef,
183
+ open,
184
+ setOpen,
185
+ query,
186
+ setQuery,
187
+ active,
188
+ setActive,
189
+ results,
190
+ busy,
191
+ rect,
192
+ placement,
193
+ cacheRef,
194
+ resolve,
195
+ closeToTrigger,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Keep the option the keyboard is on inside the visible part of the list.
201
+ *
202
+ * `aria-activedescendant` moves a highlight WITHOUT moving focus, which is the whole
203
+ * point of it — and the cost is that the browser scrolls nothing on its own. In a
204
+ * panel capped at a few hundred pixels over an account list two hundred long, Down
205
+ * walked the highlight straight out of the box and the list sat still.
206
+ *
207
+ * Optional-called because jsdom does not implement `scrollIntoView`: three consumers
208
+ * run this component in their own suites, and a picker that throws there is a worse
209
+ * trade than a list that does not scroll in a test.
210
+ */
211
+ export function useActiveOptionScroll(activeId: string | undefined): void {
212
+ useEffect(() => {
213
+ if (!activeId) return;
214
+ document.getElementById(activeId)?.scrollIntoView?.({ block: "nearest" });
215
+ }, [activeId]);
216
+ }
217
+
218
+ export interface ComboboxPanelProps<V extends string | number>
219
+ extends ComponentPropsWithoutRef<"div"> {
220
+ core: ComboboxCore<V>;
221
+ /** What the trigger's `aria-controls` names. Owned by the component that renders
222
+ * the trigger, not by the core: one id has to reach both, and only one of them
223
+ * can be the origin. */
224
+ listboxId: string;
225
+ /** Default: `combobox.search` from the {@link UiKitProvider}, else English. */
226
+ searchPlaceholder?: string;
227
+ /** Default: `combobox.noResults` from the {@link UiKitProvider}, else English. */
228
+ emptyLabel?: string;
229
+ multi?: boolean;
230
+ isSelected: (v: V) => boolean;
231
+ onChoose: (o: ComboOption<V>) => void;
232
+ showCreate: boolean;
233
+ onCreate: () => void;
234
+ createContent: ReactNode;
235
+ /** What the sheet calls itself on a phone — the field's label. Ignored on
236
+ * desktop, where the panel sits under the field that already says it. */
237
+ sheetTitle?: ReactNode;
238
+ closeLabel?: string;
239
+ }
240
+
241
+ /**
242
+ * The portalled dropdown (search header + result rows + optional create row)
243
+ * shared by the single- and multi-value comboboxes. Left-aligned to the trigger
244
+ * and sized to its width; `multi` swaps the trailing check for a leading
245
+ * checkbox. `onChoose` decides whether to close (single) or stay open (multi).
246
+ *
247
+ * ## The ARIA, which is `CommandPalette`'s
248
+ *
249
+ * This rendered a `role="listbox"` of `role="option"` rows and stopped there, so a
250
+ * reader was handed a text box and silence: the highlight the arrow keys move lived
251
+ * only in a background colour, and nothing tied the box being typed in to the list
252
+ * being filtered. `CommandPalette` has had the whole model since it was written —
253
+ * `aria-controls` from the field to the list, `aria-activedescendant` naming the row
254
+ * the keyboard is on — and it was the only thing in the package that did.
255
+ *
256
+ * Two distinct states, deliberately not conflated:
257
+ * - `aria-selected` is CHOSEN. It is the check mark, and in `multi` the checkbox.
258
+ * - `aria-activedescendant` is where the KEYBOARD is. Announcing that as selected
259
+ * would tell a reader that the row they are merely passing over is the answer.
260
+ *
261
+ * `role="option"` sits on the `<button>`, not on the `<li>` around it, for the same
262
+ * reason it does in `CommandPalette`: an option may not contain a separately
263
+ * focusable control, and the button is the thing a pointer presses. The rows are
264
+ * `tabIndex={-1}` because focus never leaves the search box — that is what
265
+ * `aria-activedescendant` is FOR.
266
+ */
267
+ export function ComboboxPanel<V extends string | number>({
268
+ core,
269
+ listboxId,
270
+ searchPlaceholder,
271
+ emptyLabel,
272
+ multi,
273
+ isSelected,
274
+ onChoose,
275
+ showCreate,
276
+ onCreate,
277
+ createContent,
278
+ sheetTitle,
279
+ closeLabel,
280
+ className,
281
+ style,
282
+ ...rest
283
+ }: ComboboxPanelProps<V>) {
284
+ const {
285
+ rect,
286
+ placement,
287
+ panelRef,
288
+ inputRef,
289
+ query,
290
+ setQuery,
291
+ results,
292
+ busy,
293
+ active,
294
+ setActive,
295
+ closeToTrigger,
296
+ } = core;
297
+ // The phone gets a full-screen sheet instead of an anchored panel (live #200).
298
+ // Same core, same results, same handlers — only the container differs, so the
299
+ // two presentations cannot drift in what they offer.
300
+ const isPhone = useMediaQuery(PHONE_QUERY, false);
301
+ const rowCount = results.length + (showCreate ? 1 : 0);
302
+ const optionId = (index: number) => `${listboxId}-option-${index}`;
303
+ const activeId = active >= 0 && active < rowCount ? optionId(active) : undefined;
304
+ useActiveOptionScroll(activeId);
305
+ // The consumers resolve their own props against the provider already; this is
306
+ // for the loading row, which no consumer names, and for a caller that renders the
307
+ // panel directly and leaves the two optional strings out.
308
+ const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS);
309
+
310
+ // The WAI-ARIA APG's listbox keyboard, in full: Up/Down step, Home/End jump, Enter
311
+ // commits, Tab leaves. Escape is the one key handled elsewhere — `useComboboxCore`
312
+ // registers it on the document, so it answers wherever focus has ended up.
313
+ const onKeyDown = (e: ReactKeyboardEvent) => {
314
+ if (e.key === "ArrowDown") {
315
+ e.preventDefault();
316
+ setActive((i) => Math.min(i + 1, rowCount - 1));
317
+ } else if (e.key === "ArrowUp") {
318
+ e.preventDefault();
319
+ setActive((i) => Math.max(i - 1, 0));
320
+ } else if (e.key === "Home") {
321
+ e.preventDefault();
322
+ setActive(0);
323
+ } else if (e.key === "End") {
324
+ e.preventDefault();
325
+ setActive(rowCount - 1);
326
+ } else if (e.key === "Enter") {
327
+ e.preventDefault();
328
+ if (active < results.length) onChoose(results[active]);
329
+ else if (showCreate) onCreate();
330
+ } else if (e.key === "Tab") {
331
+ // The panel is PORTALLED to <body>, so the browser's own Tab would move from
332
+ // here to whatever follows the portal — i.e. off the end of the document. The
333
+ // trade is one extra Tab (trigger, then onward) against never stranding focus
334
+ // at the bottom of the page.
335
+ e.preventDefault();
336
+ closeToTrigger();
337
+ }
338
+ };
339
+
340
+ if (!core.open || typeof document === "undefined") return null;
341
+
342
+ const list = (
343
+ <ul
344
+ id={listboxId}
345
+ role="listbox"
346
+ aria-multiselectable={multi}
347
+ className={cn("min-h-0 flex-1 overflow-y-auto py-1", isPhone && "flex-none")}
348
+ >
349
+ {busy && results.length === 0 && (
350
+ // The ellipsis is the picture; the words are for a reader, who otherwise met
351
+ // an empty-looking list with a lone "…" in it — or, in most readers, nothing
352
+ // at all, since punctuation alone is skipped. `relative` so the sr-only text
353
+ // has a local containing block (see sr-only-containment.test).
354
+ <li className="relative px-3 py-2 text-sm text-[var(--text-muted)]">
355
+ <span aria-hidden>…</span>
356
+ <span className="sr-only">{labels.loading}</span>
357
+ </li>
358
+ )}
359
+ {!busy && results.length === 0 && !showCreate && (
360
+ <li className="px-3 py-2 text-sm text-[var(--text-muted)]">
361
+ {emptyLabel ?? labels.noResults}
362
+ </li>
363
+ )}
364
+ {results.map((o, i) => {
365
+ const selected = isSelected(o.value);
366
+ // One heading per group rather than a grey suffix on every row (#136), and
367
+ // the thing the native <select>'s <optgroup> used to give the account
368
+ // picker for free (live #200). `results` keeps its source order, so a
369
+ // boundary is simply "different from the row above".
370
+ const startsGroup = o.group != null && o.group !== results[i - 1]?.group;
371
+ return (
372
+ // Keyed by group AND value: an option may deliberately appear twice —
373
+ // Keksdose repeats recently-used categories in a "Recent" group at the
374
+ // top (live #203) — and a bare value key would collide.
375
+ <Fragment key={`${o.group ?? ""}|${String(o.value)}`}>
376
+ {startsGroup && (
377
+ <li
378
+ role="presentation"
379
+ className={cn(
380
+ "font-semibold uppercase tracking-wide text-[var(--text-muted)]",
381
+ isPhone ? "px-4 pb-1 pt-3 text-xs" : "px-3 pb-0.5 pt-2 text-[11px] first:pt-1",
382
+ )}
383
+ >
384
+ {o.group}
385
+ </li>
386
+ )}
387
+ <li role="presentation">
388
+ <button
389
+ type="button"
390
+ id={optionId(i)}
391
+ role="option"
392
+ aria-selected={selected}
393
+ tabIndex={-1}
394
+ onMouseDown={(e) => {
395
+ e.preventDefault();
396
+ onChoose(o);
397
+ }}
398
+ onMouseEnter={() => setActive(i)}
399
+ className={cn(
400
+ isPhone
401
+ ? SHEET_ROW_CLASS
402
+ : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
403
+ i === active && !isPhone
404
+ ? "bg-[var(--bg-active)]"
405
+ : !isPhone && "hover:bg-[var(--bg-hover)]",
406
+ )}
407
+ >
408
+ {multi && (
409
+ <span
410
+ // The checkbox is a PICTURE of `aria-selected` above, so a reader
411
+ // that met both would be told twice.
412
+ aria-hidden
413
+ className={cn(
414
+ "flex size-4 shrink-0 items-center justify-center rounded border",
415
+ selected
416
+ ? "border-[var(--bg-inverse)] bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
417
+ : "border-[var(--border-strong)] bg-[var(--bg-surface)]",
418
+ )}
419
+ >
420
+ {selected && <Check className="size-3" />}
421
+ </span>
422
+ )}
423
+ {o.icon && <span className="shrink-0">{o.icon}</span>}
424
+ <span className="min-w-0 flex-1">
425
+ <span className="block truncate text-[var(--text-primary)]">{o.label}</span>
426
+ {o.sublabel && (
427
+ <span className="block truncate text-xs text-[var(--text-placeholder)]">
428
+ {o.sublabel}
429
+ </span>
430
+ )}
431
+ </span>
432
+ {!multi && selected && <Check className="size-4 shrink-0 text-[var(--brand)]" />}
433
+ </button>
434
+ </li>
435
+ </Fragment>
436
+ );
437
+ })}
438
+ {showCreate && (
439
+ <li role="presentation">
440
+ <button
441
+ type="button"
442
+ id={optionId(results.length)}
443
+ role="option"
444
+ // Never chosen — it is an action, and it disappears the moment it has
445
+ // been taken. Whether the keyboard is ON it is `aria-activedescendant`'s
446
+ // job to say.
447
+ aria-selected={false}
448
+ tabIndex={-1}
449
+ onMouseDown={(e) => {
450
+ e.preventDefault();
451
+ onCreate();
452
+ }}
453
+ onMouseEnter={() => setActive(results.length)}
454
+ className={cn(
455
+ isPhone
456
+ ? SHEET_ROW_CLASS
457
+ : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
458
+ "text-[var(--text-secondary)]",
459
+ active === results.length && !isPhone
460
+ ? "bg-[var(--bg-active)]"
461
+ : !isPhone && "hover:bg-[var(--bg-hover)]",
462
+ )}
463
+ >
464
+ <Plus className="size-4 shrink-0" />
465
+ <span className="truncate">{createContent}</span>
466
+ </button>
467
+ </li>
468
+ )}
469
+ </ul>
470
+ );
471
+
472
+ if (isPhone) {
473
+ return (
474
+ <PickerSheet
475
+ // First, so the sheet's own props below win: `rest` is typed as div
476
+ // attributes, and the DOM's `title` is a tooltip string where the sheet's is
477
+ // its heading — one name, two meanings, and the sheet's is the one the caller
478
+ // asked for.
479
+ {...rest}
480
+ className={className}
481
+ style={style}
482
+ open
483
+ onClose={() => core.setOpen(false)}
484
+ title={sheetTitle}
485
+ query={query}
486
+ onQueryChange={setQuery}
487
+ searchPlaceholder={searchPlaceholder ?? labels.search}
488
+ inputRef={inputRef}
489
+ closeLabel={closeLabel}
490
+ >
491
+ <div onKeyDown={onKeyDown}>{list}</div>
492
+ </PickerSheet>
493
+ );
494
+ }
495
+
496
+ if (!rect) return null;
497
+
498
+ return createPortal(
499
+ <div
500
+ // Spread FIRST: `onKeyDown` below is the listbox keyboard (Up/Down/Home/End/
501
+ // Enter/Tab) and the placement is measured rather than chosen, so neither is a
502
+ // caller's to replace by accident — while a `data-*` anchor or an
503
+ // `aria-describedby` has nothing to collide with.
504
+ {...rest}
505
+ ref={panelRef}
506
+ onKeyDown={onKeyDown}
507
+ // `placement` keeps the panel inside the region actually on screen: on a phone
508
+ // the search box below pulls up the keyboard, and a panel pinned under a
509
+ // low trigger would otherwise sit entirely behind it (feedback #135).
510
+ style={{
511
+ ...style,
512
+ position: "fixed",
513
+ top: placement.top,
514
+ left: rect.left,
515
+ width: rect.width,
516
+ minWidth: 220,
517
+ maxHeight: placement.maxHeight,
518
+ }}
519
+ className={cn(
520
+ "z-50 flex flex-col rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
521
+ className,
522
+ )}
523
+ >
524
+ <DropdownSearchHeader
525
+ query={query}
526
+ onQueryChange={setQuery}
527
+ inputRef={inputRef}
528
+ placeholder={searchPlaceholder ?? labels.search}
529
+ // Focus is in this box while the list is up, so this is what has to name the
530
+ // list and the row the arrows are on.
531
+ listboxId={listboxId}
532
+ activeId={activeId}
533
+ />
534
+ {/* One list, two containers: the desktop panel and the phone sheet render
535
+ the same rows through the same handlers (live #200). */}
536
+ {list}
537
+ </div>,
538
+ document.body,
539
+ );
540
+ }