@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
@@ -1,10 +1,15 @@
1
1
  import * as react from 'react';
2
+ import { ComponentPropsWithoutRef } from 'react';
2
3
 
3
- /** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).
4
- * Validation failures surface `invalidMessage` (a `sonner` toast by default, see
5
- * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +
6
- * `hint` describe what to drop. */
7
- declare function FileDropzone({ file, onFileSelected, accept, isValid, invalidMessage, onInvalid, dropLabel, browseLabel, emptyLabel, hint, }: {
4
+ /**
5
+ * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
6
+ * The DOM event of that name belongs to form validation and takes a `FormEvent`; this
7
+ * one takes the rejected `File`, and the kit's meaning is the one every caller already
8
+ * writes. Everything else a `<div>` takes reaches the root — which carries
9
+ * `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
10
+ * belongs.
11
+ */
12
+ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInvalid"> {
8
13
  file: File | null;
9
14
  onFileSelected: (file: File) => void;
10
15
  accept: string;
@@ -27,10 +32,19 @@ declare function FileDropzone({ file, onFileSelected, accept, isValid, invalidMe
27
32
  * writing, two modules over.
28
33
  */
29
34
  onInvalid?: (file: File) => void;
35
+ /** The dropzone's accessible name, and the instruction shown on it. A caller's own
36
+ * `aria-label` wins over it — see the root element below. */
30
37
  dropLabel: string;
31
38
  browseLabel: string;
32
39
  emptyLabel: string;
33
40
  hint: string;
34
- }): react.JSX.Element;
41
+ /** Extra classes for the dropzone's root. */
42
+ className?: string;
43
+ }
44
+ /** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).
45
+ * Validation failures surface `invalidMessage` (a `sonner` toast by default, see
46
+ * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +
47
+ * `hint` describe what to drop. */
48
+ declare function FileDropzone({ file, onFileSelected, accept, isValid, invalidMessage, onInvalid, dropLabel, browseLabel, emptyLabel, hint, className, "aria-label": ariaLabel, ...rest }: FileDropzoneProps): react.JSX.Element;
35
49
 
36
- export { FileDropzone };
50
+ export { FileDropzone, type FileDropzoneProps };
@@ -1,8 +1,10 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
2
3
  import { useRef, useState } from "react";
3
4
  import { Upload } from "lucide-react";
4
5
  import { Button } from "./ui.js";
5
6
  import { cn } from "../lib/cn.js";
7
+ import { useKitFileLabels } from "../i18n/kit-labels.js";
6
8
  function FileDropzone({
7
9
  file,
8
10
  onFileSelected,
@@ -13,10 +15,14 @@ function FileDropzone({
13
15
  dropLabel,
14
16
  browseLabel,
15
17
  emptyLabel,
16
- hint
18
+ hint,
19
+ className,
20
+ "aria-label": ariaLabel,
21
+ ...rest
17
22
  }) {
18
23
  const [dragOver, setDragOver] = useState(false);
19
24
  const fileInputRef = useRef(null);
25
+ const fileText = useKitFileLabels();
20
26
  const acceptFile = (f) => {
21
27
  if (!f) return;
22
28
  if (!isValid(f)) {
@@ -32,6 +38,7 @@ function FileDropzone({
32
38
  return /* @__PURE__ */ jsxs(
33
39
  "div",
34
40
  {
41
+ ...rest,
35
42
  onDragEnter: (e) => {
36
43
  e.preventDefault();
37
44
  e.stopPropagation();
@@ -64,15 +71,29 @@ function FileDropzone({
64
71
  },
65
72
  role: "button",
66
73
  tabIndex: 0,
67
- "aria-label": dropLabel,
74
+ "aria-label": ariaLabel ?? dropLabel,
68
75
  className: cn(
69
- "flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
70
- dragOver ? "border-slate-500 bg-slate-100 dark:border-slate-300 dark:bg-slate-800" : "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-500"
76
+ // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
77
+ // which Tailwind implements as `position: absolute` — so without a positioned
78
+ // ancestor its containing block is the INITIAL one, and it is laid out at its
79
+ // own offset from the top of the document. Nothing looks wrong, because the
80
+ // input is 1x1 and clipped; what breaks is the page height. On a long page
81
+ // every escaped sr-only element extends `documentElement.scrollHeight` to its
82
+ // own offset, which produces a second, whole-document scrollbar alongside the
83
+ // app shell's own — one that scrolls past the end of the content into nothing.
84
+ // Measured on the showcase: body 900px, document 47,919px.
85
+ "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
86
+ // Three states out of two border tokens: the target rests on the plain
87
+ // hairline, hover pulls it to `--border-strong`, and a live drag keeps that
88
+ // border and adds the `--bg-active` wash on top — so "let go here" still reads
89
+ // one step louder than "you are over it".
90
+ dragOver ? "border-[var(--border-strong)] bg-[var(--bg-active)]" : "border-[var(--border)] hover:border-[var(--border-strong)]",
91
+ className
71
92
  ),
72
93
  children: [
73
- /* @__PURE__ */ jsx(Upload, { className: "size-5 text-slate-500 dark:text-slate-400" }),
74
- /* @__PURE__ */ jsx("div", { className: "text-sm text-slate-700 dark:text-slate-200", children: file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel }),
75
- /* @__PURE__ */ jsx("div", { className: "text-xs text-slate-500 dark:text-slate-400", children: file ? `${Math.round(file.size / 1024)} KB` : hint }),
94
+ /* @__PURE__ */ jsx(Upload, { className: "size-5 text-[var(--text-muted)]" }),
95
+ /* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-secondary)]", children: file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel }),
96
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: file ? fileText.size(file.size) : hint }),
76
97
  /* @__PURE__ */ jsx(
77
98
  Button,
78
99
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { Upload } from \"lucide-react\";\nimport { Button } from \"./ui\";\nimport { cn } from \"../lib/cn\";\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).\n * Validation failures surface `invalidMessage` (a `sonner` toast by default, see\n * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +\n * `hint` describe what to drop. */\nexport function FileDropzone({\n file,\n onFileSelected,\n accept,\n isValid,\n invalidMessage,\n onInvalid,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n}: {\n file: File | null;\n onFileSelected: (file: File) => void;\n accept: string;\n isValid: (file: File) => boolean;\n invalidMessage: string;\n /**\n * How to surface {@link invalidMessage}. Defaults to a `sonner` toast — an\n * optional peer, imported only when a file is actually rejected. Pass your own to\n * route it somewhere else, or a no-op to silence it. The same escape hatch, for\n * the same reason, as `useWizard`'s `onValidationFailed`.\n *\n * It exists so this module does not import `sonner` STATICALLY. `package.json`\n * declares that peer `optional`, and the barrel re-exports this file — so a\n * top-level `import { toast } from \"sonner\"` made the claim false for everybody:\n * in an app that installed `@hb/ui` without sonner, `import { Button } from\n * \"@hb/ui\"` failed to resolve and `tsc --noEmit` failed on the missing types,\n * without a FileDropzone anywhere in it. Both current consumers happen to depend\n * on sonner, which is why nothing broke and why the contradiction survived:\n * `use-wizard.ts` had already answered the identical question the other way, in\n * writing, two modules over.\n */\n onInvalid?: (file: File) => void;\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n}) {\n const [dragOver, setDragOver] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n const acceptFile = (f: File | undefined | null) => {\n if (!f) return;\n if (!isValid(f)) {\n if (onInvalid) {\n onInvalid(f);\n } else {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events.\n void import(\"sonner\").then(({ toast }) => toast.error(invalidMessage));\n }\n return;\n }\n onFileSelected(f);\n };\n\n return (\n <div\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFile(e.dataTransfer.files?.[0]);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n aria-label={dropLabel}\n className={cn(\n \"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n dragOver\n ? \"border-slate-500 bg-slate-100 dark:border-slate-300 dark:bg-slate-800\"\n : \"border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-500\",\n )}\n >\n <Upload className=\"size-5 text-slate-500 dark:text-slate-400\" />\n <div className=\"text-sm text-slate-700 dark:text-slate-200\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n </div>\n <div className=\"text-xs text-slate-500 dark:text-slate-400\">\n {file ? `${Math.round(file.size / 1024)} KB` : hint}\n </div>\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n onChange={(e) => acceptFile(e.target.files?.[0])}\n className=\"sr-only\"\n />\n </div>\n );\n}\n"],"mappings":"AAqEI,SAyCE,KAzCF;AArEJ,SAAS,QAAQ,gBAAgB;AACjC,SAAS,cAAc;AACvB,SAAS,cAAc;AACvB,SAAS,UAAU;AAMZ,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GA2BG;AACD,QAAM,CAAC,UAAU,WAAW,IAAI,SAAS,KAAK;AAC9C,QAAM,eAAe,OAAyB,IAAI;AAElD,QAAM,aAAa,CAAC,MAA+B;AACjD,QAAI,CAAC,EAAG;AACR,QAAI,CAAC,QAAQ,CAAC,GAAG;AACf,UAAI,WAAW;AACb,kBAAU,CAAC;AAAA,MACb,OAAO;AAKL,aAAK,OAAO,QAAQ,EAAE,KAAK,CAAC,EAAE,MAAM,MAAM,MAAM,MAAM,cAAc,CAAC;AAAA,MACvE;AACA;AAAA,IACF;AACA,mBAAe,CAAC;AAAA,EAClB;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAa,CAAC,MAAM;AAClB,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,YAAI,EAAE,aAAa,MAAM,SAAS,OAAO,EAAG,aAAY,IAAI;AAAA,MAC9D;AAAA,MACA,YAAY,CAAC,MAAM;AACjB,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,UAAE,aAAa,aAAa;AAC5B,YAAI,CAAC,YAAY,EAAE,aAAa,MAAM,SAAS,OAAO,EAAG,aAAY,IAAI;AAAA,MAC3E;AAAA,MACA,aAAa,CAAC,MAAM;AAClB,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,YAAI,EAAE,cAAc,SAAS,EAAE,aAAqB,EAAG;AACvD,oBAAY,KAAK;AAAA,MACnB;AAAA,MACA,QAAQ,CAAC,MAAM;AACb,UAAE,eAAe;AACjB,UAAE,gBAAgB;AAClB,oBAAY,KAAK;AACjB,mBAAW,EAAE,aAAa,QAAQ,CAAC,CAAC;AAAA,MACtC;AAAA,MACA,SAAS,MAAM,aAAa,SAAS,MAAM;AAAA,MAC3C,WAAW,CAAC,MAAM;AAChB,YAAI,EAAE,QAAQ,WAAW,EAAE,QAAQ,KAAK;AACtC,YAAE,eAAe;AACjB,uBAAa,SAAS,MAAM;AAAA,QAC9B;AAAA,MACF;AAAA,MACA,MAAK;AAAA,MACL,UAAU;AAAA,MACV,cAAY;AAAA,MACZ,WAAW;AAAA,QACT;AAAA,QACA,WACI,0EACA;AAAA,MACN;AAAA,MAEA;AAAA,4BAAC,UAAO,WAAU,6CAA4C;AAAA,QAC9D,oBAAC,SAAI,WAAU,8CACZ,iBAAO,oBAAC,UAAK,WAAU,eAAe,eAAK,MAAK,IAAU,YAC7D;AAAA,QACA,oBAAC,SAAI,WAAU,8CACZ,iBAAO,GAAG,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC,QAAQ,MACjD;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,SAAS,CAAC,MAAM;AACd,gBAAE,gBAAgB;AAClB,2BAAa,SAAS,MAAM;AAAA,YAC9B;AAAA,YAEC;AAAA;AAAA,QACH;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,MAAK;AAAA,YACL;AAAA,YACA,UAAU,CAAC,MAAM,WAAW,EAAE,OAAO,QAAQ,CAAC,CAAC;AAAA,YAC/C,WAAU;AAAA;AAAA,QACZ;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload } from \"lucide-react\";\nimport { Button } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFileLabels } from \"../i18n/kit-labels\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"button\"`, so that is also where an `aria-label` or an `aria-describedby`\n * belongs.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n file: File | null;\n onFileSelected: (file: File) => void;\n accept: string;\n isValid: (file: File) => boolean;\n invalidMessage: string;\n /**\n * How to surface {@link invalidMessage}. Defaults to a `sonner` toast — an\n * optional peer, imported only when a file is actually rejected. Pass your own to\n * route it somewhere else, or a no-op to silence it. The same escape hatch, for\n * the same reason, as `useWizard`'s `onValidationFailed`.\n *\n * It exists so this module does not import `sonner` STATICALLY. `package.json`\n * declares that peer `optional`, and the barrel re-exports this file — so a\n * top-level `import { toast } from \"sonner\"` made the claim false for everybody:\n * in an app that installed `@hb/ui` without sonner, `import { Button } from\n * \"@hb/ui\"` failed to resolve and `tsc --noEmit` failed on the missing types,\n * without a FileDropzone anywhere in it. Both current consumers happen to depend\n * on sonner, which is why nothing broke and why the contradiction survived:\n * `use-wizard.ts` had already answered the identical question the other way, in\n * writing, two modules over.\n */\n onInvalid?: (file: File) => void;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).\n * Validation failures surface `invalidMessage` (a `sonner` toast by default, see\n * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +\n * `hint` describe what to drop. */\nexport function FileDropzone({\n file,\n onFileSelected,\n accept,\n isValid,\n invalidMessage,\n onInvalid,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n\n const acceptFile = (f: File | undefined | null) => {\n if (!f) return;\n if (!isValid(f)) {\n if (onInvalid) {\n onInvalid(f);\n } else {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events.\n void import(\"sonner\").then(({ toast }) => toast.error(invalidMessage));\n }\n return;\n }\n onFileSelected(f);\n };\n\n return (\n <div\n // `...rest` first: every handler below is the drop gesture itself, and the\n // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.\n {...rest}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFile(e.dataTransfer.files?.[0]);\n }}\n onClick={() => fileInputRef.current?.click()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n fileInputRef.current?.click();\n }\n }}\n role=\"button\"\n tabIndex={0}\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\".\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload className=\"size-5 text-[var(--text-muted)]\" />\n <div className=\"text-sm text-[var(--text-secondary)]\">\n {file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file ? fileText.size(file.size) : hint}\n </div>\n <Button\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n onChange={(e) => acceptFile(e.target.files?.[0])}\n className=\"sr-only\"\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,40 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
3
 
4
+ /**
5
+ * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way
6
+ * to say "the props of this" and five call sites across keksdose and kastlan had already
7
+ * written one (audit §api-design).
8
+ *
9
+ * It extends the `<div>` attributes so a caller can reach the OUTER element — the one
10
+ * carrying `role="dialog"`, and therefore the one an `aria-label`, an
11
+ * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going
12
+ * to the panel inside it, which is what it has always meant here.
13
+ */
14
+ interface FullBleedDialogProps extends ComponentPropsWithoutRef<"div"> {
15
+ open: boolean;
16
+ /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
17
+ * the platform Back gesture all call this. */
18
+ onClose: () => void;
19
+ /** What the header strip shows beside the close button. */
20
+ header?: ReactNode;
21
+ closeLabel: string;
22
+ children: ReactNode;
23
+ /**
24
+ * Push a history entry so Back dismisses this dialog instead of navigating
25
+ * (Keksdose feedback #172). On by default, because a full-screen form that ignores
26
+ * the phone's universal "go back" gesture does not merely fail to close — the
27
+ * navigation lands somewhere else with the form still notionally open.
28
+ *
29
+ * **Off for a caller whose open state is already in the URL.** The transactions
30
+ * create card is `?action=new`, so Back already pops it through the router; a
31
+ * sentinel on top of that would cost two presses to close one dialog. One owner of
32
+ * the Back press, always.
33
+ */
34
+ backCloses?: boolean;
35
+ /** Extra classes for the PANEL (not the backdrop). */
36
+ className?: string;
37
+ }
4
38
  /**
5
39
  * The phone's full-screen dialog: a panel that covers the viewport edge to edge.
6
40
  *
@@ -29,29 +63,6 @@ import { ReactNode } from 'react';
29
63
  * editor shows the row's own first column, the create card a plain title. And `open`,
30
64
  * because who decides is the caller's business — see {@link backCloses}.
31
65
  */
32
- declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, }: {
33
- open: boolean;
34
- /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —
35
- * the platform Back gesture all call this. */
36
- onClose: () => void;
37
- /** What the header strip shows beside the close button. */
38
- header?: ReactNode;
39
- closeLabel: string;
40
- children: ReactNode;
41
- /**
42
- * Push a history entry so Back dismisses this dialog instead of navigating
43
- * (Keksdose feedback #172). On by default, because a full-screen form that ignores
44
- * the phone's universal "go back" gesture does not merely fail to close — the
45
- * navigation lands somewhere else with the form still notionally open.
46
- *
47
- * **Off for a caller whose open state is already in the URL.** The transactions
48
- * create card is `?action=new`, so Back already pops it through the router; a
49
- * sentinel on top of that would cost two presses to close one dialog. One owner of
50
- * the Back press, always.
51
- */
52
- backCloses?: boolean;
53
- /** Extra classes for the PANEL (not the backdrop). */
54
- className?: string;
55
- }): react.ReactPortal | null;
66
+ declare function FullBleedDialog({ open, onClose, header, closeLabel, children, backCloses, className, ...rest }: FullBleedDialogProps): react.ReactPortal | null;
56
67
 
57
- export { FullBleedDialog };
68
+ export { FullBleedDialog, type FullBleedDialogProps };
@@ -1,4 +1,6 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useRef } from "react";
2
4
  import { createPortal } from "react-dom";
3
5
  import { X } from "lucide-react";
4
6
  import { cn } from "../lib/cn.js";
@@ -6,6 +8,7 @@ import { useBackdropClose } from "./modal.js";
6
8
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock.js";
7
9
  import { useOverlayHistory } from "../hooks/use-overlay-history.js";
8
10
  import { useCloseTransition } from "../hooks/use-close-transition.js";
11
+ import { useFocusTrap } from "../hooks/use-focus-trap.js";
9
12
  function FullBleedDialog({
10
13
  open,
11
14
  onClose,
@@ -13,34 +16,46 @@ function FullBleedDialog({
13
16
  closeLabel,
14
17
  children,
15
18
  backCloses = true,
16
- className
19
+ className,
20
+ ...rest
17
21
  }) {
18
22
  const { closing, requestClose } = useCloseTransition(onClose);
19
23
  const backdropClose = useBackdropClose(requestClose);
20
24
  useBodyScrollLock(open);
21
25
  useOverlayHistory(open && backCloses, requestClose);
26
+ const dialogRef = useRef(null);
27
+ useFocusTrap(dialogRef, { active: open });
28
+ const handleKeyDown = (e) => {
29
+ if (e.key !== "Escape") return;
30
+ e.stopPropagation();
31
+ requestClose();
32
+ };
22
33
  if (!open || typeof document === "undefined") return null;
23
34
  return createPortal(
24
35
  /* @__PURE__ */ jsx(
25
36
  "div",
26
37
  {
38
+ ...rest,
27
39
  className: cn(
28
- "fixed inset-0 z-50 flex items-stretch justify-center bg-black/40",
40
+ "fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none",
29
41
  closing ? "animate-overlay-out" : "animate-overlay"
30
42
  ),
31
43
  role: "dialog",
32
44
  "aria-modal": "true",
45
+ tabIndex: -1,
46
+ ref: dialogRef,
47
+ onKeyDown: handleKeyDown,
33
48
  ...backdropClose,
34
49
  children: /* @__PURE__ */ jsxs(
35
50
  "div",
36
51
  {
37
52
  className: cn(
38
- "flex h-full w-full flex-col overflow-hidden bg-white shadow-xl dark:bg-slate-900",
53
+ "flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl",
39
54
  closing ? "animate-sheet-out" : "animate-sheet",
40
55
  className
41
56
  ),
42
57
  children: [
43
- /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-3 dark:border-slate-800", children: [
58
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3", children: [
44
59
  /* @__PURE__ */ jsx("div", { className: "min-w-0 font-medium", children: header }),
45
60
  /* @__PURE__ */ jsx(
46
61
  "button",
@@ -48,7 +63,7 @@ function FullBleedDialog({
48
63
  type: "button",
49
64
  onClick: requestClose,
50
65
  "aria-label": closeLabel,
51
- className: "-mr-1 shrink-0 rounded p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200",
66
+ className: "-mr-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]",
52
67
  children: /* @__PURE__ */ jsx(X, { className: "size-5" })
53
68
  }
54
69
  )
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { createPortal } from \"react-dom\";\nimport type { ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n}: {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /** What the header strip shows beside the close button. */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n}) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n {...backdropClose}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-white shadow-xl dark:bg-slate-900\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-3 dark:border-slate-800\">\n <div className=\"min-w-0 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-mr-1 shrink-0 rounded p-1.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-slate-200\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n <div className=\"flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":"AA0GQ,SACE,KADF;AA1GR,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AA8B5B,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AACF,GAuBG;AAID,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QASC,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QACV,GAAG;AAAA,QAEJ;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qGACb;AAAA,oCAAC,SAAI,WAAU,uBAAuB,kBAAO;AAAA,gBAC7C;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAIA,oBAAC,SAAI,WAAU,uDAAuD,UAAS;AAAA;AAAA;AAAA,QACjF;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/components/full-bleed-dialog.tsx"],"sourcesContent":["import { useRef } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useBackdropClose } from \"./modal\";\nimport { useBodyScrollLock } from \"../hooks/use-body-scroll-lock\";\nimport { useOverlayHistory } from \"../hooks/use-overlay-history\";\nimport { useCloseTransition } from \"../hooks/use-close-transition\";\nimport { useFocusTrap } from \"../hooks/use-focus-trap\";\n\n/**\n * Named and exported, because `ComponentProps<typeof FullBleedDialog>` was the only way\n * to say \"the props of this\" and five call sites across keksdose and kastlan had already\n * written one (audit §api-design).\n *\n * It extends the `<div>` attributes so a caller can reach the OUTER element — the one\n * carrying `role=\"dialog\"`, and therefore the one an `aria-label`, an\n * `aria-describedby` or a `data-tour` anchor belongs on. {@link className} keeps going\n * to the panel inside it, which is what it has always meant here.\n */\nexport interface FullBleedDialogProps extends ComponentPropsWithoutRef<\"div\"> {\n open: boolean;\n /** The X, the backdrop (where one shows) and — unless {@link backCloses} is off —\n * the platform Back gesture all call this. */\n onClose: () => void;\n /** What the header strip shows beside the close button. */\n header?: ReactNode;\n closeLabel: string;\n children: ReactNode;\n /**\n * Push a history entry so Back dismisses this dialog instead of navigating\n * (Keksdose feedback #172). On by default, because a full-screen form that ignores\n * the phone's universal \"go back\" gesture does not merely fail to close — the\n * navigation lands somewhere else with the form still notionally open.\n *\n * **Off for a caller whose open state is already in the URL.** The transactions\n * create card is `?action=new`, so Back already pops it through the router; a\n * sentinel on top of that would cost two presses to close one dialog. One owner of\n * the Back press, always.\n */\n backCloses?: boolean;\n /** Extra classes for the PANEL (not the backdrop). */\n className?: string;\n}\n\n/**\n * The phone's full-screen dialog: a panel that covers the viewport edge to edge.\n *\n * Not {@link Modal}, and that is a decision rather than an omission. Keksdose feedback\n * #32: the inset panel #204 shipped left a strip of page showing beside it on a phone,\n * and tapping that strip dismissed a form mid-edit. With no backdrop exposed there is\n * nothing to mis-tap, so the X is the way out — `backdropClose` stays wired for any\n * layout that does leave a backdrop visible, and costs nothing where none is.\n *\n * ## Why it is a component\n *\n * It was markup inside `data-table.tsx`, where it is the phone's row EDITOR. Keksdose\n * live #307's follow-up needed the same shell for the transactions CREATE card —\n * *\"Is the creation window of the tx still different than the edit window? Since the tx\n * list still appears below when scrolling down?\"* — and the two answers to that are\n * either one component or two copies that drift. Every previous convergence in that app\n * drifted the moment a second surface was written beside the first, which is what\n * `field-layer-parity` exists to catch one layer down.\n *\n * So the panel, the header strip, the scrolling body, the body-scroll lock and the Back\n * handling are all here, once.\n *\n * ## What the caller still owns\n *\n * The HEADER's content, because the two callers name themselves differently: the row\n * editor shows the row's own first column, the create card a plain title. And `open`,\n * because who decides is the caller's business — see {@link backCloses}.\n */\nexport function FullBleedDialog({\n open,\n onClose,\n header,\n closeLabel,\n children,\n backCloses = true,\n className,\n ...rest\n}: FullBleedDialogProps) {\n // Every way OUT goes through `requestClose`, so the panel lowers itself before the\n // caller unmounts it (live #320 rework). `onClose` is still what finally runs — this\n // only delays it by the length of the animation.\n const { closing, requestClose } = useCloseTransition(onClose);\n const backdropClose = useBackdropClose(requestClose);\n // The page behind must not scroll or jump under the overlay (feedback #204).\n useBodyScrollLock(open);\n useOverlayHistory(open && backCloses, requestClose);\n\n const dialogRef = useRef<HTMLDivElement>(null);\n // The half of \"modal\" this dialog was only claiming. It has said\n // `aria-modal=\"true\"` since it was extracted from `data-table.tsx`, which tells\n // assistive technology to hide everything outside it — while the user's focus stayed\n // on the row they tapped, in the part that is now hidden. Tab then walked them\n // through a table the screen reader would not describe, and on a phone this dialog\n // covers the whole screen, so there was nothing on screen to say where they were.\n //\n // `initialFocus` is left at the default — the dialog itself, not the first field.\n // This is the phone's row EDITOR and its first child is usually an input; focusing it\n // would pull the software keyboard up over half the form before the user has said\n // which field they came for. `Modal` made the same call for the same reason.\n useFocusTrap(dialogRef, { active: open });\n\n const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key !== \"Escape\") return;\n // On the panel, the way `Modal` does it, and NOT through `useEscapeKey`: this\n // dialog is the thing a `PickerSheet` opens on top of, and a document-level\n // listener cannot tell which of the two the user meant. One press would dismiss\n // the sheet AND throw away the edit behind it — Keksdose live #309 (*\"Mouse Back\n // does not only close the select but also the whole edit or create dialog\"*) on a\n // different key. The sheet's own handler stops the event here, so the innermost\n // overlay is the one that closes, which is only true because focus is now in it.\n e.stopPropagation();\n requestClose();\n };\n\n if (!open || typeof document === \"undefined\") return null;\n\n return createPortal(\n <div\n // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):\n // the backdrop fades, the panel rises from the bottom edge it is anchored to.\n // Both are no-ops under prefers-reduced-motion — see tokens.css.\n //\n // …and the same two backwards while `closing`, which is the #320 rework. The\n // backdrop keeps taking pointer events on the way out: a tap during those 220ms\n // is a tap on a dialog that is leaving, and letting it through to the row\n // underneath would open a second one.\n //\n // `...rest` first, the dialog's own contract after it. A caller adding a\n // `data-tour` anchor must not be able to take away the role, the modality or the\n // `tabIndex` below by passing one of them too — this is the element the focus\n // trap holds, and each of those failures is invisible on screen.\n {...rest}\n className={cn(\n \"fixed inset-0 z-50 flex items-stretch justify-center bg-black/40 outline-none\",\n closing ? \"animate-overlay-out\" : \"animate-overlay\",\n )}\n role=\"dialog\"\n aria-modal=\"true\"\n // `tabIndex={-1}` is what makes the line above more than a claim: without it the\n // container cannot take focus and `useFocusTrap` silently does nothing.\n tabIndex={-1}\n ref={dialogRef}\n onKeyDown={handleKeyDown}\n {...backdropClose}\n >\n <div\n className={cn(\n \"flex h-full w-full flex-col overflow-hidden bg-[var(--bg-surface)] shadow-xl\",\n closing ? \"animate-sheet-out\" : \"animate-sheet\",\n className,\n )}\n >\n <div className=\"flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-3\">\n <div className=\"min-w-0 font-medium\">{header}</div>\n <button\n type=\"button\"\n onClick={requestClose}\n aria-label={closeLabel}\n className=\"-mr-1 shrink-0 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <X className=\"size-5\" />\n </button>\n </div>\n {/* px-3, not px-4: every pixel of chrome here is width the form fields lose\n on a phone (feedback #32). The body is the only thing that scrolls, so the\n header stays put and the page underneath cannot move at all. */}\n <div className=\"flex-1 overflow-y-auto overscroll-contain px-3 py-3\">{children}</div>\n </div>\n </div>,\n document.body,\n );\n}\n"],"mappings":";AA+JQ,SACE,KADF;AA/JR,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAE7B,SAAS,SAAS;AAElB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AACjC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAiEtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA,GAAG;AACL,GAAyB;AAIvB,QAAM,EAAE,SAAS,aAAa,IAAI,mBAAmB,OAAO;AAC5D,QAAM,gBAAgB,iBAAiB,YAAY;AAEnD,oBAAkB,IAAI;AACtB,oBAAkB,QAAQ,YAAY,YAAY;AAElD,QAAM,YAAY,OAAuB,IAAI;AAY7C,eAAa,WAAW,EAAE,QAAQ,KAAK,CAAC;AAExC,QAAM,gBAAgB,CAAC,MAAqC;AAC1D,QAAI,EAAE,QAAQ,SAAU;AAQxB,MAAE,gBAAgB;AAClB,iBAAa;AAAA,EACf;AAEA,MAAI,CAAC,QAAQ,OAAO,aAAa,YAAa,QAAO;AAErD,SAAO;AAAA,IACL;AAAA,MAAC;AAAA;AAAA,QAcE,GAAG;AAAA,QACJ,WAAW;AAAA,UACT;AAAA,UACA,UAAU,wBAAwB;AAAA,QACpC;AAAA,QACA,MAAK;AAAA,QACL,cAAW;AAAA,QAGX,UAAU;AAAA,QACV,KAAK;AAAA,QACL,WAAW;AAAA,QACV,GAAG;AAAA,QAEJ;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,UAAU,sBAAsB;AAAA,cAChC;AAAA,YACF;AAAA,YAEA;AAAA,mCAAC,SAAI,WAAU,qFACb;AAAA,oCAAC,SAAI,WAAU,uBAAuB,kBAAO;AAAA,gBAC7C;AAAA,kBAAC;AAAA;AAAA,oBACC,MAAK;AAAA,oBACL,SAAS;AAAA,oBACT,cAAY;AAAA,oBACZ,WAAU;AAAA,oBAEV,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,gBACxB;AAAA,iBACF;AAAA,cAIA,oBAAC,SAAI,WAAU,uDAAuD,UAAS;AAAA;AAAA;AAAA,QACjF;AAAA;AAAA,IACF;AAAA,IACA,SAAS;AAAA,EACX;AACF;","names":[]}
@@ -1,5 +1,5 @@
1
1
  import * as react from 'react';
2
- import { ReactNode } from 'react';
2
+ import { ComponentPropsWithoutRef, ReactNode } from 'react';
3
3
 
4
4
  interface PickerGroup {
5
5
  key: string;
@@ -14,12 +14,13 @@ interface PickerGroup {
14
14
  icon?: ReactNode;
15
15
  }[];
16
16
  }
17
- /** Panel picker for large grouped option sets: instead of one long flat
18
- * dropdown, the groups sit side by side as columns with their items listed
19
- * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback
20
- * #267 rework). A query that matches a group's name keeps the whole group;
21
- * otherwise groups are reduced to their matching items. */
22
- declare function GroupedPicker({ groups, buttonLabel, selected, onSelect, ariaLabel, filterPlaceholder, className, }: {
17
+ /**
18
+ * `onSelect` is omitted from the div's own attributes because this component's
19
+ * means "an item was chosen", not the DOM's "text was selected inside me" — two
20
+ * different signatures under one name, and the kit's meaning is the one three apps
21
+ * are written against.
22
+ */
23
+ interface GroupedPickerProps extends Omit<ComponentPropsWithoutRef<"div">, "onSelect"> {
23
24
  groups: PickerGroup[];
24
25
  /** Face of the closed picker button. */
25
26
  buttonLabel: ReactNode;
@@ -29,9 +30,19 @@ declare function GroupedPicker({ groups, buttonLabel, selected, onSelect, ariaLa
29
30
  item: string;
30
31
  } | null;
31
32
  onSelect: (groupKey: string, itemKey: string) => void;
33
+ /**
34
+ * @deprecated Use `aria-label`, the DOM spelling every control in the kit now
35
+ * takes. Kept working — three apps pass it — and it applies only when
36
+ * `aria-label` is absent; it goes in a later minor.
37
+ */
32
38
  ariaLabel?: string;
33
39
  filterPlaceholder?: string;
34
- className?: string;
35
- }): react.JSX.Element;
40
+ }
41
+ /** Panel picker for large grouped option sets: instead of one long flat
42
+ * dropdown, the groups sit side by side as columns with their items listed
43
+ * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback
44
+ * #267 rework). A query that matches a group's name keeps the whole group;
45
+ * otherwise groups are reduced to their matching items. */
46
+ declare function GroupedPicker({ groups, buttonLabel, selected, onSelect, ariaLabel, "aria-label": ariaLabelAttr, filterPlaceholder, className, ...rest }: GroupedPickerProps): react.JSX.Element;
36
47
 
37
- export { GroupedPicker, type PickerGroup };
48
+ export { GroupedPicker, type GroupedPickerProps, type PickerGroup };
@@ -1,16 +1,20 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
2
3
  import { useEffect, useMemo } from "react";
3
4
  import { ChevronDown } from "lucide-react";
4
5
  import { cn } from "../lib/cn.js";
5
6
  import { DropdownSearchHeader, useDropdownSearch } from "./dropdown.js";
7
+ import { useKitLocale } from "../i18n/kit-labels.js";
6
8
  function GroupedPicker({
7
9
  groups,
8
10
  buttonLabel,
9
11
  selected,
10
12
  onSelect,
11
13
  ariaLabel,
14
+ "aria-label": ariaLabelAttr,
12
15
  filterPlaceholder,
13
- className
16
+ className,
17
+ ...rest
14
18
  }) {
15
19
  const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();
16
20
  useEffect(() => {
@@ -21,65 +25,75 @@ function GroupedPicker({
21
25
  document.addEventListener("keydown", onKey);
22
26
  return () => document.removeEventListener("keydown", onKey);
23
27
  }, [open, setOpen]);
28
+ const locale = useKitLocale();
24
29
  const filtered = useMemo(() => {
25
- const q = query.trim().toLowerCase();
30
+ const fold = (s) => s.toLocaleLowerCase(locale);
31
+ const q = fold(query.trim());
26
32
  if (!q) return groups;
27
33
  return groups.map(
28
- (g) => g.label.toLowerCase().includes(q) ? g : { ...g, items: g.items.filter((i) => i.label.toLowerCase().includes(q)) }
34
+ (g) => fold(g.label).includes(q) ? g : { ...g, items: g.items.filter((i) => fold(i.label).includes(q)) }
29
35
  ).filter((g) => g.items.length > 0);
30
- }, [groups, query]);
31
- return /* @__PURE__ */ jsxs("div", { ref: wrapperRef, className: cn("relative", className), children: [
32
- /* @__PURE__ */ jsxs(
33
- "button",
34
- {
35
- type: "button",
36
- "aria-haspopup": "dialog",
37
- "aria-expanded": open,
38
- "aria-label": ariaLabel,
39
- onClick: () => setOpen((o) => !o),
40
- className: "flex w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-800 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800",
41
- children: [
42
- /* @__PURE__ */ jsx("span", { className: "truncate", children: buttonLabel }),
43
- /* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-slate-400" })
44
- ]
45
- }
46
- ),
47
- open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900", children: [
48
- /* @__PURE__ */ jsx(
49
- DropdownSearchHeader,
36
+ }, [groups, query, locale]);
37
+ return (
38
+ // `rest` goes on the wrapper, but the accessible NAME does not: this div is a
39
+ // positioning box with no role, so a label parked on it would be announced by
40
+ // nothing. It belongs to the control — the same split every picker in this wave
41
+ // makes, and the reason `aria-label` is destructured out rather than left in
42
+ // `rest`. Spread FIRST so the attributes below, each of them a fix with an
43
+ // incident behind it, cannot be clobbered from outside.
44
+ /* @__PURE__ */ jsxs("div", { ...rest, ref: wrapperRef, className: cn("relative", className), children: [
45
+ /* @__PURE__ */ jsxs(
46
+ "button",
50
47
  {
51
- query,
52
- onQueryChange: setQuery,
53
- inputRef,
54
- placeholder: filterPlaceholder
48
+ type: "button",
49
+ "aria-haspopup": "dialog",
50
+ "aria-expanded": open,
51
+ "aria-label": ariaLabelAttr ?? ariaLabel,
52
+ onClick: () => setOpen((o) => !o),
53
+ className: "flex w-full items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] px-2.5 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
54
+ children: [
55
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: buttonLabel }),
56
+ /* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 text-[var(--text-placeholder)]" })
57
+ ]
55
58
  }
56
59
  ),
57
- /* @__PURE__ */ jsx("div", { className: "overflow-y-auto p-3", children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500 dark:text-slate-400", children: "\u2014" }) : /* @__PURE__ */ jsx("div", { className: "columns-2 gap-4 md:columns-3 xl:columns-4", children: filtered.map((g) => /* @__PURE__ */ jsxs("div", { className: "mb-3 break-inside-avoid", children: [
58
- /* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400", children: g.label }),
59
- /* @__PURE__ */ jsx("ul", { className: "space-y-0.5", children: g.items.map((item) => {
60
- const isSelected = selected !== null && g.key === selected.group && item.key === selected.item;
61
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
62
- "button",
63
- {
64
- type: "button",
65
- onClick: () => {
66
- onSelect(g.key, item.key);
67
- setOpen(false);
68
- },
69
- className: cn(
70
- "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm",
71
- isSelected ? "bg-sky-100 font-medium text-sky-900 dark:bg-sky-900/40 dark:text-sky-200" : "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
72
- ),
73
- children: [
74
- item.icon && /* @__PURE__ */ jsx("span", { className: "shrink-0", children: item.icon }),
75
- /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: item.label })
76
- ]
77
- }
78
- ) }, item.key);
79
- }) })
80
- ] }, g.key)) }) })
60
+ open && /* @__PURE__ */ jsxs("div", { className: "absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg", children: [
61
+ /* @__PURE__ */ jsx(
62
+ DropdownSearchHeader,
63
+ {
64
+ query,
65
+ onQueryChange: setQuery,
66
+ inputRef,
67
+ placeholder: filterPlaceholder
68
+ }
69
+ ),
70
+ /* @__PURE__ */ jsx("div", { className: "overflow-y-auto p-3", children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-[var(--text-muted)]", children: "\u2014" }) : /* @__PURE__ */ jsx("div", { className: "columns-2 gap-4 md:columns-3 xl:columns-4", children: filtered.map((g) => /* @__PURE__ */ jsxs("div", { className: "mb-3 break-inside-avoid", children: [
71
+ /* @__PURE__ */ jsx("div", { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]", children: g.label }),
72
+ /* @__PURE__ */ jsx("ul", { className: "space-y-0.5", children: g.items.map((item) => {
73
+ const isSelected = selected !== null && g.key === selected.group && item.key === selected.item;
74
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
75
+ "button",
76
+ {
77
+ type: "button",
78
+ onClick: () => {
79
+ onSelect(g.key, item.key);
80
+ setOpen(false);
81
+ },
82
+ className: cn(
83
+ "flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm",
84
+ isSelected ? "bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
85
+ ),
86
+ children: [
87
+ item.icon && /* @__PURE__ */ jsx("span", { className: "shrink-0", children: item.icon }),
88
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: item.label })
89
+ ]
90
+ }
91
+ ) }, item.key);
92
+ }) })
93
+ ] }, g.key)) }) })
94
+ ] })
81
95
  ] })
82
- ] });
96
+ );
83
97
  }
84
98
  export {
85
99
  GroupedPicker
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n filterPlaceholder,\n className,\n}: {\n groups: PickerGroup[];\n /** Face of the closed picker button. */\n buttonLabel: ReactNode;\n /** Keys of the current selection, if any. */\n selected: { group: string; item: string } | null;\n onSelect: (groupKey: string, itemKey: string) => void;\n ariaLabel?: string;\n filterPlaceholder?: string;\n className?: string;\n}) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [open, setOpen]);\n\n const filtered = useMemo(() => {\n const q = query.trim().toLowerCase();\n if (!q) return groups;\n return groups\n .map((g) =>\n g.label.toLowerCase().includes(q)\n ? g\n : { ...g, items: g.items.filter((i) => i.label.toLowerCase().includes(q)) },\n )\n .filter((g) => g.items.length > 0);\n }, [groups, query]);\n\n return (\n <div ref={wrapperRef} className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={ariaLabel}\n onClick={() => setOpen((o) => !o)}\n className=\"flex w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm text-slate-800 hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800\"\n >\n <span className=\"truncate\">{buttonLabel}</span>\n <ChevronDown className=\"size-4 shrink-0 text-slate-400\" />\n </button>\n {open && (\n <div className=\"absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-900\">\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={filterPlaceholder}\n />\n <div className=\"overflow-y-auto p-3\">\n {filtered.length === 0 ? (\n <p className=\"text-sm text-slate-500 dark:text-slate-400\">—</p>\n ) : (\n <div className=\"columns-2 gap-4 md:columns-3 xl:columns-4\">\n {filtered.map((g) => (\n <div key={g.key} className=\"mb-3 break-inside-avoid\">\n <div className=\"mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400\">\n {g.label}\n </div>\n <ul className=\"space-y-0.5\">\n {g.items.map((item) => {\n const isSelected =\n selected !== null &&\n g.key === selected.group &&\n item.key === selected.item;\n return (\n <li key={item.key}>\n <button\n type=\"button\"\n onClick={() => {\n onSelect(g.key, item.key);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm\",\n isSelected\n ? \"bg-sky-100 font-medium text-sky-900 dark:bg-sky-900/40 dark:text-sky-200\"\n : \"text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800\",\n )}\n >\n {item.icon && <span className=\"shrink-0\">{item.icon}</span>}\n <span className=\"min-w-0 truncate\">{item.label}</span>\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"AAiEM,SAQE,KARF;AAjEN,SAAS,WAAW,eAAe;AAEnC,SAAS,mBAAmB;AAC5B,SAAS,UAAU;AACnB,SAAS,sBAAsB,yBAAyB;AAiBjD,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAUG;AACD,QAAM,EAAE,MAAM,SAAS,YAAY,OAAO,UAAU,SAAS,IAAI,kBAAkB;AAEnF,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,SAAQ,KAAK;AAAA,IACvC;AACA,aAAS,iBAAiB,WAAW,KAAK;AAC1C,WAAO,MAAM,SAAS,oBAAoB,WAAW,KAAK;AAAA,EAC5D,GAAG,CAAC,MAAM,OAAO,CAAC;AAElB,QAAM,WAAW,QAAQ,MAAM;AAC7B,UAAM,IAAI,MAAM,KAAK,EAAE,YAAY;AACnC,QAAI,CAAC,EAAG,QAAO;AACf,WAAO,OACJ;AAAA,MAAI,CAAC,MACJ,EAAE,MAAM,YAAY,EAAE,SAAS,CAAC,IAC5B,IACA,EAAE,GAAG,GAAG,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,MAAM,YAAY,EAAE,SAAS,CAAC,CAAC,EAAE;AAAA,IAC9E,EACC,OAAO,CAAC,MAAM,EAAE,MAAM,SAAS,CAAC;AAAA,EACrC,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,SACE,qBAAC,SAAI,KAAK,YAAY,WAAW,GAAG,YAAY,SAAS,GACvD;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,iBAAc;AAAA,QACd,iBAAe;AAAA,QACf,cAAY;AAAA,QACZ,SAAS,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,QAChC,WAAU;AAAA,QAEV;AAAA,8BAAC,UAAK,WAAU,YAAY,uBAAY;AAAA,UACxC,oBAAC,eAAY,WAAU,kCAAiC;AAAA;AAAA;AAAA,IAC1D;AAAA,IACC,QACC,qBAAC,SAAI,WAAU,sLACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,eAAe;AAAA,UACf;AAAA,UACA,aAAa;AAAA;AAAA,MACf;AAAA,MACA,oBAAC,SAAI,WAAU,uBACZ,mBAAS,WAAW,IACnB,oBAAC,OAAE,WAAU,8CAA6C,oBAAC,IAE3D,oBAAC,SAAI,WAAU,6CACZ,mBAAS,IAAI,CAAC,MACb,qBAAC,SAAgB,WAAU,2BACzB;AAAA,4BAAC,SAAI,WAAU,yFACZ,YAAE,OACL;AAAA,QACA,oBAAC,QAAG,WAAU,eACX,YAAE,MAAM,IAAI,CAAC,SAAS;AACrB,gBAAM,aACJ,aAAa,QACb,EAAE,QAAQ,SAAS,SACnB,KAAK,QAAQ,SAAS;AACxB,iBACE,oBAAC,QACC;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS,MAAM;AACb,yBAAS,EAAE,KAAK,KAAK,GAAG;AACxB,wBAAQ,KAAK;AAAA,cACf;AAAA,cACA,WAAW;AAAA,gBACT;AAAA,gBACA,aACI,6EACA;AAAA,cACN;AAAA,cAEC;AAAA,qBAAK,QAAQ,oBAAC,UAAK,WAAU,YAAY,eAAK,MAAK;AAAA,gBACpD,oBAAC,UAAK,WAAU,oBAAoB,eAAK,OAAM;AAAA;AAAA;AAAA,UACjD,KAhBO,KAAK,GAiBd;AAAA,QAEJ,CAAC,GACH;AAAA,WA/BQ,EAAE,GAgCZ,CACD,GACH,GAEJ;AAAA,OACF;AAAA,KAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/grouped-picker.tsx"],"sourcesContent":["import { useEffect, useMemo } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { DropdownSearchHeader, useDropdownSearch } from \"./dropdown\";\nimport { useKitLocale } from \"../i18n/kit-labels\";\n\nexport interface PickerGroup {\n key: string;\n label: string;\n /** `icon` is an optional leading glyph, drawn before the label — the same slot\n * `ComboOption` has always had, so the two pickers can present one option set\n * the same way. Omitted, a row is exactly what it was: label only, with no\n * reserved space, so a list where nothing has an icon does not indent. */\n items: { key: string; label: string; icon?: ReactNode }[];\n}\n\n/**\n * `onSelect` is omitted from the div's own attributes because this component's\n * means \"an item was chosen\", not the DOM's \"text was selected inside me\" — two\n * different signatures under one name, and the kit's meaning is the one three apps\n * are written against.\n */\nexport interface GroupedPickerProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSelect\"> {\n groups: PickerGroup[];\n /** Face of the closed picker button. */\n buttonLabel: ReactNode;\n /** Keys of the current selection, if any. */\n selected: { group: string; item: string } | null;\n onSelect: (groupKey: string, itemKey: string) => void;\n /**\n * @deprecated Use `aria-label`, the DOM spelling every control in the kit now\n * takes. Kept working — three apps pass it — and it applies only when\n * `aria-label` is absent; it goes in a later minor.\n */\n ariaLabel?: string;\n filterPlaceholder?: string;\n}\n\n/** Panel picker for large grouped option sets: instead of one long flat\n * dropdown, the groups sit side by side as columns with their items listed\n * beneath (feedback #267/#276), narrowed by a filter box at the top (feedback\n * #267 rework). A query that matches a group's name keeps the whole group;\n * otherwise groups are reduced to their matching items. */\nexport function GroupedPicker({\n groups,\n buttonLabel,\n selected,\n onSelect,\n ariaLabel,\n \"aria-label\": ariaLabelAttr,\n filterPlaceholder,\n className,\n ...rest\n}: GroupedPickerProps) {\n const { open, setOpen, wrapperRef, query, setQuery, inputRef } = useDropdownSearch();\n\n useEffect(() => {\n if (!open) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", onKey);\n return () => document.removeEventListener(\"keydown\", onKey);\n }, [open, setOpen]);\n\n // Case folding in the provider's locale — see `rowMatches` for the Turkish \"İ\"\n // that plain `toLowerCase` gets wrong.\n const locale = useKitLocale();\n const filtered = useMemo(() => {\n const fold = (s: string) => s.toLocaleLowerCase(locale);\n const q = fold(query.trim());\n if (!q) return groups;\n return groups\n .map((g) =>\n fold(g.label).includes(q)\n ? g\n : { ...g, items: g.items.filter((i) => fold(i.label).includes(q)) },\n )\n .filter((g) => g.items.length > 0);\n }, [groups, query, locale]);\n\n return (\n // `rest` goes on the wrapper, but the accessible NAME does not: this div is a\n // positioning box with no role, so a label parked on it would be announced by\n // nothing. It belongs to the control — the same split every picker in this wave\n // makes, and the reason `aria-label` is destructured out rather than left in\n // `rest`. Spread FIRST so the attributes below, each of them a fix with an\n // incident behind it, cannot be clobbered from outside.\n <div {...rest} ref={wrapperRef} className={cn(\"relative\", className)}>\n <button\n type=\"button\"\n aria-haspopup=\"dialog\"\n aria-expanded={open}\n aria-label={ariaLabelAttr ?? ariaLabel}\n onClick={() => setOpen((o) => !o)}\n className=\"flex w-full items-center justify-between gap-2 rounded-md border border-[var(--border-strong)] bg-[var(--bg-surface)] px-2.5 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n >\n <span className=\"truncate\">{buttonLabel}</span>\n <ChevronDown className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n </button>\n {open && (\n <div className=\"absolute left-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\">\n <DropdownSearchHeader\n query={query}\n onQueryChange={setQuery}\n inputRef={inputRef}\n placeholder={filterPlaceholder}\n />\n <div className=\"overflow-y-auto p-3\">\n {filtered.length === 0 ? (\n <p className=\"text-sm text-[var(--text-muted)]\">—</p>\n ) : (\n <div className=\"columns-2 gap-4 md:columns-3 xl:columns-4\">\n {filtered.map((g) => (\n <div key={g.key} className=\"mb-3 break-inside-avoid\">\n <div className=\"mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {g.label}\n </div>\n <ul className=\"space-y-0.5\">\n {g.items.map((item) => {\n const isSelected =\n selected !== null &&\n g.key === selected.group &&\n item.key === selected.item;\n return (\n <li key={item.key}>\n <button\n type=\"button\"\n onClick={() => {\n onSelect(g.key, item.key);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-left text-sm\",\n isSelected\n ? \"bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )}\n >\n {item.icon && <span className=\"shrink-0\">{item.icon}</span>}\n <span className=\"min-w-0 truncate\">{item.label}</span>\n </button>\n </li>\n );\n })}\n </ul>\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":";AA0FM,SAQE,KARF;AA1FN,SAAS,WAAW,eAAe;AAEnC,SAAS,mBAAmB;AAC5B,SAAS,UAAU;AACnB,SAAS,sBAAsB,yBAAyB;AACxD,SAAS,oBAAoB;AAuCtB,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,QAAM,EAAE,MAAM,SAAS,YAAY,OAAO,UAAU,SAAS,IAAI,kBAAkB;AAEnF,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AACX,UAAM,QAAQ,CAAC,MAAqB;AAClC,UAAI,EAAE,QAAQ,SAAU,SAAQ,KAAK;AAAA,IACvC;AACA,aAAS,iBAAiB,WAAW,KAAK;AAC1C,WAAO,MAAM,SAAS,oBAAoB,WAAW,KAAK;AAAA,EAC5D,GAAG,CAAC,MAAM,OAAO,CAAC;AAIlB,QAAM,SAAS,aAAa;AAC5B,QAAM,WAAW,QAAQ,MAAM;AAC7B,UAAM,OAAO,CAAC,MAAc,EAAE,kBAAkB,MAAM;AACtD,UAAM,IAAI,KAAK,MAAM,KAAK,CAAC;AAC3B,QAAI,CAAC,EAAG,QAAO;AACf,WAAO,OACJ;AAAA,MAAI,CAAC,MACJ,KAAK,EAAE,KAAK,EAAE,SAAS,CAAC,IACpB,IACA,EAAE,GAAG,GAAG,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,KAAK,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,EAAE;AAAA,IACtE,EACC,OAAO,CAAC,MAAM,EAAE,MAAM,SAAS,CAAC;AAAA,EACrC,GAAG,CAAC,QAAQ,OAAO,MAAM,CAAC;AAE1B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOE,qBAAC,SAAK,GAAG,MAAM,KAAK,YAAY,WAAW,GAAG,YAAY,SAAS,GACjE;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,iBAAc;AAAA,UACd,iBAAe;AAAA,UACf,cAAY,iBAAiB;AAAA,UAC7B,SAAS,MAAM,QAAQ,CAAC,MAAM,CAAC,CAAC;AAAA,UAChC,WAAU;AAAA,UAEV;AAAA,gCAAC,UAAK,WAAU,YAAY,uBAAY;AAAA,YACxC,oBAAC,eAAY,WAAU,kDAAiD;AAAA;AAAA;AAAA,MAC1E;AAAA,MACC,QACC,qBAAC,SAAI,WAAU,kKACb;AAAA;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,eAAe;AAAA,YACf;AAAA,YACA,aAAa;AAAA;AAAA,QACf;AAAA,QACA,oBAAC,SAAI,WAAU,uBACZ,mBAAS,WAAW,IACnB,oBAAC,OAAE,WAAU,oCAAmC,oBAAC,IAEjD,oBAAC,SAAI,WAAU,6CACZ,mBAAS,IAAI,CAAC,MACb,qBAAC,SAAgB,WAAU,2BACzB;AAAA,8BAAC,SAAI,WAAU,+EACZ,YAAE,OACL;AAAA,UACA,oBAAC,QAAG,WAAU,eACX,YAAE,MAAM,IAAI,CAAC,SAAS;AACrB,kBAAM,aACJ,aAAa,QACb,EAAE,QAAQ,SAAS,SACnB,KAAK,QAAQ,SAAS;AACxB,mBACE,oBAAC,QACC;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAS,MAAM;AACb,2BAAS,EAAE,KAAK,KAAK,GAAG;AACxB,0BAAQ,KAAK;AAAA,gBACf;AAAA,gBACA,WAAW;AAAA,kBACT;AAAA,kBACA,aACI,+DACA;AAAA,gBACN;AAAA,gBAEC;AAAA,uBAAK,QAAQ,oBAAC,UAAK,WAAU,YAAY,eAAK,MAAK;AAAA,kBACpD,oBAAC,UAAK,WAAU,oBAAoB,eAAK,OAAM;AAAA;AAAA;AAAA,YACjD,KAhBO,KAAK,GAiBd;AAAA,UAEJ,CAAC,GACH;AAAA,aA/BQ,EAAE,GAgCZ,CACD,GACH,GAEJ;AAAA,SACF;AAAA,OAEJ;AAAA;AAEJ;","names":[]}