@eifi1/ui-kit 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +89 -19
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +74 -19
  63. package/dist/components/dropdown.js +72 -22
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +21 -7
  75. package/dist/components/file-dropzone.js +28 -7
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +21 -2
  105. package/dist/components/number-input.js +2 -1
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +25 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +5 -3
  176. package/dist/components/user-avatar.js +4 -2
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +14 -1
  270. package/dist/theme/palette-presets.js +231 -3
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +66 -13
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,1740 @@
1
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import {
5
+ ArrowDown,
6
+ ArrowUp,
7
+ ArrowUpDown,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ Filter,
11
+ X,
12
+ } from "lucide-react";
13
+ import { useSearchParams } from "react-router";
14
+ import { Card } from "./ui";
15
+ import { cn } from "../lib/cn";
16
+ import {
17
+ defaultFilterState,
18
+ isFilterActive,
19
+ resolveFilter,
20
+ rowMatches,
21
+ } from "./data-table-filters";
22
+ import type { ColumnFilter, FilterValue } from "./data-table-filters";
23
+ import { nextSorts } from "./data-table-sort";
24
+ import type { SortState } from "./data-table-sort";
25
+ import { FilterPopover } from "./data-table-filter-popover";
26
+ import { Pagination } from "./data-table-pagination";
27
+ import { SwipeableRow, type SwipeAction } from "./swipeable-row";
28
+ import { useBackdropClose } from "./modal";
29
+ import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
30
+ import { useOverlayHistory } from "../hooks/use-overlay-history";
31
+ import { FullBleedDialog } from "./full-bleed-dialog";
32
+ import { Popover } from "./popover";
33
+ import { useMediaQuery } from "../hooks/use-media-query";
34
+ import { useAnnounce } from "../hooks/use-announce";
35
+ import { resolveDataTableLabels, type DataTableLabels } from "./data-table-labels";
36
+ import { useKitLabelOverrides, useKitLocale } from "../i18n/kit-labels";
37
+ import { Tooltip } from "./tooltip";
38
+
39
+ // ---------- Types ----------
40
+
41
+ export interface DataTableColumn<T> {
42
+ key: string;
43
+ header: ReactNode;
44
+ cell: (row: T) => ReactNode;
45
+ sortBy?: (row: T) => string | number | null | undefined;
46
+ filter?: ColumnFilter<T>;
47
+ filterBy?: (row: T) => string;
48
+ className?: string;
49
+ headClassName?: string;
50
+ // Mobile card layout: when no `mobilePrimary` column is set the first
51
+ // visible non-hidden column acts as the primary. The primary cell renders
52
+ // bold at the top of the card without a label; the rest stack below as
53
+ // labelled key/value pairs. `mobileHidden` skips a column entirely on
54
+ // narrow viewports — useful for noisy details (IDs, raw URLs, internal
55
+ // bookkeeping) that would crowd a card without aiding scanning.
56
+ mobilePrimary?: boolean;
57
+ mobileHidden?: boolean;
58
+ /**
59
+ * Set on a column whose cell renders its own `<a>` (or anything else that must
60
+ * keep its own click): the row anchor of `rowHref` then skips it and lands on
61
+ * the next column that allows wrapping (Keksdose feedback #451). Without it the
62
+ * fallback can wrap such a cell the moment the headline column is hidden from
63
+ * the settings panel — `<a>` inside `<a>`, which React warns about and which
64
+ * kills the inner link outright, since RowLink cancels the click on the capture
65
+ * phase and the cell's own `stopPropagation` then blocks the row handler too.
66
+ * The rejected alternative was sniffing the rendered output for an anchor: a
67
+ * cell that renders a link is a fact its author knows and the renderer cannot
68
+ * see without reaching into React elements it deliberately treats as opaque.
69
+ */
70
+ noRowLink?: boolean;
71
+ }
72
+
73
+ /** A column resize in flight. Held in a ref rather than in state: the live width is
74
+ * written straight onto the column's cells, so a drag costs zero renders and the
75
+ * one `setWidths` happens when the pointer comes up. */
76
+ interface Resize {
77
+ key: string;
78
+ pointerId: number;
79
+ startX: number;
80
+ startWidth: number;
81
+ /** What React last rendered this column at, so a CANCELLED drag can put the header
82
+ * back — React cannot, it never saw the inline width the drag wrote. */
83
+ hadWidth: number | undefined;
84
+ /** The header and the body cells under it, collected ONCE when the drag starts.
85
+ * A `<th>` alone cannot shrink a column whose `<td>`s already carry a pinned
86
+ * width — and nothing re-renders during a drag, so the list cannot go stale. */
87
+ cells: HTMLElement[];
88
+ width: number;
89
+ /** A press with no drag in it is not a resize — the handle is also the
90
+ * double-click target that CLEARS a width, and committing the header's current
91
+ * size on the way past would pin every column the user ever brushed. */
92
+ moved: boolean;
93
+ }
94
+
95
+ export interface ServerPagination {
96
+ page: number; // 0-based
97
+ pageSize: number;
98
+ total: number;
99
+ onPageChange: (page: number) => void;
100
+ onPageSizeChange?: (pageSize: number) => void;
101
+ isLoading?: boolean;
102
+ }
103
+
104
+ export interface DataTableProps<T> {
105
+ rows: T[];
106
+ columns: DataTableColumn<T>[];
107
+ rowKey: (row: T) => string | number;
108
+ defaultPageSize?: number;
109
+ onRowClick?: (row: T) => void;
110
+ /**
111
+ * The URL that shows this row, when one exists — the table then renders a REAL
112
+ * anchor for it (Keksdose feedback #451, "to be able to middle click with the
113
+ * mouse and open in a new tab"). A row that opens through `onRowClick` alone is
114
+ * invisible to the browser: no middle click, no ⌘/Ctrl-click, no "copy link
115
+ * address", no status-bar preview, no long-press menu on a phone. The
116
+ * alternative was an `onAuxClick` that calls `window.open` — it buys back only
117
+ * the middle click and loses the other four, none of which JavaScript can fake.
118
+ *
119
+ * Return `undefined` for a row that has no address of its own. Only wire this up
120
+ * where the OPEN row is genuinely in the URL (a `?row=` param, a detail route);
121
+ * a row whose expansion lives in local component state has no link to hand out,
122
+ * and a link the target page cannot honour is worse than no link.
123
+ *
124
+ * The anchor covers the row's primary cell, not the whole `<tr>`: an `<a>` may
125
+ * not wrap table cells, and the absolutely-positioned row overlay that is the
126
+ * usual workaround would sit on top of the per-cell controls these rows carry
127
+ * (status toggles, the selection checkbox, nested links) and swallow them.
128
+ */
129
+ rowHref?: (row: T) => string | undefined;
130
+ expandedRow?: (row: T) => ReactNode | null;
131
+ isExpanded?: (row: T) => boolean;
132
+ rowClassName?: (row: T) => string | undefined;
133
+ /**
134
+ * Extra DOM attributes for a row's container — the `<tr>` on desktop, the
135
+ * `<li>` on mobile. For hooks that have to sit on the row itself rather than
136
+ * inside a cell: guided-tour anchors, analytics ids, e2e selectors. Undefined
137
+ * values are dropped, so a per-row conditional needs no filtering at the call
138
+ * site. Not a styling escape hatch — that is `rowClassName`.
139
+ */
140
+ rowAttributes?: (row: T) => Record<string, string | undefined> | undefined;
141
+ /**
142
+ * Set false for a table that is short by construction — an invoice's line
143
+ * items, a wizard's picks — where the pager is chrome around a list that can
144
+ * never need one. Renders every row and drops the footer entirely. Ignored in
145
+ * `serverPagination` mode, where the pager is the only way to reach the rest
146
+ * of the data.
147
+ */
148
+ paginated?: boolean;
149
+ empty?: ReactNode;
150
+ /**
151
+ * If set, the table persists filter / sort / page-size state to localStorage under this key.
152
+ * Use a stable, unique key per table (e.g. "transactions-table").
153
+ */
154
+ storageKey?: string;
155
+ /**
156
+ * When true, mirror filter/sort/page state into URL search params so views are
157
+ * shareable and bookmarkable. URL params used: `f.<column>`, `sort`, `p`, `ps`.
158
+ * URL state takes precedence over localStorage on initial load. Requires the
159
+ * consuming app to render inside a react-router Router.
160
+ */
161
+ urlSync?: boolean;
162
+ /**
163
+ * When true (desktop only), the table fills its parent's height and scrolls
164
+ * INTERNALLY, with the header pinned — so the page itself doesn't add a second,
165
+ * outer scrollbar. The parent must give it a bounded height (e.g. a flex
166
+ * column inside the viewport-locked app shell). See feedback #207.
167
+ */
168
+ fillHeight?: boolean;
169
+ /** How tall the scrolling body may get before it scrolls, as a CSS length.
170
+ *
171
+ * Default `calc(100dvh - 12rem)`, which is what every caller had before this
172
+ * existed: a bound is what makes the sticky header work, because a header
173
+ * can only stick to a wrapper that actually scrolls. The cap assumes the
174
+ * table has page chrome above and below it, and a table that is the last
175
+ * thing on its page can afford more — ASPICE Atlas report #12, "for the 25
176
+ * elements there should in best case be no scrolling necessary".
177
+ *
178
+ * Ignored under `fillHeight`, which bounds the body by its flex parent
179
+ * instead and is the stronger statement of the same intent. */
180
+ maxBodyHeight?: string;
181
+ /**
182
+ * When provided, the table renders the supplied rows as-is (no client-side
183
+ * filtering/sorting/slicing) and the pagination footer is driven by these
184
+ * server-controlled values. Column-level filters and sorting are hidden
185
+ * UNLESS the caller takes them over via `onFiltersChange`/`onSortsChange` —
186
+ * the caller is then expected to translate the state into the server query
187
+ * that produces `rows`.
188
+ */
189
+ serverPagination?: ServerPagination;
190
+ /**
191
+ * Controlled filter/sort state. When the `on*Change` callback is provided
192
+ * the table stops owning that piece of state: it renders `filters`/`sorts`
193
+ * as given and reports user interactions through the callback (including
194
+ * in serverPagination mode, where the UI is otherwise disabled). The owner
195
+ * is responsible for any page reset on change.
196
+ */
197
+ filters?: FilterState;
198
+ onFiltersChange?: (next: FilterState) => void;
199
+ sorts?: SortState[];
200
+ onSortsChange?: (next: SortState[]) => void;
201
+ /**
202
+ * Opt-in multi-row selection (desktop table only). When provided, a leading
203
+ * checkbox column is rendered with a select-all box in the header; the owner
204
+ * holds the selected set and reacts via the callbacks. Rows for which
205
+ * `isSelectable` returns false render no checkbox and are excluded from
206
+ * select-all. See feedback #285 (bulk edit).
207
+ */
208
+ selection?: {
209
+ isSelectable?: (row: T) => boolean;
210
+ isSelected: (row: T) => boolean;
211
+ onToggle: (row: T, checked: boolean) => void;
212
+ /**
213
+ * Commit a whole Shift+click range in ONE call (audit 2026-09-22).
214
+ *
215
+ * Optional and purely additive: without it the range is still walked row by
216
+ * row, exactly as before. With it, an owner holding a `Set` copies that Set
217
+ * once instead of once per row — a 400-row range was 400 `onToggle` calls,
218
+ * 400 Set copies and 400 renders of this table, all to reach a state the
219
+ * owner could have reached in one.
220
+ *
221
+ * `rows` is already filtered by `isSelectable` and is in the order the table
222
+ * is showing, so it can be applied as-is.
223
+ */
224
+ onToggleMany?: (rows: T[], checked: boolean) => void;
225
+ allSelected: boolean;
226
+ someSelected: boolean;
227
+ onToggleAll: (checked: boolean) => void;
228
+ };
229
+ /** User-facing strings (English defaults); pass translated overrides. Merged over
230
+ * `dataTable` from `<UiKitProvider labels>`, so a call site only states what
231
+ * differs for THIS table — typically `table`, its accessible name. */
232
+ labels?: Partial<DataTableLabels>;
233
+ /** BCP-47 locale for the date filter's calendar and the pager's numbers. Falls
234
+ * back to `<UiKitProvider locale>`, then to the runtime's default. */
235
+ locale?: string;
236
+ /** localStorage namespace prefix for `storageKey` persistence. Defaults to
237
+ * "hbui-table:"; pass your app's own prefix to keep a stable namespace. */
238
+ storageKeyPrefix?: string;
239
+ /**
240
+ * On phones, surface the expanded row's detail in a full-screen dialog instead
241
+ * of unfolding it inline (feedback #204). Desktop always uses inline expansion.
242
+ */
243
+ mobileExpandAsDialog?: boolean;
244
+ /**
245
+ * Group the mobile card list into sections, each with a sticky header — the
246
+ * "assistance"-style list (like the transactions list grouped by date, but by
247
+ * whatever key you return, e.g. a name's first letter). The returned string is
248
+ * the section a row belongs to; rows must already be ordered so equal keys are
249
+ * contiguous (pair with a matching default sort). `mobileGroupLabel` formats
250
+ * the header. Desktop is unaffected. Feedback #317.
251
+ */
252
+ mobileGroupBy?: (row: T) => string;
253
+ mobileGroupLabel?: (key: string) => ReactNode;
254
+ /**
255
+ * Fully replace the default mobile card body (bold primary + labelled
256
+ * key/value rows) with a compact custom layout, while keeping the shared
257
+ * clickable/expandable row wrapper. Use when the stacked label/value grid
258
+ * wastes space (feedback #317). Desktop is unaffected.
259
+ */
260
+ mobileCard?: (row: T) => ReactNode;
261
+ /**
262
+ * Swipe actions for a mobile row, revealed by dragging it horizontally. Return
263
+ * `null` (or empty sides) for rows that should not move — a locked row, one whose
264
+ * mutation is in flight, one the user has expanded.
265
+ *
266
+ * Mobile only, and deliberately so: the desktop table already has room for an
267
+ * actions column, and a drag gesture on a pointer device is a worse version of a
268
+ * button. Wraps only the row body, so an expansion panel below stays put while the
269
+ * row above it slides.
270
+ */
271
+ mobileSwipeActions?: (row: T) => { left?: SwipeAction[]; right?: SwipeAction[] } | null;
272
+ /**
273
+ * Renders as a full-width row above the data rows in the desktop table
274
+ * (mobile has no equivalent list-row slot, so it's desktop-only). For a
275
+ * toggle that shows/hides a slice of rows, style it as an expand/collapse
276
+ * disclosure (chevron + label) so it reads as part of the table rather
277
+ * than a floating control above it. See feedback #10/#11 (transactions'
278
+ * "hide upcoming/scheduled" toggle).
279
+ */
280
+ leadingRow?: ReactNode;
281
+ }
282
+
283
+ export type FilterState = Record<string, FilterValue>;
284
+ export type { SortState };
285
+
286
+ /** Drop the undefined entries of a {@link DataTableProps.rowAttributes} map, so a
287
+ * caller can write `{ "data-x": cond ? "y" : undefined }` and get no attribute
288
+ * at all rather than the string "undefined" in the DOM. */
289
+ function cleanAttrs(
290
+ attrs: Record<string, string | undefined> | undefined,
291
+ ): Record<string, string> | undefined {
292
+ if (!attrs) return undefined;
293
+ const out: Record<string, string> = {};
294
+ for (const [k, v] of Object.entries(attrs)) if (v !== undefined) out[k] = v;
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * A column's name as a plain string.
300
+ *
301
+ * `header` is a ReactNode — an icon, a unit suffix, a tooltip-wrapped span — and
302
+ * neither a live region nor the column-settings checklist can speak a React element.
303
+ * The `key` fallback is the one the settings panel has always used; a column whose
304
+ * header is not a string and whose key is not human-readable is a call site that
305
+ * should pass a string header, and that is visible in what gets announced.
306
+ */
307
+ function columnLabel<T>(col: DataTableColumn<T>): string {
308
+ return typeof col.header === "string" ? col.header : col.key;
309
+ }
310
+
311
+ /** A click the APP owns. Anything else — middle, ⌘/Ctrl, Shift, Alt — belongs to
312
+ * the browser, and the whole feature is not touching it. */
313
+ const isPlainLeftClick = (e: React.MouseEvent) =>
314
+ e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
315
+
316
+ /**
317
+ * The anchor a linkable row is opened through (Keksdose feedback #451).
318
+ *
319
+ * The plain left click is OURS: cancelled, then handed to the row's own handler, so
320
+ * clicking a row still expands it in place instead of reloading the page. Every
321
+ * other click — middle, ⌘/Ctrl, Shift, Alt — is left completely untouched, which is
322
+ * the entire point of using an anchor rather than an `onAuxClick` + `window.open`:
323
+ * that imitation buys back the middle click and still loses ⌘/Ctrl-click, "copy link
324
+ * address", the status-bar preview and the phone's long-press menu.
325
+ *
326
+ * Modified clicks DO stop propagating: on a table with `selection`, ⌘/Ctrl-click and
327
+ * Shift-click on a row mean "select" (feedback #289), and a row must not change its
328
+ * selection while the browser is opening a tab. The rest of the row still selects —
329
+ * the link is one cell, not the row.
330
+ *
331
+ * `draggable={false}` because an anchor otherwise hijacks a horizontal mouse drag as
332
+ * a link-drag: that is both drag-to-select-text on the desktop table and the mouse
333
+ * path through SwipeableRow's gesture on the mobile card.
334
+ */
335
+ function RowLink({
336
+ href,
337
+ onActivate,
338
+ className,
339
+ children,
340
+ ...rest
341
+ }: {
342
+ href: string;
343
+ /** Runs on a plain left click. Omit it where an ANCESTOR already handles the
344
+ * click (the desktop `<tr>`), or the row would toggle twice. */
345
+ onActivate?: () => void;
346
+ className?: string;
347
+ children: ReactNode;
348
+ } & Omit<
349
+ React.AnchorHTMLAttributes<HTMLAnchorElement>,
350
+ "href" | "className" | "children" | "onClick" | "onClickCapture"
351
+ >) {
352
+ return (
353
+ <a
354
+ {...rest}
355
+ href={href}
356
+ draggable={false}
357
+ className={className}
358
+ // CAPTURE phase, and it has to be: on the mobile card the whole card is the
359
+ // anchor, so a nested control sits INSIDE it — and the ones that matter
360
+ // (the feedback row's status toggles) stop the click propagating so the row
361
+ // won't also expand. A bubble-phase handler would never run for those, and
362
+ // the browser would follow the link out from under the button press. Cancelling
363
+ // here happens before any descendant can silence the event; `stopPropagation`
364
+ // does not undo a `preventDefault`.
365
+ onClickCapture={(e) => {
366
+ if (isPlainLeftClick(e)) e.preventDefault();
367
+ }}
368
+ onClick={(e) => {
369
+ if (!isPlainLeftClick(e)) {
370
+ e.stopPropagation();
371
+ return;
372
+ }
373
+ // Already cancelled above; this phase only decides what opens. A nested
374
+ // control that stopped propagation deliberately never reaches it, which is
375
+ // exactly how the row behaved before it became a link.
376
+ onActivate?.();
377
+ }}
378
+ >
379
+ {children}
380
+ </a>
381
+ );
382
+ }
383
+
384
+ // ---------- Main DataTable ----------
385
+
386
+
387
+
388
+ import { DEFAULT_PERSIST_PREFIX, useTableState } from "./use-table-state";
389
+ import { useMobileReveal } from "./use-mobile-reveal";
390
+
391
+ export function DataTable<T>({
392
+ rows,
393
+ columns,
394
+ rowKey,
395
+ defaultPageSize = 25,
396
+ onRowClick,
397
+ rowHref,
398
+ expandedRow,
399
+ isExpanded,
400
+ rowClassName,
401
+ rowAttributes,
402
+ paginated = true,
403
+ empty,
404
+ storageKey,
405
+ urlSync = false,
406
+ fillHeight = false,
407
+ maxBodyHeight = "calc(100dvh - 12rem)",
408
+ serverPagination,
409
+ filters: filtersProp,
410
+ onFiltersChange,
411
+ sorts: sortsProp,
412
+ onSortsChange,
413
+ selection,
414
+ labels: labelsProp,
415
+ locale: localeProp,
416
+ storageKeyPrefix = DEFAULT_PERSIST_PREFIX,
417
+ mobileExpandAsDialog = false,
418
+ mobileGroupBy,
419
+ mobileGroupLabel,
420
+ mobileCard,
421
+ mobileSwipeActions,
422
+ leadingRow,
423
+ }: DataTableProps<T>) {
424
+ const isServer = !!serverPagination;
425
+ // prop > provider > English, through the table's own resolver rather than
426
+ // `useKitLabels`: that resolver derives `columnsCount` from a translated `columns`,
427
+ // and a plain merge would skip it. `presets` is a record and is merged key by key,
428
+ // as the provider merges it, so a call site renaming one preset keeps the provider's
429
+ // other ten. Memoised because the announcement effects below list `labels` as a
430
+ // dependency, and a fresh object every render would re-run them for nothing.
431
+ const labelOverrides = useKitLabelOverrides("dataTable");
432
+ const labels = useMemo(
433
+ () =>
434
+ resolveDataTableLabels(
435
+ labelOverrides || labelsProp
436
+ ? {
437
+ ...labelOverrides,
438
+ ...labelsProp,
439
+ presets: { ...labelOverrides?.presets, ...labelsProp?.presets },
440
+ }
441
+ : undefined,
442
+ ),
443
+ [labelOverrides, labelsProp],
444
+ );
445
+ const locale = useKitLocale(localeProp);
446
+ const [, setSearchParams] = useSearchParams();
447
+ // Sorting, filtering and paging all change WHICH rows are on screen without
448
+ // moving focus — the header button the user pressed is still the header button
449
+ // they are on — so there is no other channel to say it on. See use-announce.ts.
450
+ const { announce, regionProps } = useAnnounce();
451
+ const {
452
+ sorts,
453
+ filters,
454
+ setInternalSorts,
455
+ setInternalFilters,
456
+ page,
457
+ setPage,
458
+ pageSize,
459
+ setPageSize,
460
+ widths,
461
+ setWidths,
462
+ hiddenCols,
463
+ setHiddenCols,
464
+ showSettings,
465
+ setShowSettings,
466
+ headRefs,
467
+ } = useTableState({
468
+ columns,
469
+ storageKey,
470
+ storageKeyPrefix,
471
+ urlSync,
472
+ setSearchParams,
473
+ defaultPageSize,
474
+ sortsProp,
475
+ filtersProp,
476
+ });
477
+
478
+ // Every call site builds `columns` inline — `columns={[{ key: "name", … }]}` right
479
+ // there in the JSX — so the array is a new object on every render of the page around
480
+ // the table, and a `useMemo` keyed on its identity memoises nothing. The three below
481
+ // walk all the rows; a sibling field being typed into re-filtered and re-sorted the
482
+ // whole dataset on every keystroke.
483
+ //
484
+ // What they actually need from `columns` is WHICH columns there are, so that is what
485
+ // they are keyed on. The trade-off is deliberate and narrow: swapping a column's
486
+ // `sortBy`/`filter`/`filterBy` FUNCTION while its `key` stays the same no longer
487
+ // invalidates on its own. A rule that changes is a different column — give it a
488
+ // different key — and anything the rule reads (rows, filters, sorts) is a dependency
489
+ // in its own right.
490
+ const columnSig = columns.map((c) => c.key).join("\u0000");
491
+ // The select filters' declared option lists are the one part of a column definition
492
+ // the memo below reads as DATA rather than as a rule, and a caller may recompute
493
+ // them (a lookup that arrived, a saved view applied) without the column set moving.
494
+ const selectOptionsSig = columns
495
+ .map((c) => {
496
+ const f = resolveFilter(c);
497
+ if (!f || f.type !== "select" || !f.options) return "";
498
+ return `${c.key}:${f.options.map((o) => `${o.value}=${o.label ?? ""}`).join(",")}`;
499
+ })
500
+ .join("\u0000");
501
+
502
+ const selectOptionsByKey = useMemo(() => {
503
+ const map: Record<string, { value: string; label: string }[]> = {};
504
+ for (const col of columns) {
505
+ const f = resolveFilter(col);
506
+ if (!f || f.type !== "select") continue;
507
+ if (f.options && f.options.length > 0) {
508
+ map[col.key] = f.options.map((o) => ({ value: o.value, label: o.label ?? o.value }));
509
+ continue;
510
+ }
511
+ const seen = new Set<string>();
512
+ for (const row of rows) {
513
+ const v = f.getValue(row);
514
+ if (v && !seen.has(v)) seen.add(v);
515
+ }
516
+ map[col.key] = Array.from(seen)
517
+ .sort()
518
+ .map((v) => ({ value: v, label: v }));
519
+ }
520
+ return map;
521
+ // `columns` by signature — see the note above the signatures.
522
+ // eslint-disable-next-line react-hooks/exhaustive-deps
523
+ }, [columnSig, selectOptionsSig, rows]);
524
+
525
+ const filtered = useMemo(() => {
526
+ if (isServer) return rows;
527
+ let result = rows;
528
+ for (const col of columns) {
529
+ const state = filters[col.key];
530
+ if (!state || !isFilterActive(state)) continue;
531
+ result = result.filter((row) => rowMatches(col, row, state, locale));
532
+ }
533
+ return result;
534
+ // eslint-disable-next-line react-hooks/exhaustive-deps
535
+ }, [rows, columnSig, filters, isServer, locale]);
536
+
537
+ const sorted = useMemo(() => {
538
+ if (isServer) return filtered;
539
+ // Priority chain: the first sort key that distinguishes two rows wins;
540
+ // nulls sort last for that key regardless of direction (as before).
541
+ const chain = sorts
542
+ .map((s) => {
543
+ const col = columns.find((c) => c.key === s.key);
544
+ return col?.sortBy ? { sortBy: col.sortBy, dir: s.dir } : null;
545
+ })
546
+ .filter((c): c is { sortBy: (row: T) => string | number | null | undefined; dir: "asc" | "desc" } => c !== null);
547
+ if (!chain.length) return filtered;
548
+ const copy = [...filtered];
549
+ copy.sort((a, b) => {
550
+ for (const { sortBy, dir } of chain) {
551
+ const av = sortBy(a);
552
+ const bv = sortBy(b);
553
+ if (av == null && bv == null) continue;
554
+ if (av == null) return 1;
555
+ if (bv == null) return -1;
556
+ if (av < bv) return dir === "asc" ? -1 : 1;
557
+ if (av > bv) return dir === "asc" ? 1 : -1;
558
+ }
559
+ return 0;
560
+ });
561
+ return copy;
562
+ // eslint-disable-next-line react-hooks/exhaustive-deps
563
+ }, [filtered, sorts, columnSig, isServer]);
564
+
565
+ // `paginated={false}` is expressed as "page size = everything" rather than as a
566
+ // second code path, so slicing, the page clamp and the range summary all keep
567
+ // exactly one definition.
568
+ const unpaged = !isServer && !paginated;
569
+ const effectivePageSize = isServer
570
+ ? serverPagination!.pageSize
571
+ : unpaged || pageSize === Infinity
572
+ ? sorted.length || 1
573
+ : pageSize;
574
+ const paginationTotal = isServer ? serverPagination!.total : sorted.length;
575
+ const paginationPageSize = isServer ? serverPagination!.pageSize : pageSize;
576
+ const totalPages = isServer
577
+ ? Math.max(1, Math.ceil(serverPagination!.total / Math.max(1, serverPagination!.pageSize)))
578
+ : Math.max(1, Math.ceil(sorted.length / effectivePageSize));
579
+ const safePage = isServer
580
+ ? Math.min(Math.max(0, serverPagination!.page), totalPages - 1)
581
+ : Math.min(page, totalPages - 1);
582
+ const slice =
583
+ isServer || unpaged || pageSize === Infinity
584
+ ? sorted
585
+ : sorted.slice(safePage * effectivePageSize, safePage * effectivePageSize + effectivePageSize);
586
+
587
+ const toggleSort = (key: string, additive: boolean) => {
588
+ const next = nextSorts(sorts, key, additive);
589
+ if (onSortsChange) onSortsChange(next);
590
+ else setInternalSorts(next);
591
+
592
+ // Announce the column the user just pressed, not `next[0]`: a shift-click adds a
593
+ // criterion BEHIND the existing one, and answering "Sorted by Date" to a click on
594
+ // Name misreports what the click did. The third click drops the column entirely,
595
+ // which is the state a user is most likely to have reached by accident.
596
+ const col = columns.find((c) => c.key === key);
597
+ const name = col ? columnLabel(col) : key;
598
+ const entry = next.find((s) => s.key === key);
599
+ announce(
600
+ !entry
601
+ ? labels.sortCleared(name)
602
+ : entry.dir === "asc"
603
+ ? labels.sortedAscending(name)
604
+ : labels.sortedDescending(name),
605
+ );
606
+ };
607
+
608
+ const setFilterValue = (key: string, value: FilterValue) => {
609
+ if (onFiltersChange) {
610
+ onFiltersChange({ ...filters, [key]: value });
611
+ return; // page reset is the controlling owner's job
612
+ }
613
+ setInternalFilters((s) => ({ ...s, [key]: value }));
614
+ setPage(0);
615
+ };
616
+
617
+ const clearFilter = (key: string) => {
618
+ if (onFiltersChange) {
619
+ const rest = { ...filters };
620
+ delete rest[key];
621
+ onFiltersChange(rest);
622
+ return;
623
+ }
624
+ setInternalFilters((s) => {
625
+ const rest = { ...s };
626
+ delete rest[key];
627
+ return rest;
628
+ });
629
+ setPage(0);
630
+ };
631
+
632
+ // Clear every column filter at once (drives the mobile filter sheet's
633
+ // "clear all"). Only touches keys the table actually exposes as filters.
634
+ const clearAllFilters = () => {
635
+ const keys = columns
636
+ .filter((col) => ((!isServer || onFiltersChange) ? resolveFilter(col) : null))
637
+ .map((col) => col.key);
638
+ if (!keys.length) return;
639
+ if (onFiltersChange) {
640
+ const rest = { ...filters };
641
+ for (const k of keys) delete rest[k];
642
+ onFiltersChange(rest);
643
+ return;
644
+ }
645
+ setInternalFilters((s) => {
646
+ const rest = { ...s };
647
+ for (const k of keys) delete rest[k];
648
+ return rest;
649
+ });
650
+ setPage(0);
651
+ };
652
+
653
+ // The result count only exists AFTER the table has re-filtered, so the sentence is
654
+ // spoken from the render that produced the new number rather than from the handler
655
+ // that changed the filter. Keyed on a serialised copy of the filter state, for two
656
+ // reasons: a controlled `filters` prop arrives with a fresh identity on every
657
+ // parent render, and a change the OWNER makes (a saved view applied, a filter chip
658
+ // dismissed outside the table) moves the same rows and deserves the same sentence.
659
+ const filterKey = useMemo(() => JSON.stringify(filters), [filters]);
660
+ // Seeded with the mount value: a live region that speaks on first paint talks over
661
+ // the page the user has just opened, and every table on that page does it at once.
662
+ const announcedFilterKey = useRef(filterKey);
663
+ useEffect(() => {
664
+ if (announcedFilterKey.current === filterKey) return;
665
+ announcedFilterKey.current = filterKey;
666
+ // A server-paginated table does not do the filtering: the matching rows arrive on
667
+ // some later render from the caller, and `serverPagination.total` is ALREADY the
668
+ // filtered total — so the "n of m" sentence has no second number to give here.
669
+ // Saying nothing is better than announcing the page size as a result count.
670
+ if (isServer) return;
671
+ announce(labels.filterResults(filtered.length, rows.length));
672
+ }, [filterKey, filtered.length, rows.length, isServer, announce, labels]);
673
+
674
+ // 1-based, because that is what the page strip shows and what a person says.
675
+ const goToPage = (p: number) => {
676
+ if (isServer) serverPagination!.onPageChange(p);
677
+ else setPage(p);
678
+ announce(labels.pageChanged(p + 1, totalPages));
679
+ };
680
+
681
+ const visibleColumns = useMemo(
682
+ () => columns.filter((c) => !hiddenCols.has(c.key)),
683
+ [columns, hiddenCols],
684
+ );
685
+
686
+ // Which cell carries the row's link when `rowHref` is set (feedback #451). The
687
+ // `mobilePrimary` column is the row's headline by declaration, so it is both the
688
+ // widest target and the one the pointer is already over; falls back to the first
689
+ // column, and falls back again if the user has hidden the primary one from the
690
+ // column panel. Resolved from `visibleColumns` for exactly that reason — off a
691
+ // raw `columns` lookup a hidden headline would leave the row with no link at all.
692
+ //
693
+ // `noRowLink` columns are skipped in BOTH steps: wrapping a cell that owns an
694
+ // anchor nests two links and disables the inner one (see the prop's note). If
695
+ // nothing visible is left to wrap, the row gets NO anchor — an anchor over the
696
+ // one cell there is would cancel that cell's own click and hand back nothing,
697
+ // and a row without a link still behaves exactly as it did before #451.
698
+ const linkColumn = rowHref
699
+ ? (visibleColumns.find((c) => c.mobilePrimary && !c.noRowLink) ??
700
+ visibleColumns.find((c) => !c.noRowLink))
701
+ : undefined;
702
+
703
+ // ---- Column resize ----
704
+ //
705
+ // Pointer events with capture, rather than a `mousemove`/`mouseup` pair on `window`.
706
+ // Three defects in one eleven-line handler (audit 2026-09-22):
707
+ //
708
+ // * a `setWidths` per `mousemove` re-rendered every row of the table on every pixel
709
+ // of the drag — on a 500-row table that is the whole body, sixty times a second;
710
+ // * the two `window` listeners were added from inside an event handler, so nothing
711
+ // removed them if the table unmounted mid-drag (the row beneath opens a route),
712
+ // and `document.body.style` stayed at `user-select: none; cursor: col-resize` for
713
+ // the rest of the session — the whole page unselectable, with the wrong cursor;
714
+ // * `mousedown` is a mouse. Under a finger the handle did nothing at all.
715
+ //
716
+ // `setPointerCapture` answers all three: the handle itself receives every move and
717
+ // the terminating `pointerup`/`pointercancel` wherever the pointer travels, so there
718
+ // is nothing global to leak, and a touch drag works because a finger is a pointer.
719
+ // The live width is written straight onto the column's own cells — the same three
720
+ // properties the render sets, so the drag and the commit cannot disagree — and
721
+ // `setWidths` is called once, when the drag ends.
722
+ const resizing = useRef<Resize | null>(null);
723
+
724
+ /** Every cell in one column, header first. `data-col` rather than a cell index:
725
+ * an expanded row holds a single `colSpan` cell, so the nth `<td>` of a row is
726
+ * not reliably the nth column. */
727
+ const columnCells = (key: string): HTMLElement[] => {
728
+ const th = headRefs.get(key);
729
+ if (!th) return [];
730
+ const table = th.closest("table");
731
+ if (!table) return [th];
732
+ return [
733
+ th,
734
+ ...Array.from(table.querySelectorAll<HTMLElement>("tbody [data-col]")).filter(
735
+ (el) => el.dataset.col === key,
736
+ ),
737
+ ];
738
+ };
739
+
740
+ const applyLiveWidth = (cells: HTMLElement[], width: number | undefined) => {
741
+ const px = width == null ? "" : `${width}px`;
742
+ for (const el of cells) {
743
+ el.style.width = px;
744
+ el.style.minWidth = px;
745
+ el.style.maxWidth = px;
746
+ }
747
+ };
748
+
749
+ const releaseResizeChrome = (handle: Element, pointerId: number) => {
750
+ document.body.style.userSelect = "";
751
+ document.body.style.cursor = "";
752
+ // Optional calls: jsdom implements neither, and the handlers sit on the handle
753
+ // itself, so a run without capture behaves the same for anything that stays
754
+ // inside the element.
755
+ if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture?.(pointerId);
756
+ };
757
+
758
+ const startResize = (e: React.PointerEvent<HTMLElement>, key: string) => {
759
+ // Secondary buttons open menus; only a primary press starts a drag.
760
+ if (e.button !== 0) return;
761
+ e.preventDefault();
762
+ e.stopPropagation();
763
+ const th = headRefs.get(key);
764
+ const startWidth = th?.getBoundingClientRect().width ?? 100;
765
+ resizing.current = {
766
+ key,
767
+ pointerId: e.pointerId,
768
+ startX: e.clientX,
769
+ startWidth,
770
+ hadWidth: widths[key],
771
+ cells: columnCells(key),
772
+ width: startWidth,
773
+ moved: false,
774
+ };
775
+ e.currentTarget.setPointerCapture?.(e.pointerId);
776
+ document.body.style.userSelect = "none";
777
+ document.body.style.cursor = "col-resize";
778
+ };
779
+
780
+ const moveResize = (e: React.PointerEvent<HTMLElement>) => {
781
+ const drag = resizing.current;
782
+ if (!drag || drag.pointerId !== e.pointerId) return;
783
+ drag.width = Math.max(40, Math.round(drag.startWidth + (e.clientX - drag.startX)));
784
+ drag.moved = true;
785
+ applyLiveWidth(drag.cells, drag.width);
786
+ };
787
+
788
+ const finishResize = (e: React.PointerEvent<HTMLElement>, commit: boolean) => {
789
+ const drag = resizing.current;
790
+ // Before the early return: the page gets its selection and its cursor back even
791
+ // for a pointer we were not tracking.
792
+ releaseResizeChrome(e.currentTarget, e.pointerId);
793
+ if (!drag || drag.pointerId !== e.pointerId) return;
794
+ resizing.current = null;
795
+ if (commit && drag.moved) {
796
+ setWidths((w) => ({ ...w, [drag.key]: drag.width }));
797
+ return;
798
+ }
799
+ // Cancelled — the browser took the pointer for a system gesture — or the press
800
+ // never became a drag. Either way the column goes back to the width React last
801
+ // rendered it at, which nothing else can do: the inline width the drag wrote is
802
+ // invisible to React, so a re-render with an unchanged `widths` would keep it.
803
+ applyLiveWidth(drag.cells, drag.hadWidth);
804
+ };
805
+
806
+ // The last exit path. A table that unmounts mid-drag takes the handle, and with it
807
+ // every handler above, out of the document — this is the only place left to hand the
808
+ // page back its text selection and its cursor.
809
+ useEffect(
810
+ () => () => {
811
+ if (!resizing.current) return;
812
+ resizing.current = null;
813
+ document.body.style.userSelect = "";
814
+ document.body.style.cursor = "";
815
+ },
816
+ [],
817
+ );
818
+
819
+ const autoSizeAll = () => {
820
+ // Clear widths so the table reflows to natural sizes, then snapshot each
821
+ // column's width and write it back — except for the last visible column,
822
+ // which is left unconstrained so it absorbs any remaining horizontal space.
823
+ setWidths({});
824
+ requestAnimationFrame(() => {
825
+ const next: Record<string, number> = {};
826
+ const cols = columns.filter((c) => !hiddenCols.has(c.key));
827
+ cols.forEach((col, idx) => {
828
+ if (idx === cols.length - 1) return;
829
+ const th = headRefs.get(col.key);
830
+ if (th) next[col.key] = Math.ceil(th.getBoundingClientRect().width);
831
+ });
832
+ setWidths(next);
833
+ });
834
+ };
835
+
836
+ const visibleCount = visibleColumns.length;
837
+ const totalCount = columns.length;
838
+ // Spans the full row width including the optional leading selection column.
839
+ const totalColSpan = visibleCount + (selection ? 1 : 0);
840
+ const columnsCountLabel = labels.columnsCount(visibleCount, totalCount);
841
+ // Match Tailwind's `md` breakpoint: we render either the table or the card
842
+ // list — never both — so we don't double up DOM nodes that screen readers and
843
+ // integration tests would have to disambiguate.
844
+ const isMdUp = useMediaQuery("(min-width: 768px)", true);
845
+
846
+ // ---- Mobile card list (md:hidden) ----
847
+ // Renders the same paged/filtered/sorted slice but as stacked cards instead
848
+ // of a horizontally-scrolling table. Filters / sort / column settings are
849
+ // intentionally hidden here — the mobile layout assumes the user wants to
850
+ // scan rows quickly. Switch to a wider viewport for fine-grained control.
851
+ const mobileColumns = visibleColumns.filter((c) => !c.mobileHidden);
852
+ const mobilePrimaryCol =
853
+ mobileColumns.find((c) => c.mobilePrimary) ?? mobileColumns[0] ?? null;
854
+ const mobileSecondaryColumns = mobileColumns.filter((c) => c !== mobilePrimaryCol);
855
+ // The phone card IS the anchor, so unlike the desktop table there is no other
856
+ // cell to move the row link into: if any cell the card renders owns a link, the
857
+ // card itself cannot be one (feedback #451 — same nesting rule as `linkColumn`
858
+ // above). It then falls back to the pre-#451 role="button" card, which still
859
+ // opens the row and leaves the cell's own link working. A caller's `mobileCard`
860
+ // renderer is opaque to us; a link inside one is the caller's to declare by
861
+ // marking the column it comes from.
862
+ const mobileCardLinkable = mobileColumns.every((c) => !c.noRowLink);
863
+
864
+ const { mobileSlice, canRevealMoreMobile, loadMoreRef } = useMobileReveal({
865
+ rows: sorted,
866
+ slice,
867
+ isServer,
868
+ isMdUp,
869
+ defaultPageSize,
870
+ });
871
+ // The row whose selection was last toggled, so Shift+click can select the range up
872
+ // to it (feedback #289). Held as its KEY, not as an index into `slice`: the slice is
873
+ // re-derived from the current sort, filter and page, so an index taken before a
874
+ // header was clicked addresses a different row afterwards — and the range then ran
875
+ // from a row the user had never touched.
876
+ const selectionAnchor = useRef<string | number | null>(null);
877
+ const canSelect = (row: T) => !!selection && (selection.isSelectable?.(row) ?? true);
878
+ const selectRange = (toIndex: number) => {
879
+ if (!selection || selectionAnchor.current === null) return false;
880
+ const fromIndex = slice.findIndex((r) => rowKey(r) === selectionAnchor.current);
881
+ // The anchor has been filtered, sorted or paged off the screen: there is no range
882
+ // on display to draw, so the click falls through to the plain single-row toggle.
883
+ if (fromIndex < 0) return false;
884
+ const [lo, hi] = [fromIndex, toIndex].sort((a, b) => a - b);
885
+ const range: T[] = [];
886
+ for (let i = lo; i <= hi; i++) {
887
+ const r = slice[i];
888
+ if (r && canSelect(r)) range.push(r);
889
+ }
890
+ // One call where the owner can take one. See `selection.onToggleMany`: the row-by-
891
+ // row loop below is the fallback for an owner that has not adopted it, not the
892
+ // shape this should be in.
893
+ if (selection.onToggleMany) selection.onToggleMany(range, true);
894
+ else for (const r of range) selection.onToggle(r, true);
895
+ return true;
896
+ };
897
+
898
+ // When dialog-mode is on, the expanded row's detail goes into a bottom-sheet
899
+ // modal instead of unfolding inline. Only one row is ever expanded at a time.
900
+ const mobileDialogRow = mobileExpandAsDialog && !isMdUp ? mobileSlice.find((r) => isExpanded?.(r)) : undefined;
901
+ const mobileDialogContent = mobileDialogRow ? expandedRow?.(mobileDialogRow) : null;
902
+ // The full-screen row dialog. Its panel, header, scroll lock and Back handling all
903
+ // live in {@link FullBleedDialog} now — this was the only copy of that shell until
904
+ // the transactions create card needed the same one (Keksdose live #307's follow-up),
905
+ // and two copies of a dialog is how the row editor and the create form come to look
906
+ // like two different products.
907
+ const dialogOpen = !!(mobileDialogRow && mobileDialogContent);
908
+ const closeDialog = () => {
909
+ if (mobileDialogRow) onRowClick?.(mobileDialogRow);
910
+ };
911
+
912
+ // One mobile card. Extracted so the flat list and the grouped list (below)
913
+ // share identical row markup.
914
+ const renderMobileRow = (row: T) => {
915
+ const expanded = isExpanded?.(row) ?? false;
916
+ const expansion = expanded ? expandedRow?.(row) : null;
917
+ const tint = rowClassName?.(row);
918
+ // Use div+role="button" rather than a real <button> so cells that
919
+ // contain their own interactive controls (status toggles, action
920
+ // icons) don't end up as illegal nested buttons.
921
+ const interactive = !!onRowClick;
922
+ // Say that the card OPENS something (Keksdose feedback #163). A phone card had
923
+ // only a cursor and a tap-tint to advertise its editor — on the invoice review
924
+ // screen, whose entire purpose is correcting mis-read lines, that read as "these
925
+ // values are not editable". A chevron is the affordance every list on a phone
926
+ // uses for exactly this, and it points the way the row actually opens: right into
927
+ // a sheet, or down into an inline panel that flips it when expanded.
928
+ const opensDetail = interactive && !!expandedRow;
929
+ const swipe = mobileSwipeActions?.(row);
930
+ // An expanded row never swipes: the editor below it owns the horizontal space,
931
+ // and dragging the header away from its own form reads as a glitch.
932
+ const swipeEnabled = !!swipe && !expanded;
933
+ const href = mobileCardLinkable ? rowHref?.(row) : undefined;
934
+ const cardClass = cn(
935
+ "w-full px-4 py-3 text-left flex items-center gap-3",
936
+ // Live #320's other half: the card acknowledges the touch before the sheet
937
+ // arrives. On a cold route the data can take a beat, and an unacknowledged tap
938
+ // reads as "did that register?" — which is most of what "abrupt" means here.
939
+ // `origin-center` + a 0.5% squeeze is deliberately almost subliminal: this fires
940
+ // on every row of a long list, so anything larger becomes the list's personality
941
+ // rather than feedback. `transition-transform` alone, so the existing background
942
+ // flip stays instant.
943
+ interactive &&
944
+ "cursor-pointer transition-transform duration-100 active:scale-[0.995] active:bg-[var(--bg-active)] motion-reduce:transition-none motion-reduce:active:scale-100",
945
+ );
946
+ // The card's content is written once and worn by either tag below. Note that
947
+ // the primary cell is rendered RAW here, never through `linkColumn` — the card
948
+ // itself is the anchor, and routing it through `linkColumn` too would nest an
949
+ // <a> inside an <a>. Desktop and mobile are mutually exclusive (`isMdUp`), so
950
+ // exactly one anchor exists per row.
951
+ const cardInner = (
952
+ <>
953
+ {/* The card's own content keeps the column it always had; the chevron sits
954
+ beside it rather than inside, so a caller's `mobileCard` is untouched. */}
955
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
956
+ {mobileCard ? (
957
+ mobileCard(row)
958
+ ) : (
959
+ <>
960
+ {mobilePrimaryCol && <div className="font-medium">{mobilePrimaryCol.cell(row)}</div>}
961
+ {mobileSecondaryColumns.length > 0 && (
962
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
963
+ {mobileSecondaryColumns.map((col) => (
964
+ <Fragment key={col.key}>
965
+ <dt className="text-xs uppercase tracking-wide text-[var(--text-muted)] self-center">
966
+ {col.header}
967
+ </dt>
968
+ <dd className="min-w-0 text-[var(--text-secondary)] self-center">
969
+ {col.cell(row)}
970
+ </dd>
971
+ </Fragment>
972
+ ))}
973
+ </dl>
974
+ )}
975
+ </>
976
+ )}
977
+ </div>
978
+ {opensDetail &&
979
+ // `aria-hidden`: the row already announces itself as a button with
980
+ // `aria-expanded`, so the icon would only add a second, wordless stop.
981
+ (mobileExpandAsDialog ? (
982
+ <ChevronRight aria-hidden className="size-4 shrink-0 text-[var(--text-placeholder)]" />
983
+ ) : (
984
+ <ChevronDown
985
+ aria-hidden
986
+ className={cn(
987
+ "size-4 shrink-0 text-[var(--text-placeholder)] transition-transform",
988
+ expanded && "rotate-180",
989
+ )}
990
+ />
991
+ ))}
992
+ </>
993
+ );
994
+ const body =
995
+ interactive && href ? (
996
+ // A card with a URL of its own is a LINK, not a div wearing role="button"
997
+ // (feedback #451): the phone's long-press "open in new tab" and a screen
998
+ // reader's link semantics both come free, and the fake role goes away.
999
+ <RowLink
1000
+ href={href}
1001
+ onActivate={() => onRowClick!(row)}
1002
+ aria-expanded={expandedRow ? expanded : undefined}
1003
+ className={cardClass}
1004
+ // Enter already activates a link natively, and that routes through
1005
+ // RowLink's own onClick — handling it here too would open the row twice.
1006
+ // Space is the only key left, and only so it opens the row instead of
1007
+ // scrolling the list.
1008
+ onKeyDown={(e) => {
1009
+ if (e.key !== " ") return;
1010
+ e.preventDefault();
1011
+ onRowClick!(row);
1012
+ }}
1013
+ >
1014
+ {cardInner}
1015
+ </RowLink>
1016
+ ) : (
1017
+ <div
1018
+ role={interactive ? "button" : undefined}
1019
+ tabIndex={interactive ? 0 : undefined}
1020
+ onClick={interactive ? () => onRowClick!(row) : undefined}
1021
+ onKeyDown={
1022
+ interactive
1023
+ ? (e) => {
1024
+ if (e.key === "Enter" || e.key === " ") {
1025
+ e.preventDefault();
1026
+ onRowClick!(row);
1027
+ }
1028
+ }
1029
+ : undefined
1030
+ }
1031
+ aria-expanded={expandedRow ? expanded : undefined}
1032
+ className={cardClass}
1033
+ >
1034
+ {cardInner}
1035
+ </div>
1036
+ );
1037
+ return (
1038
+ <li key={rowKey(row)} className={cn(tint)} {...cleanAttrs(rowAttributes?.(row))}>
1039
+ {swipeEnabled ? (
1040
+ <SwipeableRow enabled left={swipe!.left} right={swipe!.right}>
1041
+ {body}
1042
+ </SwipeableRow>
1043
+ ) : (
1044
+ body
1045
+ )}
1046
+ {expansion && !mobileExpandAsDialog && (
1047
+ <div className="px-4 py-3 bg-[var(--bg-surface-2)] border-t border-[var(--border)]">
1048
+ {expansion}
1049
+ </div>
1050
+ )}
1051
+ </li>
1052
+ );
1053
+ };
1054
+
1055
+ // Consecutive grouping of the (already-sorted) mobile slice into labelled
1056
+ // sections for the "assistance"-style list (feedback #317). Null unless the
1057
+ // caller opts in via `mobileGroupBy`.
1058
+ const mobileGroups: { key: string; rows: T[] }[] | null = mobileGroupBy
1059
+ ? mobileSlice.reduce<{ key: string; rows: T[] }[]>((acc, row) => {
1060
+ const key = mobileGroupBy(row);
1061
+ const last = acc[acc.length - 1];
1062
+ if (last && last.key === key) last.rows.push(row);
1063
+ else acc.push({ key, rows: [row] });
1064
+ return acc;
1065
+ }, [])
1066
+ : null;
1067
+
1068
+ return (
1069
+ <Card flush className={cn("overflow-clip", fillHeight && "md:flex md:flex-1 md:flex-col md:min-h-0")}>
1070
+ {/* Outside both viewport branches on purpose. A screen reader subscribes to a
1071
+ live region when it encounters it, so one that appears together with its
1072
+ first message is usually missed — and crossing the md breakpoint swaps the
1073
+ card list for the table wholesale, which would remount a region living
1074
+ inside either one. */}
1075
+ <span {...regionProps} />
1076
+ {!isMdUp && (
1077
+ <div>
1078
+ {/* Mobile filter access (feedback #299): the per-column filter popovers
1079
+ live in the desktop header, which the card list doesn't render — so
1080
+ expose the same filters through a bottom sheet here. */}
1081
+ {(!isServer || onFiltersChange) && (
1082
+ <MobileFilters
1083
+ columns={columns}
1084
+ filters={filters}
1085
+ selectOptionsByKey={selectOptionsByKey}
1086
+ isServer={isServer}
1087
+ hasFilterCallback={!!onFiltersChange}
1088
+ onSetFilter={setFilterValue}
1089
+ onClearFilter={clearFilter}
1090
+ onClearAll={clearAllFilters}
1091
+ labels={labels}
1092
+ locale={locale}
1093
+ />
1094
+ )}
1095
+ <ul className="divide-y divide-[var(--border)]">
1096
+ {mobileGroups
1097
+ ? mobileGroups.map((g) => (
1098
+ <Fragment key={`hb-group:${g.key}`}>
1099
+ <li className="sticky top-0 z-10 bg-[var(--bg-surface-2)] px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)] backdrop-blur">
1100
+ {mobileGroupLabel ? mobileGroupLabel(g.key) : g.key}
1101
+ </li>
1102
+ {g.rows.map(renderMobileRow)}
1103
+ </Fragment>
1104
+ ))
1105
+ : mobileSlice.map(renderMobileRow)}
1106
+ {mobileSlice.length === 0 && (
1107
+ <li className="px-4 py-6 text-center text-sm text-[var(--text-muted)]">
1108
+ {empty ?? "—"}
1109
+ </li>
1110
+ )}
1111
+ {/* Endless-scroll sentinel: observed by IntersectionObserver to pull in
1112
+ the next chunk as it nears the viewport (feedback #232). */}
1113
+ {canRevealMoreMobile && (
1114
+ <li
1115
+ ref={loadMoreRef}
1116
+ className="px-4 py-4 text-center text-xs text-[var(--text-placeholder)]"
1117
+ >
1118
+ {labels.loading}
1119
+ </li>
1120
+ )}
1121
+ </ul>
1122
+ {/* Client-side mobile lists scroll endlessly (sentinel above); only
1123
+ server-paginated tables keep the pager here. */}
1124
+ {isServer && (
1125
+ <Pagination
1126
+ page={safePage}
1127
+ totalPages={totalPages}
1128
+ pageSize={paginationPageSize}
1129
+ total={paginationTotal}
1130
+ onPage={goToPage}
1131
+ onPageSize={(n) => serverPagination!.onPageSizeChange?.(n)}
1132
+ labels={labels}
1133
+ locale={locale}
1134
+ />
1135
+ )}
1136
+ {/* The row's own first column is this dialog's title — the same cell the card
1137
+ behind it shows, so the panel names the row it opened from. */}
1138
+ <FullBleedDialog
1139
+ open={dialogOpen}
1140
+ onClose={closeDialog}
1141
+ closeLabel={labels.close}
1142
+ header={mobileDialogRow ? mobilePrimaryCol?.cell(mobileDialogRow) : null}
1143
+ >
1144
+ {mobileDialogContent}
1145
+ </FullBleedDialog>
1146
+ </div>
1147
+ )}
1148
+ {isMdUp && (
1149
+ <div className={cn("flex", fillHeight && "min-h-0 flex-1")}>
1150
+ <div className={cn("relative min-w-0 flex-1", fillHeight && "flex flex-col min-h-0")}>
1151
+ {/* scrollbar-gutter:stable reserves the vertical-scrollbar space up
1152
+ front. Expanding a row adds height, which can bring the scrollbar
1153
+ in, which narrows the content box — and in an `auto` table layout
1154
+ that re-computes every column width. Reserving the gutter is the
1155
+ other half of the `w-0 min-w-full` fix on the expansion cell below
1156
+ (feedback #104: "expanding an item resizes the columns"). */}
1157
+ <div
1158
+ className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "flex-1 min-h-0")}
1159
+ style={fillHeight ? undefined : { maxHeight: maxBodyHeight }}
1160
+ >
1161
+ {/* A table with no name is "table" in a screen reader's list of tables, and
1162
+ an app renders several. `labels.table` is how a call site says which. */}
1163
+ <table className="w-full text-sm" aria-label={labels.table}>
1164
+ {/* Sticky header. position:sticky pins to the nearest scroll-container
1165
+ ancestor — so the header can only stick to THIS wrapper, never the
1166
+ page. For that to actually hold the header in place, the wrapper
1167
+ must be the thing that scrolls vertically: hence overflow-auto +
1168
+ a bounded max-height (rather than overflow-x-auto, which scrolls
1169
+ only sideways and lets the header ride away on page scroll). The
1170
+ header then stays put as you scroll the rows. position:sticky on
1171
+ <thead> isn't reliable across browsers, so we put it on each <th>
1172
+ (see headClassName below). */}
1173
+ <thead className="bg-[var(--bg-surface-2)] text-[var(--text-secondary)]">
1174
+ <tr className="text-left">
1175
+ {selection && (
1176
+ <th
1177
+ scope="col"
1178
+ className="sticky top-0 z-10 w-10 bg-[var(--bg-surface-2)] px-3 py-2 align-middle backdrop-blur-sm"
1179
+ >
1180
+ <input
1181
+ type="checkbox"
1182
+ className="size-4 align-middle accent-indigo-600"
1183
+ checked={selection.allSelected}
1184
+ ref={(el) => {
1185
+ if (el) el.indeterminate = selection.someSelected && !selection.allSelected;
1186
+ }}
1187
+ onChange={(e) => selection.onToggleAll(e.target.checked)}
1188
+ aria-label={labels.selectAllRows}
1189
+ />
1190
+ </th>
1191
+ )}
1192
+ {visibleColumns.map((col) => {
1193
+ // In server mode the utilities stay hidden unless the caller
1194
+ // takes them over and feeds them back into the server query.
1195
+ const sortable = (!isServer || !!onSortsChange) && !!col.sortBy;
1196
+ const filter = !isServer || onFiltersChange ? resolveFilter(col) : null;
1197
+ const filterState = filters[col.key] ?? (filter ? defaultFilterState(filter) : undefined);
1198
+ const sortIdx = sorts.findIndex((s) => s.key === col.key);
1199
+ const active = sortIdx !== -1;
1200
+ const Icon = active ? (sorts[sortIdx].dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
1201
+ const filterActive = filterState && isFilterActive(filterState);
1202
+ const isRightAligned = col.headClassName?.includes("text-right");
1203
+ const width = widths[col.key];
1204
+ return (
1205
+ <th
1206
+ key={col.key}
1207
+ ref={(el) => {
1208
+ if (el) headRefs.set(col.key, el);
1209
+ else headRefs.delete(col.key);
1210
+ }}
1211
+ scope="col"
1212
+ // Only a header the user can actually sort says anything: an
1213
+ // `aria-sort` on a plain column is a control that is not there,
1214
+ // and a persisted or URL sort can name a column with no `sortBy`
1215
+ // (the sort chain drops it silently) — which would otherwise
1216
+ // claim a sort the table is not applying.
1217
+ //
1218
+ // "none" on the rest is the half that was missing. Without it a
1219
+ // sortable header is indistinguishable from a plain one until the
1220
+ // user presses it. Secondary criteria read "none" too: ARIA allows
1221
+ // exactly one sorted column, and the priority is carried by the
1222
+ // badge and by what the header button announces on press.
1223
+ aria-sort={
1224
+ sortable
1225
+ ? sortIdx === 0
1226
+ ? sorts[0].dir === "asc"
1227
+ ? "ascending"
1228
+ : "descending"
1229
+ : "none"
1230
+ : undefined
1231
+ }
1232
+ style={width ? { width, minWidth: width, maxWidth: width } : undefined}
1233
+ className={cn(
1234
+ "relative px-3 py-2 font-medium align-middle whitespace-nowrap",
1235
+ // Keep the header visible while the user scrolls the page.
1236
+ // Each <th> carries its own bg so the row doesn't render
1237
+ // transparent over the data rows underneath.
1238
+ "sticky top-0 z-10 bg-[var(--bg-surface-2)] backdrop-blur-sm",
1239
+ col.headClassName,
1240
+ )}
1241
+ >
1242
+ <div
1243
+ className={cn(
1244
+ "flex items-center gap-1",
1245
+ isRightAligned && "flex-row-reverse",
1246
+ )}
1247
+ >
1248
+ <Tooltip label={sortable ? labels.sortHint : undefined} portal>
1249
+ <button
1250
+ type="button"
1251
+ onClick={(e) => sortable && toggleSort(col.key, e.shiftKey)}
1252
+ disabled={!sortable}
1253
+ className={cn(
1254
+ "flex items-center gap-1 text-left",
1255
+ sortable && "hover:text-[var(--text-primary)]",
1256
+ )}
1257
+ >
1258
+ <span>{col.header}</span>
1259
+ {sortable && (
1260
+ <Icon className={cn("size-3", active ? "opacity-100" : "opacity-40")} />
1261
+ )}
1262
+ {/* Priority badge, only meaningful with 2+ sort keys */}
1263
+ {active && sorts.length > 1 && (
1264
+ <span className="text-[9px] font-semibold leading-none text-brand">
1265
+ {sortIdx + 1}
1266
+ </span>
1267
+ )}
1268
+ </button>
1269
+ </Tooltip>
1270
+ {filter && (
1271
+ <Popover
1272
+ width={filter.type === "date" ? 420 : undefined}
1273
+ // The panel is the column's filter, and says so. Unnamed it
1274
+ // was announced as the kit-wide fallback, "Popover".
1275
+ labels={{ panel: labels.filter }}
1276
+ trigger={({ toggle, ref }) => (
1277
+ <button
1278
+ type="button"
1279
+ ref={ref}
1280
+ onClick={toggle}
1281
+ aria-label={labels.filter}
1282
+ className={cn(
1283
+ "rounded p-1 transition-colors",
1284
+ filterActive
1285
+ ? "bg-[var(--brand-bg)] text-[var(--brand-muted)]"
1286
+ : "text-[var(--text-placeholder)] hover:text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
1287
+ )}
1288
+ >
1289
+ <Filter className="size-3" />
1290
+ </button>
1291
+ )}
1292
+ >
1293
+ {() => (
1294
+ <FilterPopover
1295
+ column={col}
1296
+ state={filterState ?? defaultFilterState(filter)}
1297
+ onChange={(next) => setFilterValue(col.key, next)}
1298
+ onClear={() => clearFilter(col.key)}
1299
+ selectOptions={selectOptionsByKey[col.key] ?? []}
1300
+ labels={labels}
1301
+ locale={locale}
1302
+ />
1303
+ )}
1304
+ </Popover>
1305
+ )}
1306
+ </div>
1307
+ <span
1308
+ role="separator"
1309
+ aria-orientation="vertical"
1310
+ onPointerDown={(e) => startResize(e, col.key)}
1311
+ onPointerMove={moveResize}
1312
+ onPointerUp={(e) => finishResize(e, true)}
1313
+ onPointerCancel={(e) => finishResize(e, false)}
1314
+ onDoubleClick={(e) => {
1315
+ e.preventDefault();
1316
+ setWidths((w) => {
1317
+ const next = { ...w };
1318
+ delete next[col.key];
1319
+ return next;
1320
+ });
1321
+ }}
1322
+ // `touch-none`: without it the browser claims a finger drag for
1323
+ // scrolling before the first `pointermove` ever arrives.
1324
+ className="absolute right-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize select-none touch-none hover:bg-[var(--brand-bg-hover)]"
1325
+ />
1326
+ </th>
1327
+ );
1328
+ })}
1329
+ </tr>
1330
+ </thead>
1331
+ <tbody>
1332
+ {leadingRow && (
1333
+ <tr className="border-t border-[var(--border)]">
1334
+ <td colSpan={totalColSpan} className="p-0">
1335
+ {leadingRow}
1336
+ </td>
1337
+ </tr>
1338
+ )}
1339
+ {slice.map((row, rowIndex) => {
1340
+ const expanded = isExpanded?.(row) ?? false;
1341
+ const expansion = expanded ? expandedRow?.(row) : null;
1342
+ const rowInteractive = !!onRowClick || !!selection;
1343
+ const href = rowHref?.(row);
1344
+ return (
1345
+ <Fragment key={rowKey(row)}>
1346
+ <tr
1347
+ {...cleanAttrs(rowAttributes?.(row))}
1348
+ className={cn(
1349
+ "border-t border-[var(--border)]",
1350
+ rowInteractive && "cursor-pointer hover:bg-[var(--bg-hover)]",
1351
+ rowClassName?.(row),
1352
+ )}
1353
+ onClick={
1354
+ rowInteractive
1355
+ ? (e) => {
1356
+ // Modifier-clicks anywhere in the row drive
1357
+ // selection instead of expanding it (feedback #289):
1358
+ // Ctrl/Cmd toggles a single row, Shift selects the
1359
+ // range from the last-toggled row (inclusive of this
1360
+ // one) — the same as the checkbox, but for the whole
1361
+ // row, which is what the checkbox-only version was
1362
+ // missing.
1363
+ if (selection && canSelect(row)) {
1364
+ if (e.shiftKey) {
1365
+ // Drop the text selection a shift-click would
1366
+ // otherwise smear across the rows.
1367
+ window.getSelection?.()?.removeAllRanges();
1368
+ if (selectRange(rowIndex)) return;
1369
+ // No anchor yet: treat it as the first pick.
1370
+ selectionAnchor.current = rowKey(row);
1371
+ selection.onToggle(row, !selection.isSelected(row));
1372
+ return;
1373
+ }
1374
+ if (e.metaKey || e.ctrlKey) {
1375
+ selectionAnchor.current = rowKey(row);
1376
+ selection.onToggle(row, !selection.isSelected(row));
1377
+ return;
1378
+ }
1379
+ }
1380
+ onRowClick?.(row);
1381
+ }
1382
+ : undefined
1383
+ }
1384
+ >
1385
+ {selection && (
1386
+ <td
1387
+ className="w-10 px-3 py-2 align-top"
1388
+ // Don't let selecting a row also trigger the row click
1389
+ // (which expands/edits it).
1390
+ onClick={(e) => e.stopPropagation()}
1391
+ >
1392
+ {canSelect(row) && (
1393
+ <input
1394
+ type="checkbox"
1395
+ className="size-4 accent-indigo-600"
1396
+ checked={selection.isSelected(row)}
1397
+ // Shift+click selects the range from the last toggled
1398
+ // row; preventDefault stops the native toggle (and the
1399
+ // onChange that would double-handle it).
1400
+ onClick={(e) => {
1401
+ if (e.shiftKey && selectRange(rowIndex)) e.preventDefault();
1402
+ }}
1403
+ onChange={(e) => {
1404
+ selectionAnchor.current = rowKey(row);
1405
+ selection.onToggle(row, e.target.checked);
1406
+ }}
1407
+ aria-label={labels.selectRow}
1408
+ />
1409
+ )}
1410
+ </td>
1411
+ )}
1412
+ {visibleColumns.map((col) => {
1413
+ const width = widths[col.key];
1414
+ return (
1415
+ <td
1416
+ key={col.key}
1417
+ data-col={col.key}
1418
+ style={width ? { width, minWidth: width, maxWidth: width } : undefined}
1419
+ className={cn("px-3 py-2 align-top", width && "overflow-hidden text-ellipsis", col.className)}
1420
+ >
1421
+ {col === linkColumn && href ? (
1422
+ // No `onActivate`: the click is left to bubble to the
1423
+ // <tr> handler above, which already owns expand-vs-select.
1424
+ // Calling it here as well would toggle the row twice.
1425
+ <RowLink href={href} className="block">
1426
+ {col.cell(row)}
1427
+ </RowLink>
1428
+ ) : (
1429
+ col.cell(row)
1430
+ )}
1431
+ </td>
1432
+ );
1433
+ })}
1434
+ </tr>
1435
+ {expansion && (
1436
+ <tr className="bg-[var(--bg-surface-2)] border-t border-[var(--border)]">
1437
+ {/* The detail panel spans every column. In an `auto` table
1438
+ layout (the default here — columns only get fixed style
1439
+ widths once the user resizes/auto-sizes them) the cell's
1440
+ content preferred-width is redistributed across the
1441
+ spanned columns, so a long expansion visibly re-sizes the
1442
+ columns above it — and one-line content does not, since
1443
+ its preferred width is small (feedback #104). Wrapping the
1444
+ content in `w-0 min-w-full` pins the cell's preferred
1445
+ width to ~0 (the explicit width:0 child), so it can never
1446
+ perturb the columns; the div then fills to the full row
1447
+ width only at paint time, and long text wraps within it. */}
1448
+ <td colSpan={totalColSpan} className="p-0">
1449
+ <div className="w-0 min-w-full px-3 py-3">{expansion}</div>
1450
+ </td>
1451
+ </tr>
1452
+ )}
1453
+ </Fragment>
1454
+ );
1455
+ })}
1456
+ {slice.length === 0 && (
1457
+ <tr>
1458
+ <td
1459
+ colSpan={totalColSpan}
1460
+ className="px-3 py-4 text-center text-[var(--text-muted)]"
1461
+ >
1462
+ {empty ?? "—"}
1463
+ </td>
1464
+ </tr>
1465
+ )}
1466
+ </tbody>
1467
+ </table>
1468
+ </div>
1469
+ {!unpaged && (
1470
+ <Pagination
1471
+ page={safePage}
1472
+ totalPages={totalPages}
1473
+ pageSize={paginationPageSize}
1474
+ total={paginationTotal}
1475
+ onPage={goToPage}
1476
+ onPageSize={
1477
+ isServer
1478
+ ? (n) => serverPagination!.onPageSizeChange?.(n)
1479
+ : (n) => {
1480
+ setPageSize(n);
1481
+ setPage(0);
1482
+ }
1483
+ }
1484
+ labels={labels}
1485
+ locale={locale}
1486
+ />
1487
+ )}
1488
+ </div>
1489
+ <Tooltip label={columnsCountLabel} portal>
1490
+ <button
1491
+ type="button"
1492
+ onClick={() => setShowSettings(true)}
1493
+ aria-label={columnsCountLabel}
1494
+ aria-hidden={showSettings}
1495
+ tabIndex={showSettings ? -1 : 0}
1496
+ className={cn(
1497
+ "group flex shrink-0 items-start justify-center overflow-hidden pt-3 transition-[width] duration-200 ease-out",
1498
+ "border-l border-[var(--border)]",
1499
+ "hover:bg-[var(--bg-hover)]",
1500
+ showSettings ? "w-0 border-l-0" : "w-8 cursor-pointer",
1501
+ )}
1502
+ >
1503
+ <span
1504
+ className="whitespace-nowrap text-xs font-medium tracking-wide text-[var(--text-muted)] group-hover:text-[var(--text-secondary)]"
1505
+ style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
1506
+ >
1507
+ {columnsCountLabel}
1508
+ </span>
1509
+ </button>
1510
+ </Tooltip>
1511
+ <div
1512
+ className={cn(
1513
+ "shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
1514
+ showSettings ? "w-56" : "w-0",
1515
+ )}
1516
+ >
1517
+ <div className="flex w-56 flex-col border-l border-[var(--border)] p-2">
1518
+ <div className="mb-2 flex items-center justify-between gap-1">
1519
+ <div className="text-xs font-medium uppercase tracking-wide text-[var(--text-muted)]">
1520
+ {columnsCountLabel}
1521
+ </div>
1522
+ <Tooltip label={labels.close} portal>
1523
+ <button
1524
+ type="button"
1525
+ onClick={() => setShowSettings(false)}
1526
+ aria-label={labels.close}
1527
+ className="rounded p-0.5 text-[var(--text-placeholder)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
1528
+ >
1529
+ <X className="size-4" />
1530
+ </button>
1531
+ </Tooltip>
1532
+ </div>
1533
+ <button
1534
+ type="button"
1535
+ onClick={autoSizeAll}
1536
+ className="mb-2 rounded border border-[var(--border)] px-2 py-1 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
1537
+ >
1538
+ {labels.autoSize}
1539
+ </button>
1540
+ <div className="flex flex-col gap-0.5">
1541
+ {columns.map((col) => {
1542
+ const checked = !hiddenCols.has(col.key);
1543
+ const headerLabel = columnLabel(col);
1544
+ return (
1545
+ <label
1546
+ key={col.key}
1547
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 text-xs hover:bg-[var(--bg-hover)]"
1548
+ >
1549
+ <input
1550
+ type="checkbox"
1551
+ className="size-3.5"
1552
+ checked={checked}
1553
+ onChange={(e) => {
1554
+ setHiddenCols((s) => {
1555
+ const next = new Set(s);
1556
+ if (e.target.checked) next.delete(col.key);
1557
+ else next.add(col.key);
1558
+ return next;
1559
+ });
1560
+ }}
1561
+ />
1562
+ <span className="truncate">{headerLabel}</span>
1563
+ </label>
1564
+ );
1565
+ })}
1566
+ </div>
1567
+ </div>
1568
+ </div>
1569
+ </div>
1570
+ )}
1571
+ </Card>
1572
+ );
1573
+ }
1574
+
1575
+ // ---------- Mobile filter sheet ----------
1576
+
1577
+ /**
1578
+ * Filter access for the mobile card list (feedback #299). The desktop table
1579
+ * houses per-column filters in its header; the card list has no header, so the
1580
+ * same filters were unreachable on phones. This renders a compact "Filters" bar
1581
+ * that opens a bottom sheet listing each filterable column as a collapsible
1582
+ * section, reusing the very same {@link FilterPopover} and filter state the
1583
+ * desktop table drives — so it filters identically and stays URL-synced.
1584
+ */
1585
+ function MobileFilters<T>({
1586
+ columns,
1587
+ filters,
1588
+ selectOptionsByKey,
1589
+ isServer,
1590
+ hasFilterCallback,
1591
+ onSetFilter,
1592
+ onClearFilter,
1593
+ onClearAll,
1594
+ labels,
1595
+ locale,
1596
+ }: {
1597
+ columns: DataTableColumn<T>[];
1598
+ filters: FilterState;
1599
+ selectOptionsByKey: Record<string, { value: string; label: string }[]>;
1600
+ isServer: boolean;
1601
+ hasFilterCallback: boolean;
1602
+ onSetFilter: (key: string, value: FilterValue) => void;
1603
+ onClearFilter: (key: string) => void;
1604
+ onClearAll: () => void;
1605
+ labels: DataTableLabels;
1606
+ locale?: string;
1607
+ }) {
1608
+ const [open, setOpen] = useState(false);
1609
+ const [expanded, setExpanded] = useState<string | null>(null);
1610
+ const backdropClose = useBackdropClose(() => setOpen(false));
1611
+ useBodyScrollLock(open);
1612
+ // Back dismisses the filter sheet too (Keksdose feedback #172) — it is the same
1613
+ // full-screen surface, and the filters it sets are already in the URL, so closing
1614
+ // it never loses anything.
1615
+ useOverlayHistory(open, () => setOpen(false));
1616
+
1617
+ const filterable = useMemo(
1618
+ () =>
1619
+ columns
1620
+ .map((col) => ({ col, filter: !isServer || hasFilterCallback ? resolveFilter(col) : null }))
1621
+ .filter((x): x is { col: DataTableColumn<T>; filter: ColumnFilter<T> } => !!x.filter),
1622
+ [columns, isServer, hasFilterCallback],
1623
+ );
1624
+
1625
+ if (filterable.length === 0) return null;
1626
+
1627
+ const activeCount = filterable.filter(({ col }) => {
1628
+ const s = filters[col.key];
1629
+ return s && isFilterActive(s);
1630
+ }).length;
1631
+
1632
+ return (
1633
+ <div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-2">
1634
+ <button
1635
+ type="button"
1636
+ onClick={() => setOpen(true)}
1637
+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
1638
+ >
1639
+ <Filter className="size-4" />
1640
+ <span>{labels.filters}</span>
1641
+ {activeCount > 0 && (
1642
+ <span className="inline-flex min-w-4 items-center justify-center rounded-full bg-[var(--info-bg)] px-1 text-[11px] font-semibold text-[var(--info)]">
1643
+ {activeCount}
1644
+ </span>
1645
+ )}
1646
+ </button>
1647
+ {activeCount > 0 && (
1648
+ <button
1649
+ type="button"
1650
+ onClick={onClearAll}
1651
+ className="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
1652
+ >
1653
+ {labels.clearAll}
1654
+ </button>
1655
+ )}
1656
+ {open &&
1657
+ createPortal(
1658
+ <div
1659
+ className="fixed inset-0 z-50 flex items-end justify-center bg-black/40"
1660
+ role="dialog"
1661
+ aria-modal="true"
1662
+ {...backdropClose}
1663
+ >
1664
+ <div className="flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-2xl bg-[var(--bg-surface)] shadow-xl">
1665
+ <div className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-4 py-3">
1666
+ <div className="font-medium">{labels.filters}</div>
1667
+ <button
1668
+ type="button"
1669
+ onClick={() => setOpen(false)}
1670
+ aria-label={labels.close}
1671
+ className="-mr-1 rounded p-1.5 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]"
1672
+ >
1673
+ <X className="size-5" />
1674
+ </button>
1675
+ </div>
1676
+ <div className="flex-1 divide-y divide-[var(--border)] overflow-y-auto overscroll-contain">
1677
+ {filterable.map(({ col, filter }) => {
1678
+ const state = filters[col.key];
1679
+ const active = !!state && isFilterActive(state);
1680
+ const isItemOpen = expanded === col.key;
1681
+ return (
1682
+ <div key={col.key}>
1683
+ <button
1684
+ type="button"
1685
+ onClick={() => setExpanded(isItemOpen ? null : col.key)}
1686
+ aria-expanded={isItemOpen}
1687
+ className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
1688
+ >
1689
+ <span className="flex items-center gap-2 text-sm font-medium text-[var(--text-secondary)]">
1690
+ {col.header}
1691
+ {active && <span className="size-1.5 rounded-full bg-brand" />}
1692
+ </span>
1693
+ <ChevronDown
1694
+ className={cn(
1695
+ "size-4 shrink-0 text-[var(--text-placeholder)] transition-transform",
1696
+ isItemOpen && "rotate-180",
1697
+ )}
1698
+ />
1699
+ </button>
1700
+ {isItemOpen && (
1701
+ <div className="px-4 pb-3">
1702
+ <FilterPopover
1703
+ column={col}
1704
+ state={state ?? defaultFilterState(filter)}
1705
+ onChange={(next) => onSetFilter(col.key, next)}
1706
+ onClear={() => onClearFilter(col.key)}
1707
+ selectOptions={selectOptionsByKey[col.key] ?? []}
1708
+ labels={labels}
1709
+ locale={locale}
1710
+ />
1711
+ </div>
1712
+ )}
1713
+ </div>
1714
+ );
1715
+ })}
1716
+ </div>
1717
+ <div className="flex items-center justify-between gap-2 border-t border-[var(--border)] px-4 py-3">
1718
+ <button
1719
+ type="button"
1720
+ onClick={onClearAll}
1721
+ disabled={activeCount === 0}
1722
+ className="text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] disabled:opacity-40"
1723
+ >
1724
+ {labels.clearAll}
1725
+ </button>
1726
+ <button
1727
+ type="button"
1728
+ onClick={() => setOpen(false)}
1729
+ className="rounded-md bg-[var(--bg-inverse)] px-4 py-2 text-sm font-medium text-[var(--text-inverse)]"
1730
+ >
1731
+ {labels.done}
1732
+ </button>
1733
+ </div>
1734
+ </div>
1735
+ </div>,
1736
+ document.body,
1737
+ )}
1738
+ </div>
1739
+ );
1740
+ }