@eifi1/ui-kit 0.11.0 → 0.13.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 (397) hide show
  1. package/README.md +59 -34
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/amount-input.d.ts +40 -4
  12. package/dist/components/amount-input.js +50 -12
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/bulk-action-bar.d.ts +10 -2
  21. package/dist/components/bulk-action-bar.js.map +1 -1
  22. package/dist/components/button-group.d.ts +51 -2
  23. package/dist/components/button-group.js +5 -1
  24. package/dist/components/button-group.js.map +1 -1
  25. package/dist/components/calculator.d.ts +15 -4
  26. package/dist/components/calendar-heatmap.d.ts +41 -3
  27. package/dist/components/calendar-heatmap.js +48 -7
  28. package/dist/components/calendar-heatmap.js.map +1 -1
  29. package/dist/components/chart.js +14 -8
  30. package/dist/components/chart.js.map +1 -1
  31. package/dist/components/chip.d.ts +2 -1
  32. package/dist/components/chip.js +26 -18
  33. package/dist/components/chip.js.map +1 -1
  34. package/dist/components/choice-card.d.ts +35 -3
  35. package/dist/components/choice-card.js +83 -25
  36. package/dist/components/choice-card.js.map +1 -1
  37. package/dist/components/combobox-core.d.ts +3 -1
  38. package/dist/components/combobox-core.js +18 -5
  39. package/dist/components/combobox-core.js.map +1 -1
  40. package/dist/components/combobox.d.ts +2 -2
  41. package/dist/components/combobox.js +31 -9
  42. package/dist/components/combobox.js.map +1 -1
  43. package/dist/components/copy-button.d.ts +51 -70
  44. package/dist/components/copy-button.js +1 -1
  45. package/dist/components/copy-button.js.map +1 -1
  46. package/dist/components/data-table-cells.d.ts +63 -0
  47. package/dist/components/data-table-cells.js +64 -0
  48. package/dist/components/data-table-cells.js.map +1 -0
  49. package/dist/components/data-table-filter-popover.d.ts +1 -1
  50. package/dist/components/data-table-filter-popover.js +8 -1
  51. package/dist/components/data-table-filter-popover.js.map +1 -1
  52. package/dist/components/data-table-filters.d.ts +1 -1
  53. package/dist/components/data-table-filters.js +31 -1
  54. package/dist/components/data-table-filters.js.map +1 -1
  55. package/dist/components/data-table-labels.d.ts +13 -0
  56. package/dist/components/data-table-labels.js +8 -1
  57. package/dist/components/data-table-labels.js.map +1 -1
  58. package/dist/components/data-table.d.ts +1 -1
  59. package/dist/components/data-table.js +125 -27
  60. package/dist/components/data-table.js.map +1 -1
  61. package/dist/components/date-picker.d.ts +3 -1
  62. package/dist/components/date-picker.js +15 -2
  63. package/dist/components/date-picker.js.map +1 -1
  64. package/dist/components/description-list.d.ts +23 -3
  65. package/dist/components/description-list.js +23 -3
  66. package/dist/components/description-list.js.map +1 -1
  67. package/dist/components/dialog-frame.d.ts +1 -0
  68. package/dist/components/entity-combobox.d.ts +1 -1
  69. package/dist/components/entity-combobox.js +40 -20
  70. package/dist/components/entity-combobox.js.map +1 -1
  71. package/dist/components/error-boundary.d.ts +281 -0
  72. package/dist/components/error-boundary.js +302 -0
  73. package/dist/components/error-boundary.js.map +1 -0
  74. package/dist/components/field-sync.js +90 -83
  75. package/dist/components/field-sync.js.map +1 -1
  76. package/dist/components/field.d.ts +30 -4
  77. package/dist/components/field.js +40 -17
  78. package/dist/components/field.js.map +1 -1
  79. package/dist/components/file-button.d.ts +50 -208
  80. package/dist/components/file-dropzone.d.ts +49 -2
  81. package/dist/components/floating-panel.d.ts +13 -4
  82. package/dist/components/floating-panel.js +7 -3
  83. package/dist/components/floating-panel.js.map +1 -1
  84. package/dist/components/form-actions.d.ts +53 -0
  85. package/dist/components/form-actions.js +98 -0
  86. package/dist/components/form-actions.js.map +1 -0
  87. package/dist/components/full-bleed-dialog.js +4 -0
  88. package/dist/components/full-bleed-dialog.js.map +1 -1
  89. package/dist/components/hover-menu.js +64 -56
  90. package/dist/components/hover-menu.js.map +1 -1
  91. package/dist/components/image-grid.d.ts +67 -0
  92. package/dist/components/image-grid.js +112 -0
  93. package/dist/components/image-grid.js.map +1 -0
  94. package/dist/components/lightbox.d.ts +104 -0
  95. package/dist/components/lightbox.js +229 -0
  96. package/dist/components/lightbox.js.map +1 -0
  97. package/dist/components/line-items.d.ts +114 -0
  98. package/dist/components/line-items.js +238 -0
  99. package/dist/components/line-items.js.map +1 -0
  100. package/dist/components/list.d.ts +37 -4
  101. package/dist/components/list.js +35 -14
  102. package/dist/components/list.js.map +1 -1
  103. package/dist/components/loading-state.d.ts +24 -0
  104. package/dist/components/loading-state.js +32 -0
  105. package/dist/components/loading-state.js.map +1 -0
  106. package/dist/components/menu-item.d.ts +2 -1
  107. package/dist/components/menu-item.js +5 -1
  108. package/dist/components/menu-item.js.map +1 -1
  109. package/dist/components/mini-calendar.d.ts +13 -1
  110. package/dist/components/mini-calendar.js +46 -2
  111. package/dist/components/mini-calendar.js.map +1 -1
  112. package/dist/components/modal.d.ts +20 -1
  113. package/dist/components/modal.js +26 -2
  114. package/dist/components/modal.js.map +1 -1
  115. package/dist/components/month-picker.d.ts +21 -1
  116. package/dist/components/month-picker.js +139 -26
  117. package/dist/components/month-picker.js.map +1 -1
  118. package/dist/components/multi-entity-combobox.d.ts +1 -1
  119. package/dist/components/multi-entity-combobox.js +42 -20
  120. package/dist/components/multi-entity-combobox.js.map +1 -1
  121. package/dist/components/nav-pills.d.ts +2 -1
  122. package/dist/components/nav-pills.js +5 -1
  123. package/dist/components/nav-pills.js.map +1 -1
  124. package/dist/components/number-field.d.ts +24 -5
  125. package/dist/components/number-field.js +5 -7
  126. package/dist/components/number-field.js.map +1 -1
  127. package/dist/components/number-input.d.ts +25 -5
  128. package/dist/components/number-input.js +7 -1
  129. package/dist/components/number-input.js.map +1 -1
  130. package/dist/components/numpad-sheet.d.ts +20 -5
  131. package/dist/components/numpad-sheet.js +3 -2
  132. package/dist/components/numpad-sheet.js.map +1 -1
  133. package/dist/components/page-header.d.ts +35 -6
  134. package/dist/components/page-header.js +7 -1
  135. package/dist/components/page-header.js.map +1 -1
  136. package/dist/components/passkeys-setting.d.ts +67 -0
  137. package/dist/components/passkeys-setting.js +214 -0
  138. package/dist/components/passkeys-setting.js.map +1 -0
  139. package/dist/components/picker-sheet.js +5 -1
  140. package/dist/components/picker-sheet.js.map +1 -1
  141. package/dist/components/pie-chart-labels.d.ts +20 -0
  142. package/dist/components/pie-chart-labels.js +12 -0
  143. package/dist/components/pie-chart-labels.js.map +1 -0
  144. package/dist/components/pie-chart.d.ts +101 -0
  145. package/dist/components/pie-chart.js +283 -0
  146. package/dist/components/pie-chart.js.map +1 -0
  147. package/dist/components/popover.js +3 -0
  148. package/dist/components/popover.js.map +1 -1
  149. package/dist/components/progress-bar.d.ts +44 -3
  150. package/dist/components/progress-bar.js +58 -22
  151. package/dist/components/progress-bar.js.map +1 -1
  152. package/dist/components/qr-code.d.ts +34 -0
  153. package/dist/components/qr-code.js +57 -0
  154. package/dist/components/qr-code.js.map +1 -0
  155. package/dist/components/search-field.d.ts +15 -3
  156. package/dist/components/search-field.js +5 -1
  157. package/dist/components/search-field.js.map +1 -1
  158. package/dist/components/settings-fields.d.ts +49 -1
  159. package/dist/components/signed-amount.d.ts +122 -0
  160. package/dist/components/signed-amount.js +152 -0
  161. package/dist/components/signed-amount.js.map +1 -0
  162. package/dist/components/skeleton.d.ts +10 -3
  163. package/dist/components/skeleton.js +12 -2
  164. package/dist/components/skeleton.js.map +1 -1
  165. package/dist/components/stat-tile.d.ts +2 -1
  166. package/dist/components/stat-tile.js +6 -3
  167. package/dist/components/stat-tile.js.map +1 -1
  168. package/dist/components/swipeable-row.js +4 -3
  169. package/dist/components/swipeable-row.js.map +1 -1
  170. package/dist/components/table.d.ts +39 -5
  171. package/dist/components/table.js +45 -11
  172. package/dist/components/table.js.map +1 -1
  173. package/dist/components/text-link.d.ts +238 -0
  174. package/dist/components/text-link.js +144 -0
  175. package/dist/components/text-link.js.map +1 -0
  176. package/dist/components/text.d.ts +26 -2
  177. package/dist/components/text.js +16 -2
  178. package/dist/components/text.js.map +1 -1
  179. package/dist/components/time-input.d.ts +49 -1
  180. package/dist/components/toggle-group.d.ts +35 -0
  181. package/dist/components/toggle-group.js +43 -14
  182. package/dist/components/toggle-group.js.map +1 -1
  183. package/dist/components/toggle-legend.d.ts +53 -3
  184. package/dist/components/toggle-legend.js +54 -1
  185. package/dist/components/toggle-legend.js.map +1 -1
  186. package/dist/components/tooltip.d.ts +51 -1
  187. package/dist/components/tooltip.js +59 -35
  188. package/dist/components/tooltip.js.map +1 -1
  189. package/dist/components/ui.d.ts +51 -779
  190. package/dist/components/ui.js +364 -34
  191. package/dist/components/ui.js.map +1 -1
  192. package/dist/components/use-table-state.d.ts +3 -52
  193. package/dist/components/use-table-state.js +128 -30
  194. package/dist/components/use-table-state.js.map +1 -1
  195. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  196. package/dist/data-table.d.ts +2 -1
  197. package/dist/data-table.js +5 -1
  198. package/dist/data-table.js.map +1 -1
  199. package/dist/feedback/feedback-attachment.js +4 -6
  200. package/dist/feedback/feedback-attachment.js.map +1 -1
  201. package/dist/feedback/feedback-dialog.d.ts +26 -3
  202. package/dist/feedback/feedback-dialog.js +21 -5
  203. package/dist/feedback/feedback-dialog.js.map +1 -1
  204. package/dist/feedback/feedback-inbox.js +86 -75
  205. package/dist/feedback/feedback-inbox.js.map +1 -1
  206. package/dist/feedback/feedback-thread.d.ts +173 -0
  207. package/dist/feedback/feedback-thread.js +264 -0
  208. package/dist/feedback/feedback-thread.js.map +1 -0
  209. package/dist/feedback.d.ts +2 -1
  210. package/dist/feedback.js +1 -0
  211. package/dist/feedback.js.map +1 -1
  212. package/dist/hooks/use-authed-src.d.ts +71 -0
  213. package/dist/hooks/use-authed-src.js +63 -0
  214. package/dist/hooks/use-authed-src.js.map +1 -0
  215. package/dist/hooks/use-file-drop.d.ts +49 -2
  216. package/dist/hooks/use-hotkey.d.ts +59 -0
  217. package/dist/hooks/use-hotkey.js +105 -0
  218. package/dist/hooks/use-hotkey.js.map +1 -0
  219. package/dist/hooks/use-search-param-state.d.ts +83 -0
  220. package/dist/hooks/use-search-param-state.js +94 -0
  221. package/dist/hooks/use-search-param-state.js.map +1 -0
  222. package/dist/i18n/defaults.d.ts +15 -4
  223. package/dist/i18n/defaults.js +31 -1
  224. package/dist/i18n/defaults.js.map +1 -1
  225. package/dist/i18n/kit-labels.d.ts +46 -312
  226. package/dist/i18n/kit-labels.js +20 -5
  227. package/dist/i18n/kit-labels.js.map +1 -1
  228. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  229. package/dist/i18n/locales/de-CH.d.ts +15 -4
  230. package/dist/i18n/locales/de-informal.d.ts +15 -4
  231. package/dist/i18n/locales/de.d.ts +15 -4
  232. package/dist/i18n/locales/de.js +164 -3
  233. package/dist/i18n/locales/de.js.map +1 -1
  234. package/dist/i18n/locales/es.d.ts +15 -4
  235. package/dist/i18n/locales/es.js +161 -3
  236. package/dist/i18n/locales/es.js.map +1 -1
  237. package/dist/i18n/locales/fr.d.ts +15 -4
  238. package/dist/i18n/locales/fr.js +161 -3
  239. package/dist/i18n/locales/fr.js.map +1 -1
  240. package/dist/i18n/locales/hu.d.ts +15 -4
  241. package/dist/i18n/locales/hu.js +161 -3
  242. package/dist/i18n/locales/hu.js.map +1 -1
  243. package/dist/i18n/locales/it.d.ts +15 -4
  244. package/dist/i18n/locales/it.js +161 -3
  245. package/dist/i18n/locales/it.js.map +1 -1
  246. package/dist/i18n/locales/zh.d.ts +15 -4
  247. package/dist/i18n/locales/zh.js +161 -3
  248. package/dist/i18n/locales/zh.js.map +1 -1
  249. package/dist/index.d.ts +39 -19
  250. package/dist/index.js +40 -1
  251. package/dist/index.js.map +1 -1
  252. package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
  253. package/dist/lib/format.d.ts +162 -0
  254. package/dist/lib/format.js +151 -0
  255. package/dist/lib/format.js.map +1 -0
  256. package/dist/lib/qr-encode.d.ts +44 -0
  257. package/dist/lib/qr-encode.js +338 -0
  258. package/dist/lib/qr-encode.js.map +1 -0
  259. package/dist/rhf/fields.d.ts +283 -0
  260. package/dist/rhf/fields.js +631 -0
  261. package/dist/rhf/fields.js.map +1 -0
  262. package/dist/rhf/form.d.ts +49 -1
  263. package/dist/rhf/line-items.d.ts +31 -0
  264. package/dist/rhf/line-items.js +59 -0
  265. package/dist/rhf/line-items.js.map +1 -0
  266. package/dist/rhf.d.ts +61 -1
  267. package/dist/rhf.js +2 -0
  268. package/dist/rhf.js.map +1 -1
  269. package/dist/search/command-palette.js +20 -14
  270. package/dist/search/command-palette.js.map +1 -1
  271. package/dist/shell/app-shell.d.ts +99 -2
  272. package/dist/shell/app-shell.js +120 -57
  273. package/dist/shell/app-shell.js.map +1 -1
  274. package/dist/shell/auth-layout.d.ts +74 -0
  275. package/dist/shell/auth-layout.js +97 -0
  276. package/dist/shell/auth-layout.js.map +1 -0
  277. package/dist/shell/top-bar-brand.d.ts +81 -0
  278. package/dist/shell/top-bar-brand.js +47 -0
  279. package/dist/shell/top-bar-brand.js.map +1 -0
  280. package/dist/shell/topbar-action-menu.d.ts +21 -4
  281. package/dist/shell/topbar-action-menu.js +18 -2
  282. package/dist/shell/topbar-action-menu.js.map +1 -1
  283. package/dist/shell.d.ts +56 -1
  284. package/dist/shell.js +2 -0
  285. package/dist/shell.js.map +1 -1
  286. package/dist/tour/tour.js +10 -4
  287. package/dist/tour/tour.js.map +1 -1
  288. package/dist/wizard/stepper-nav.d.ts +48 -1
  289. package/dist/wizard/use-wizard.js +4 -2
  290. package/dist/wizard/use-wizard.js.map +1 -1
  291. package/dist/wizard/wizard-context.d.ts +24 -1
  292. package/dist/wizard/wizard-context.js +19 -2
  293. package/dist/wizard/wizard-context.js.map +1 -1
  294. package/dist/wizard/wizard-step.d.ts +30 -5
  295. package/dist/wizard/wizard-step.js +34 -3
  296. package/dist/wizard/wizard-step.js.map +1 -1
  297. package/dist/wizard.d.ts +50 -3
  298. package/package.json +6 -6
  299. package/src/chart.ts +4 -0
  300. package/src/components/account-settings-labels.ts +190 -0
  301. package/src/components/account-settings.tsx +110 -63
  302. package/src/components/amount-input.tsx +109 -10
  303. package/src/components/authed-image.tsx +190 -0
  304. package/src/components/breadcrumbs.tsx +8 -2
  305. package/src/components/bulk-action-bar.tsx +16 -3
  306. package/src/components/button-group.tsx +7 -2
  307. package/src/components/calendar-heatmap.tsx +105 -10
  308. package/src/components/chart.tsx +19 -8
  309. package/src/components/chip.tsx +14 -4
  310. package/src/components/choice-card.tsx +136 -25
  311. package/src/components/combobox-core.tsx +33 -9
  312. package/src/components/combobox.tsx +33 -9
  313. package/src/components/copy-button.tsx +3 -2
  314. package/src/components/data-table-cells.tsx +119 -0
  315. package/src/components/data-table-filter-popover.tsx +13 -5
  316. package/src/components/data-table-filters.ts +77 -0
  317. package/src/components/data-table-labels.ts +23 -0
  318. package/src/components/data-table.tsx +230 -13
  319. package/src/components/date-picker.tsx +19 -3
  320. package/src/components/description-list.tsx +52 -3
  321. package/src/components/entity-combobox.tsx +25 -7
  322. package/src/components/error-boundary.tsx +640 -0
  323. package/src/components/field-sync.tsx +48 -40
  324. package/src/components/field.tsx +56 -5
  325. package/src/components/floating-panel.tsx +20 -7
  326. package/src/components/form-actions.tsx +191 -0
  327. package/src/components/full-bleed-dialog.tsx +4 -0
  328. package/src/components/hover-menu.tsx +26 -17
  329. package/src/components/image-grid.tsx +181 -0
  330. package/src/components/lightbox.tsx +374 -0
  331. package/src/components/line-items.tsx +385 -0
  332. package/src/components/list.tsx +82 -19
  333. package/src/components/loading-state.tsx +47 -0
  334. package/src/components/menu-item.tsx +7 -2
  335. package/src/components/mini-calendar.tsx +72 -3
  336. package/src/components/modal.tsx +65 -3
  337. package/src/components/month-picker.tsx +181 -26
  338. package/src/components/multi-entity-combobox.tsx +27 -7
  339. package/src/components/nav-pills.tsx +8 -1
  340. package/src/components/number-field.tsx +15 -10
  341. package/src/components/number-input.tsx +17 -1
  342. package/src/components/numpad-sheet.tsx +6 -1
  343. package/src/components/page-header.tsx +42 -5
  344. package/src/components/passkeys-setting.tsx +331 -0
  345. package/src/components/picker-sheet.tsx +10 -1
  346. package/src/components/pie-chart-labels.ts +32 -0
  347. package/src/components/pie-chart.tsx +479 -0
  348. package/src/components/popover.tsx +3 -0
  349. package/src/components/progress-bar.tsx +129 -19
  350. package/src/components/qr-code.tsx +83 -0
  351. package/src/components/search-field.tsx +20 -4
  352. package/src/components/signed-amount.tsx +295 -0
  353. package/src/components/skeleton.tsx +21 -3
  354. package/src/components/stat-tile.tsx +11 -3
  355. package/src/components/swipeable-row.tsx +7 -7
  356. package/src/components/table.tsx +102 -13
  357. package/src/components/text-link.tsx +356 -0
  358. package/src/components/text.tsx +46 -2
  359. package/src/components/toggle-group.tsx +93 -20
  360. package/src/components/toggle-legend.tsx +108 -2
  361. package/src/components/tooltip.tsx +79 -8
  362. package/src/components/ui.tsx +707 -49
  363. package/src/components/use-table-state.ts +265 -33
  364. package/src/data-table.ts +10 -0
  365. package/src/feedback/feedback-attachment.tsx +10 -7
  366. package/src/feedback/feedback-dialog.tsx +49 -7
  367. package/src/feedback/feedback-inbox.tsx +7 -0
  368. package/src/feedback/feedback-thread.tsx +510 -0
  369. package/src/feedback.ts +1 -0
  370. package/src/hooks/use-authed-src.ts +161 -0
  371. package/src/hooks/use-hotkey.ts +172 -0
  372. package/src/hooks/use-search-param-state.ts +187 -0
  373. package/src/i18n/defaults.ts +30 -0
  374. package/src/i18n/kit-labels.tsx +118 -8
  375. package/src/i18n/locales/de.ts +161 -0
  376. package/src/i18n/locales/es.ts +158 -0
  377. package/src/i18n/locales/fr.ts +161 -0
  378. package/src/i18n/locales/hu.ts +159 -0
  379. package/src/i18n/locales/it.ts +159 -0
  380. package/src/i18n/locales/zh.ts +158 -0
  381. package/src/index.ts +59 -0
  382. package/src/lib/format.ts +348 -0
  383. package/src/lib/qr-encode.ts +420 -0
  384. package/src/rhf/fields.tsx +1135 -0
  385. package/src/rhf/line-items.tsx +130 -0
  386. package/src/rhf.ts +6 -0
  387. package/src/search/command-palette.tsx +29 -19
  388. package/src/shell/app-shell.tsx +153 -3
  389. package/src/shell/auth-layout.tsx +190 -0
  390. package/src/shell/top-bar-brand.tsx +76 -0
  391. package/src/shell/topbar-action-menu.tsx +32 -4
  392. package/src/shell.ts +2 -0
  393. package/src/tour/tour.tsx +16 -5
  394. package/src/wizard/use-wizard.ts +8 -4
  395. package/src/wizard/wizard-context.tsx +41 -1
  396. package/src/wizard/wizard-step.tsx +75 -6
  397. package/tokens.css +12 -0
@@ -1,8 +1,19 @@
1
1
  import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useRef } from "react";
2
- import type { ButtonHTMLAttributes, ChangeEvent, ComponentType, InputHTMLAttributes, ReactNode } from "react";
2
+ import type {
3
+ ButtonHTMLAttributes,
4
+ ChangeEvent,
5
+ ComponentType,
6
+ InputHTMLAttributes,
7
+ MouseEvent,
8
+ ReactElement,
9
+ ReactNode,
10
+ Ref,
11
+ } from "react";
3
12
  import { Check, Minus } from "lucide-react";
4
13
  import { cn } from "../lib/cn";
5
14
  import { assignRef, hasMessage, mergeDescribedBy } from "./choice-parts";
15
+ import { useKitLink } from "../i18n/kit-labels";
16
+ import { pickLinkRenderer } from "./text-link";
6
17
 
7
18
  /**
8
19
  * A checkbox or a radio, presented as a bordered card: a title, a line of description,
@@ -231,6 +242,30 @@ const META_TONE: Record<ActionCardMetaTone, string> = {
231
242
  success: "text-[var(--success)]",
232
243
  };
233
244
 
245
+ /** The colour of {@link ActionCardProps.icon}. `default` is the secondary text colour
246
+ * the icon has always had; `brand` tints it (keksdose's privacy enrolment). */
247
+ export type ActionCardIconTone = "default" | "muted" | "brand" | "warning" | "danger" | "info" | "success";
248
+
249
+ const ICON_TONE: Record<ActionCardIconTone, string> = {
250
+ default: "text-[var(--text-secondary)]",
251
+ ...META_TONE,
252
+ brand: "text-[var(--brand)]",
253
+ };
254
+
255
+ /** What {@link ActionCardProps.renderLink} (and the provider's `linkComponent`) is
256
+ * handed. Spread it onto your router's link — `({ href, ...p }) => <Link to={href} {...p} />`. */
257
+ export interface ActionCardLinkProps {
258
+ href: string;
259
+ /** The card's whole look — keep it. */
260
+ className: string;
261
+ children: ReactNode;
262
+ ref?: Ref<HTMLAnchorElement>;
263
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
264
+ id?: string;
265
+ [key: `aria-${string}`]: string | boolean | number | undefined;
266
+ [key: `data-${string}`]: unknown;
267
+ }
268
+
234
269
  export interface ActionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "title"> {
235
270
  /** What the card does — the button's accessible name. */
236
271
  title: ReactNode;
@@ -247,6 +282,21 @@ export interface ActionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
247
282
  metaTone?: ActionCardMetaTone;
248
283
  /** A Lucide icon (or any component taking a `className`), shown at the card's start. */
249
284
  icon?: ChoiceCardProps["icon"];
285
+ /** The icon's colour. Default `default` (the secondary text colour). */
286
+ iconTone?: ActionCardIconTone;
287
+ /** Classes on the icon, after {@link iconTone} — a size, a tinted tile behind it. */
288
+ iconClassName?: string;
289
+ /**
290
+ * Makes the card a LINK to another page rather than a button that acts: kastlan's
291
+ * section tiles on the group overview (group-overview-page), each an icon, a name and
292
+ * a line on what is there. A real `<a href>`, so a middle click opens it in a new
293
+ * tab. A `disabled` card renders the inert button instead — a link cannot be
294
+ * disabled. The ref then reaches the `<a>`.
295
+ */
296
+ href?: string;
297
+ /** Your router's link for an in-app `href`. Default: the `<UiKitProvider
298
+ * linkComponent>`, then a plain `<a>`. Ignored without `href`. */
299
+ renderLink?: (props: ActionCardLinkProps) => ReactElement;
250
300
  }
251
301
 
252
302
  /**
@@ -254,7 +304,8 @@ export interface ActionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
254
304
  * card's icon, title and description, plus a `meta` line, that runs `onClick` the
255
305
  * moment it is pressed. keksdose's privacy enrolment (privacy-enroll-dialog:345,
256
306
  * `CustodyOption`) offers two custody modes this way — picking one IS the next step,
257
- * so there is no checked state to show and no "Continue" to press after it.
307
+ * so there is no checked state to show and no "Continue" to press after it. With
308
+ * `href` it is the same card as a link (kastlan's overview section tiles).
258
309
  *
259
310
  * A separate component rather than `ChoiceCard as="button"`: a ChoiceCard is an
260
311
  * `<input>` (its ref, its `checked`, its form value), and a button shares none of it.
@@ -262,9 +313,24 @@ export interface ActionCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
262
313
  * "Keep the key yourself, button" and then the explanation, not one long name.
263
314
  */
264
315
  export const ActionCard = forwardRef<HTMLButtonElement, ActionCardProps>(function ActionCard(
265
- { title, description, meta, metaTone = "muted", icon: Icon, className, id, type = "button", ...rest },
316
+ {
317
+ title,
318
+ description,
319
+ meta,
320
+ metaTone = "muted",
321
+ icon: Icon,
322
+ iconTone = "default",
323
+ iconClassName,
324
+ href,
325
+ renderLink,
326
+ className,
327
+ id,
328
+ type = "button",
329
+ ...rest
330
+ },
266
331
  ref,
267
332
  ) {
333
+ const kitLink = useKitLink();
268
334
  const generated = useId();
269
335
  const baseId = id ?? generated;
270
336
  const titleId = `${baseId}-title`;
@@ -272,28 +338,23 @@ export const ActionCard = forwardRef<HTMLButtonElement, ActionCardProps>(functio
272
338
  const metaId = `${baseId}-meta`;
273
339
  const showDescription = hasMessage(description);
274
340
  const showMeta = hasMessage(meta);
275
- return (
276
- <button
277
- ref={ref}
278
- id={id}
279
- type={type}
280
- {...rest}
281
- aria-labelledby={rest["aria-labelledby"] ?? titleId}
282
- aria-describedby={mergeDescribedBy(
283
- rest["aria-describedby"],
284
- showDescription && descriptionId,
285
- showMeta && metaId,
286
- )}
287
- className={cn(
288
- "flex w-full items-start gap-3 rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-3 text-start shadow-sm transition-colors",
289
- "hover:border-[var(--brand)] hover:bg-[var(--bg-hover)]",
290
- // The outline, as on ChoiceCard: the card is what the eye is on.
291
- "focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
292
- "disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-[var(--border)] disabled:hover:bg-[var(--bg-surface)]",
293
- className,
294
- )}
295
- >
296
- {Icon && <Icon aria-hidden className="mt-0.5 size-5 shrink-0 text-[var(--text-secondary)]" />}
341
+ const look = cn(
342
+ "flex w-full items-start gap-3 rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] p-3 text-start shadow-sm transition-colors",
343
+ "hover:border-[var(--brand)] hover:bg-[var(--bg-hover)]",
344
+ // The outline, as on ChoiceCard: the card is what the eye is on.
345
+ "focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
346
+ "disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-[var(--border)] disabled:hover:bg-[var(--bg-surface)]",
347
+ className,
348
+ );
349
+ const labelledBy = rest["aria-labelledby"] ?? titleId;
350
+ const describedBy = mergeDescribedBy(
351
+ rest["aria-describedby"],
352
+ showDescription && descriptionId,
353
+ showMeta && metaId,
354
+ );
355
+ const body = (
356
+ <>
357
+ {Icon && <Icon aria-hidden className={cn("mt-0.5 size-5 shrink-0", ICON_TONE[iconTone], iconClassName)} />}
297
358
  <span className="min-w-0 flex-1 space-y-1">
298
359
  <span id={titleId} className="block text-sm font-medium leading-5 text-[var(--text-primary)]">
299
360
  {title}
@@ -309,11 +370,61 @@ export const ActionCard = forwardRef<HTMLButtonElement, ActionCardProps>(functio
309
370
  </span>
310
371
  )}
311
372
  </span>
373
+ </>
374
+ );
375
+
376
+ if (href !== undefined && !rest.disabled) {
377
+ // The button-only attributes stay behind; `aria-*`, `data-*` and handlers ride along.
378
+ const {
379
+ disabled: _disabled,
380
+ form: _form,
381
+ formAction: _formAction,
382
+ name: _name,
383
+ value: _value,
384
+ onClick,
385
+ ...anchorRest
386
+ } = rest;
387
+ const linkProps: ActionCardLinkProps = {
388
+ ...(anchorRest as Partial<ActionCardLinkProps>),
389
+ ref: ref as unknown as Ref<HTMLAnchorElement>,
390
+ id,
391
+ href,
392
+ "aria-labelledby": labelledBy,
393
+ "aria-describedby": describedBy,
394
+ onClick: onClick as unknown as ActionCardLinkProps["onClick"],
395
+ className: look,
396
+ children: body,
397
+ };
398
+ const render = pickLinkRenderer(renderLink, kitLink, href);
399
+ if (render) return <RenderedActionLink render={render} {...linkProps} />;
400
+ const { children: content, ...anchor } = linkProps;
401
+ return <a {...anchor}>{content}</a>;
402
+ }
403
+
404
+ return (
405
+ <button
406
+ ref={ref}
407
+ id={id}
408
+ type={type}
409
+ {...rest}
410
+ aria-labelledby={labelledBy}
411
+ aria-describedby={describedBy}
412
+ className={look}
413
+ >
414
+ {body}
312
415
  </button>
313
416
  );
314
417
  });
315
418
  ActionCard.displayName = "ActionCard";
316
419
 
420
+ /** Calls the link renderer as a component, so a router link's hooks are its own. */
421
+ function RenderedActionLink({
422
+ render,
423
+ ...props
424
+ }: ActionCardLinkProps & { render: (props: ActionCardLinkProps) => ReactElement }) {
425
+ return render(props);
426
+ }
427
+
317
428
  /* ── ChoiceCardGroup ─────────────────────────────────────────────────────── */
318
429
 
319
430
  export interface ChoiceCardOption<T extends string> {
@@ -10,6 +10,7 @@ import type {
10
10
  } from "react";
11
11
  import { Check, Plus } from "lucide-react";
12
12
  import { cn } from "../lib/cn";
13
+ import { CLIPS_ATTRIBUTE } from "../lib/clipping";
13
14
  import { DropdownSearchHeader } from "./dropdown";
14
15
  import { PickerSheet, SHEET_ROW_CLASS } from "./picker-sheet";
15
16
  import { useMediaQuery } from "../hooks/use-media-query";
@@ -260,7 +261,9 @@ export interface ComboboxCore<V extends string | number> {
260
261
  rect: AnchorRect | null;
261
262
  /** Where the dropdown goes, clamped to the visible viewport (feedback #135). */
262
263
  placement: AnchoredPanel;
263
- cacheRef: RefObject<Map<V, ComboOption<V>>>;
264
+ /** Put an option in the label cache, so {@link ComboboxCore.resolve} still knows a
265
+ * value the caller just chose after the result list has moved on. */
266
+ rememberOption: (o: ComboOption<V>) => void;
264
267
  resolve: (v: V) => ComboOption<V> | null;
265
268
  /** Close and hand focus back to the trigger — the keyboard paths only. See
266
269
  * {@link useDropdown} for why a pointer dismissal must not do this. */
@@ -318,10 +321,23 @@ export function useComboboxCore<V extends string | number>({
318
321
  useOutsideClick([triggerRef, panelRef], close, open && !isPhone);
319
322
  useEscapeKey(closeToTrigger, open);
320
323
 
321
- // Reset the query + focus the search box each time the panel opens.
324
+ // Reset the query each time the panel opens, and the highlighted row whenever the
325
+ // visible set changes (a new query, or the panel opening or closing). Adjusted while
326
+ // rendering against the previous values rather than in an effect, so the render that
327
+ // opens the panel already searches for "" and highlights the first row.
328
+ const [prevOpen, setPrevOpen] = useState(open);
329
+ const [prevQuery, setPrevQuery] = useState(query);
330
+ if (open !== prevOpen || query !== prevQuery) {
331
+ const opened = open && !prevOpen;
332
+ setPrevOpen(open);
333
+ setPrevQuery(opened ? "" : query);
334
+ if (opened) setQuery("");
335
+ setActive(0);
336
+ }
337
+
338
+ // Focus the search box each time the panel opens.
322
339
  useEffect(() => {
323
340
  if (!open) return;
324
- setQuery("");
325
341
  const id = requestAnimationFrame(() => inputRef.current?.focus());
326
342
  return () => cancelAnimationFrame(id);
327
343
  }, [open]);
@@ -345,11 +361,9 @@ export function useComboboxCore<V extends string | number>({
345
361
  }, [options, results]);
346
362
  const resolve = (v: V): ComboOption<V> | null =>
347
363
  options?.find((o) => o.value === v) ?? cacheRef.current.get(v) ?? null;
348
-
349
- // Reset the highlighted row whenever the visible set changes.
350
- useEffect(() => {
351
- setActive(0);
352
- }, [query, open]);
364
+ const rememberOption = (o: ComboOption<V>) => {
365
+ cacheRef.current.set(o.value, o);
366
+ };
353
367
 
354
368
  return {
355
369
  triggerRef,
@@ -368,7 +382,7 @@ export function useComboboxCore<V extends string | number>({
368
382
  minChars,
369
383
  rect,
370
384
  placement,
371
- cacheRef,
385
+ rememberOption,
372
386
  resolve,
373
387
  closeToTrigger,
374
388
  };
@@ -636,6 +650,10 @@ export function ComboboxPanel<V extends string | number>({
636
650
  // The rows are provisional while a lookup is in flight — whether or not the
637
651
  // previous query's are still showing.
638
652
  aria-busy={busy || undefined}
653
+ // It scrolls, and says so where jsdom can see it (see `CLIPS_ATTRIBUTE`): a
654
+ // Tooltip in a row then portals out in a test exactly as it does in the
655
+ // browser, instead of joining the option's accessible name (keksdose E8).
656
+ {...{ [CLIPS_ATTRIBUTE]: "" }}
639
657
  className={cn("min-h-0 flex-1 overflow-y-auto py-1", isPhone && "flex-none")}
640
658
  >
641
659
  {results.map((o, i) => {
@@ -772,6 +790,9 @@ export function ComboboxPanel<V extends string | number>({
772
790
  inputRef={inputRef}
773
791
  closeLabel={closeLabel}
774
792
  >
793
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
794
+ itself: it catches the listbox keys bubbling from the sheet's search <input>,
795
+ which holds focus while the list is up. */}
775
796
  <div onKeyDown={onKeyDown} className="relative">
776
797
  {list}
777
798
  {announcement}
@@ -791,6 +812,9 @@ export function ComboboxPanel<V extends string | number>({
791
812
  : { left: rect.left };
792
813
 
793
814
  return createPortal(
815
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
816
+ itself: it catches the listbox keys bubbling from the search <input> inside it,
817
+ which holds focus while the panel is open. */
794
818
  <div
795
819
  // Spread FIRST: `onKeyDown` below is the listbox keyboard (Up/Down/Home/End/
796
820
  // Enter/Tab) and the placement is measured rather than chosen, so neither is a
@@ -113,7 +113,7 @@ function SuggestionList({
113
113
  dir={dir}
114
114
  style={{ top, left: rect.left, width: rect.width }}
115
115
  >
116
- <ul id={id} role="listbox" className={LIST_CLASS} style={{ maxHeight }}>
116
+ <ul id={id} role="listbox" data-clips="" className={LIST_CLASS} style={{ maxHeight }}>
117
117
  {children}
118
118
  </ul>
119
119
  </div>,
@@ -238,9 +238,19 @@ export function Combobox({
238
238
  onBlur,
239
239
  onSubmit,
240
240
  "aria-label": ariaLabel,
241
+ // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
242
+ // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
243
+ // on the wrapper div the hint and required state described nothing.
244
+ "aria-describedby": ariaDescribedBy,
245
+ "aria-invalid": ariaInvalid,
246
+ "aria-required": ariaRequired,
241
247
  ...rest
242
248
  }: ComboboxProps) {
243
- const field = useComboboxFieldError(error, invalid);
249
+ const field = useComboboxFieldError(
250
+ error,
251
+ invalid || ariaInvalid === true || ariaInvalid === "true",
252
+ ariaDescribedBy,
253
+ );
244
254
  const generated = useId();
245
255
  const fieldId = id ?? generated;
246
256
  // Derived from the GENERATED id, never from `id`: a caller's id is theirs to
@@ -366,9 +376,9 @@ export function Combobox({
366
376
  : null;
367
377
 
368
378
  return (
369
- // `rest` dresses the outer box — a `data-tour` anchor, a test id, a form-level
370
- // `aria-describedby`. Not the NAME: that belongs on the <input> below, which is
371
- // the combobox a reader meets. Spread FIRST, so the field's ARIA and the
379
+ // `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,
380
+ // description, invalid or required state: those belong on the <input> below,
381
+ // which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the
372
382
  // handlers carrying live #309 and dev#549 cannot be replaced from outside.
373
383
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
374
384
  {/* A real <label for> since lenkbank's tests met a field `getByLabelText` could
@@ -407,8 +417,10 @@ export function Combobox({
407
417
  aria-autocomplete="list"
408
418
  aria-invalid={field.isInvalid || undefined}
409
419
  aria-describedby={field.describedBy}
420
+ aria-required={ariaRequired}
410
421
  autoComplete="off"
411
422
  disabled={disabled}
423
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
412
424
  autoFocus={autoFocus}
413
425
  onBlur={onBlur}
414
426
  onMouseDown={primaryOnly.onMouseDown}
@@ -729,12 +741,22 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
729
741
  invalid,
730
742
  error,
731
743
  "aria-label": ariaLabel,
744
+ // Off `rest` and onto the <input>, which is the combobox a reader meets: `Field`'s
745
+ // render-prop spreads `{ id, aria-describedby, aria-invalid, aria-required }`, and
746
+ // on the wrapper div the hint and required state described nothing.
747
+ "aria-describedby": ariaDescribedBy,
748
+ "aria-invalid": ariaInvalid,
749
+ "aria-required": ariaRequired,
732
750
  ...rest
733
751
  }: InlineEntityComboboxProps<V, C>) {
734
752
  // The clear value reads as "nothing selected", whichever one the caller picked —
735
753
  // so from here down `value` is the id or `null`, as it always was.
736
754
  const value: V | null = rawValue == null || rawValue === clearValue ? null : (rawValue as V);
737
- const field = useComboboxFieldError(error, invalid);
755
+ const field = useComboboxFieldError(
756
+ error,
757
+ invalid || ariaInvalid === true || ariaInvalid === "true",
758
+ ariaDescribedBy,
759
+ );
738
760
  const generated = useId();
739
761
  const fieldId = id ?? generated;
740
762
  // See the twin above on why these hang off the generated id.
@@ -853,9 +875,9 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
853
875
  };
854
876
 
855
877
  return (
856
- // `rest` dresses the outer box — a `data-tour` anchor, a test id, a form-level
857
- // `aria-describedby`. Not the NAME: that belongs on the <input> below, which is
858
- // the combobox a reader meets. Spread FIRST, so the field's ARIA and the
878
+ // `rest` dresses the outer box — a `data-tour` anchor, a test id. Not the NAME,
879
+ // description, invalid or required state: those belong on the <input> below,
880
+ // which is the combobox a reader meets. Spread FIRST, so the field's ARIA and the
859
881
  // handlers carrying live #309 and dev#549 cannot be replaced from outside.
860
882
  <div {...rest} ref={wrapperRef} className={cn("relative", className)}>
861
883
  {/* A real <label for> — see {@link Combobox}. */}
@@ -889,8 +911,10 @@ export function InlineEntityCombobox<V extends string | number, C extends ComboC
889
911
  aria-autocomplete="list"
890
912
  aria-invalid={field.isInvalid || undefined}
891
913
  aria-describedby={field.describedBy}
914
+ aria-required={ariaRequired}
892
915
  autoComplete="off"
893
916
  disabled={disabled}
917
+ // eslint-disable-next-line jsx-a11y/no-autofocus -- a documented prop the caller opts into (off by default); the field never takes focus on its own.
894
918
  autoFocus={autoFocus}
895
919
  // `inputMode="none"` rather than readOnly, for the same reason Combobox
896
920
  // above gives: the sheet carries the keyboard, and a readOnly field would
@@ -56,7 +56,8 @@ export interface CopyButtonProps
56
56
  stopPropagation?: boolean;
57
57
  /** Icon variant only: where the result tooltip opens. */
58
58
  tooltipSide?: TooltipSide;
59
- /** Pass through to the tooltip — needed inside a scroll container (see Tooltip). */
59
+ /** Pass through to the tooltip. Left unset, the Tooltip decides on its own — it
60
+ * portals inside a clipping container (a DataTable's `data-clips` scroller). */
60
61
  tooltipPortal?: boolean;
61
62
  /** ms until the button returns to idle. Default 2000. */
62
63
  resetAfter?: number;
@@ -93,7 +94,7 @@ export function CopyButton({
93
94
  tone,
94
95
  stopPropagation,
95
96
  tooltipSide = "top",
96
- tooltipPortal = false,
97
+ tooltipPortal,
97
98
  resetAfter = 2000,
98
99
  onCopied,
99
100
  labels,
@@ -0,0 +1,119 @@
1
+ import type { ReactNode } from "react";
2
+ import { Check, Minus, X } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { DEFAULT_DATA_TABLE_LABELS, type DataTableLabels } from "./data-table-labels";
6
+ import type { DataTableColumn } from "./data-table";
7
+
8
+ export interface BooleanMarkProps {
9
+ /** `true` → a check, `false` → a cross (or a dash, see `falseAs`), `null` /
10
+ * `undefined` → a dash. */
11
+ value: boolean | null | undefined;
12
+ /**
13
+ * How `false` is drawn. `"cross"` (default) when "no" is news worth seeing — a
14
+ * failed check, a missing document. `"dash"` for a column that is mostly `false` and
15
+ * reads as a list of the `true` ones (kastlan's BoolCell): a column of crosses is
16
+ * noise. The screen-reader text says "No" either way.
17
+ */
18
+ falseAs?: "cross" | "dash";
19
+ /** The spoken words, over `dataTable.booleanTrue` / `booleanFalse` / `booleanUnset`
20
+ * from `<UiKitProvider labels>`. */
21
+ labels?: Partial<Pick<DataTableLabels, "booleanTrue" | "booleanFalse" | "booleanUnset">>;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * A yes/no value in a table cell: a green check, a muted cross, or a faint dash for
27
+ * "not set" — with the word for a screen reader, since a glyph alone is announced as
28
+ * nothing at all (or as "image").
29
+ *
30
+ * kastlan's `BoolCell` (shared/components/data-table/bool-cell.tsx) drew the icons with
31
+ * no text, so a reader walking the table heard an empty cell for every row; the apps'
32
+ * Yes/No chips were the other answer, and too loud for a column of them.
33
+ */
34
+ export function BooleanMark({ value, falseAs = "cross", labels: labelsProp, className }: BooleanMarkProps) {
35
+ const labels = useKitLabels("dataTable", DEFAULT_DATA_TABLE_LABELS, labelsProp);
36
+ const state = value === true ? "true" : value === false ? "false" : "unset";
37
+ const text =
38
+ state === "true" ? labels.booleanTrue : state === "false" ? labels.booleanFalse : labels.booleanUnset;
39
+ const Icon = state === "true" ? Check : state === "false" && falseAs === "cross" ? X : Minus;
40
+ return (
41
+ <span data-value={state} className={cn("inline-flex items-center align-middle", className)}>
42
+ <Icon
43
+ aria-hidden
44
+ className={cn(
45
+ "size-4",
46
+ state === "true"
47
+ ? "text-[var(--success)]"
48
+ : state === "false" && falseAs === "cross"
49
+ ? "text-[var(--text-muted)]"
50
+ : "text-[var(--text-placeholder)]",
51
+ )}
52
+ />
53
+ <span className="sr-only">{text}</span>
54
+ </span>
55
+ );
56
+ }
57
+
58
+ export interface BooleanColumnOptions<T> {
59
+ key: string;
60
+ header: ReactNode;
61
+ /** The value of this row. */
62
+ value: (row: T) => boolean | null | undefined;
63
+ /** See {@link BooleanMarkProps.falseAs}. */
64
+ falseAs?: "cross" | "dash";
65
+ /** Sortable, `true` first on the first click (a column is sorted to find the yes
66
+ * rows). Default true. */
67
+ sortable?: boolean;
68
+ /**
69
+ * A select filter over the column, with these option labels — pass them translated.
70
+ * Omitted: no filter. The option values are `"true"` / `"false"` (and `"unset"` when
71
+ * `unset` is given), which is also what `filterHref` takes for this column.
72
+ */
73
+ filterOptions?: { true: string; false: string; unset?: string };
74
+ /** Further column settings (`mobileHidden`, `headClassName`, …), merged over. */
75
+ column?: Partial<DataTableColumn<T>>;
76
+ }
77
+
78
+ /**
79
+ * A {@link DataTableColumn} for a yes/no field: {@link BooleanMark} cells,
80
+ * sortable with `true` first, and optionally a Yes/No select filter.
81
+ */
82
+ export function booleanColumn<T>({
83
+ key,
84
+ header,
85
+ value,
86
+ falseAs,
87
+ sortable = true,
88
+ filterOptions,
89
+ column,
90
+ }: BooleanColumnOptions<T>): DataTableColumn<T> {
91
+ const asKey = (row: T) => {
92
+ const v = value(row);
93
+ return v === true ? "true" : v === false ? "false" : "unset";
94
+ };
95
+ return {
96
+ key,
97
+ header,
98
+ cell: (row) => <BooleanMark value={value(row)} falseAs={falseAs} />,
99
+ // 0 for yes, 1 for no, null for unset (which the table sorts last either way).
100
+ sortBy: sortable
101
+ ? (row) => {
102
+ const v = value(row);
103
+ return v == null ? null : v ? 0 : 1;
104
+ }
105
+ : undefined,
106
+ filter: filterOptions
107
+ ? {
108
+ type: "select",
109
+ getValue: asKey,
110
+ options: [
111
+ { value: "true", label: filterOptions.true },
112
+ { value: "false", label: filterOptions.false },
113
+ ...(filterOptions.unset !== undefined ? [{ value: "unset", label: filterOptions.unset }] : []),
114
+ ],
115
+ }
116
+ : undefined,
117
+ ...column,
118
+ };
119
+ }
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "react";
1
2
  import { X } from "lucide-react";
2
3
  import { cn } from "../lib/cn";
3
4
  import { MiniCalendar } from "./mini-calendar";
@@ -53,6 +54,15 @@ export function FilterPopover<T>({
53
54
  // No `"en"` fallback any more: that pinned the calendar to English in a table that
54
55
  // was never handed a locale, under a provider that had one.
55
56
  const locale = useKitLocale(localeProp);
57
+ // What `autoFocus` did, done by hand: a callback ref runs in the same commit phase
58
+ // React's own autofocus does, once when the input mounts (it is stable across
59
+ // renders, so a re-render does not re-take focus).
60
+ const focusOnMount = useCallback(
61
+ (el: HTMLInputElement | null) => {
62
+ if (el && autoFocus) el.focus();
63
+ },
64
+ [autoFocus],
65
+ );
56
66
  const filter = resolveFilter(column);
57
67
  if (!filter) return null;
58
68
 
@@ -80,11 +90,9 @@ export function FilterPopover<T>({
80
90
  <input
81
91
  type="text"
82
92
  // A prop now, defaulting to what the popover needs; see `autoFocus` on the
83
- // props above. Deliberately NOT an eslint-disable: `jsx-a11y/no-autofocus` is
84
- // one of the config's ratcheted warnings and this is still one of the four it
85
- // counts, because the default here is still to take focus. Silencing it would
86
- // shrink the backlog without emptying it.
87
- autoFocus={autoFocus}
93
+ // props above. Focused by `focusOnMount` rather than the `autoFocus`
94
+ // attribute, because here taking focus is the default, not an opt-in.
95
+ ref={focusOnMount}
88
96
  value={v.q}
89
97
  onChange={(e) => onChange({ type: "text", q: e.target.value })}
90
98
  placeholder={labels.filterPlaceholder}
@@ -174,3 +174,80 @@ export function decodeFilterValueOfType(type: FilterValue["type"], raw: string):
174
174
  export function decodeFilterValue<T>(filter: ColumnFilter<T>, raw: string): FilterValue {
175
175
  return decodeFilterValueOfType(filter.type, raw);
176
176
  }
177
+
178
+ // ---------- Filter builders ----------
179
+ //
180
+ // Terse builders for `DataTableColumn.filter`, lifted from kastlan's column-filters.ts,
181
+ // which every converted list page imports. The one that earns its keep is `selectFilter`:
182
+ // in `serverPagination` mode a select filter MUST declare its options (the table would
183
+ // otherwise derive them from the rows of the page on screen), and the options an app has
184
+ // to hand are usually a translated `{ value: label }` map.
185
+
186
+ /** A free-text filter over `getValue`. */
187
+ export function textFilter<T>(getValue: (row: T) => string): ColumnFilter<T> {
188
+ return { type: "text", getValue };
189
+ }
190
+
191
+ /**
192
+ * A multi-select filter. `options` is an options array, or a `{ value: label }` record
193
+ * (a translated status map) — kept in its key order. Left out, the table derives the
194
+ * options from the rows it holds.
195
+ */
196
+ export function selectFilter<T>(
197
+ getValue: (row: T) => string,
198
+ options?: { value: string; label?: string }[] | Record<string, string>,
199
+ ): ColumnFilter<T> {
200
+ const opts = Array.isArray(options)
201
+ ? options
202
+ : options
203
+ ? Object.entries(options).map(([value, label]) => ({ value, label }))
204
+ : undefined;
205
+ return { type: "select", getValue, options: opts };
206
+ }
207
+
208
+ /** A from/to date filter over an ISO date (or date-time) string. */
209
+ export function dateFilter<T>(getValue: (row: T) => string | null | undefined): ColumnFilter<T> {
210
+ return { type: "date", getValue };
211
+ }
212
+
213
+ /** A min/max (and absolute-value) filter over a number. */
214
+ export function numberFilter<T>(getValue: (row: T) => number | null | undefined): ColumnFilter<T> {
215
+ return { type: "number", getValue };
216
+ }
217
+
218
+ /**
219
+ * A link to a list page with one column filter already applied — in the table's own URL
220
+ * scheme (`f.<column>=<encoded>`), which a table with `urlSync` (or
221
+ * {@link useTableUrlState}) reads on mount. kastlan's `buildListUrl`, which had to
222
+ * restate the `f.` prefix to do it.
223
+ *
224
+ * `value` is a string for a text filter, an array for a select filter, or any
225
+ * {@link FilterValue}. A filter that narrows nothing returns `path` unchanged. A `path`
226
+ * that already carries a query keeps it; a `#fragment` stays at the end.
227
+ *
228
+ * filterHref("/units", "status", ["vacant"]) // "/units?f.status=vacant"
229
+ */
230
+ export function filterHref(
231
+ path: string,
232
+ column: string,
233
+ value: string | string[] | FilterValue,
234
+ /** The target table's `urlSync` prefix, if it has one (`{ prefix: "inv." }` →
235
+ * `inv.f.<column>`). */
236
+ options?: { prefix?: string },
237
+ ): string {
238
+ const state: FilterValue =
239
+ typeof value === "string"
240
+ ? { type: "text", q: value }
241
+ : Array.isArray(value)
242
+ ? { type: "select", values: value }
243
+ : value;
244
+ const encoded = encodeFilterValue(state);
245
+ if (encoded == null) return path;
246
+ const hashAt = path.indexOf("#");
247
+ const base = hashAt >= 0 ? path.slice(0, hashAt) : path;
248
+ const hash = hashAt >= 0 ? path.slice(hashAt) : "";
249
+ const queryAt = base.indexOf("?");
250
+ const params = new URLSearchParams(queryAt >= 0 ? base.slice(queryAt + 1) : "");
251
+ params.set(`${options?.prefix ?? ""}f.${column}`, encoded);
252
+ return `${queryAt >= 0 ? base.slice(0, queryAt) : base}?${params.toString()}${hash}`;
253
+ }