@eifi1/ui-kit 0.53.0 → 0.55.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 (902) hide show
  1. package/README.md +96 -82
  2. package/dist/account/delete-account-setting.d.ts +3 -2
  3. package/dist/account/delete-account-setting.js.map +1 -1
  4. package/dist/admin/plan-change.js +2 -2
  5. package/dist/admin/plan-change.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +6 -0
  7. package/dist/admin/user-roster.js +4 -0
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/auth-errors.d.ts +36 -0
  10. package/dist/auth/auth-errors.js +20 -0
  11. package/dist/auth/auth-errors.js.map +1 -1
  12. package/dist/billing/billing-labels.d.ts +26 -0
  13. package/dist/billing/billing-labels.js +9 -0
  14. package/dist/billing/billing-labels.js.map +1 -1
  15. package/dist/billing/subscription-standing.d.ts +61 -0
  16. package/dist/billing/subscription-standing.js +51 -0
  17. package/dist/billing/subscription-standing.js.map +1 -0
  18. package/dist/billing/usage-meter.d.ts +52 -0
  19. package/dist/billing/usage-meter.js +52 -0
  20. package/dist/billing/usage-meter.js.map +1 -0
  21. package/dist/chart.d.ts +1 -0
  22. package/dist/chart.js +4 -0
  23. package/dist/chart.js.map +1 -1
  24. package/dist/components/alert-banner.js +2 -3
  25. package/dist/components/alert-banner.js.map +1 -1
  26. package/dist/components/amount-input.d.ts +2 -1
  27. package/dist/components/amount-input.js +3 -15
  28. package/dist/components/amount-input.js.map +1 -1
  29. package/dist/components/autocomplete.js +2 -2
  30. package/dist/components/autocomplete.js.map +1 -1
  31. package/dist/components/bulk-action-bar.js +2 -1
  32. package/dist/components/bulk-action-bar.js.map +1 -1
  33. package/dist/components/button.d.ts +35 -1
  34. package/dist/components/button.js +5 -9
  35. package/dist/components/button.js.map +1 -1
  36. package/dist/components/calendar-heatmap.js +6 -4
  37. package/dist/components/calendar-heatmap.js.map +1 -1
  38. package/dist/components/camera-button.d.ts +89 -0
  39. package/dist/components/camera-button.js +122 -0
  40. package/dist/components/camera-button.js.map +1 -0
  41. package/dist/components/camera-capture.d.ts +66 -0
  42. package/dist/components/camera-capture.js +190 -0
  43. package/dist/components/camera-capture.js.map +1 -0
  44. package/dist/components/camera-labels.d.ts +20 -0
  45. package/dist/components/camera-labels.js +15 -0
  46. package/dist/components/camera-labels.js.map +1 -0
  47. package/dist/components/chart-edge.d.ts +9 -0
  48. package/dist/components/chart-edge.js +14 -3
  49. package/dist/components/chart-edge.js.map +1 -1
  50. package/dist/components/chart-zoom-core.js +34 -29
  51. package/dist/components/chart-zoom-core.js.map +1 -1
  52. package/dist/components/checkbox-group.js +1 -1
  53. package/dist/components/checkbox-group.js.map +1 -1
  54. package/dist/components/combobox-core.js +3 -2
  55. package/dist/components/combobox-core.js.map +1 -1
  56. package/dist/components/combobox.js +2 -2
  57. package/dist/components/combobox.js.map +1 -1
  58. package/dist/components/consent-again-notice-labels.d.ts +25 -0
  59. package/dist/components/consent-again-notice-labels.js +15 -0
  60. package/dist/components/consent-again-notice-labels.js.map +1 -0
  61. package/dist/components/consent-again-notice.d.ts +85 -0
  62. package/dist/components/consent-again-notice.js +94 -0
  63. package/dist/components/consent-again-notice.js.map +1 -0
  64. package/dist/components/country-select.js +2 -7
  65. package/dist/components/country-select.js.map +1 -1
  66. package/dist/components/currency-option-list.d.ts +2 -1
  67. package/dist/components/currency-option-list.js +10 -3
  68. package/dist/components/currency-option-list.js.map +1 -1
  69. package/dist/components/currency-select.d.ts +3 -1
  70. package/dist/components/currency-select.js +2 -2
  71. package/dist/components/currency-select.js.map +1 -1
  72. package/dist/components/danger-confirm.d.ts +22 -1
  73. package/dist/components/danger-confirm.js +52 -30
  74. package/dist/components/danger-confirm.js.map +1 -1
  75. package/dist/components/data-table-column-settings.js +2 -2
  76. package/dist/components/data-table-column-settings.js.map +1 -1
  77. package/dist/components/data-table-filter-popover.d.ts +2 -1
  78. package/dist/components/data-table-filter-popover.js +3 -2
  79. package/dist/components/data-table-filter-popover.js.map +1 -1
  80. package/dist/components/data-table-filters.d.ts +33 -3
  81. package/dist/components/data-table-filters.js +6 -4
  82. package/dist/components/data-table-filters.js.map +1 -1
  83. package/dist/components/data-table-fit.js.map +1 -1
  84. package/dist/components/data-table-virtual.js +13 -3
  85. package/dist/components/data-table-virtual.js.map +1 -1
  86. package/dist/components/date-field.js +2 -2
  87. package/dist/components/date-field.js.map +1 -1
  88. package/dist/components/date-picker.js +2 -2
  89. package/dist/components/date-picker.js.map +1 -1
  90. package/dist/components/date-range-picker.js +2 -6
  91. package/dist/components/date-range-picker.js.map +1 -1
  92. package/dist/components/dialog-frame.d.ts +5 -1
  93. package/dist/components/dialog-frame.js +6 -2
  94. package/dist/components/dialog-frame.js.map +1 -1
  95. package/dist/components/dropdown.js +7 -3
  96. package/dist/components/dropdown.js.map +1 -1
  97. package/dist/components/entity-combobox.js +2 -2
  98. package/dist/components/entity-combobox.js.map +1 -1
  99. package/dist/components/field-parts.d.ts +108 -6
  100. package/dist/components/field-parts.js +59 -6
  101. package/dist/components/field-parts.js.map +1 -1
  102. package/dist/components/field-suggestion-labels.d.ts +2 -0
  103. package/dist/components/field-suggestion-labels.js +10 -0
  104. package/dist/components/field-suggestion-labels.js.map +1 -0
  105. package/dist/components/field.js +1 -1
  106. package/dist/components/field.js.map +1 -1
  107. package/dist/components/file-button.d.ts +1 -1
  108. package/dist/components/file-button.js.map +1 -1
  109. package/dist/components/floating-panel.d.ts +7 -6
  110. package/dist/components/floating-panel.js +3 -17
  111. package/dist/components/floating-panel.js.map +1 -1
  112. package/dist/components/form-actions.d.ts +3 -2
  113. package/dist/components/form-actions.js.map +1 -1
  114. package/dist/components/hover-menu.js +3 -2
  115. package/dist/components/hover-menu.js.map +1 -1
  116. package/dist/components/icon-button.d.ts +23 -5
  117. package/dist/components/icon-button.js +6 -8
  118. package/dist/components/icon-button.js.map +1 -1
  119. package/dist/components/inline-confirm.js +2 -1
  120. package/dist/components/inline-confirm.js.map +1 -1
  121. package/dist/components/inline-entity-combobox.js +2 -2
  122. package/dist/components/inline-entity-combobox.js.map +1 -1
  123. package/dist/components/input.js +2 -2
  124. package/dist/components/input.js.map +1 -1
  125. package/dist/components/jump-calendar.js +3 -3
  126. package/dist/components/jump-calendar.js.map +1 -1
  127. package/dist/components/legal.d.ts +13 -1
  128. package/dist/components/legal.js +12 -0
  129. package/dist/components/legal.js.map +1 -1
  130. package/dist/components/legended-series-chart.d.ts +80 -0
  131. package/dist/components/legended-series-chart.js +70 -0
  132. package/dist/components/legended-series-chart.js.map +1 -0
  133. package/dist/components/lightbox.js +2 -1
  134. package/dist/components/lightbox.js.map +1 -1
  135. package/dist/components/mini-calendar.js +16 -7
  136. package/dist/components/mini-calendar.js.map +1 -1
  137. package/dist/components/modal.d.ts +48 -0
  138. package/dist/components/modal.js +26 -6
  139. package/dist/components/modal.js.map +1 -1
  140. package/dist/components/month-picker.d.ts +1 -1
  141. package/dist/components/month-picker.js +6 -6
  142. package/dist/components/month-picker.js.map +1 -1
  143. package/dist/components/multi-entity-combobox.js +2 -2
  144. package/dist/components/multi-entity-combobox.js.map +1 -1
  145. package/dist/components/multi-select.js +2 -6
  146. package/dist/components/multi-select.js.map +1 -1
  147. package/dist/components/number-input.js +2 -2
  148. package/dist/components/number-input.js.map +1 -1
  149. package/dist/components/page-header-layout.d.ts +18 -0
  150. package/dist/components/page-header-layout.js +23 -0
  151. package/dist/components/page-header-layout.js.map +1 -0
  152. package/dist/components/page-header-skeleton.d.ts +41 -0
  153. package/dist/components/page-header-skeleton.js +52 -0
  154. package/dist/components/page-header-skeleton.js.map +1 -0
  155. package/dist/components/page-header.js +4 -17
  156. package/dist/components/page-header.js.map +1 -1
  157. package/dist/components/popover.js +7 -2
  158. package/dist/components/popover.js.map +1 -1
  159. package/dist/components/release-notes-labels.d.ts +16 -0
  160. package/dist/components/release-notes-labels.js +18 -0
  161. package/dist/components/release-notes-labels.js.map +1 -0
  162. package/dist/components/release-notes.d.ts +65 -0
  163. package/dist/components/release-notes.js +65 -0
  164. package/dist/components/release-notes.js.map +1 -0
  165. package/dist/components/select.js +2 -2
  166. package/dist/components/select.js.map +1 -1
  167. package/dist/components/series-chart-labels.d.ts +9 -0
  168. package/dist/components/series-chart-labels.js +3 -1
  169. package/dist/components/series-chart-labels.js.map +1 -1
  170. package/dist/components/series-chart.d.ts +5 -0
  171. package/dist/components/series-chart.js +89 -36
  172. package/dist/components/series-chart.js.map +1 -1
  173. package/dist/components/stat-tile.d.ts +21 -2
  174. package/dist/components/stat-tile.js +4 -2
  175. package/dist/components/stat-tile.js.map +1 -1
  176. package/dist/components/toggle-group.js +3 -2
  177. package/dist/components/toggle-group.js.map +1 -1
  178. package/dist/components/treemap-layout.js.map +1 -1
  179. package/dist/components/treemap.d.ts +17 -15
  180. package/dist/components/treemap.js +167 -85
  181. package/dist/components/treemap.js.map +1 -1
  182. package/dist/currencies.d.ts +16 -2
  183. package/dist/currencies.js +2 -1
  184. package/dist/currencies.js.map +1 -1
  185. package/dist/eslint/index.js +1 -1
  186. package/dist/eslint/role-utilities.d.ts +2 -2
  187. package/dist/eslint/role-utilities.js.map +1 -1
  188. package/dist/feedback/feedback-context-labels.js +2 -1
  189. package/dist/feedback/feedback-context-labels.js.map +1 -1
  190. package/dist/feedback/feedback-context.d.ts +19 -3
  191. package/dist/feedback/feedback-context.js +12 -1
  192. package/dist/feedback/feedback-context.js.map +1 -1
  193. package/dist/feedback/feedback-diagnostics.d.ts +17 -0
  194. package/dist/feedback/feedback-diagnostics.js +27 -0
  195. package/dist/feedback/feedback-diagnostics.js.map +1 -0
  196. package/dist/feedback/feedback-dialog.d.ts +10 -0
  197. package/dist/feedback/feedback-dialog.js +6 -4
  198. package/dist/feedback/feedback-dialog.js.map +1 -1
  199. package/dist/feedback/feedback-inbox-page.d.ts +260 -0
  200. package/dist/feedback/feedback-inbox-page.js +207 -0
  201. package/dist/feedback/feedback-inbox-page.js.map +1 -0
  202. package/dist/feedback/feedback-record.d.ts +5 -0
  203. package/dist/feedback/feedback-record.js.map +1 -1
  204. package/dist/feedback/feedback-row-detail-labels.js +1 -0
  205. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  206. package/dist/feedback/feedback-row-detail.d.ts +3 -0
  207. package/dist/feedback/feedback-row-detail.js +3 -0
  208. package/dist/feedback/feedback-row-detail.js.map +1 -1
  209. package/dist/feedback/feedback-submit-dialog.d.ts +5 -1
  210. package/dist/feedback/feedback-submit-dialog.js +15 -3
  211. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  212. package/dist/feedback/feedback-submit.d.ts +30 -2
  213. package/dist/feedback/feedback-submit.js +5 -2
  214. package/dist/feedback/feedback-submit.js.map +1 -1
  215. package/dist/feedback/feedback-vocabulary.js +2 -2
  216. package/dist/feedback/feedback-vocabulary.js.map +1 -1
  217. package/dist/feedback.d.ts +1 -0
  218. package/dist/feedback.js +6 -0
  219. package/dist/feedback.js.map +1 -1
  220. package/dist/hooks/use-debounce.d.ts +32 -5
  221. package/dist/hooks/use-debounce.js +6 -5
  222. package/dist/hooks/use-debounce.js.map +1 -1
  223. package/dist/hooks/use-file-picker.d.ts +3 -1
  224. package/dist/hooks/use-file-picker.js.map +1 -1
  225. package/dist/hooks/use-focus-trap.d.ts +19 -3
  226. package/dist/hooks/use-focus-trap.js +15 -5
  227. package/dist/hooks/use-focus-trap.js.map +1 -1
  228. package/dist/hooks/use-has-camera.d.ts +36 -0
  229. package/dist/hooks/use-has-camera.js +53 -0
  230. package/dist/hooks/use-has-camera.js.map +1 -0
  231. package/dist/hooks/use-hotkey.js +2 -1
  232. package/dist/hooks/use-hotkey.js.map +1 -1
  233. package/dist/hooks/use-overlay-history.d.ts +6 -1
  234. package/dist/hooks/use-overlay-history.js +10 -2
  235. package/dist/hooks/use-overlay-history.js.map +1 -1
  236. package/dist/i18n/defaults.js +11 -1
  237. package/dist/i18n/defaults.js.map +1 -1
  238. package/dist/i18n/german.js +78 -4
  239. package/dist/i18n/german.js.map +1 -1
  240. package/dist/i18n/kit-labels.d.ts +20 -0
  241. package/dist/i18n/kit-labels.js.map +1 -1
  242. package/dist/i18n/locales/en.js +95 -5
  243. package/dist/i18n/locales/en.js.map +3 -3
  244. package/dist/i18n/locales/es.js +74 -4
  245. package/dist/i18n/locales/es.js.map +1 -1
  246. package/dist/i18n/locales/fr.js +75 -3
  247. package/dist/i18n/locales/fr.js.map +1 -1
  248. package/dist/i18n/locales/hu.js +77 -4
  249. package/dist/i18n/locales/hu.js.map +1 -1
  250. package/dist/i18n/locales/it.js +77 -4
  251. package/dist/i18n/locales/it.js.map +1 -1
  252. package/dist/i18n/locales/zh.js +74 -4
  253. package/dist/i18n/locales/zh.js.map +1 -1
  254. package/dist/i18n/review.js +22 -1
  255. package/dist/i18n/review.js.map +1 -1
  256. package/dist/index.d.ts +27 -8
  257. package/dist/index.js +41 -2
  258. package/dist/index.js.map +1 -1
  259. package/dist/landing/landing-labels.d.ts +8 -0
  260. package/dist/landing/landing-labels.js +2 -1
  261. package/dist/landing/landing-labels.js.map +1 -1
  262. package/dist/lib/changelog.d.ts +130 -0
  263. package/dist/lib/changelog.js +137 -0
  264. package/dist/lib/changelog.js.map +1 -0
  265. package/dist/lib/clipboard-minus.js +2 -2
  266. package/dist/lib/clipboard-minus.js.map +1 -1
  267. package/dist/lib/dates.d.ts +36 -4
  268. package/dist/lib/dates.js +49 -13
  269. package/dist/lib/dates.js.map +1 -1
  270. package/dist/lib/editable-content.d.ts +33 -0
  271. package/dist/lib/editable-content.js +15 -0
  272. package/dist/lib/editable-content.js.map +1 -0
  273. package/dist/lib/focus-left.d.ts +47 -1
  274. package/dist/lib/focus-left.js +11 -2
  275. package/dist/lib/focus-left.js.map +1 -1
  276. package/dist/lib/format.d.ts +37 -2
  277. package/dist/lib/format.js +25 -1
  278. package/dist/lib/format.js.map +1 -1
  279. package/dist/lib/intl-cache.d.ts +4 -2
  280. package/dist/lib/intl-cache.js.map +1 -1
  281. package/dist/lib/server-wake.d.ts +49 -9
  282. package/dist/lib/server-wake.js +25 -14
  283. package/dist/lib/server-wake.js.map +1 -1
  284. package/dist/lib/session-refresh.d.ts +53 -0
  285. package/dist/lib/session-refresh.js +27 -1
  286. package/dist/lib/session-refresh.js.map +1 -1
  287. package/dist/lib/translation-review.d.ts +26 -0
  288. package/dist/lib/translation-review.js +14 -0
  289. package/dist/lib/translation-review.js.map +1 -1
  290. package/dist/rhf/fields.js +3 -3
  291. package/dist/rhf/fields.js.map +1 -1
  292. package/dist/shell/app-shell.d.ts +25 -1
  293. package/dist/shell/app-shell.js +3 -1
  294. package/dist/shell/app-shell.js.map +1 -1
  295. package/dist/shell/dev-menu.d.ts +3 -0
  296. package/dist/shell/dev-menu.js +4 -2
  297. package/dist/shell/dev-menu.js.map +1 -1
  298. package/dist/shell/held-changes-banner-labels.d.ts +23 -0
  299. package/dist/shell/held-changes-banner-labels.js +14 -0
  300. package/dist/shell/held-changes-banner-labels.js.map +1 -0
  301. package/dist/shell/held-changes-banner.d.ts +75 -0
  302. package/dist/shell/held-changes-banner.js +106 -0
  303. package/dist/shell/held-changes-banner.js.map +1 -0
  304. package/dist/shell/sync-status-indicator.d.ts +27 -12
  305. package/dist/shell/sync-status-indicator.js +13 -4
  306. package/dist/shell/sync-status-indicator.js.map +1 -1
  307. package/dist/shell/top-bar-parts-labels.d.ts +5 -0
  308. package/dist/shell/top-bar-parts-labels.js +3 -1
  309. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  310. package/dist/shell.d.ts +2 -0
  311. package/dist/shell.js +3 -0
  312. package/dist/shell.js.map +1 -1
  313. package/dist/sync/engine.d.ts +11 -2
  314. package/dist/sync/engine.js +13 -5
  315. package/dist/sync/engine.js.map +1 -1
  316. package/dist/sync/hooks.d.ts +3 -2
  317. package/dist/sync/hooks.js +4 -2
  318. package/dist/sync/hooks.js.map +1 -1
  319. package/dist/testing/css-variables.generated.d.ts +2 -2
  320. package/dist/testing/css-variables.generated.js +1 -1
  321. package/dist/testing/css-variables.generated.js.map +1 -1
  322. package/dist/theme/chart-palette.d.ts +18 -0
  323. package/dist/theme/chart-palette.js +17 -1
  324. package/dist/theme/chart-palette.js.map +1 -1
  325. package/dist/theme/contrast-tokens.d.ts +3 -3
  326. package/dist/theme/contrast-tokens.js.map +1 -1
  327. package/dist/theme/contrast.d.ts +4 -4
  328. package/dist/theme/contrast.js.map +1 -1
  329. package/dist/tour/tour-overlay.js +2 -1
  330. package/dist/tour/tour-overlay.js.map +1 -1
  331. package/package.json +5 -9
  332. package/tokens.css +19 -54
  333. package/src/account/account-parts.ts +0 -47
  334. package/src/account/account-settings-labels.ts +0 -240
  335. package/src/account/account-settings.tsx +0 -698
  336. package/src/account/data-export-setting-labels.ts +0 -20
  337. package/src/account/data-export-setting.tsx +0 -200
  338. package/src/account/delete-account-setting-labels.ts +0 -33
  339. package/src/account/delete-account-setting.tsx +0 -341
  340. package/src/account/email-change-setting-labels.ts +0 -31
  341. package/src/account/email-change-setting.tsx +0 -443
  342. package/src/account/passkeys-setting.tsx +0 -445
  343. package/src/account/sessions-setting-labels.ts +0 -24
  344. package/src/account/sessions-setting.tsx +0 -283
  345. package/src/admin/admin-action-confirm-labels.ts +0 -19
  346. package/src/admin/admin-action-confirm.tsx +0 -509
  347. package/src/admin/admin-action-log-labels.ts +0 -83
  348. package/src/admin/admin-action-log.tsx +0 -464
  349. package/src/admin/admin-area-labels.ts +0 -114
  350. package/src/admin/admin-forbidden.tsx +0 -75
  351. package/src/admin/admin-overview.tsx +0 -258
  352. package/src/admin/admin-parts.ts +0 -68
  353. package/src/admin/admin-sections.tsx +0 -208
  354. package/src/admin/admin-tool-links.tsx +0 -90
  355. package/src/admin/invitations-panel-labels.ts +0 -31
  356. package/src/admin/invitations-panel.tsx +0 -516
  357. package/src/admin/mail-test-card-labels.ts +0 -19
  358. package/src/admin/mail-test-card.tsx +0 -189
  359. package/src/admin/plan-change-labels.ts +0 -5
  360. package/src/admin/plan-change.tsx +0 -474
  361. package/src/admin/reviewer-scope-editor-labels.ts +0 -16
  362. package/src/admin/reviewer-scope-editor.tsx +0 -224
  363. package/src/admin/role-select-labels.ts +0 -16
  364. package/src/admin/role-select.tsx +0 -282
  365. package/src/admin/transfer-ownership-dialog-labels.ts +0 -20
  366. package/src/admin/transfer-ownership-dialog.tsx +0 -162
  367. package/src/admin/user-roster-labels.ts +0 -17
  368. package/src/admin/user-roster.tsx +0 -559
  369. package/src/auth/accept-invitation-labels.ts +0 -21
  370. package/src/auth/accept-invitation.tsx +0 -210
  371. package/src/auth/auth-errors-labels.ts +0 -23
  372. package/src/auth/auth-errors.ts +0 -370
  373. package/src/auth/complete-name-dialog-labels.ts +0 -16
  374. package/src/auth/complete-name-dialog.tsx +0 -253
  375. package/src/auth/email-tag.ts +0 -75
  376. package/src/auth/forgot-password-form-labels.ts +0 -20
  377. package/src/auth/forgot-password-form.tsx +0 -221
  378. package/src/auth/form-rules.ts +0 -40
  379. package/src/auth/new-password-fields.tsx +0 -85
  380. package/src/auth/not-found-page-labels.ts +0 -12
  381. package/src/auth/not-found-page.tsx +0 -95
  382. package/src/auth/register-form-labels.ts +0 -31
  383. package/src/auth/register-form.tsx +0 -456
  384. package/src/auth/reset-password-form-labels.ts +0 -27
  385. package/src/auth/reset-password-form.tsx +0 -341
  386. package/src/auth/sign-in-form-labels.ts +0 -44
  387. package/src/auth/sign-in-form.tsx +0 -858
  388. package/src/auth/status-parts.tsx +0 -132
  389. package/src/auth/verify-email-labels.ts +0 -26
  390. package/src/auth/verify-email.tsx +0 -445
  391. package/src/billing/billing-banner.tsx +0 -258
  392. package/src/billing/billing-labels.ts +0 -381
  393. package/src/billing/billing-lock.ts +0 -108
  394. package/src/billing/billing-overview.ts +0 -157
  395. package/src/billing/billing-standing.ts +0 -80
  396. package/src/billing/cancel-request.ts +0 -97
  397. package/src/billing/cancel-subscription.tsx +0 -98
  398. package/src/billing/checkout-processing.ts +0 -394
  399. package/src/billing/checkout-return.ts +0 -90
  400. package/src/billing/pay-page.ts +0 -330
  401. package/src/billing/plan-card.tsx +0 -562
  402. package/src/billing/plan-change-labels.ts +0 -66
  403. package/src/billing/plan-limit-notice.tsx +0 -148
  404. package/src/billing/plan-limit-toast.tsx +0 -90
  405. package/src/billing/plan-limit.ts +0 -52
  406. package/src/billing/plan-price.ts +0 -98
  407. package/src/billing/plan-switch.tsx +0 -216
  408. package/src/billing/provider-page.ts +0 -84
  409. package/src/billing/subscription-actions.tsx +0 -247
  410. package/src/billing/subscription-status-chip.tsx +0 -95
  411. package/src/chart.ts +0 -103
  412. package/src/components/account-chips-labels.ts +0 -16
  413. package/src/components/account-chips.tsx +0 -474
  414. package/src/components/alert-banner.tsx +0 -670
  415. package/src/components/amount-input.tsx +0 -815
  416. package/src/components/appearance-labels.ts +0 -63
  417. package/src/components/authed-image-labels.ts +0 -11
  418. package/src/components/authed-image.tsx +0 -209
  419. package/src/components/autocomplete.tsx +0 -645
  420. package/src/components/breadcrumbs-labels.ts +0 -10
  421. package/src/components/breadcrumbs.tsx +0 -205
  422. package/src/components/bulk-action-bar-labels.ts +0 -11
  423. package/src/components/bulk-action-bar.tsx +0 -443
  424. package/src/components/button-group.tsx +0 -223
  425. package/src/components/button-look.ts +0 -256
  426. package/src/components/button.tsx +0 -339
  427. package/src/components/calculator.tsx +0 -341
  428. package/src/components/calendar-heatmap-labels.ts +0 -15
  429. package/src/components/calendar-heatmap.tsx +0 -594
  430. package/src/components/card.tsx +0 -377
  431. package/src/components/chart-edge.ts +0 -106
  432. package/src/components/chart-figure.tsx +0 -35
  433. package/src/components/chart-height.ts +0 -30
  434. package/src/components/chart-zoom-core.tsx +0 -627
  435. package/src/components/chart-zoom.tsx +0 -22
  436. package/src/components/chart.tsx +0 -554
  437. package/src/components/checkbox-group.tsx +0 -247
  438. package/src/components/checkbox.tsx +0 -346
  439. package/src/components/chip-input.tsx +0 -370
  440. package/src/components/chip-labels.ts +0 -13
  441. package/src/components/chip.tsx +0 -843
  442. package/src/components/choice-card.tsx +0 -544
  443. package/src/components/column-mapper-labels.ts +0 -38
  444. package/src/components/column-mapper.tsx +0 -655
  445. package/src/components/combobox-core.tsx +0 -1321
  446. package/src/components/combobox-suggestions.tsx +0 -131
  447. package/src/components/combobox.tsx +0 -716
  448. package/src/components/confirm-context.ts +0 -7
  449. package/src/components/confirm-dialog-labels.ts +0 -13
  450. package/src/components/confirm-dialog.tsx +0 -341
  451. package/src/components/copy-button-labels.ts +0 -13
  452. package/src/components/copy-button.tsx +0 -192
  453. package/src/components/country-select-labels.ts +0 -11
  454. package/src/components/country-select.tsx +0 -539
  455. package/src/components/currency-option-list.tsx +0 -180
  456. package/src/components/currency-select.tsx +0 -341
  457. package/src/components/danger-confirm-labels.ts +0 -20
  458. package/src/components/danger-confirm.tsx +0 -934
  459. package/src/components/data-table-cells.tsx +0 -118
  460. package/src/components/data-table-column-settings.tsx +0 -198
  461. package/src/components/data-table-filter-popover.tsx +0 -281
  462. package/src/components/data-table-filters.ts +0 -287
  463. package/src/components/data-table-fit.ts +0 -301
  464. package/src/components/data-table-groups.tsx +0 -288
  465. package/src/components/data-table-head.tsx +0 -280
  466. package/src/components/data-table-labels.ts +0 -198
  467. package/src/components/data-table-leading-row.tsx +0 -58
  468. package/src/components/data-table-mobile.tsx +0 -1117
  469. package/src/components/data-table-pagination.tsx +0 -202
  470. package/src/components/data-table-resize.ts +0 -188
  471. package/src/components/data-table-row-actions.tsx +0 -302
  472. package/src/components/data-table-row.tsx +0 -496
  473. package/src/components/data-table-selection.ts +0 -71
  474. package/src/components/data-table-shared.tsx +0 -287
  475. package/src/components/data-table-sort.ts +0 -163
  476. package/src/components/data-table-virtual.ts +0 -576
  477. package/src/components/data-table.tsx +0 -2598
  478. package/src/components/date-field.tsx +0 -684
  479. package/src/components/date-format-setting.tsx +0 -64
  480. package/src/components/date-picker.tsx +0 -404
  481. package/src/components/date-range-picker.tsx +0 -518
  482. package/src/components/description-list-labels.ts +0 -9
  483. package/src/components/description-list.tsx +0 -386
  484. package/src/components/dialog-frame-labels.ts +0 -7
  485. package/src/components/dialog-frame.tsx +0 -276
  486. package/src/components/disclosure.tsx +0 -476
  487. package/src/components/dropdown.tsx +0 -461
  488. package/src/components/empty-state.tsx +0 -136
  489. package/src/components/entity-combobox.tsx +0 -422
  490. package/src/components/error-boundary-labels.ts +0 -20
  491. package/src/components/error-boundary.tsx +0 -675
  492. package/src/components/facing-pair.ts +0 -93
  493. package/src/components/field-base-labels.ts +0 -20
  494. package/src/components/field-base.tsx +0 -656
  495. package/src/components/field-parts.tsx +0 -825
  496. package/src/components/field-strip.tsx +0 -129
  497. package/src/components/field.tsx +0 -206
  498. package/src/components/file-button.tsx +0 -295
  499. package/src/components/file-dropzone.tsx +0 -559
  500. package/src/components/floating-panel-labels.ts +0 -10
  501. package/src/components/floating-panel.tsx +0 -828
  502. package/src/components/focus-ring.ts +0 -16
  503. package/src/components/form-actions-labels.ts +0 -11
  504. package/src/components/form-actions.tsx +0 -578
  505. package/src/components/full-bleed-dialog.tsx +0 -294
  506. package/src/components/grouped-picker.tsx +0 -184
  507. package/src/components/help-popover.tsx +0 -117
  508. package/src/components/hover-menu.tsx +0 -343
  509. package/src/components/iban-input-labels.ts +0 -14
  510. package/src/components/iban-input.tsx +0 -236
  511. package/src/components/icon-button.tsx +0 -959
  512. package/src/components/icon-picker-labels.ts +0 -13
  513. package/src/components/icon-picker.tsx +0 -232
  514. package/src/components/image-grid-labels.ts +0 -11
  515. package/src/components/image-grid.tsx +0 -202
  516. package/src/components/inline-confirm.tsx +0 -116
  517. package/src/components/inline-edit-field-labels.ts +0 -11
  518. package/src/components/inline-edit-field.tsx +0 -552
  519. package/src/components/inline-entity-combobox.tsx +0 -849
  520. package/src/components/input.tsx +0 -394
  521. package/src/components/jump-calendar.tsx +0 -265
  522. package/src/components/language-select.tsx +0 -93
  523. package/src/components/legal-labels.ts +0 -105
  524. package/src/components/legal-page.tsx +0 -711
  525. package/src/components/legal.tsx +0 -668
  526. package/src/components/lightbox-labels.ts +0 -18
  527. package/src/components/lightbox.tsx +0 -399
  528. package/src/components/line-items-labels.ts +0 -14
  529. package/src/components/line-items.tsx +0 -602
  530. package/src/components/list-labels.ts +0 -12
  531. package/src/components/list.tsx +0 -870
  532. package/src/components/loading-state.tsx +0 -78
  533. package/src/components/measured-grid-labels.ts +0 -25
  534. package/src/components/measured-grid.tsx +0 -837
  535. package/src/components/menu-item.tsx +0 -241
  536. package/src/components/mini-calendar-labels.ts +0 -17
  537. package/src/components/mini-calendar.tsx +0 -789
  538. package/src/components/modal.tsx +0 -505
  539. package/src/components/money-draft.ts +0 -83
  540. package/src/components/money-field.tsx +0 -76
  541. package/src/components/month-grid.tsx +0 -229
  542. package/src/components/month-picker-labels.ts +0 -22
  543. package/src/components/month-picker.tsx +0 -1087
  544. package/src/components/multi-entity-combobox.tsx +0 -369
  545. package/src/components/multi-select.tsx +0 -376
  546. package/src/components/nav-pills.tsx +0 -198
  547. package/src/components/number-field.tsx +0 -327
  548. package/src/components/number-input.tsx +0 -558
  549. package/src/components/numeric-field.ts +0 -169
  550. package/src/components/numpad-sheet.tsx +0 -285
  551. package/src/components/one-time-code-input.tsx +0 -270
  552. package/src/components/page-contents-labels.ts +0 -9
  553. package/src/components/page-contents.tsx +0 -282
  554. package/src/components/page-header-context.ts +0 -8
  555. package/src/components/page-header.tsx +0 -298
  556. package/src/components/password-strength-labels.ts +0 -22
  557. package/src/components/password-strength.tsx +0 -241
  558. package/src/components/phone-input-labels.ts +0 -10
  559. package/src/components/phone-input.tsx +0 -282
  560. package/src/components/picker-sheet.tsx +0 -330
  561. package/src/components/pie-chart-labels.ts +0 -40
  562. package/src/components/pie-chart.tsx +0 -480
  563. package/src/components/popover-labels.ts +0 -14
  564. package/src/components/popover.tsx +0 -268
  565. package/src/components/progress-bar-labels.ts +0 -10
  566. package/src/components/progress-bar.tsx +0 -479
  567. package/src/components/qr-code.tsx +0 -76
  568. package/src/components/reauth-dialog-labels.ts +0 -13
  569. package/src/components/reauth-dialog.tsx +0 -265
  570. package/src/components/row-actions-labels.ts +0 -12
  571. package/src/components/row-actions.tsx +0 -795
  572. package/src/components/scroll-area.tsx +0 -123
  573. package/src/components/search-field.tsx +0 -192
  574. package/src/components/select.tsx +0 -294
  575. package/src/components/separator.tsx +0 -33
  576. package/src/components/series-chart-axes.ts +0 -342
  577. package/src/components/series-chart-budget.ts +0 -150
  578. package/src/components/series-chart-labels.ts +0 -33
  579. package/src/components/series-chart-thin.ts +0 -180
  580. package/src/components/series-chart-ticks.ts +0 -194
  581. package/src/components/series-chart.tsx +0 -1906
  582. package/src/components/server-wake-labels.ts +0 -11
  583. package/src/components/server-wake.tsx +0 -129
  584. package/src/components/settings-fields.tsx +0 -237
  585. package/src/components/share-card-labels.ts +0 -30
  586. package/src/components/share-card.tsx +0 -675
  587. package/src/components/signature-pad-labels.ts +0 -24
  588. package/src/components/signature-pad.tsx +0 -747
  589. package/src/components/signed-amount-labels.ts +0 -10
  590. package/src/components/signed-amount.tsx +0 -336
  591. package/src/components/skeleton.tsx +0 -80
  592. package/src/components/slider.tsx +0 -338
  593. package/src/components/sparkline-labels.ts +0 -14
  594. package/src/components/sparkline.tsx +0 -322
  595. package/src/components/spinner.tsx +0 -81
  596. package/src/components/stat-tile-labels.ts +0 -15
  597. package/src/components/stat-tile.tsx +0 -725
  598. package/src/components/status-dot.tsx +0 -170
  599. package/src/components/swatch-picker-labels.ts +0 -10
  600. package/src/components/swatch-picker.tsx +0 -283
  601. package/src/components/swipeable-row.tsx +0 -386
  602. package/src/components/switch.tsx +0 -273
  603. package/src/components/table-skeleton.tsx +0 -136
  604. package/src/components/table.tsx +0 -647
  605. package/src/components/tabs-labels.ts +0 -13
  606. package/src/components/tabs.tsx +0 -693
  607. package/src/components/text-link.tsx +0 -359
  608. package/src/components/text.tsx +0 -150
  609. package/src/components/textarea.tsx +0 -330
  610. package/src/components/tile-radio.tsx +0 -332
  611. package/src/components/time-input.tsx +0 -121
  612. package/src/components/toast-labels.ts +0 -12
  613. package/src/components/toast.tsx +0 -519
  614. package/src/components/toggle-group.tsx +0 -752
  615. package/src/components/toggle-legend.tsx +0 -394
  616. package/src/components/tooltip.tsx +0 -1149
  617. package/src/components/translation-review-editor.tsx +0 -290
  618. package/src/components/translation-review-labels.ts +0 -181
  619. package/src/components/translation-review-parts.tsx +0 -272
  620. package/src/components/translation-review.tsx +0 -1240
  621. package/src/components/tree-view.tsx +0 -589
  622. package/src/components/treemap-labels.ts +0 -47
  623. package/src/components/treemap-layout.ts +0 -210
  624. package/src/components/treemap.tsx +0 -1184
  625. package/src/components/trigger-aria.ts +0 -44
  626. package/src/components/use-anchor-dir.ts +0 -20
  627. package/src/components/use-mobile-reveal.ts +0 -62
  628. package/src/components/use-table-state.ts +0 -753
  629. package/src/components/user-avatar.tsx +0 -116
  630. package/src/components/wizard-stepper.tsx +0 -42
  631. package/src/components/write-lock-labels.ts +0 -10
  632. package/src/components/write-lock.tsx +0 -253
  633. package/src/currencies.ts +0 -70
  634. package/src/data-table.ts +0 -42
  635. package/src/demo/demo-banner.tsx +0 -153
  636. package/src/demo/demo-countdown.ts +0 -136
  637. package/src/demo/demo-ended.tsx +0 -90
  638. package/src/demo/demo-labels.ts +0 -78
  639. package/src/demo/demo-session.ts +0 -32
  640. package/src/demo/demo-start.tsx +0 -156
  641. package/src/eslint/index.ts +0 -161
  642. package/src/eslint/role-utilities.generated.ts +0 -146
  643. package/src/eslint/role-utilities.ts +0 -233
  644. package/src/feedback/feedback-attachment-download.tsx +0 -77
  645. package/src/feedback/feedback-attachment-labels.ts +0 -37
  646. package/src/feedback/feedback-attachment.tsx +0 -1239
  647. package/src/feedback/feedback-capture.ts +0 -287
  648. package/src/feedback/feedback-comment-thread.tsx +0 -353
  649. package/src/feedback/feedback-context-labels.ts +0 -10
  650. package/src/feedback/feedback-context.tsx +0 -213
  651. package/src/feedback/feedback-crash.ts +0 -547
  652. package/src/feedback/feedback-dialog-labels.ts +0 -29
  653. package/src/feedback/feedback-dialog.tsx +0 -440
  654. package/src/feedback/feedback-entry-file.tsx +0 -150
  655. package/src/feedback/feedback-inbox.tsx +0 -552
  656. package/src/feedback/feedback-labels.ts +0 -144
  657. package/src/feedback/feedback-menu-labels.ts +0 -12
  658. package/src/feedback/feedback-menu.tsx +0 -177
  659. package/src/feedback/feedback-mutation.ts +0 -20
  660. package/src/feedback/feedback-page-link.ts +0 -90
  661. package/src/feedback/feedback-public-labels.ts +0 -81
  662. package/src/feedback/feedback-public-list.tsx +0 -524
  663. package/src/feedback/feedback-public-switch.tsx +0 -102
  664. package/src/feedback/feedback-public-types.ts +0 -101
  665. package/src/feedback/feedback-record.ts +0 -423
  666. package/src/feedback/feedback-replies-context.ts +0 -9
  667. package/src/feedback/feedback-replies.tsx +0 -343
  668. package/src/feedback/feedback-rework-entry-chip.tsx +0 -18
  669. package/src/feedback/feedback-row-detail-labels.ts +0 -30
  670. package/src/feedback/feedback-row-detail.tsx +0 -858
  671. package/src/feedback/feedback-status-undo.ts +0 -109
  672. package/src/feedback/feedback-submit-dialog.tsx +0 -118
  673. package/src/feedback/feedback-submit.tsx +0 -237
  674. package/src/feedback/feedback-swipe.tsx +0 -192
  675. package/src/feedback/feedback-table-labels.ts +0 -27
  676. package/src/feedback/feedback-table.tsx +0 -912
  677. package/src/feedback/feedback-thread-labels.ts +0 -22
  678. package/src/feedback/feedback-thread-parts-labels.ts +0 -41
  679. package/src/feedback/feedback-thread.tsx +0 -905
  680. package/src/feedback/feedback-vocabulary.ts +0 -263
  681. package/src/feedback/feedback-vote-button.tsx +0 -124
  682. package/src/feedback.ts +0 -116
  683. package/src/hooks/use-anchored-panel.ts +0 -396
  684. package/src/hooks/use-anchored-rect.ts +0 -77
  685. package/src/hooks/use-announce.ts +0 -85
  686. package/src/hooks/use-authed-src.ts +0 -167
  687. package/src/hooks/use-body-scroll-lock.ts +0 -119
  688. package/src/hooks/use-breakpoint.ts +0 -133
  689. package/src/hooks/use-close-transition.ts +0 -107
  690. package/src/hooks/use-copy-to-clipboard.ts +0 -122
  691. package/src/hooks/use-debounce.ts +0 -114
  692. package/src/hooks/use-dismiss.ts +0 -371
  693. package/src/hooks/use-document-title.ts +0 -33
  694. package/src/hooks/use-file-drop.ts +0 -121
  695. package/src/hooks/use-file-picker-labels.ts +0 -28
  696. package/src/hooks/use-file-picker.tsx +0 -359
  697. package/src/hooks/use-focus-trap.ts +0 -199
  698. package/src/hooks/use-hotkey.ts +0 -189
  699. package/src/hooks/use-large-text.ts +0 -41
  700. package/src/hooks/use-long-press.ts +0 -140
  701. package/src/hooks/use-media-query.ts +0 -39
  702. package/src/hooks/use-mounted.ts +0 -39
  703. package/src/hooks/use-noindex.ts +0 -37
  704. package/src/hooks/use-overlay-history.ts +0 -714
  705. package/src/hooks/use-row-swipe.ts +0 -207
  706. package/src/hooks/use-search-param-state.ts +0 -434
  707. package/src/hooks/use-windowed-rows.ts +0 -225
  708. package/src/i18n/defaults.ts +0 -267
  709. package/src/i18n/german.ts +0 -2057
  710. package/src/i18n/kit-labels.tsx +0 -1003
  711. package/src/i18n/languages.ts +0 -349
  712. package/src/i18n/locales/de-CH.ts +0 -24
  713. package/src/i18n/locales/en.ts +0 -267
  714. package/src/i18n/locales/es.ts +0 -1997
  715. package/src/i18n/locales/fr.ts +0 -2035
  716. package/src/i18n/locales/hu.ts +0 -2002
  717. package/src/i18n/locales/it.ts +0 -2012
  718. package/src/i18n/locales/zh.ts +0 -1910
  719. package/src/i18n/plurals.ts +0 -86
  720. package/src/i18n/review.ts +0 -475
  721. package/src/i18n/shared-labels.ts +0 -138
  722. package/src/i18n/swiss.ts +0 -29
  723. package/src/index.ts +0 -1179
  724. package/src/landing/access.ts +0 -93
  725. package/src/landing/landing-actions.tsx +0 -97
  726. package/src/landing/landing-labels.ts +0 -59
  727. package/src/landing/landing-sections.tsx +0 -341
  728. package/src/landing/page-seo.ts +0 -261
  729. package/src/landing/public-header.tsx +0 -121
  730. package/src/landing/public-page.tsx +0 -106
  731. package/src/landing/routing.tsx +0 -225
  732. package/src/lib/broadcast.ts +0 -52
  733. package/src/lib/calc.ts +0 -323
  734. package/src/lib/chip-edge-snap.ts +0 -212
  735. package/src/lib/clipboard-minus.ts +0 -107
  736. package/src/lib/clipping.ts +0 -34
  737. package/src/lib/cn.ts +0 -28
  738. package/src/lib/column-mapping.ts +0 -234
  739. package/src/lib/countries.ts +0 -91
  740. package/src/lib/data-attributes.ts +0 -14
  741. package/src/lib/date-format.ts +0 -227
  742. package/src/lib/dates.ts +0 -266
  743. package/src/lib/day-format.ts +0 -96
  744. package/src/lib/decimal-marks.ts +0 -108
  745. package/src/lib/describe-failure.ts +0 -18
  746. package/src/lib/device-settings.ts +0 -147
  747. package/src/lib/direction.ts +0 -74
  748. package/src/lib/document-navigation.ts +0 -17
  749. package/src/lib/document-title.ts +0 -41
  750. package/src/lib/download.ts +0 -172
  751. package/src/lib/float-clearance.ts +0 -70
  752. package/src/lib/floating-stack.ts +0 -70
  753. package/src/lib/focus-left.ts +0 -156
  754. package/src/lib/format.ts +0 -522
  755. package/src/lib/http-status.ts +0 -38
  756. package/src/lib/iban.ts +0 -251
  757. package/src/lib/ime.ts +0 -24
  758. package/src/lib/intl-cache.ts +0 -25
  759. package/src/lib/iso-day.ts +0 -13
  760. package/src/lib/lazy-with-fallback.ts +0 -82
  761. package/src/lib/logger.ts +0 -69
  762. package/src/lib/minus.ts +0 -50
  763. package/src/lib/path-pattern.ts +0 -55
  764. package/src/lib/person-name.ts +0 -155
  765. package/src/lib/phone.ts +0 -208
  766. package/src/lib/plain-text.ts +0 -22
  767. package/src/lib/presence.ts +0 -18
  768. package/src/lib/qr-encode.ts +0 -444
  769. package/src/lib/quiet-focus.ts +0 -32
  770. package/src/lib/reduced-motion.ts +0 -22
  771. package/src/lib/refs.ts +0 -22
  772. package/src/lib/round.ts +0 -43
  773. package/src/lib/safe-storage.ts +0 -119
  774. package/src/lib/server-wake.ts +0 -315
  775. package/src/lib/session-refresh.ts +0 -472
  776. package/src/lib/share-format.ts +0 -18
  777. package/src/lib/status-tone.ts +0 -43
  778. package/src/lib/strip-fade.ts +0 -287
  779. package/src/lib/table-text.ts +0 -554
  780. package/src/lib/text-case.ts +0 -19
  781. package/src/lib/text-match.ts +0 -169
  782. package/src/lib/thenable.ts +0 -19
  783. package/src/lib/translation-review.ts +0 -717
  784. package/src/notifications/device-zone-report.ts +0 -67
  785. package/src/notifications/feedback-notification-types-labels.ts +0 -33
  786. package/src/notifications/feedback-notification-types.ts +0 -71
  787. package/src/notifications/notification-bell.tsx +0 -119
  788. package/src/notifications/notification-inbox.tsx +0 -309
  789. package/src/notifications/notification-parts.tsx +0 -81
  790. package/src/notifications/notification-types-setting.tsx +0 -406
  791. package/src/notifications/notification-types.ts +0 -89
  792. package/src/notifications/notifications-labels.ts +0 -287
  793. package/src/notifications/push-device-setting.tsx +0 -230
  794. package/src/notifications/push-devices-setting.tsx +0 -374
  795. package/src/notifications/push-worker.ts +0 -168
  796. package/src/notifications/quiet-hours-setting.tsx +0 -238
  797. package/src/notifications/unsubscribe-panel.tsx +0 -200
  798. package/src/notifications/use-push-device.ts +0 -448
  799. package/src/offline/local-first.ts +0 -100
  800. package/src/offline/pwa-update-prompt-labels.ts +0 -9
  801. package/src/offline/pwa-update-prompt.tsx +0 -78
  802. package/src/offline/query-persistence.ts +0 -391
  803. package/src/offline/service-worker-updates.ts +0 -81
  804. package/src/offline/shared-computer-labels.ts +0 -8
  805. package/src/offline/shared-computer.tsx +0 -87
  806. package/src/offline/sign-out-pending-dialog-labels.ts +0 -21
  807. package/src/offline/sign-out-pending-dialog.tsx +0 -135
  808. package/src/offline.ts +0 -37
  809. package/src/push-worker.ts +0 -25
  810. package/src/rhf/fields.tsx +0 -2187
  811. package/src/rhf/form-core.tsx +0 -324
  812. package/src/rhf/form.tsx +0 -12
  813. package/src/rhf/line-items.tsx +0 -135
  814. package/src/rhf/use-rhf-wizard-step.ts +0 -104
  815. package/src/rhf.ts +0 -21
  816. package/src/search/command-palette-labels.ts +0 -16
  817. package/src/search/command-palette.tsx +0 -624
  818. package/src/search/global-search-labels.ts +0 -13
  819. package/src/search/global-search.tsx +0 -660
  820. package/src/search/search-index.ts +0 -283
  821. package/src/search/use-command-key.ts +0 -23
  822. package/src/search.ts +0 -14
  823. package/src/settings/device-reset-setting-labels.ts +0 -20
  824. package/src/settings/device-reset-setting.tsx +0 -116
  825. package/src/settings/settings-card-classes.ts +0 -11
  826. package/src/settings/settings-catalogue.ts +0 -150
  827. package/src/settings/settings-context.ts +0 -33
  828. package/src/settings/settings-heading.tsx +0 -67
  829. package/src/settings/settings-labels.ts +0 -87
  830. package/src/settings/settings-layout.tsx +0 -471
  831. package/src/settings/settings-section.tsx +0 -108
  832. package/src/settings/use-account-appearance.ts +0 -148
  833. package/src/settings/use-account-language.ts +0 -146
  834. package/src/settings/use-settings-focus.ts +0 -128
  835. package/src/settings/use-settings-route.ts +0 -258
  836. package/src/shell/account-menu-language.tsx +0 -130
  837. package/src/shell/account-menu-text-size.tsx +0 -80
  838. package/src/shell/admin-menu.tsx +0 -183
  839. package/src/shell/app-shell-labels.ts +0 -11
  840. package/src/shell/app-shell.tsx +0 -1560
  841. package/src/shell/auth-layout.tsx +0 -186
  842. package/src/shell/company-switcher-labels.ts +0 -13
  843. package/src/shell/company-switcher.tsx +0 -136
  844. package/src/shell/dev-menu.tsx +0 -257
  845. package/src/shell/fill-height.tsx +0 -45
  846. package/src/shell/language-code.ts +0 -5
  847. package/src/shell/option-switcher-menu.tsx +0 -84
  848. package/src/shell/sync-status-indicator.tsx +0 -303
  849. package/src/shell/top-bar-actions.tsx +0 -87
  850. package/src/shell/top-bar-brand.tsx +0 -78
  851. package/src/shell/top-bar-parts-labels.ts +0 -148
  852. package/src/shell/top-bar.tsx +0 -40
  853. package/src/shell/topbar-action-menu.tsx +0 -567
  854. package/src/shell/topbar-controls.tsx +0 -269
  855. package/src/shell/tour-menu.tsx +0 -187
  856. package/src/shell.ts +0 -108
  857. package/src/sync/db.ts +0 -129
  858. package/src/sync/engine.ts +0 -1011
  859. package/src/sync/hooks.ts +0 -98
  860. package/src/sync/testing.ts +0 -73
  861. package/src/sync/wire.ts +0 -61
  862. package/src/sync.ts +0 -37
  863. package/src/table-text.ts +0 -8
  864. package/src/testing/css-variables.generated.ts +0 -112
  865. package/src/testing/css-variables.ts +0 -189
  866. package/src/testing/index.ts +0 -29
  867. package/src/testing/react.ts +0 -58
  868. package/src/testing/tour.ts +0 -41
  869. package/src/testing/viewport.ts +0 -357
  870. package/src/theme/chart-palette.ts +0 -232
  871. package/src/theme/color.ts +0 -272
  872. package/src/theme/contrast-tokens.ts +0 -268
  873. package/src/theme/contrast.ts +0 -250
  874. package/src/theme/palette-derive.ts +0 -825
  875. package/src/theme/palette-presets.ts +0 -544
  876. package/src/theme/palette-store.ts +0 -108
  877. package/src/theme/text-size.ts +0 -289
  878. package/src/theme/theme-store.ts +0 -157
  879. package/src/tour/tour-catalogue-labels.ts +0 -75
  880. package/src/tour/tour-catalogue.tsx +0 -335
  881. package/src/tour/tour-dock.ts +0 -134
  882. package/src/tour/tour-engine.ts +0 -37
  883. package/src/tour/tour-gates.ts +0 -218
  884. package/src/tour/tour-guide-button.tsx +0 -64
  885. package/src/tour/tour-guide-labels.ts +0 -15
  886. package/src/tour/tour-info.ts +0 -144
  887. package/src/tour/tour-labels.ts +0 -23
  888. package/src/tour/tour-overlay.tsx +0 -1170
  889. package/src/tour/tour.tsx +0 -750
  890. package/src/tour/use-tour-progress.ts +0 -671
  891. package/src/tour/use-tour-url-sync.ts +0 -266
  892. package/src/tour.ts +0 -21
  893. package/src/vite-env.d.ts +0 -23
  894. package/src/wizard/stepper-nav.tsx +0 -360
  895. package/src/wizard/types.ts +0 -275
  896. package/src/wizard/use-wizard.ts +0 -560
  897. package/src/wizard/validation.ts +0 -39
  898. package/src/wizard/wizard-context.tsx +0 -74
  899. package/src/wizard/wizard-labels.ts +0 -29
  900. package/src/wizard/wizard-step.tsx +0 -76
  901. package/src/wizard/wizard-summary.tsx +0 -84
  902. package/src/wizard.ts +0 -15
@@ -1,2598 +0,0 @@
1
- import { Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
- import type { ReactNode, TdHTMLAttributes, ThHTMLAttributes } from "react";
3
- import { Card } from "./card";
4
- import { Spinner } from "./spinner";
5
- import { isTableOwnColumn, useRowActionColumn } from "./data-table-row-actions";
6
- import { useColumnResize } from "./data-table-resize";
7
- import { useRangeSelection } from "./data-table-selection";
8
- import { columnLabel, logicalAlign, resolveDataTableLabels } from "./data-table-shared";
9
- import {
10
- MobileFilters,
11
- MobileLeadingRow,
12
- MobileSelectBar,
13
- MobileSort,
14
- StackedFields,
15
- keepsOwnClicks,
16
- mobileCardColumns,
17
- useCardColumnWarnings,
18
- } from "./data-table-mobile";
19
- import { DataTableMobileRow, DataTableRow, type DataTableRowCtx } from "./data-table-row";
20
- import { DataTableHead } from "./data-table-head";
21
- import { DataTableLeadingRow } from "./data-table-leading-row";
22
- import {
23
- DataTableGroupBand,
24
- DataTableLoadMoreFooter,
25
- groupLevels,
26
- groupRows,
27
- useGroupBandTops,
28
- type DataTableGroupBy,
29
- type DataTableLoadMore,
30
- type RowGroup,
31
- } from "./data-table-groups";
32
- export type { DataTableGroupBy, DataTableLoadMore } from "./data-table-groups";
33
- import {
34
- VIRTUAL_SELECT_COL,
35
- rowNavTarget,
36
- useRowWindow,
37
- type DataTableVirtualize,
38
- type VirtualGroup,
39
- } from "./data-table-virtual";
40
- export type { DataTableVirtualize } from "./data-table-virtual";
41
- import { ColumnSettingsPanel } from "./data-table-column-settings";
42
- import { useLargeText } from "../hooks/use-large-text";
43
- import { cn } from "../lib/cn";
44
- import { isFilterActive, resolveFilter, rowMatches } from "./data-table-filters";
45
- import { matchesSearch, searchWords } from "../lib/text-match";
46
- import type { ColumnFilter, FilterValue } from "./data-table-filters";
47
- import { compareSortValues, nextSorts } from "./data-table-sort";
48
- import type { SortCycle, SortDir, SortState } from "./data-table-sort";
49
- import { Pagination } from "./data-table-pagination";
50
- import type { SwipeAction } from "./swipeable-row";
51
- import type { RowActionsCollapse } from "./row-actions";
52
- import type { CommitScope } from "./write-lock";
53
- import { FullBleedDialog } from "./full-bleed-dialog";
54
- import { usePhoneLayout } from "../hooks/use-breakpoint";
55
- import { useAnnounce } from "../hooks/use-announce";
56
- import { DEFAULT_DATA_TABLE_LABELS, type DataTableLabels } from "./data-table-labels";
57
- import {
58
- useKitLabelOverrides,
59
- useKitLocale,
60
- useKitTableStorageKeyPrefix,
61
- type LabelOverride,
62
- } from "../i18n/kit-labels";
63
- import { useEdgeFade } from "../lib/strip-fade";
64
- import { dirOf, type Direction } from "../lib/direction";
65
- import { focusContainer, focusFirst, focusIsLost, tabStops, whenFocusLeftForNothing } from "../lib/focus-left";
66
-
67
- // ---------- Types ----------
68
-
69
- export interface DataTableColumn<T> {
70
- key: string;
71
- header: ReactNode;
72
- cell: (row: T) => ReactNode;
73
- /** What a client sort orders the column by. Strings collate in the table's `locale`
74
- * (digits as numbers, case and accents left out; 0.51.7, audit 2026-10-30 DSP-3),
75
- * numbers compare as numbers and sort before strings, `null` and `undefined` sort
76
- * last in either direction. The order is `compareSortValues`, exported for an app's
77
- * own list beside the table (0.52). */
78
- sortBy?: (row: T) => string | number | null | undefined;
79
- filter?: ColumnFilter<T>;
80
- filterBy?: (row: T) => string;
81
- className?: string;
82
- headClassName?: string;
83
- // Mobile card layout: when no `mobilePrimary` column is set the first
84
- // visible non-hidden column acts as the primary. The primary cell renders
85
- // bold at the top of the card without a label; the rest stack below as
86
- // labelled key/value pairs. `mobileHidden` skips a column entirely on
87
- // narrow viewports — useful for noisy details (IDs, raw URLs, internal
88
- // bookkeeping) that would crowd a card without aiding scanning.
89
- mobilePrimary?: boolean;
90
- mobileHidden?: boolean;
91
- /**
92
- * This column on the phone's default card (0.49 KIT-7; ignored with `mobileCard`):
93
- * `"primary"` — the card's title, as `mobilePrimary`; `"secondary"` — one of its
94
- * fields; `"hidden"` — not on the card, as `mobileHidden`. The card shows at most
95
- * three fields: the columns marked `"secondary"` when any
96
- * is, else the first three visible columns after the title. A field whose value is
97
- * empty — the cell returns `null`, `undefined`, `""` or the formatters' empty "—" —
98
- * is left out of that card. The columns the card does not show go into the opened
99
- * row with `mobileDetailsInRow`. A `noRowLink` column (the cell's own buttons or link)
100
- * counts as a field: past the third it leaves the card with its action, and a
101
- * development build says so once per table and column key. Give a column of row
102
- * actions `role: "actions"` instead: it is then no field, and the card draws it after
103
- * them.
104
- */
105
- mobile?: "primary" | "secondary" | "hidden";
106
- /**
107
- * What the column is (0.49 KIT-7), for the phone's default card; the desktop table is
108
- * unchanged.
109
- *
110
- * - `"status"`: its value sits as a chip beside the title instead of as a label/value
111
- * line, and takes none of the card's three fields. A cell that returns text is drawn
112
- * in a neutral `Chip`; one that returns an element (a `Chip` with its own tone, a
113
- * `StatusDot`) is drawn as it is.
114
- * - `"actions"`: the row's buttons (`rowActionsColumn` sets it). Never the card's title
115
- * and never one of its three fields: the card draws it after them, at its end,
116
- * whether or not other columns are marked `mobile: "secondary"`; only `mobileHidden`
117
- * / `mobile: "hidden"` take it off. Its cell keeps its own clicks, as `noRowLink`:
118
- * no row link wraps it, and a card that draws it is not a link.
119
- */
120
- role?: "status" | "actions";
121
- /**
122
- * Set on a column whose cell renders its own `<a>` (or anything else that must
123
- * keep its own click): the row anchor of `rowHref` then skips it and lands on
124
- * the next column that allows wrapping (Keksdose feedback #451). Without it the
125
- * fallback can wrap such a cell the moment the headline column is hidden from
126
- * the settings panel — `<a>` inside `<a>`, which React warns about and which
127
- * kills the inner link outright, since RowLink cancels the click on the capture
128
- * phase and the cell's own `stopPropagation` then blocks the row handler too.
129
- * The rejected alternative was sniffing the rendered output for an anchor: a
130
- * cell that renders a link is a fact its author knows and the renderer cannot
131
- * see without reaching into React elements it deliberately treats as opaque.
132
- */
133
- noRowLink?: boolean;
134
- /**
135
- * The direction this column sorts in on its FIRST click. `"desc"` for money and
136
- * count columns, where the rows worth reading are the large ones and an ascending
137
- * first click opens on the zero rows (keksdose's aggregated report tables). The
138
- * rest of the cycle follows from it — see {@link DataTableProps.sortCycle}.
139
- * Default `"asc"`.
140
- */
141
- firstSort?: SortDir;
142
- /**
143
- * The column's name as plain text, for the places a ReactNode `header` cannot go:
144
- * the phone sort control's options ({@link DataTableProps.mobileSort}), the spoken
145
- * "Sorted by …" and the column-settings checklist. Only needed when `header` is not
146
- * a string (an icon, a unit suffix); without it those places fall back to the
147
- * header string, then to `key`.
148
- */
149
- headerText?: string;
150
- /**
151
- * Extra attributes for this column's `<td>` in a given row — a `data-*` hook, a
152
- * `title`, an `aria-describedby`, a class. keksdose's accounts page
153
- * (accounts-page.tsx, the name and IBAN columns) is why it exists: its demo mode
154
- * blurs everything marked `data-private`, and with nowhere to put the marker on the
155
- * cell it wrapped every value in an inner `<span data-private>` — which blurs the
156
- * text but not the cell, and has to be remembered in every `cell` renderer.
157
- *
158
- * MERGED under the kit's own, which win: `className` is joined before the column's
159
- * `className` (so the column's alignment is still the alignment), `style` is laid
160
- * under a resized column's pinned width, and `data-col` and a `role` stay the
161
- * table's — `data-col` is how a resize finds the column's cells, and a `<td>` that
162
- * claims another role leaves its row's cell count wrong for a screen reader.
163
- *
164
- * On a phone the card field (`<dd>`, or the primary line) gets the `data-*`
165
- * attributes only: the rest — padding classes, `colSpan`, a width — describe a
166
- * table cell and would mean something else, or nothing, on a card.
167
- */
168
- cellProps?: (row: T) => DataTableCellProps | undefined;
169
- /** The same for the column's `<th>`, once. `scope`, `aria-sort`, `data-col` and the
170
- * width a resize pins stay the table's, and the class list goes before
171
- * `headClassName` for the same reason as in {@link cellProps}. */
172
- headProps?: DataTableHeadProps;
173
- /**
174
- * This column's cell in the table's totals row — a `<tfoot>` on a wide screen, a
175
- * labelled summary card under the list on a phone. kastlan's trial balance is why
176
- * it exists: it hand-built a debit/credit/balance strip in a `div` UNDER the table,
177
- * which lined up with no column, dropped out of the table's semantics, and stayed
178
- * put when the user hid or resized the columns it was summing.
179
- *
180
- * `rows` is every row that passes the current filters (in the current sort order),
181
- * NOT just the page on screen: a total that changes when the user pages is not a
182
- * total, and the trial balance has to balance on page 1 as much as on page 7. The
183
- * page is the second argument for the rare "subtotal of this page" footer.
184
- *
185
- * Under `serverPagination` the table only ever holds one page, so both arguments are
186
- * that page — a server-side table computes its totals on the server and returns
187
- * them here (`footer: () => fmt(response.totalDebit)`), ignoring the arguments.
188
- *
189
- * The cell takes the column's `className` (so a `text-end` amount column totals
190
- * end-aligned), its resized width, and drops out with the column when it is hidden.
191
- */
192
- footer?: (rows: T[], page: T[]) => ReactNode;
193
- /**
194
- * This column takes the width the others leave, and its text ellipsises (0.48,
195
- * keksdose live #451): `w-full max-w-0` on its cells, so in the table's auto layout it
196
- * is "the rest of the row" and one long title can no longer widen the table. One per
197
- * table. It has no resize handle, and a width stored for it (before it grew) is
198
- * ignored: it always takes the rest. Resize the others to change it. Opt-in: without it nothing
199
- * changes. It keeps at least 10rem: with {@link fitPriority} columns the table leaves
200
- * those out first, and scrolls sideways once none is left. Every other cell keeps one
201
- * line at Normal text size (`whitespace-nowrap`; a column's own
202
- * `not-large:whitespace-normal` lets its prose wrap), so a figure or a name is never
203
- * broken in two to make room for it.
204
- */
205
- grow?: boolean;
206
- /**
207
- * The least this `grow` column keeps, in rem (0.51.4; default 10): the floor below which
208
- * the table leaves out `fitPriority` columns, and once none is left, scrolls sideways.
209
- * Lower it for a column whose first words carry it — the feedback inbox's Subject, 8,
210
- * so the inbox at 768px with a sidebar keeps Subject, Status and the row actions
211
- * without scrolling (Kurvenschmiede). In rem, so Large text asks for more.
212
- */
213
- growMin?: number;
214
- /**
215
- * Leave this column out while the TABLE is too narrow for it (0.48, keksdose live
216
- * #451) — measured on the table's own scroller, so a sidebar, the window and Large
217
- * text count alike. The lowest number goes first (ties: the later column first), and
218
- * comes back last, once the scroller is as wide as the table needed with it. Columns
219
- * without it never go. Desktop only: the phone's card list is not narrowed.
220
- *
221
- * The Columns panel counts only what is drawn ("Columns (8/10)") and marks a column
222
- * left out this way "hidden for width". Ticking it there keeps it in for as long as
223
- * the table is mounted (not persisted): the table makes room by leaving out the next
224
- * one instead, and scrolls sideways once none is left. Unticking hides it as any column.
225
- */
226
- fitPriority?: number;
227
- /**
228
- * Start this column HIDDEN (0.48, keksdose dev #617) — for as long as the view saved
229
- * under `storageKey` holds no entry for it: on a first visit, on a device whose saved
230
- * view predates the column (or the flag), and for a column the app adds later. The
231
- * user's own choice in the Columns panel always wins: ticking it is remembered
232
- * (`shown` in the saved view), unticking it again stores it as any hidden column. The
233
- * default itself is never written into the saved view, so dropping the flag brings the
234
- * column back on every device where nobody chose. In the panel it is simply unticked —
235
- * not "hidden for width", which `fitPriority` alone says. Ignored, like every hidden
236
- * column, with `columnSettings={false}`: nothing could bring it back.
237
- */
238
- defaultHidden?: boolean;
239
- }
240
-
241
- /** A `data-*` attribute, typed so `{ "data-private": "" }` needs no cast. */
242
- type DataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined };
243
-
244
- /** What {@link DataTableColumn.cellProps} may return. */
245
- export type DataTableCellProps = TdHTMLAttributes<HTMLTableCellElement> & DataAttributes;
246
-
247
- /** What {@link DataTableColumn.headProps} takes. */
248
- export type DataTableHeadProps = ThHTMLAttributes<HTMLTableCellElement> & DataAttributes;
249
-
250
- export interface ServerPagination {
251
- page: number; // 0-based
252
- pageSize: number;
253
- total: number;
254
- onPageChange: (page: number) => void;
255
- /** Without it the page size is the server's to choose, and the pager shows no
256
- * page-size select at all — one that changed nothing was a control that lied. */
257
- onPageSizeChange?: (pageSize: number) => void;
258
- /**
259
- * A page is being fetched. The table marks itself `aria-busy`, dims the rows it is
260
- * still showing (they are the PREVIOUS page's, and about to go), and replaces the
261
- * empty state with a loading row when there is nothing to show yet — "no results"
262
- * while the first page is still in flight is a claim the table cannot make.
263
- *
264
- * The pager stays usable. Disabling it would drop focus from the very button the
265
- * user just pressed (a disabled button cannot hold focus), and a second click
266
- * during a fetch is a request the owner already has to handle for a slow network.
267
- */
268
- isLoading?: boolean;
269
- }
270
-
271
- export interface DataTableProps<T> {
272
- /** `readonly` since 0.52 (refactor audit 2026-10-30, API-7): the table never changes the
273
- * arrays it is handed, so a caller's `as const` list or a query's frozen result needs no
274
- * copy. Only widens: a mutable array is still one. The same for `columns` and `sorts`. */
275
- rows: readonly T[];
276
- columns: readonly DataTableColumn<T>[];
277
- rowKey: (row: T) => string | number;
278
- defaultPageSize?: number;
279
- onRowClick?: (row: T) => void;
280
- /**
281
- * The URL that shows this row, when one exists — the table then renders a REAL
282
- * anchor for it (Keksdose feedback #451, "to be able to middle click with the
283
- * mouse and open in a new tab"). A row that opens through `onRowClick` alone is
284
- * invisible to the browser: no middle click, no ⌘/Ctrl-click, no "copy link
285
- * address", no status-bar preview, no long-press menu on a phone. The
286
- * alternative was an `onAuxClick` that calls `window.open` — it buys back only
287
- * the middle click and loses the other four, none of which JavaScript can fake.
288
- *
289
- * Return `undefined` for a row that has no address of its own. Only wire this up
290
- * where the OPEN row is genuinely in the URL (a `?row=` param, a detail route);
291
- * a row whose expansion lives in local component state has no link to hand out,
292
- * and a link the target page cannot honour is worse than no link.
293
- *
294
- * The anchor covers the row's primary cell, not the whole `<tr>`: an `<a>` may
295
- * not wrap table cells, and the absolutely-positioned row overlay that is the
296
- * usual workaround would sit on top of the per-cell controls these rows carry
297
- * (status toggles, the selection checkbox, nested links) and swallow them.
298
- *
299
- * With `onRowClick` a plain click opens the row through it, in place. Without one
300
- * (0.52, refactor audit 2026-10-30, DSP-23 b) the click follows the link — through the
301
- * provider's `linkComponent` for an in-app path, as every kit link — and the phone
302
- * card is that link too; before, the click was cancelled and opened nothing.
303
- */
304
- rowHref?: (row: T) => string | undefined;
305
- expandedRow?: (row: T) => ReactNode | null;
306
- isExpanded?: (row: T) => boolean;
307
- /**
308
- * Extra classes for a row — the `<tr>` on desktop, the card on mobile. Merged last,
309
- * so a `hover:` fill here replaces the row's own `hover:bg-hover`.
310
- *
311
- * **A row you paint has to pick its own hover** (0.33, docs/colour-roles-harmonization.md
312
- * §12.2): the kit cannot see your rest colour, and `--bg-hover` is mixed against the
313
- * card. The rule: a hover differs from its rest by 1.08:1 or more, and every text role
314
- * on it keeps 4.5:1.
315
- * - A row that rests on the well (`bg-surface-2`) hovers to the CARD,
316
- * `hover:bg-surface`: in light the well is the page, and `--bg-hover` sits 1.04:1
317
- * from it.
318
- * - A row whose well MARKS A STATE (selected) keeps its fill and draws a 1 px inside
319
- * line instead, since the card would read as "not selected":
320
- * `bg-surface-2 hover:bg-surface-2 hover:outline hover:-outline-offset-1 hover:outline-strong`.
321
- * An outline, not a shadow: a shadow on a `<tr>` is not painted by every engine.
322
- * - Not `--bg-active` for a selected row: its muted and money text fall to 3.8–4.7:1.
323
- */
324
- rowClassName?: (row: T) => string | undefined;
325
- /**
326
- * Extra DOM attributes for a row's container — the `<tr>` on desktop, the
327
- * `<li>` on mobile. For hooks that have to sit on the row itself rather than
328
- * inside a cell: guided-tour anchors, analytics ids, e2e selectors. Undefined
329
- * values are dropped, so a per-row conditional needs no filtering at the call
330
- * site. Not a styling escape hatch — that is `rowClassName`.
331
- */
332
- rowAttributes?: (row: T) => Record<string, string | undefined> | undefined;
333
- /**
334
- * Set false for a table that is short by construction — an invoice's line
335
- * items, a wizard's picks — where the pager is chrome around a list that can
336
- * never need one. Renders every row and drops the footer entirely. Ignored in
337
- * `serverPagination` mode, where the pager is the only way to reach the rest
338
- * of the data.
339
- */
340
- paginated?: boolean;
341
- empty?: ReactNode;
342
- /**
343
- * If set, the table persists filter / sort / page-size state to localStorage under this key.
344
- * Use a stable, unique key per table (e.g. "transactions-table").
345
- */
346
- storageKey?: string;
347
- /**
348
- * When true, mirror filter/sort/page state into URL search params so views are
349
- * shareable and bookmarkable. URL params used: `f.<column>`, `sort`, `p`, `ps`.
350
- * URL state takes precedence over localStorage on initial load. Requires the
351
- * consuming app to render inside a react-router Router (0.49: only with `urlSync` — a
352
- * table without it needs none). With `urlSync` and no Router, a development build
353
- * throws "DataTable urlSync requires a Router"; a production build keeps the view in
354
- * local state instead.
355
- *
356
- * `{ prefix }` namespaces them, for two URL-synced tables on one page:
357
- * `urlSync={{ prefix: "inv." }}` uses `inv.f.<column>`, `inv.sort`, `inv.p`, `inv.ps`.
358
- */
359
- urlSync?: TableUrlSync;
360
- /**
361
- * When true, the table fills its parent's height and scrolls INTERNALLY, with the
362
- * header pinned — so the page itself doesn't add a second, outer scrollbar. The parent
363
- * must give it a bounded height (e.g. a flex column inside the viewport-locked app
364
- * shell). See feedback #207.
365
- *
366
- * **The list page** (0.40, docs/page-layout-harmonization.md §1): the root carries
367
- * `data-fill-height`, and from `md` up AppShell's `<main>` turns into a flex column
368
- * while such a table is inside — the shell's banners keep their height, the page takes
369
- * the rest. Wrap every element between `<main>` and the table in `FillHeight` (or merge
370
- * `FILL_HEIGHT_CLASS`); nothing else to wire. The body never shrinks below `basis-48`
371
- * (12rem, about five rows): on a window too short for that, `<main>` scrolls again.
372
- *
373
- * **On a phone** the card list becomes the scroller ONLY when its parent really bounds
374
- * it — measured, not assumed (0.40, keksdose live #420): the list takes the flex
375
- * room it is given, and only when its rows overflow that room does it turn
376
- * `overflow-y-auto overscroll-contain`, with the filter button pinned above. Under an
377
- * unbounded parent — `AppShell` on a phone, where the DOCUMENT scrolls — it stays in
378
- * the page's flow and grows to its content. Until 0.40 it was a scroller either way,
379
- * and a scroller with nothing to scroll and `overscroll-contain` swallowed the page's
380
- * own scroll: a swipe that started on a card moved nothing. So `fillHeight` is safe
381
- * to pass at every width; `fillHeight={useBreakpoint("md")}` is no longer needed.
382
- *
383
- * The cure for TWO SCROLLBARS (round 0.34 C14, kastlan feedback 55): the default
384
- * `maxBodyHeight` scrolls the body inside a page that scrolls too. How a page fills
385
- * the height is in {@link DataTableProps.maxBodyHeight}'s note.
386
- */
387
- fillHeight?: boolean;
388
- /** How tall the scrolling body may get before it scrolls, as a CSS length.
389
- *
390
- * Default `calc(100dvh - 12rem)`, which is what every caller had before this
391
- * existed: a bound is what makes the sticky header work, because a header
392
- * can only stick to a wrapper that actually scrolls. The cap assumes the
393
- * table has page chrome above and below it, and a table that is the last
394
- * thing on its page can afford more — ASPICE Atlas report #12, "for the 25
395
- * elements there should in best case be no scrolling necessary".
396
- *
397
- * Ignored under `fillHeight`, which bounds the body by its flex parent
398
- * instead and is the stronger statement of the same intent.
399
- *
400
- * **Two scrollbars** (round 0.34 C14, kastlan feedback 55). The cap scrolls the
401
- * body INSIDE a page that, in most shells, scrolls itself: a table taller than
402
- * the cap on a page with anything else on it shows two scrollbars side by side,
403
- * and the wheel moves whichever is under the pointer. For a list page whose table
404
- * is the point, fill the height instead, from `md` up (a phone scrolls the page
405
- * and its card list as one) — docs/page-layout-harmonization.md §1.2 says which
406
- * pages fill:
407
- *
408
- * 1. `<DataTable fillHeight>`: the table marks itself (`data-fill-height`), and
409
- * AppShell's `<main>` becomes a flex column while it is inside;
410
- * 2. each element between `<main>` and the table is a `FillHeight` (the page's
411
- * root, any wrapper on the way), or merges `FILL_HEIGHT_CLASS`.
412
- *
413
- * Only the body then scrolls, under its pinned header, and every other page grows
414
- * with its content as before (kastlan e3985c9, its leases list). A short table that
415
- * is the last thing on its page can instead drop the cap: `maxBodyHeight="none"`. */
416
- maxBodyHeight?: string;
417
- /**
418
- * When provided, the table renders the supplied rows as-is (no client-side
419
- * filtering/sorting/slicing) and the pagination footer is driven by these
420
- * server-controlled values. Column-level filters and sorting are hidden
421
- * UNLESS the caller takes them over via `onFiltersChange`/`onSortsChange` —
422
- * the caller is then expected to translate the state into the server query
423
- * that produces `rows`.
424
- */
425
- serverPagination?: ServerPagination;
426
- /**
427
- * 0.48.1 (keksdose K-052-1): the rows are already filtered and sorted by the server —
428
- * render them as given. No client filter, no client sort, no client paging (every row
429
- * shows, as with `paginated={false}`). The column filters and sort headers behave as
430
- * under `serverPagination`: hidden unless the owner takes them over with
431
- * `onFiltersChange` / `onSortsChange`, and then a click only REPORTS the new state (a
432
- * header click calls `onSortsChange` with the next sort and the rows keep their order
433
- * until the owner re-queries and passes new ones).
434
- *
435
- * Defaults to `true` when {@link loadMore} is set — the slices it loads come from the
436
- * server in its order, and re-filtering them dropped rows (the other leg of each
437
- * transfer). Pass `serverRows={false}` for a client table that loads more chunks but
438
- * filters and sorts what it holds itself. `serverPagination` implies it regardless.
439
- */
440
- serverRows?: boolean;
441
- /**
442
- * Controlled filter/sort state. When the `on*Change` callback is provided
443
- * the table stops owning that piece of state: it renders `filters`/`sorts`
444
- * as given and reports user interactions through the callback (including
445
- * in serverPagination mode, where the UI is otherwise disabled).
446
- *
447
- * Page reset: on a client-side table, a change in the VALUE of `filters` or
448
- * `sorts` sends the table back to page 1 — whoever made the change, the user
449
- * through the header or the owner from outside (a saved view, a chip). Compared
450
- * by value, so passing a fresh but equal object on every render does not reset
451
- * anything. In `serverPagination` mode the owner's `page` is the page, and
452
- * resetting it on a filter change stays the owner's job.
453
- */
454
- filters?: FilterState;
455
- onFiltersChange?: (next: FilterState) => void;
456
- sorts?: readonly SortState[];
457
- onSortsChange?: (next: SortState[]) => void;
458
- /**
459
- * Controlled page size, as `sorts` above: the table pages by it and hands its pager's
460
- * choice to `onPageSizeChange` (`Infinity` is "All"). For tables that page ALIKE —
461
- * `TranslationReviewPanel`'s groups share one page size as they share one sort
462
- * (0.35). Left out, the table keeps its own, from `defaultPageSize`, the URL and
463
- * `storageKey`. A change of value sends a client-side table back to page 1. In
464
- * `serverPagination` mode the page size is `serverPagination.pageSize`.
465
- */
466
- pageSize?: number;
467
- onPageSizeChange?: (pageSize: number) => void;
468
- /**
469
- * Opt-in multi-row selection. When provided, a leading
470
- * checkbox column is rendered with a select-all box in the header; the owner
471
- * holds the selected set and reacts via the callbacks. Rows for which
472
- * `isSelectable` returns false render no checkbox and are excluded from
473
- * select-all. See feedback #285 (bulk edit).
474
- *
475
- * **On a phone** (0.40, keksdose live #421) the cards enter a selection MODE: a long
476
- * press on a card (500 ms, keksdose's register gesture) selects it and turns every
477
- * card into a toggle with its box showing; a tap then selects or deselects. A
478
- * keyboard or screen reader enters through "Select rows" (`labels.selectRows`),
479
- * visually hidden until focused; in the mode a bar above the cards holds "Select all
480
- * rows" and Cancel (`labels.cancelSelection`). The mode lasts while anything is
481
- * selected — the bulk bar's Clear, the last box unticked or Cancel ends it — and swipe
482
- * actions rest while it is on. Render the owner's `BulkActionBar` as on desktop; it
483
- * appears with the first selected row. With `selection` the cards' text is not
484
- * selectable on a phone, and a linked card's long press selects instead of opening
485
- * the browser's link menu.
486
- */
487
- selection?: {
488
- isSelectable?: (row: T) => boolean;
489
- isSelected: (row: T) => boolean;
490
- onToggle: (row: T, checked: boolean) => void;
491
- /**
492
- * Commit a whole Shift+click range in ONE call (audit 2026-09-22).
493
- *
494
- * Optional and purely additive: without it the range is still walked row by
495
- * row, exactly as before. With it, an owner holding a `Set` copies that Set
496
- * once instead of once per row — a 400-row range was 400 `onToggle` calls,
497
- * 400 Set copies and 400 renders of this table, all to reach a state the
498
- * owner could have reached in one.
499
- *
500
- * `rows` is already filtered by `isSelectable` and is in the order the table
501
- * is showing, so it can be applied as-is.
502
- */
503
- onToggleMany?: (rows: T[], checked: boolean) => void;
504
- allSelected: boolean;
505
- someSelected: boolean;
506
- onToggleAll: (checked: boolean) => void;
507
- };
508
- /** User-facing strings (English defaults); pass translated overrides. Merged over
509
- * `dataTable` from `<UiKitProvider labels>`, so a call site only states what
510
- * differs for THIS table — typically `table`, its accessible name. */
511
- labels?: LabelOverride<DataTableLabels>;
512
- /** BCP-47 locale for the date filter's calendar and the pager's numbers. Falls
513
- * back to `<UiKitProvider locale>`, then to the runtime's default. */
514
- locale?: string;
515
- /** localStorage namespace prefix for `storageKey` persistence: the stored key is this
516
- * plus `storageKey`. Falls back to `<UiKitProvider tableStorageKeyPrefix>` (0.34, set
517
- * your app's prefix there once), then to "hbui-table:". This prop wins over both. */
518
- storageKeyPrefix?: string;
519
- /**
520
- * On phones, surface the expanded row's detail in a full-screen dialog instead
521
- * of unfolding it inline (feedback #204). Desktop always uses inline expansion.
522
- */
523
- mobileExpandAsDialog?: boolean;
524
- /**
525
- * Show the `mobileHidden` columns in the OPENED row on the phone layout — inline
526
- * under the card, or in the `mobileExpandAsDialog` dialog — so a column dropped from
527
- * the card to keep it short is not lost (0.32, docs/text-size-harmonization.md §4:
528
- * "a new `mobileDetailsInRow` puts the `mobileHidden` columns into the opened row, so
529
- * nothing is lost"). At Large a card holds fewer facts per screen, and dropping one
530
- * from the card must not drop it from the phone.
531
- *
532
- * Each column is a label above its value, BEFORE what `expandedRow` renders: the
533
- * row's own facts first, then the app's detail or editor. A row opens as it always
534
- * does (`onRowClick` toggling `isExpanded`); with this set it may open without an
535
- * `expandedRow` at all. `rowActions`' column is one of them — the actions, which the
536
- * card otherwise offers only as swipes, reachable by a tap. Desktop is unaffected.
537
- */
538
- mobileDetailsInRow?: boolean;
539
- /**
540
- * The row's name, as plain text — for the "⋯" menu its `rowActions` collapse into at
541
- * Large ("Actions for Ada Example", §10.8). Without it every row's menu button is
542
- * named "Actions", which a screen reader cannot tell apart down the list.
543
- */
544
- rowName?: (row: T) => string | undefined;
545
- /**
546
- * The phone row dialog's `backCloses` ({@link FullBleedDialog}): whether it pushes
547
- * its own history entry so Back dismisses it. On by default — right for a row whose
548
- * expansion is component state, where nothing else would answer Back.
549
- *
550
- * **Set it `false` when the open row is in the URL** and opening it PUSHES (a
551
- * `?row=` written with `useSearchParamState(…, { history: "replace-on-clear" })` or
552
- * `"push"`): Back already closes the row through the router, and a second entry on
553
- * top of the page's is two owners of one Back press (keksdose dev #584). Keep it on
554
- * when opening the row REPLACES the URL — then nothing was pushed, and the dialog's
555
- * own entry is the only thing Back can pop.
556
- *
557
- * The table cannot decide this for you: `isExpanded` is a predicate, and neither it
558
- * nor `rowHref` says whether opening pushed or replaced (keksdose's feedback page
559
- * has `rowHref` with a REPLACING `?row=`, and needs the entry). Ignored unless
560
- * `mobileExpandAsDialog`.
561
- */
562
- mobileDialogBackCloses?: boolean;
563
- /**
564
- * Group the mobile card list into sections, each with a sticky header — the
565
- * "assistance"-style list (like the transactions list grouped by date, but by
566
- * whatever key you return, e.g. a name's first letter). The returned string is
567
- * the section a row belongs to; rows must already be ordered so equal keys are
568
- * contiguous (pair with a matching default sort). `mobileGroupLabel` formats
569
- * the header. Desktop is unaffected. Feedback #317.
570
- */
571
- mobileGroupBy?: ((row: T) => string) | DataTableGroupBy<T>;
572
- /** The section header's words for `mobileGroupBy`'s key. Wins over a level's own
573
- * `label` when `mobileGroupBy` is a {@link DataTableGroupBy}. */
574
- mobileGroupLabel?: (key: string) => ReactNode;
575
- /**
576
- * Fully replace the default mobile card body (bold primary + labelled
577
- * key/value rows) with a compact custom layout, while keeping the shared
578
- * clickable/expandable row wrapper. Use when the stacked label/value grid
579
- * wastes space (feedback #317). Desktop is unaffected.
580
- *
581
- * It replaces the WHOLE body, `mobilePrimary` included: a column's
582
- * `mobilePrimary` cell is not drawn when `mobileCard` is set, so anything that
583
- * cell carried (keksdose's environment and rework chips, 0.27) must be drawn by
584
- * the card itself — as `FeedbackMobileCard` does.
585
- */
586
- mobileCard?: (row: T) => ReactNode;
587
- /**
588
- * A "Sort by" select and a direction button above the phone card list (keksdose
589
- * K10). The card list has no header row, so without it a phone user is stuck with
590
- * whatever order the table opened in. It drives the same sort state as the column
591
- * headers — `sorts`/`onSortsChange` when controlled, the URL and `storageKey` when
592
- * not — and offers the columns a header click could sort (those with a `sortBy`).
593
- * Each option is named by the column's `headerText`, else its string `header`.
594
- *
595
- * Off by default, so no existing phone layout gains a row. Strings:
596
- * {@link DataTableSortLabels}. Phones only; the desktop table is unchanged.
597
- */
598
- mobileSort?: boolean;
599
- /**
600
- * Swipe actions for a mobile row, revealed by dragging it horizontally. Return
601
- * `null` (or empty sides) for rows that should not move — a locked row, one whose
602
- * mutation is in flight, one the user has expanded.
603
- *
604
- * Prefer `start`/`end`: actions committed by dragging the row toward the reading
605
- * START or END of the line. In a left-to-right layout `end` is a drag to the
606
- * right, and in a right-to-left one it is a drag to the left — so a "drag forward
607
- * to archive" row stays forward in Arabic or Hebrew without a second definition.
608
- * `left`/`right` are the physical directions and keep meaning exactly that; each
609
- * is used only where the logical side for that direction is not given.
610
- *
611
- * Mobile only, and deliberately so: the desktop table already has room for an
612
- * actions column, and a drag gesture on a pointer device is a worse version of a
613
- * button. Wraps only the row body, so an expansion panel below stays put while the
614
- * row above it slides. The desktop's buttons for the same actions come with it — see
615
- * {@link DataTableProps.desktopSwipeActions}.
616
- */
617
- mobileSwipeActions?: (row: T) => MobileSwipeActions | null;
618
- /**
619
- * The desktop half of `mobileSwipeActions` (0.46, keksdose dev#598 — Marcel: every
620
- * table whose phone cards swipe gets a desktop actions column with the same actions):
621
- * an actions column DERIVED from the very plan the card swipes with, so the two
622
- * viewports offer the same functions and cannot drift. Each button runs the swipe's own
623
- * `onCommit` — the same confirm, the same undo toast. The reading-end side's actions
624
- * first, then the start side's, each once (by `SwipeAction.id`, else by label); a row
625
- * whose plan is `null` gets an empty cell. The column is hidden on the phone, where the
626
- * actions are the swipes, kept off the column settings, and its tooltips open toward
627
- * the row's start.
628
- *
629
- * On by default wherever `mobileSwipeActions` is given, so a new swipeable table cannot
630
- * forget it — except beside `rowActions`, whose column is the desktop's already (pass
631
- * `true` or options to add this one too). `false` where the desktop row carries every
632
- * one of those actions as its own buttons; say so at the call site. Options:
633
- * {@link DataTableDesktopSwipeActions}.
634
- */
635
- desktopSwipeActions?: boolean | DataTableDesktopSwipeActions<T>;
636
- /**
637
- * Renders as a full-width row above the data rows in the desktop table, and since
638
- * 0.51.7 as the first item of the phone's card list (audit 2026-10-30 DSP-2: the card
639
- * list dropped it, and kastlan's inline "Add meter" editor never appeared on a phone).
640
- * On the phone it is no card: no row link, swipe or selection toggle. For a
641
- * toggle that shows/hides a slice of rows, style it as an expand/collapse
642
- * disclosure (chevron + label) so it reads as part of the table rather
643
- * than a floating control above it. See feedback #10/#11 (transactions'
644
- * "hide upcoming/scheduled" toggle).
645
- */
646
- leadingRow?: ReactNode;
647
- /**
648
- * 0.48 (keksdose dev #603: "it should rather be sticky, so the user can show hide them
649
- * everywhere on click"): pin the {@link leadingRow} under the sticky header while the
650
- * rows scroll beneath both — its `top` is the header's measured height. It sticks
651
- * within the table's own scroller (a bounded body: `fillHeight` or `maxBodyHeight`).
652
- * On a phone (0.51.7) it is pinned at the top of the card list's scroller (the list
653
- * under a bounded `fillHeight`, else the page), with `mobileGroupBy`'s section headers
654
- * under it. Default: it scrolls with the rows.
655
- */
656
- leadingRowSticky?: boolean;
657
- /**
658
- * 0.48 (keksdose dev #605): group the desktop table's rows under full-width bands, one
659
- * level or two (period → day). Each band is a row header spanning the visible columns
660
- * (selection column included, hidden and fit-hidden columns not) with a label and an
661
- * end slot (counts, totals), and opens its own `<tbody>`, so a screen reader announces
662
- * it as the reader enters the group; the period band opens the first day's. Rows must
663
- * already be ordered so equal keys are contiguous (as for `mobileGroupBy`); grouping
664
- * runs over the rows on screen, page by page.
665
- *
666
- * The bands are sticky: the period under the header (and under a sticky
667
- * {@link leadingRow}), the day under the period — measured offsets, inside the table's
668
- * own scroller (`fillHeight` or `maxBodyHeight`). The `leadingRow` stays above the first
669
- * group. On a phone pass the same level to `mobileGroupBy`:
670
- * `groupBy={[period, day]} mobileGroupBy={period}`. See {@link DataTableGroupBy}.
671
- */
672
- groupBy?: DataTableGroupBy<T> | readonly [DataTableGroupBy<T>, DataTableGroupBy<T>?];
673
- /**
674
- * 0.48 (keksdose dev #605): a "load more" footer in place of the pager. The table
675
- * renders every row it is given (as `paginated={false}` does — pass that too, to say
676
- * so), and the footer asks the owner for the next slice: as it scrolls into view (an
677
- * IntersectionObserver, guarded against double calls while one is in flight) and with
678
- * a button the keyboard reaches, named by the app ("Load August 2026"). With
679
- * `serverPagination` it replaces that pager as well. Both layouts: on a phone it is the
680
- * card list's last item. Since 0.48.1 it implies {@link serverRows} — the rows are
681
- * rendered in the order given, unfiltered — unless `serverRows={false}`. See
682
- * {@link DataTableLoadMore}.
683
- */
684
- loadMore?: DataTableLoadMore;
685
- /**
686
- * 0.48.2 (keksdose dev #632, Marcel: "The table is extremely slow. Is it virtualised…?"):
687
- * render only the desktop rows near the visible part of the scroller, plus `overscan`
688
- * rows each side ({@link DataTableVirtualize}), with spacer rows standing in for the
689
- * rest. For long lists — keksdose's register under `loadMore` holds 1000+ rows. Opt-in;
690
- * without it every row renders, as before.
691
- *
692
- * - **Scroller:** the bounded body (`fillHeight` or `maxBodyHeight`), or the page when the
693
- * body is unbounded (`maxBodyHeight="none"`): the window is what of the table both the
694
- * scroller and the browser window show.
695
- * - **Kept rendered:** the header, the `leadingRow`, the group bands (sticky and stacked
696
- * as before; a period's band while any of its rows is in view), every OPEN row (so an
697
- * inline editor in `expandedRow` keeps its state), the roving tab stop and a row
698
- * holding the focus. The `loadMore` footer follows the last spacer and loads once.
699
- * - **Heights:** each rendered row is measured and remembered by its key; a row not yet
700
- * seen counts as the measured average (`estimateRowHeight` before any). The scroll
701
- * height settles as the rows are seen; the browser's scroll anchoring holds the rows in
702
- * view while a spacer above is corrected (Safari has none: a jump of the difference
703
- * is possible there on the first pass).
704
- * - **Columns:** the header widths are measured once and locked in a `<colgroup>`, so the
705
- * columns do not move as rows come and go; a wider cell scrolling in widens its column
706
- * (once). Re-measured when the rows, the columns, a width or the scroller's width
707
- * change. A `grow` column and a resized one are not locked.
708
- * - **Keyboard and screen reader:** Arrow/Home/End move over every row — the target is
709
- * rendered, then focused (End reaches the last row); Shift+click ranges and select-all
710
- * work on the data, not the DOM. The table has `aria-rowcount`, every rendered row its
711
- * `aria-rowindex` (bands, details and the totals row counted); spacers are
712
- * `aria-hidden`.
713
- * - **Trade-offs:** the browser's find-in-page sees only the rendered rows, and so does
714
- * print; an `id` from `rowAttributes` (an anchor, an `aria-controls` target, a
715
- * `scrollIntoView` on a fresh row) exists only while its row is rendered. Memoise
716
- * `columns` (and `mobileCard`): the rows bail out of a render only then, and a scroll
717
- * renders the rows that come in, not the whole window.
718
- * - **Phone:** desktop only. The card list renders every row it holds (`loadMore`,
719
- * `paginated={false}`) or reveals them in chunks as it is scrolled (client paging).
720
- */
721
- virtualize?: boolean | DataTableVirtualize;
722
- /**
723
- * Row height. `"compact"` is lenkbank's in-card tables: five `text-xs` tables with
724
- * `px-2 py-1` cells sitting inside cards, which at the default padding came out at
725
- * twice their height and pushed the card's own content below the fold — the whole
726
- * reason they were still hand-rolled `<table>`s. It tightens the header, the cells,
727
- * the selection checkboxes, the pager and the phone cards together, so a compact
728
- * table is compact everywhere rather than a dense body under a roomy header.
729
- *
730
- * Defaults to `"comfortable"`, which is the table every caller has today.
731
- */
732
- density?: DataTableDensity;
733
- /**
734
- * What a repeated click on a sorted header does. `"tri"` (the default, and the only
735
- * behaviour before 0.8.0): first direction → the other → unsorted. `"toggle"`: first
736
- * direction ⇄ the other, never unsorted — for a report table that is always ranked
737
- * by something. Each column's first direction is its own `firstSort`. `aria-sort`
738
- * and the spoken "Sorted by …" follow whatever the click produced, so they are
739
- * right under either cycle.
740
- */
741
- sortCycle?: SortCycle;
742
- /**
743
- * The desktop table's power-user chrome, as one switch. `"full"` (default) is the
744
- * table as it has always been. `"minimal"` is a compact report table in a half-width
745
- * card: no column-settings rail, no resize handles, no multi-sort (and so no
746
- * "Shift-click to add a sort" tooltip) — the three pieces that cost width and
747
- * attention and that a ten-row summary never needs. Each can still be set on its
748
- * own below; an explicit boolean wins over the preset.
749
- */
750
- chrome?: DataTableChrome;
751
- /**
752
- * The vertical "Columns (n/m)" rail and its show/hide + auto-size panel. Default:
753
- * on, unless `chrome="minimal"`. With it off, every column is shown and a hidden
754
- * set persisted under `storageKey` is ignored — there would be no way to get a
755
- * column back.
756
- */
757
- columnSettings?: boolean;
758
- /**
759
- * The drag handles on the header's trailing edges. Default: on, unless
760
- * `chrome="minimal"`. With it off, widths persisted under `storageKey` are ignored
761
- * for the same reason as above: nothing could reset them.
762
- */
763
- resizable?: boolean;
764
- /**
765
- * Shift-click adds a secondary sort criterion. Default: on, unless
766
- * `chrome="minimal"`. With it off, a shift-click sorts like a plain click and the
767
- * header drops the tooltip that advertises the gesture.
768
- */
769
- multiSort?: boolean;
770
- /**
771
- * The card around the table — border, surface, radius, shadow. Set false to sit the
772
- * table inside the app's own card, under its own caption and CSV button, without a
773
- * frame inside a frame. The table's layout is unchanged; only the chrome goes.
774
- * Default true.
775
- */
776
- frame?: boolean;
777
- /**
778
- * Fade out an edge of the desktop table's scroller while columns are hidden behind
779
- * it (0.25) — the kit {@link Table}'s `edgeFade`, on the scroller this table draws
780
- * itself (DataTable renders its own `<table>`, not through Table, so the prop is
781
- * its own too). Measured: nothing is drawn while every column fits; the end with
782
- * columns behind it fades, both ends once scrolled into the middle, the reading
783
- * direction's ends in RTL. A `mask-image`, so no layout moves and no colour is
784
- * assumed; never animated. The scroller's own scrollbars stay out of it — its
785
- * vertical one sits at the inline end, in the very band that fades. Focus landing
786
- * in a cell behind the fade (a row action, a link, the selection box) is scrolled
787
- * clear of it. The scroller carries `data-overflow` (`"start"`, `"end"`, `"both"`).
788
- *
789
- * The phone layout is a card list that never scrolls sideways; it is unaffected.
790
- *
791
- * Off by default. It is visual only, but not neutral: on the bump it would change
792
- * how every overflowing desktop table in an app looks, and the mask covers
793
- * everything the scroller paints — the sticky header and totals rows, an app's own
794
- * marks at the scroller's edges. Opt in per table, or app-wide from your own wrapper.
795
- */
796
- edgeFade?: boolean;
797
- /** Extra classes for the table's root element (the card, or with `frame={false}`
798
- * the plain wrapper). Merged last, so a caller's margin or width wins. */
799
- className?: string;
800
- /**
801
- * Per-row actions: an actions column of icon buttons at the row's end on desktop,
802
- * and the same actions as swipe actions on a phone card (where the column is not
803
- * shown; with `mobileDetailsInRow` also as buttons in the opened row). At Large and
804
- * Extra large two or more collapse into one "⋯" menu with each action's words
805
- * (0.32, §10.8, `RowActions`) — name the rows with `rowName` so each menu button is
806
- * "Actions for …". kastlan's `ResourceListPage` appended this column by hand to every CRUD
807
- * list — the edit pencil, the delete bin, a `useConfirm` before the delete, and a
808
- * `stopPropagation` on each so a press did not also open the row.
809
- *
810
- * `kind: "edit"` / `"delete"` bring the icon, the label (`labels.edit` /
811
- * `labels.delete`), the tone and the swipe side; any other action states its own
812
- * `label` and `icon`. `confirm` is the hook-in for a confirmation: pass the kit's
813
- * `useConfirm()` (or anything answering a boolean) and the action runs only on a yes.
814
- *
815
- * const confirm = useConfirm();
816
- * rowActions={[
817
- * { kind: "edit", onAction: openEditor },
818
- * { kind: "delete", onAction: (r) => remove(r.id),
819
- * confirm: (r) => confirm({ title: t("delete", { name: r.name }), tone: "danger" }) },
820
- * ]}
821
- *
822
- * A function receives the row, for actions that differ per row. An explicit
823
- * `mobileSwipeActions` wins over the swipes derived from here.
824
- */
825
- rowActions?: DataTableRowAction<T>[] | ((row: T) => DataTableRowAction<T>[]);
826
- /**
827
- * The `rowActions` column's heading (0.46.1, keksdose dev#598 rework — Marcel: a
828
- * VISIBLE "Actions" heading on every table with an actions column). Default: the
829
- * provider's `dataTable.actions` ("Actions"), shown, end-aligned over the buttons. A
830
- * node replaces it; `false` keeps the pre-0.46.1 heading, named for a screen reader
831
- * only.
832
- */
833
- rowActionsHeader?: ReactNode | false;
834
- /**
835
- * The bar above the table — a search field, filter chips, a create button. Inside
836
- * the table's frame, above the header, on desktop and phone alike (resource-list
837
- * pages set a search box in a `div` above the card, outside the frame it filters).
838
- */
839
- toolbar?: ReactNode;
840
- /**
841
- * The totals row's name — "Total", "Summe", "Totals as of 30 June". It is the row's
842
- * header: rendered as a `<th scope="row">` in the first visible column that has no
843
- * `footer` of its own (falling back to the first visible column if every one has a
844
- * value), so a screen reader reads "Total, Debit, 12 400.00" for the cell beside it.
845
- * On a phone it heads the summary card, which is also named by it.
846
- *
847
- * There is no built-in default word: a new kit label key is a silent English string
848
- * in every translated app (see `missingKitLabels`), and the caller has the
849
- * word in its own catalogue already. Without it the row is unlabelled on screen —
850
- * pass one.
851
- */
852
- footerLabel?: ReactNode;
853
- /**
854
- * Show the totals row. Default: shown when any column declares a `footer`. Set
855
- * false to drop it without touching the column definitions (a "show totals"
856
- * switch). The row is also left out while there are no rows to total — a row of
857
- * zeroes under "No results" says nothing the empty state has not.
858
- */
859
- footer?: boolean;
860
- }
861
-
862
- /** One entry of {@link DataTableProps.rowActions}. */
863
- export interface DataTableRowAction<T> {
864
- /** A preset: `"edit"` (pencil, `labels.edit`, swipes toward the END) or `"delete"`
865
- * (bin, `labels.delete`, danger tone, swipes toward the START). */
866
- kind?: "edit" | "delete";
867
- /** The action's name — its tooltip, accessible name and swipe label. Required
868
- * without a `kind`. */
869
- label?: string;
870
- /** The glyph. Required without a `kind`. */
871
- icon?: ReactNode;
872
- /** Runs after `confirm` (when given) has said yes. */
873
- onAction: (row: T) => void;
874
- /** Asked before `onAction`; a `false` (or a promise of one) cancels. */
875
- confirm?: (row: T) => boolean | Promise<boolean>;
876
- /** Default: `"danger"` for `kind: "delete"`, else `"default"`. */
877
- tone?: "default" | "danger";
878
- /** Leave the action out for this row. */
879
- hidden?: (row: T) => boolean;
880
- /** Why the action is locked for this row — the button stays focusable and says so,
881
- * and the row gets no swipe for it. See `IconButton`'s `disabledReason`. */
882
- disabledReason?: (row: T) => ReactNode;
883
- /** Which way the phone card is dragged to commit it, or `false` for no swipe.
884
- * Default: `"start"` for a danger action, `"end"` otherwise. */
885
- swipe?: "start" | "end" | false;
886
- }
887
-
888
- /** See {@link DataTableProps.chrome}. */
889
- export type DataTableChrome = "full" | "minimal";
890
-
891
- /** See {@link DataTableProps.density}. */
892
- export type DataTableDensity = "comfortable" | "compact";
893
-
894
- export type FilterState = Record<string, FilterValue>;
895
- export type { SortState };
896
-
897
- /** What {@link DataTableProps.mobileSwipeActions} returns for one row. */
898
- export interface MobileSwipeActions {
899
- /** Committed by dragging toward the reading start (left in LTR, right in RTL). */
900
- start?: SwipeAction[];
901
- /** Committed by dragging toward the reading end (right in LTR, left in RTL). */
902
- end?: SwipeAction[];
903
- /** Committed by dragging physically LEFT, whatever the direction. */
904
- left?: SwipeAction[];
905
- /** Committed by dragging physically RIGHT, whatever the direction. */
906
- right?: SwipeAction[];
907
- }
908
-
909
- /** How {@link DataTableProps.desktopSwipeActions} draws the column (0.46, dev#598). */
910
- export interface DataTableDesktopSwipeActions<T> {
911
- /** Leave an action out because the row already has its own button for it — keksdose's
912
- * feedback inbox, whose status cell offers `advance` and `wont_do`
913
- * (`feedbackSwipeOmitShown`). Match on `action.id` rather than the translated label. */
914
- omit?: (action: SwipeAction, row: T) => boolean;
915
- /** The row's name for the "⋯" menu ("Actions for …"). Default: the table's `rowName`. */
916
- name?: (row: T) => string | null | undefined;
917
- /** {@link RowActionsCollapse}; default `"auto"`. A table whose rows offer more than two
918
- * actions passes `"menu"`, so the column does not switch between icons and a menu. */
919
- collapse?: RowActionsCollapse;
920
- /**
921
- * Whether an action COMMITS, refused with the reason under a locked
922
- * `WriteLockProvider` ({@link CommitScope}) — a swipe has no such state, so a plan
923
- * usually returns nothing for a locked row and the desktop shows nothing either; with
924
- * this the plan can stay and the desktop says why. Per action as a function. Default:
925
- * no commit scope (the plan decides).
926
- */
927
- commit?: CommitScope | ((action: SwipeAction, row: T) => CommitScope | undefined);
928
- /**
929
- * The column's heading (0.46.1, keksdose dev#598 rework — Marcel: a VISIBLE "Actions"
930
- * heading on every table with an actions column). Default: the provider's
931
- * `dataTable.actions` ("Actions"), shown, end-aligned over the buttons. A node
932
- * replaces it; `false` keeps the 0.46.0 heading, named for a screen reader only.
933
- */
934
- header?: ReactNode | false;
935
- }
936
-
937
- /**
938
- * A totals-row cell: pinned to the scroller's bottom edge, on the header's surface.
939
- * The rule above it is an inset shadow rather than a `border-t`: the table collapses
940
- * its borders, and a collapsed border belongs to the TABLE, not the cell, so it stays
941
- * behind at the bottom of the rows while the sticky cell rides over them.
942
- */
943
- const FOOTER_CELL = "sticky bottom-0 z-10 bg-surface-2 shadow-[inset_0_2px_0_var(--border-strong)]";
944
-
945
- /** Where focus that leaves a row is still the row's (DSP-4): an overlay — a confirm, a
946
- * menu, a sheet — hands it back or closes with the row it was about. The kit marks its
947
- * own (`data-kit-layer`); an app's dialog says what it is. */
948
- const OVERLAY_SELECTOR = '[data-kit-layer],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[role="menu"]';
949
-
950
- /** `rows`' places by `rowKey`, the first row of a key winning as a `findIndex`'s did; the
951
- * map built at the first lookup (0.52 code review, data-table.tsx). */
952
- function keyIndex<T>(rows: readonly T[], rowKey: (row: T) => string | number): (key: string | number) => number {
953
- let at: Map<string | number, number> | null = null;
954
- return (key) => {
955
- if (!at) {
956
- at = new Map();
957
- for (let i = rows.length - 1; i >= 0; i--) at.set(rowKey(rows[i]), i);
958
- }
959
- return at.get(key) ?? -1;
960
- };
961
- }
962
-
963
- /** How long a filter's "n of m rows" waits for the typing to pause (DSP-23 d): longer than
964
- * the gap between two keystrokes, short enough to answer the word just typed. */
965
- const FILTER_ANNOUNCE_DELAY_MS = 500;
966
-
967
- // ---------- Phone sort control (keksdose K10) ----------
968
-
969
- /**
970
- * The phone sort control's strings (keksdose K10). They are `dataTable` strings —
971
- * read from the table's resolved labels, so the `labels` prop and the provider's
972
- * `dataTable` namespace reach them — and are declared here, with the table's other
973
- * public types (the control itself, `MobileSort`, is in data-table-mobile.tsx), with
974
- * English defaults until `DataTableLabels` declares them itself.
975
- */
976
- export interface DataTableSortLabels {
977
- /** The control's visible label: "Sort by". */
978
- sortBy: string;
979
- /** The "no sort" option — the rows in the order they were given: "Default order". */
980
- sortDefault: string;
981
- /** The direction button's name while the sort ascends: "Ascending". */
982
- sortAscending: string;
983
- /** …and while it descends: "Descending". */
984
- sortDescending: string;
985
- }
986
-
987
- /** The phone sort control's words, as they sit in {@link DEFAULT_DATA_TABLE_LABELS}
988
- * (DataTableLabels extends DataTableSortLabels; one source for the English). */
989
- export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
990
- sortBy: DEFAULT_DATA_TABLE_LABELS.sortBy,
991
- sortDefault: DEFAULT_DATA_TABLE_LABELS.sortDefault,
992
- sortAscending: DEFAULT_DATA_TABLE_LABELS.sortAscending,
993
- sortDescending: DEFAULT_DATA_TABLE_LABELS.sortDescending,
994
- };
995
-
996
- const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
997
-
998
- // `virtualize`'s constants (0.48.2): the band levels a group's `<tbody>` opens with.
999
- const NO_BANDS: readonly (1 | 2)[] = [];
1000
- const BAND_1: readonly (1 | 2)[] = [1];
1001
- const BAND_2: readonly (1 | 2)[] = [2];
1002
- const BAND_1_2: readonly (1 | 2)[] = [1, 2];
1003
- const NO_VIRTUAL_OPTIONS: DataTableVirtualize = {};
1004
-
1005
- // `moveRowFocus` (the roving rows' Arrow/Home/End) lives with the row in data-table-row.tsx.
1006
-
1007
- // ---------- Main DataTable ----------
1008
-
1009
- import { DEFAULT_PERSIST_PREFIX, useTableState, type TableUrlSync } from "./use-table-state";
1010
- import { useMobileReveal } from "./use-mobile-reveal";
1011
- import { GROW_CELL_CLASS, GROW_PEER_CELL_CLASS, useColumnFit } from "./data-table-fit";
1012
-
1013
- /**
1014
- * rowMatches for one column's filter state, the text filter's query folded once (0.52
1015
- * code review, data-table-filters.ts): folded again for every row it tested, a 5000-row
1016
- * table folded the same query 5000 times per keystroke. Module-private: data-table-filters
1017
- * is re-exported whole by @eifi1/ui-kit/data-table, and this is no public API.
1018
- */
1019
- function rowMatcher<T>(col: DataTableColumn<T>, state: FilterValue, locale?: string): (row: T) => boolean {
1020
- const filter = resolveFilter(col);
1021
- if (filter?.type === "text" && state.type === "text" && state.q.trim()) {
1022
- const words = searchWords(state.q, locale);
1023
- return (row) => matchesSearch(words, [filter.getValue(row)], locale);
1024
- }
1025
- return (row) => rowMatches(col, row, state, locale);
1026
- }
1027
-
1028
- export function DataTable<T>({
1029
- rows,
1030
- columns: columnsProp,
1031
- rowKey,
1032
- defaultPageSize = 25,
1033
- onRowClick,
1034
- rowHref,
1035
- expandedRow,
1036
- isExpanded,
1037
- rowClassName,
1038
- rowAttributes,
1039
- paginated = true,
1040
- empty,
1041
- storageKey,
1042
- urlSync = false,
1043
- fillHeight = false,
1044
- maxBodyHeight = "calc(100dvh - 12rem)",
1045
- serverPagination,
1046
- serverRows: serverRowsProp,
1047
- filters: filtersProp,
1048
- onFiltersChange,
1049
- sorts: sortsProp,
1050
- onSortsChange,
1051
- pageSize: pageSizeProp,
1052
- onPageSizeChange,
1053
- selection,
1054
- labels: labelsProp,
1055
- locale: localeProp,
1056
- storageKeyPrefix: storageKeyPrefixProp,
1057
- mobileExpandAsDialog = false,
1058
- mobileDetailsInRow = false,
1059
- rowName,
1060
- mobileDialogBackCloses = true,
1061
- mobileGroupBy,
1062
- mobileGroupLabel,
1063
- mobileCard,
1064
- mobileSort = false,
1065
- mobileSwipeActions,
1066
- desktopSwipeActions,
1067
- leadingRow,
1068
- leadingRowSticky = false,
1069
- groupBy,
1070
- loadMore,
1071
- virtualize = false,
1072
- density = "comfortable",
1073
- sortCycle = "tri",
1074
- chrome = "full",
1075
- columnSettings: columnSettingsProp,
1076
- resizable: resizableProp,
1077
- multiSort: multiSortProp,
1078
- frame = true,
1079
- edgeFade = false,
1080
- className,
1081
- rowActions,
1082
- rowActionsHeader,
1083
- toolbar,
1084
- footerLabel,
1085
- footer: footerProp,
1086
- }: DataTableProps<T>) {
1087
- const compact = density === "compact";
1088
- const minimal = chrome === "minimal";
1089
- const columnSettings = columnSettingsProp ?? !minimal;
1090
- const resizable = resizableProp ?? !minimal;
1091
- const multiSort = multiSortProp ?? !minimal;
1092
- const isServer = !!serverPagination;
1093
- // The server filtered and sorted these rows (0.48.1, K-052-1): no client filter, sort
1094
- // or paging, and the sort/filter UI only reports to the owner. `isServer` keeps
1095
- // meaning "the server pages" (its pager, its page and page size).
1096
- const serverRows = isServer || (serverRowsProp ?? !!loadMore);
1097
- const isLoading = !!serverPagination?.isLoading;
1098
- // prop > provider > English, through the table's own resolver rather than
1099
- // `useKitLabels`: that resolver derives `columnsCount` from a translated `columns`,
1100
- // and a plain merge would skip it. `presets` is a record and is merged key by key,
1101
- // as the provider merges it, so a call site renaming one preset keeps the provider's
1102
- // other ten. Memoised because the announcement effects below list `labels` as a
1103
- // dependency, and a fresh object every render would re-run them for nothing.
1104
- const labelOverrides = useKitLabelOverrides("dataTable");
1105
- // prop > provider > the kit's default, the order every kit setting resolves in (round
1106
- // 0.34 C5, KS U4): one prefix on the provider reaches the tables an app cannot pass a
1107
- // prop to, `TranslationReviewPanel`'s among them.
1108
- const providerStorageKeyPrefix = useKitTableStorageKeyPrefix();
1109
- const storageKeyPrefix = storageKeyPrefixProp ?? providerStorageKeyPrefix ?? DEFAULT_PERSIST_PREFIX;
1110
- const labels = useMemo(() => resolveDataTableLabels(labelOverrides, labelsProp), [labelOverrides, labelsProp]);
1111
- const locale = useKitLocale(localeProp);
1112
-
1113
- // ---- Row actions ---- (data-table-row-actions.tsx)
1114
- // The actions column joins the caller's columns; on a phone the same actions are the
1115
- // card's swipes, unless the caller's own `mobileSwipeActions` says otherwise.
1116
- const { columns, swipesFromActions } = useRowActionColumn({
1117
- columns: columnsProp,
1118
- rowActions,
1119
- rowActionsHeader,
1120
- rowName,
1121
- labels,
1122
- density,
1123
- mobileSwipeActions,
1124
- desktopSwipeActions,
1125
- });
1126
- const swipeActionsFor = mobileSwipeActions ?? (rowActions ? swipesFromActions : undefined);
1127
-
1128
- // Sorting, filtering and paging all change WHICH rows are on screen without
1129
- // moving focus — the header button the user pressed is still the header button
1130
- // they are on — so there is no other channel to say it on. See use-announce.ts.
1131
- const { announce, regionProps } = useAnnounce();
1132
- const {
1133
- sorts,
1134
- filters,
1135
- setInternalSorts,
1136
- setInternalFilters,
1137
- page,
1138
- setPage,
1139
- pageSize,
1140
- setPageSize,
1141
- widths: storedWidths,
1142
- setWidths,
1143
- hiddenCols: storedHiddenCols,
1144
- setHiddenCols,
1145
- showSettings,
1146
- setShowSettings,
1147
- headRefs,
1148
- } = useTableState({
1149
- columns,
1150
- storageKey,
1151
- storageKeyPrefix,
1152
- urlSync,
1153
- defaultPageSize,
1154
- sortsProp,
1155
- filtersProp,
1156
- // Ignored in `serverPagination` mode, where the owner's is the page size.
1157
- pageSizeProp: isServer ? undefined : pageSizeProp,
1158
- });
1159
-
1160
- // With the controls that change them switched off, persisted widths and hidden
1161
- // columns are ignored rather than applied: a table cannot be left in a state the
1162
- // user has no control to get out of. They stay in storage untouched, so turning
1163
- // the control back on brings them back.
1164
- // A `grow` column takes the rest of the row by design (0.48): a width stored for it
1165
- // (a drag or "Auto-size columns" before it grew, KS N2) is ignored, and it has no
1166
- // handle, so it can never pin it again. Ignored rather than deleted from storage, as
1167
- // the widths above; the next resize or auto-size writes a blob without it.
1168
- // Keyed on the `grow` column's key, not the columns array: an inline `columns={…}`
1169
- // gave every render new widths, and the fit measured the table for it (DSP-19).
1170
- const growKey = columns.find((c) => c.grow)?.key;
1171
- const widths = useMemo(() => {
1172
- if (!resizable) return {};
1173
- if (!growKey || !(growKey in storedWidths)) return storedWidths;
1174
- const { [growKey]: _ignored, ...rest } = storedWidths;
1175
- return rest;
1176
- }, [resizable, storedWidths, growKey]);
1177
- const hiddenCols = useMemo(
1178
- () => (columnSettings ? storedHiddenCols : new Set<string>()),
1179
- [columnSettings, storedHiddenCols],
1180
- );
1181
-
1182
- // Every call site builds `columns` inline — `columns={[{ key: "name", … }]}` right
1183
- // there in the JSX — so the array is a new object on every render of the page around
1184
- // the table, and a `useMemo` keyed on its identity memoises nothing. The three below
1185
- // walk all the rows; a sibling field being typed into re-filtered and re-sorted the
1186
- // whole dataset on every keystroke.
1187
- //
1188
- // What they actually need from `columns` is WHICH columns there are, so that is what
1189
- // they are keyed on. The trade-off is deliberate and narrow: swapping a column's
1190
- // `sortBy`/`filter`/`filterBy` FUNCTION while its `key` stays the same no longer
1191
- // invalidates on its own. A rule that changes is a different column — give it a
1192
- // different key — and anything the rule reads (rows, filters, sorts) is a dependency
1193
- // in its own right.
1194
- const columnSig = columns.map((c) => c.key).join("\u0000");
1195
- // The select filters' declared option lists are the one part of a column definition
1196
- // the memo below reads as DATA rather than as a rule, and a caller may recompute
1197
- // them (a lookup that arrived, a saved view applied) without the column set moving.
1198
- const selectOptionsSig = columns
1199
- .map((c) => {
1200
- const f = resolveFilter(c);
1201
- if (!f || f.type !== "select" || !f.options) return "";
1202
- return `${c.key}:${f.options.map((o) => `${o.value}=${o.label ?? ""}`).join(",")}`;
1203
- })
1204
- .join("\u0000");
1205
-
1206
- const selectOptionsByKey = useMemo(() => {
1207
- const map: Record<string, { value: string; label: string }[]> = {};
1208
- for (const col of columns) {
1209
- const f = resolveFilter(col);
1210
- if (!f || f.type !== "select") continue;
1211
- if (f.options && f.options.length > 0) {
1212
- map[col.key] = f.options.map((o) => ({ value: o.value, label: o.label ?? o.value }));
1213
- continue;
1214
- }
1215
- const seen = new Set<string>();
1216
- for (const row of rows) {
1217
- const v = f.getValue(row);
1218
- if (v && !seen.has(v)) seen.add(v);
1219
- }
1220
- map[col.key] = Array.from(seen)
1221
- .sort((a, b) => compareSortValues(a, b, locale))
1222
- .map((v) => ({ value: v, label: v }));
1223
- }
1224
- return map;
1225
- // `columns` by signature — see the note above the signatures.
1226
- // eslint-disable-next-line react-hooks/exhaustive-deps
1227
- }, [columnSig, selectOptionsSig, rows, locale]);
1228
-
1229
- const filtered = useMemo(() => {
1230
- if (serverRows) return rows;
1231
- let result = rows;
1232
- for (const col of columns) {
1233
- const state = filters[col.key];
1234
- if (!state || !isFilterActive(state)) continue;
1235
- // One matcher per filter, its query folded once (0.52 code review, data-table-filters.ts).
1236
- result = result.filter(rowMatcher(col, state, locale));
1237
- }
1238
- return result;
1239
- // eslint-disable-next-line react-hooks/exhaustive-deps
1240
- }, [rows, columnSig, filters, serverRows, locale]);
1241
-
1242
- const sorted = useMemo(() => {
1243
- if (serverRows) return filtered;
1244
- // Priority chain: the first sort key that distinguishes two rows wins;
1245
- // nulls sort last for that key regardless of direction (as before).
1246
- const chain = sorts
1247
- .map((s) => {
1248
- const col = columns.find((c) => c.key === s.key);
1249
- return col?.sortBy ? { sortBy: col.sortBy, dir: s.dir } : null;
1250
- })
1251
- .filter((c): c is { sortBy: (row: T) => string | number | null | undefined; dir: "asc" | "desc" } => c !== null);
1252
- if (!chain.length) return filtered;
1253
- const copy = [...filtered];
1254
- copy.sort((a, b) => {
1255
- for (const { sortBy, dir } of chain) {
1256
- const av = sortBy(a);
1257
- const bv = sortBy(b);
1258
- if (av == null && bv == null) continue;
1259
- if (av == null) return 1;
1260
- if (bv == null) return -1;
1261
- // The order `compareSortValues` exports (0.52): one rule, here and in an app's
1262
- // own list beside the table. Nulls are last above, in either direction.
1263
- const order = compareSortValues(av, bv, locale);
1264
- if (order !== 0) return dir === "asc" ? order : -order;
1265
- }
1266
- return 0;
1267
- });
1268
- return copy;
1269
- // eslint-disable-next-line react-hooks/exhaustive-deps
1270
- }, [filtered, sorts, columnSig, serverRows, locale]);
1271
-
1272
- // `paginated={false}` is expressed as "page size = everything" rather than as a
1273
- // second code path, so slicing, the page clamp and the range summary all keep
1274
- // exactly one definition.
1275
- const unpaged = !isServer && (!paginated || !!loadMore || serverRows);
1276
- const effectivePageSize = isServer
1277
- ? serverPagination!.pageSize
1278
- : unpaged || pageSize === Infinity
1279
- ? sorted.length || 1
1280
- : pageSize;
1281
- const paginationTotal = isServer ? serverPagination!.total : sorted.length;
1282
- const paginationPageSize = isServer ? serverPagination!.pageSize : pageSize;
1283
- const totalPages = isServer
1284
- ? Math.max(1, Math.ceil(serverPagination!.total / Math.max(1, serverPagination!.pageSize)))
1285
- : Math.max(1, Math.ceil(sorted.length / effectivePageSize));
1286
- const safePage = isServer
1287
- ? Math.min(Math.max(0, serverPagination!.page), totalPages - 1)
1288
- : Math.min(page, totalPages - 1);
1289
- // Memoised (refactor audit 2026-10-30, DSP-19): the fit, the rows' window and the
1290
- // range selection compare the page's rows by identity, and a fresh `slice(…)` on every
1291
- // render of a paginated table — the default — had the fit lay the whole table out on a
1292
- // ticked box, a keystroke, any parent render.
1293
- const wholeList = isServer || unpaged || pageSize === Infinity;
1294
- const slice = useMemo(
1295
- () =>
1296
- wholeList ? sorted : sorted.slice(safePage * effectivePageSize, safePage * effectivePageSize + effectivePageSize),
1297
- [wholeList, sorted, safePage, effectivePageSize],
1298
- );
1299
-
1300
- const toggleSort = (key: string, shiftKey: boolean) => {
1301
- const col = columns.find((c) => c.key === key);
1302
- // The cycle is the table's, the first direction the column's. The announcement
1303
- // below reads the RESULT, so it can never describe a step the cycle did not take.
1304
- const next = nextSorts(sorts, key, multiSort && shiftKey, {
1305
- firstDir: col?.firstSort,
1306
- cycle: sortCycle,
1307
- });
1308
- commitSorts(next, key);
1309
- };
1310
-
1311
- // Header clicks and the phone sort control (`mobileSort`) land here alike, so both
1312
- // go through the same owner and say the same thing.
1313
- const commitSorts = (next: SortState[], key: string) => {
1314
- const col = columns.find((c) => c.key === key);
1315
- if (onSortsChange) onSortsChange(next);
1316
- else setInternalSorts(next);
1317
-
1318
- // Announce the column the user just pressed, not `next[0]`: a shift-click adds a
1319
- // criterion BEHIND the existing one, and answering "Sorted by Date" to a click on
1320
- // Name misreports what the click did. The third click drops the column entirely,
1321
- // which is the state a user is most likely to have reached by accident.
1322
- const name = col ? columnLabel(col, labels) : key;
1323
- const entry = next.find((s) => s.key === key);
1324
- announce(
1325
- !entry
1326
- ? labels.sortCleared(name)
1327
- : entry.dir === "asc"
1328
- ? labels.sortedAscending(name)
1329
- : labels.sortedDescending(name),
1330
- );
1331
- };
1332
-
1333
- const setFilterValue = (key: string, value: FilterValue) => {
1334
- if (onFiltersChange) {
1335
- onFiltersChange({ ...filters, [key]: value });
1336
- // No `setPage(0)` here: the page resets when the new value comes back in
1337
- // through the prop — see the controlled reset in useTableState. Resetting now
1338
- // would move to page 1 for a change the owner may yet refuse.
1339
- return;
1340
- }
1341
- setInternalFilters((s) => ({ ...s, [key]: value }));
1342
- setPage(0);
1343
- };
1344
-
1345
- const clearFilter = (key: string) => {
1346
- if (onFiltersChange) {
1347
- const rest = { ...filters };
1348
- delete rest[key];
1349
- onFiltersChange(rest);
1350
- return;
1351
- }
1352
- setInternalFilters((s) => {
1353
- const rest = { ...s };
1354
- delete rest[key];
1355
- return rest;
1356
- });
1357
- setPage(0);
1358
- };
1359
-
1360
- // Clear every column filter at once (drives the mobile filter sheet's
1361
- // "clear all"). Only touches keys the table actually exposes as filters.
1362
- const clearAllFilters = () => {
1363
- const keys = columns
1364
- .filter((col) => (!serverRows || onFiltersChange ? resolveFilter(col) : null))
1365
- .map((col) => col.key);
1366
- if (!keys.length) return;
1367
- if (onFiltersChange) {
1368
- const rest = { ...filters };
1369
- for (const k of keys) delete rest[k];
1370
- onFiltersChange(rest);
1371
- return;
1372
- }
1373
- setInternalFilters((s) => {
1374
- const rest = { ...s };
1375
- for (const k of keys) delete rest[k];
1376
- return rest;
1377
- });
1378
- setPage(0);
1379
- };
1380
-
1381
- // The result count only exists AFTER the table has re-filtered, so the sentence is
1382
- // spoken from the render that produced the new number rather than from the handler
1383
- // that changed the filter. Keyed on a serialised copy of the filter state, for two
1384
- // reasons: a controlled `filters` prop arrives with a fresh identity on every
1385
- // parent render, and a change the OWNER makes (a saved view applied, a filter chip
1386
- // dismissed outside the table) moves the same rows and deserves the same sentence.
1387
- const filterKey = useMemo(() => JSON.stringify(filters), [filters]);
1388
- // Seeded with the mount value: a live region that speaks on first paint talks over
1389
- // the page the user has just opened, and every table on that page does it at once.
1390
- const announcedFilterKey = useRef(filterKey);
1391
- // Spoken once the typing pauses (refactor audit 2026-10-30, DSP-23 d): every keystroke
1392
- // in a text filter is a new filter, and "12 of 137 rows" on each one — through the live
1393
- // region's 50 ms reset — talked over the very letters being typed. The count is read
1394
- // when the sentence goes out, so it is the rows the pause left on screen.
1395
- const filterSpeech = useRef({ shown: filtered.length, total: rows.length, labels });
1396
- useLayoutEffect(() => {
1397
- filterSpeech.current = { shown: filtered.length, total: rows.length, labels };
1398
- });
1399
- const filterTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
1400
- useEffect(() => () => clearTimeout(filterTimer.current), []);
1401
- useEffect(() => {
1402
- if (announcedFilterKey.current === filterKey) return;
1403
- announcedFilterKey.current = filterKey;
1404
- // A server-paginated table does not do the filtering: the matching rows arrive on
1405
- // some later render from the caller, and `serverPagination.total` is ALREADY the
1406
- // filtered total — so the "n of m" sentence has no second number to give here.
1407
- // Saying nothing is better than announcing the page size as a result count.
1408
- if (serverRows) return;
1409
- clearTimeout(filterTimer.current);
1410
- filterTimer.current = setTimeout(() => {
1411
- const { shown, total, labels: words } = filterSpeech.current;
1412
- announce(words.filterResults(shown, total));
1413
- }, FILTER_ANNOUNCE_DELAY_MS);
1414
- }, [filterKey, serverRows, announce]);
1415
-
1416
- // 1-based, because that is what the page strip shows and what a person says.
1417
- const goToPage = (p: number) => {
1418
- if (isServer) serverPagination!.onPageChange(p);
1419
- else setPage(p);
1420
- announce(labels.pageChanged(p + 1, totalPages));
1421
- };
1422
-
1423
- // 0.48 (keksdose live #451): the `fitPriority` columns the table's width leaves out
1424
- // (data-table-fit.ts). Desktop only; the scroller is read after render.
1425
- // Large and Extra large: read here, as the fit re-measures when it changes.
1426
- const large = useLargeText();
1427
- const { autoHidden, keep: keepColumn } = useColumnFit({
1428
- columns,
1429
- hiddenCols,
1430
- headRefs,
1431
- scroller: () => desktopScroller.current,
1432
- enabled: !usePhoneLayout(),
1433
- // Measured only when one of these changed (0.48.2): not on a ticked box. The columns
1434
- // by their keys (DSP-19): an inline `columns={…}` is a new array every render. The
1435
- // locale (0.52 code review, data-table.tsx): the cells' dates, numbers and the kit's
1436
- // words follow it under the same rows and keys; the fit adds the headers' drawn text.
1437
- content: [slice, columnSig, hiddenCols, widths, density, large, locale],
1438
- });
1439
- const visibleColumns = useMemo(
1440
- () => columns.filter((c) => !hiddenCols.has(c.key) && !autoHidden.has(c.key)),
1441
- [columns, hiddenCols, autoHidden],
1442
- );
1443
- // With a `grow` column drawn, every other cell keeps one line at Normal (kastlan S2,
1444
- // 0.48): the `w-full` column squeezed them to their min-content width.
1445
- const growDrawn = visibleColumns.some((c) => c.grow);
1446
-
1447
- // Which cell carries the row's link when `rowHref` is set (feedback #451). The
1448
- // `mobilePrimary` column is the row's headline by declaration, so it is both the
1449
- // widest target and the one the pointer is already over; falls back to the first
1450
- // column, and falls back again if the user has hidden the primary one from the
1451
- // column panel. Resolved from `visibleColumns` for exactly that reason — off a
1452
- // raw `columns` lookup a hidden headline would leave the row with no link at all.
1453
- //
1454
- // `noRowLink` (and `role: "actions"`) columns are skipped in BOTH steps: wrapping a
1455
- // cell that owns an anchor nests two links and disables the inner one (see the prop's
1456
- // note). If nothing visible is left to wrap, the row gets NO anchor — an anchor
1457
- // over the one cell there is would cancel that cell's own click and hand back
1458
- // nothing, and a row without a link still behaves exactly as it did before #451.
1459
- const linkColumn = rowHref
1460
- ? (visibleColumns.find((c) => c.mobilePrimary && !keepsOwnClicks(c)) ??
1461
- visibleColumns.find((c) => !keepsOwnClicks(c)))
1462
- : undefined;
1463
-
1464
- // ---- Column resize ---- (data-table-resize.ts: pointer capture, one `setWidths` per
1465
- // drag, and the page's cursor and selection handed back on an unmount mid-drag)
1466
- const { startResize, moveResize, finishResize, autoSizeAll } = useColumnResize({
1467
- headRefs,
1468
- widths,
1469
- setWidths,
1470
- columns,
1471
- // The columns left out for width have no header to measure either.
1472
- hiddenCols: autoHidden.size > 0 ? new Set([...hiddenCols, ...autoHidden]) : hiddenCols,
1473
- });
1474
-
1475
- const visibleCount = visibleColumns.length;
1476
- const totalCount = columns.length;
1477
- // Spans the full row width including the optional leading selection column.
1478
- const totalColSpan = visibleCount + (selection ? 1 : 0);
1479
-
1480
- // ---- Totals row ----
1481
- // `sorted`, not `slice`: the totals are of everything the filters let through, so
1482
- // paging does not move them — see `DataTableColumn.footer`. In server mode `sorted`
1483
- // IS the page (the table holds nothing else), which the prop's note spells out.
1484
- const hasFooterColumns = columns.some((c) => c.footer);
1485
- const showFooter = footerProp !== false && hasFooterColumns && sorted.length > 0;
1486
- // The row header sits in the first visible column that has nothing of its own to
1487
- // show — so hiding the "Account" column moves "Total" into "Name" rather than
1488
- // dropping it — and on the first visible column when every one carries a value.
1489
- const footerLabelCol = showFooter
1490
- ? (visibleColumns.find((c) => !c.footer && !isTableOwnColumn(c.key)) ?? visibleColumns[0])
1491
- : undefined;
1492
- // `footer` keeps its `T[]` parameters: narrowed to `readonly`, a caller's footer typed
1493
- // `(rows: Row[]) => …` would no longer fit (API-7 only widens). The table hands over the
1494
- // arrays it holds, as before, and changes neither.
1495
- const footerValue = (col: DataTableColumn<T>) => col.footer?.(sorted as T[], slice as T[]);
1496
- const footerHeadingId = useId();
1497
- // Each open row's detail row, for its disclosure button's `aria-controls`.
1498
- const detailIdBase = useId();
1499
- // The actions column is the table's own, not one the user can hide: out of the count.
1500
- const ownColumns = columns.filter((c) => isTableOwnColumn(c.key)).length;
1501
- const ownVisible = visibleColumns.filter((c) => isTableOwnColumn(c.key)).length;
1502
- const columnsCountLabel = labels.columnsCount(visibleCount - ownVisible, totalCount - ownColumns);
1503
- // Match the `md:` breakpoint at the text size in force: we render either the table or
1504
- // the card list — never both — so we don't double up DOM nodes that screen readers and
1505
- // integration tests would have to disambiguate. `usePhoneLayout()` itself, not its twin
1506
- // `useBreakpoint("md")`: the same query an app's own phone checks ask (keksdose's
1507
- // sticky editor footer), so the card list and the full-screen row dialog switch with
1508
- // them and never a frame apart (§10.4 "usePhoneLayout() is the single answer"; 0.32.1,
1509
- // keksdose's 0.32 report, where `md` and `max-md` both missed at 767.5 px). Without
1510
- // `matchMedia` (SSR, jsdom) it is the table, as before.
1511
- const isMdUp = !usePhoneLayout();
1512
- // `edgeFade` (0.25) on the desktop scroller; the phone list never scrolls sideways.
1513
- const desktopScroller = useRef<HTMLDivElement | null>(null);
1514
- // 0.47 (keksdose S4): the scroller's visible width, as `--dt-visible-w` on it, for the
1515
- // expansion row's pinned content (below). Set on the element, not in state: a resize
1516
- // re-renders nothing. Only a table that can expand a row measures.
1517
- // The group bands and the load-more footer pin their content the same way (0.48).
1518
- const levels = groupLevels(groupBy);
1519
- const measuresVisibleWidth = isMdUp && (!!expandedRow || levels.length > 0 || !!loadMore);
1520
- useEffect(() => {
1521
- const el = desktopScroller.current;
1522
- if (!measuresVisibleWidth || !el || typeof ResizeObserver === "undefined") return;
1523
- const ro = new ResizeObserver(() => el.style.setProperty("--dt-visible-w", `${el.clientWidth}px`));
1524
- ro.observe(el);
1525
- return () => ro.disconnect();
1526
- }, [measuresVisibleWidth]);
1527
- const edgeFadeProps = useEdgeFade(desktopScroller, edgeFade && isMdUp);
1528
-
1529
- // ---- Mobile card list (md:hidden) ----
1530
- // Renders the same paged/filtered/sorted slice but as stacked cards instead
1531
- // of a horizontally-scrolling table. Column settings stay desktop-only; the
1532
- // filters come through a bottom sheet (feedback #299) and the sort through the
1533
- // opt-in `mobileSort` row (keksdose K10), both on the table's own state.
1534
- // Memoised on `visibleColumns`: the cards compare their columns by identity (0.48.2).
1535
- const mobileColumns = useMemo(
1536
- () => visibleColumns.filter((c) => !c.mobileHidden && c.mobile !== "hidden"),
1537
- [visibleColumns],
1538
- );
1539
- // The default card (0.49 KIT-7): the title, the status chips beside it, at most three
1540
- // fields — the ones marked `mobile: "secondary"`, else the first after the title — and
1541
- // the `role: "actions"` columns after them.
1542
- const {
1543
- primary: mobilePrimaryCol,
1544
- status: mobileStatusColumns,
1545
- fields: mobileSecondaryColumns,
1546
- actions: mobileActionColumns,
1547
- } = useMemo(() => mobileCardColumns(mobileColumns), [mobileColumns]);
1548
- // A development build names a `noRowLink` column (a per-row delete) the card drops.
1549
- useCardColumnWarnings(
1550
- columns,
1551
- !mobileCard && !mobileDetailsInRow,
1552
- storageKey ?? (typeof urlSync === "object" ? urlSync.prefix : undefined),
1553
- );
1554
- // The columns the card leaves out, for the opened row (`mobileDetailsInRow`): the
1555
- // hidden ones and, without a `mobileCard`, the ones past the card's three fields.
1556
- const onCard = new Set<DataTableColumn<T>>([
1557
- ...(mobilePrimaryCol ? [mobilePrimaryCol] : []),
1558
- ...mobileStatusColumns,
1559
- ...mobileSecondaryColumns,
1560
- ...mobileActionColumns,
1561
- ]);
1562
- const mobileDetailColumns = mobileDetailsInRow
1563
- ? visibleColumns.filter((c) => (mobileCard ? c.mobileHidden || c.mobile === "hidden" : !onCard.has(c)))
1564
- : [];
1565
- // Large and Extra large (§4 "DataTable phone card"): the card's labels go ABOVE their
1566
- // values, nothing in it truncates, and the rows get more room. Most of it is a
1567
- // `large:` class; the label/value grid needs a different STRUCTURE (each pair in a
1568
- // box of its own), which a class cannot write. (`large` is read above, by the fit.)
1569
- // What an opened phone row shows: the card's left-out columns first (when asked
1570
- // for), then the app's own `expandedRow`. Desktop keeps `expandedRow` alone.
1571
- const opensRow = !!expandedRow || mobileDetailColumns.length > 0;
1572
- const openedRowContent = (row: T): ReactNode => {
1573
- const detail = expandedRow?.(row) ?? null;
1574
- if (mobileDetailColumns.length === 0) return detail;
1575
- return (
1576
- <div data-row-details="" className={cn("flex flex-col", compact ? "gap-2" : "gap-3")}>
1577
- <StackedFields columns={mobileDetailColumns} row={row} compact={compact} />
1578
- {detail}
1579
- </div>
1580
- );
1581
- };
1582
- // The phone card IS the anchor, so unlike the desktop table there is no other
1583
- // cell to move the row link into: if any cell the card renders owns a link, the
1584
- // card itself cannot be one (feedback #451 — same nesting rule as `linkColumn`
1585
- // above). It then falls back to the pre-#451 role="button" card, which still
1586
- // opens the row and leaves the cell's own link working. A caller's `mobileCard`
1587
- // renderer is opaque to us; a link inside one is the caller's to declare by
1588
- // marking the column it comes from.
1589
- const mobileCardLinkable = (mobileCard ? mobileColumns : [...onCard]).every((c) => !keepsOwnClicks(c));
1590
- // ...except on the default card (0.50 K4, kastlan): its title becomes the row's link
1591
- // (or, with only `onRowClick`, a button), stretched over the card, and the own-click
1592
- // cells — a tel: link, the actions row — sit above it, so "tap to open" stays and no
1593
- // control nests in another. A `mobileCard` stays the fallback above (its controls
1594
- // cannot be lifted), and so does a card whose title keeps its own clicks.
1595
- const mobileCardStretched =
1596
- !mobileCard && !mobileCardLinkable && !!mobilePrimaryCol && !keepsOwnClicks(mobilePrimaryCol);
1597
- // The card list's reading direction, for the one thing on it CSS cannot flip: which
1598
- // physical drag a logical `start`/`end` swipe action means. Read off the DOM through
1599
- // a ref callback (it runs at commit, like a layout effect) because `dir` is an
1600
- // attribute of some ancestor the table does not own; LTR until the first commit,
1601
- // which is also before any finger can have touched a row.
1602
- const [mobileDir, setMobileDir] = useState<Direction>("ltr");
1603
- const mobileRootRef = (el: HTMLElement | null) => {
1604
- if (!el) return;
1605
- const d = dirOf(el);
1606
- if (d !== mobileDir) setMobileDir(d);
1607
- };
1608
-
1609
- // `fillHeight` on a phone (0.40, keksdose live #420): the card list is a scroller only
1610
- // while its parent bounds it — its rows overflow the flex room it was given. Under
1611
- // AppShell's phone layout the DOCUMENT scrolls, the list's room is its own content,
1612
- // and a scroller there (with `overscroll-contain`) only swallowed the page's scroll.
1613
- // Measured before paint on every render (rows revealed, filtered, opened), and on a
1614
- // resize of the list or its parent; one read of two heights.
1615
- const phoneFill = fillHeight && !isMdUp;
1616
- const phoneListRef = useRef<HTMLUListElement | null>(null);
1617
- const [phoneListScrolls, setPhoneListScrolls] = useState(false);
1618
- // eslint-disable-next-line react-hooks/exhaustive-deps -- every render on purpose: what overflows is the rows, and React bails out on an unchanged boolean
1619
- useLayoutEffect(() => {
1620
- const el = phoneListRef.current;
1621
- if (!phoneFill || !el) return;
1622
- setPhoneListScrolls(el.scrollHeight > el.clientHeight + 1);
1623
- });
1624
- useEffect(() => {
1625
- const el = phoneListRef.current;
1626
- if (!phoneFill || !el || typeof ResizeObserver === "undefined") return;
1627
- const ro = new ResizeObserver(() => setPhoneListScrolls(el.scrollHeight > el.clientHeight + 1));
1628
- ro.observe(el);
1629
- if (el.parentElement) ro.observe(el.parentElement);
1630
- return () => ro.disconnect();
1631
- }, [phoneFill]);
1632
- // A sticky `leadingRow`'s height on a phone (audit 2026-10-30 DSP-2): the section
1633
- // headers stick under it, as the desktop's group bands stick under the desktop row.
1634
- const [phoneLeadHeight, setPhoneLeadHeight] = useState(0);
1635
-
1636
- const { mobileSlice, canRevealMoreMobile, loadMoreRef } = useMobileReveal({
1637
- // Read only there; its `T[]` widens to `readonly T[]` with its owner (API-7).
1638
- rows: sorted as T[],
1639
- slice: slice as T[],
1640
- isServer,
1641
- isMdUp,
1642
- unpaged,
1643
- defaultPageSize,
1644
- });
1645
- // ---- Phone selection mode (0.40, keksdose live #421) ----
1646
- // The cards had no boxes, so `selection`'s bulk actions were unreachable on a phone.
1647
- // A long press on a card (kk's register gesture) — or "Select rows", the keyboard's
1648
- // and screen reader's way — enters the mode: every card shows its box and a tap
1649
- // toggles it. The mode lasts while anything is selected, so emptying the selection
1650
- // (the bulk bar's Clear, the last box unticked, Cancel) leaves it, as kk's does.
1651
- const anySelected = !!selection && (selection.someSelected || (selection.allSelected && slice.length > 0));
1652
- const [phoneSelectMode, setPhoneSelectMode] = useState(false);
1653
- const [hadSelection, setHadSelection] = useState(anySelected);
1654
- if (anySelected !== hadSelection) {
1655
- // Adjusted while rendering, so the frame the selection empties is already out of
1656
- // the mode (an effect would paint a frame of boxes first).
1657
- setHadSelection(anySelected);
1658
- if (!anySelected) setPhoneSelectMode(false);
1659
- }
1660
- const phoneSelecting = !!selection && !isMdUp && (phoneSelectMode || anySelected);
1661
- // ---- Shift+click range selection ---- (data-table-selection.ts; feedback #289,
1662
- // anchored on the row's KEY so a sort, filter or page change cannot move it)
1663
- const { canSelect, selectRange, setAnchor, markRangeClick, takeRangeClick } = useRangeSelection({
1664
- selection,
1665
- slice,
1666
- rowKey,
1667
- });
1668
- // ---- Keyboard-activatable rows ----
1669
- //
1670
- // A desktop row with `onRowClick` opened on a mouse click and on nothing else: no
1671
- // tab stop, no Enter, no focus ring (keksdose's report tables hand-rolled all three,
1672
- // which is half of why they could not move onto this table). The row now takes
1673
- // focus itself, as a ROVING tab stop: one row of the table is in the tab order
1674
- // (the last one focused, else the first), ArrowUp/ArrowDown/Home/End move between
1675
- // rows, Enter and Space activate.
1676
- //
1677
- // Why the `<tr>` rather than the two alternatives:
1678
- // * `role="button"` on the row would replace its `row` role, and the cells with
1679
- // it — a screen reader would stop reading the table as a table.
1680
- // * A button or link in one cell is what `rowHref` already is where the row HAS a
1681
- // URL. Without one, a synthetic button would have to wrap caller-rendered cell
1682
- // content, which nests the controls those cells carry (status toggles, links)
1683
- // inside it — illegal markup, and the reason the mobile card is a div.
1684
- // A focused `<tr>` keeps its role and reads out its cells. Roving rather than a tab
1685
- // stop per row, so a 100-row page does not put 100 stops between the header and
1686
- // the pager.
1687
- const rowsFocusable = !!onRowClick;
1688
- const [focusRowKey, setFocusRowKey] = useState<string | number | null>(null);
1689
- // The rows' places by key (0.52 code review, data-table.tsx): the tab stop's check
1690
- // below, the focus note and its recovery each walked the rows calling `rowKey` — on
1691
- // every render, at each focus, in every commit while a row was noted: under
1692
- // `virtualize`, 3000 rows, ~15000 calls per arrow step at the table's end. Built at the
1693
- // first lookup, once per array, so a table nobody focuses builds none. Keyed on the
1694
- // rows alone: `rowKey` is a pure function of its row, and a call site writes it inline.
1695
- // eslint-disable-next-line react-hooks/exhaustive-deps
1696
- const sliceIndex = useMemo(() => keyIndex(slice, rowKey), [slice]);
1697
- // eslint-disable-next-line react-hooks/exhaustive-deps
1698
- const mobileIndex = useMemo(() => keyIndex(mobileSlice, rowKey), [mobileSlice]);
1699
- const tabRowKey =
1700
- focusRowKey !== null && sliceIndex(focusRowKey) !== -1 ? focusRowKey : slice.length ? rowKey(slice[0]) : null;
1701
-
1702
- // What the memoised rows' handlers read at event time (0.48.2, K-052-2): the latest
1703
- // props, refreshed every commit, so a row that bailed out of a render still toggles
1704
- // through the current `selection` and opens through the current `onRowClick`.
1705
- // `virtualize` (0.48.2): desktop only, and only with rows to window. Arrow/Home/End then
1706
- // move over every row, rendered or not: the target is rendered, then focused.
1707
- const virtualOn = !!virtualize && isMdUp && slice.length > 0;
1708
- const focusRowRef = useRef<((index: number) => void) | undefined>(undefined);
1709
- // ---- When the focused row leaves (refactor audit 2026-10-30, DSP-4) ----
1710
- // The row holding the focus — its key, and where it stood — noted as the focus comes
1711
- // into it (the rows report it, through React, so a menu or a dialog a row opened in a
1712
- // portal is the row's too). A delete, a status change that filters the row out, an
1713
- // approval: the row's DOM goes, the focus with it to <body>, and the commit that took it
1714
- // puts the focus on the row now in its place (below).
1715
- const heldRow = useRef<{ key: string | number; index: number } | null>(null);
1716
- const noteRowFocus = (key: string | number) => {
1717
- heldRow.current = { key, index: (isMdUp ? sliceIndex : mobileIndex)(key) };
1718
- };
1719
- // A gone row's note waiting for the overlay that holds the focus (below): watched as
1720
- // a node until it leaves the page, since its close need not render the table.
1721
- const overlayWatch = useRef<{ layer: Element; stop: () => void } | null>(null);
1722
- const settleRef = useRef<() => void>(() => {});
1723
- const watchOverlay = (layer: Element) => {
1724
- if (overlayWatch.current?.layer === layer) return;
1725
- overlayWatch.current?.stop();
1726
- const observer = new MutationObserver(() => {
1727
- if (layer.isConnected) return;
1728
- stop();
1729
- settleRef.current();
1730
- });
1731
- const stop = () => {
1732
- observer.disconnect();
1733
- if (overlayWatch.current?.layer === layer) overlayWatch.current = null;
1734
- };
1735
- observer.observe(layer.ownerDocument.body, { childList: true, subtree: true });
1736
- overlayWatch.current = { layer, stop };
1737
- };
1738
- useEffect(() => () => overlayWatch.current?.stop(), []);
1739
- const latestRowCtx: DataTableRowCtx<T> = {
1740
- noteFocus: noteRowFocus,
1741
- moveFocus: virtualOn
1742
- ? (from, key) => {
1743
- const target = rowNavTarget(from, key, slice.length);
1744
- if (target === null) return false;
1745
- focusRowRef.current?.(target);
1746
- return true;
1747
- }
1748
- : undefined,
1749
- onRowClick,
1750
- onToggle: selection?.onToggle,
1751
- isSelected: (row) => !!selection?.isSelected(row),
1752
- canSelect,
1753
- selectRange,
1754
- setAnchor,
1755
- markRangeClick,
1756
- takeRangeClick,
1757
- setFocusRowKey,
1758
- onLongPress: (row) => {
1759
- setPhoneSelectMode(true);
1760
- setAnchor(rowKey(row));
1761
- if (selection && !selection.isSelected(row)) selection.onToggle(row, true);
1762
- },
1763
- };
1764
- const rowCtx = useRef(latestRowCtx);
1765
- useLayoutEffect(() => {
1766
- rowCtx.current = latestRowCtx;
1767
- });
1768
-
1769
- // When dialog-mode is on, the expanded row's detail goes into a bottom-sheet
1770
- // modal instead of unfolding inline. Only one row is ever expanded at a time.
1771
- const mobileDialogRow = mobileExpandAsDialog && !isMdUp ? mobileSlice.find((r) => isExpanded?.(r)) : undefined;
1772
- const mobileDialogContent = mobileDialogRow ? openedRowContent(mobileDialogRow) : null;
1773
- // The full-screen row dialog. Its panel, header, scroll lock and Back handling all
1774
- // live in {@link FullBleedDialog} now — this was the only copy of that shell until
1775
- // the transactions create card needed the same one (Keksdose live #307's follow-up),
1776
- // and two copies of a dialog is how the row editor and the create form come to look
1777
- // like two different products.
1778
- const dialogOpen = !!(mobileDialogRow && mobileDialogContent);
1779
- const closeDialog = () => {
1780
- if (mobileDialogRow) onRowClick?.(mobileDialogRow);
1781
- };
1782
-
1783
- // One mobile card (`MobileRow`, data-table-mobile.tsx). A function so the flat list
1784
- // and the grouped list (below) share identical row markup.
1785
- const renderMobileRow = (row: T, index: number) => {
1786
- const key = rowKey(row);
1787
- const expanded = isExpanded?.(row) ?? false;
1788
- return (
1789
- <DataTableMobileRow<T>
1790
- key={key}
1791
- ctx={rowCtx}
1792
- rowKey={key}
1793
- rowIndex={index}
1794
- row={row}
1795
- expanded={expanded}
1796
- expansion={expanded ? openedRowContent(row) : null}
1797
- opensRow={opensRow}
1798
- className={rowClassName?.(row)}
1799
- attrs={rowAttributes?.(row)}
1800
- interactive={!!onRowClick}
1801
- href={mobileCardLinkable || mobileCardStretched ? rowHref?.(row) : undefined}
1802
- swipeActionsFor={swipeActionsFor}
1803
- mobileDir={mobileDir}
1804
- mobileCardLinkable={mobileCardLinkable}
1805
- mobileCardStretched={mobileCardStretched}
1806
- mobileCard={mobileCard}
1807
- mobilePrimaryCol={mobilePrimaryCol}
1808
- mobileStatusColumns={mobileStatusColumns}
1809
- mobileSecondaryColumns={mobileSecondaryColumns}
1810
- mobileActionColumns={mobileActionColumns}
1811
- mobileExpandAsDialog={mobileExpandAsDialog}
1812
- compact={compact}
1813
- large={large}
1814
- selectionOn={!!selection}
1815
- selectActive={phoneSelecting}
1816
- selectable={canSelect(row)}
1817
- selected={!!selection && selection.isSelected(row)}
1818
- />
1819
- );
1820
- };
1821
-
1822
- // Consecutive grouping of the (already-sorted) mobile slice into labelled
1823
- // sections for the "assistance"-style list (feedback #317). Null unless the
1824
- // caller opts in via `mobileGroupBy`.
1825
- // The desktop's bands (0.48, keksdose dev #605), over the rows on screen.
1826
- const tableRef = useRef<HTMLTableElement | null>(null);
1827
- const desktopGroups = isMdUp && levels.length > 0 ? groupRows(slice, levels) : null;
1828
- const bandTops = useGroupBandTops(
1829
- tableRef,
1830
- !!desktopGroups,
1831
- `${levels.length}:${leadingRowSticky && !!leadingRow}:${desktopGroups?.[0]?.key ?? ""}`,
1832
- );
1833
- // ---- Row windowing ---- (0.48.2, keksdose dev #632; data-table-virtual.ts)
1834
- // One run per innermost group, with its bands (`virtualOn` is above, with the rows' context).
1835
- const virtualGroups: VirtualGroup[] = [];
1836
- const virtualBands: { g1: RowGroup<T>; g2: RowGroup<T> | null }[] = [];
1837
- if (virtualOn) {
1838
- if (!desktopGroups) virtualGroups.push({ start: 0, end: slice.length, bands: NO_BANDS, first: 0 });
1839
- else
1840
- for (const g1 of desktopGroups) {
1841
- const first = virtualGroups.length;
1842
- if (!g1.children) {
1843
- virtualGroups.push({ start: g1.start, end: g1.start + g1.rows.length, bands: BAND_1, first });
1844
- virtualBands.push({ g1, g2: null });
1845
- continue;
1846
- }
1847
- g1.children.forEach((g2, j) => {
1848
- virtualGroups.push({
1849
- start: g2.start,
1850
- end: g2.start + g2.rows.length,
1851
- bands: j === 0 ? BAND_1_2 : BAND_2,
1852
- first,
1853
- });
1854
- virtualBands.push({ g1, g2 });
1855
- });
1856
- }
1857
- }
1858
- const expandedIndices: number[] = [];
1859
- if (virtualOn && isExpanded && expandedRow) slice.forEach((r, i) => isExpanded(r) && expandedIndices.push(i));
1860
- const virtualColKeys = [...(selection ? [VIRTUAL_SELECT_COL] : []), ...visibleColumns.map((c) => c.key)];
1861
- const virtualLockable = new Set(
1862
- virtualColKeys.filter((k) => !widths[k] && !visibleColumns.some((c) => c.key === k && c.grow)),
1863
- );
1864
- const rowWindow = useRowWindow({
1865
- enabled: virtualOn,
1866
- rows: slice,
1867
- rowKey,
1868
- groups: virtualGroups,
1869
- expanded: expandedIndices,
1870
- pinnedKeys: [tabRowKey],
1871
- scrollerRef: desktopScroller,
1872
- tableRef,
1873
- options: typeof virtualize === "object" ? virtualize : NO_VIRTUAL_OPTIONS,
1874
- compact,
1875
- rowsBefore: 1 + (leadingRow ? 1 : 0),
1876
- rowsAfter: showFooter ? 1 : 0,
1877
- colKeys: virtualColKeys,
1878
- lockable: virtualLockable,
1879
- resetKey: `${Object.entries(widths).join(";")}|${density}|${large}|${slice.length}:${
1880
- slice.length ? `${rowKey(slice[0])}:${rowKey(slice[slice.length - 1])}` : ""
1881
- }`,
1882
- });
1883
- // What `moveFocus` (in the rows' context, above) reaches: the window's focus mover.
1884
- const focusRowAt = rowWindow?.focusRow;
1885
- useLayoutEffect(() => {
1886
- focusRowRef.current = focusRowAt;
1887
- }, [focusRowAt]);
1888
-
1889
- // The commit that took the focused row (DSP-4): if the focus fell with it — to <body>,
1890
- // not into a dialog or a field the app chose — it goes to the row now at that index,
1891
- // else the last row, else the table itself (`tabIndex=-1` while empty, named by
1892
- // `labels.table`); the card on a phone. A focusable row (`onRowClick`) takes it itself,
1893
- // any other its first control. The remaining count is said, as a filter's is: the
1894
- // focus moved without the user moving it. translation-review's `keepFocusAt` is the
1895
- // kit's earlier copy of the rule, for its groups.
1896
- //
1897
- // Every commit while a row is noted, a lookup by its key. A focus the user took
1898
- // elsewhere drops the note (`onBlur` on the root, below), and since the 0.52 code review
1899
- // so does one that went to nothing — a click on the page's background: kept, it was the
1900
- // one case where a later removal (a refetch, a sync) pulled the focus back in.
1901
- const settleFocus = () => {
1902
- const held = heldRow.current;
1903
- if (!held) return;
1904
- const now = (isMdUp ? sliceIndex : mobileIndex)(held.key);
1905
- if (now !== -1) {
1906
- held.index = now;
1907
- return;
1908
- }
1909
- const list = isMdUp ? tableRef.current : phoneListRef.current;
1910
- // In an overlay — a confirm, an edit drawer — the focus is not lost yet: the overlay
1911
- // hands it back to its opener as it closes, and the opener was in the row (0.52 code
1912
- // review, data-table.tsx: an optimistic delete with its confirm still open dropped the
1913
- // note, and the confirm's close dropped the focus to <body>). The note waits for the
1914
- // overlay to leave the page, watched as a node: the table need not render when it does
1915
- // (`useConfirm`'s provider, a Modal's exit). Not an overlay the table itself is in.
1916
- const layer = focusIsLost() ? null : document.activeElement?.closest(OVERLAY_SELECTOR);
1917
- if (layer && !(list && layer.contains(list))) {
1918
- watchOverlay(layer);
1919
- return;
1920
- }
1921
- heldRow.current = null;
1922
- overlayWatch.current?.stop();
1923
- if (!list || !focusIsLost()) return;
1924
- const shown = isMdUp ? slice : mobileSlice;
1925
- const at = shown.length === 0 ? -1 : Math.min(Math.max(held.index, 0), shown.length - 1);
1926
- if (at === -1) list.focus();
1927
- else if (isMdUp && virtualOn && rowsFocusable) focusRowRef.current?.(at);
1928
- else {
1929
- const row = list.querySelector<HTMLElement>(
1930
- isMdUp ? `:scope > tbody > tr[data-row-index="${at}"]` : `:scope > li[data-row-index="${at}"]`,
1931
- );
1932
- // Each of the row's stops in turn, then the table (0.52 code review): `focus()` on
1933
- // one that cannot hold it — `display: none`, in a closed <details>, a hidden input —
1934
- // does nothing, and the focus stayed on <body>.
1935
- const stops = !row ? [] : isMdUp && rowsFocusable ? [row] : tabStops(row, { includeSelf: false });
1936
- if (!focusFirst(stops)) focusContainer(list);
1937
- }
1938
- announce(labels.filterResults(paginationTotal, isServer ? paginationTotal : rows.length));
1939
- };
1940
- useLayoutEffect(() => {
1941
- settleRef.current = settleFocus;
1942
- settleFocus();
1943
- });
1944
-
1945
- // `mobileGroupBy` may be a desktop `groupBy` level (0.48), its label and end slot too.
1946
- const mobileLevel = typeof mobileGroupBy === "function" ? null : (mobileGroupBy ?? null);
1947
- const mobileKey = typeof mobileGroupBy === "function" ? mobileGroupBy : mobileGroupBy?.key;
1948
- // `start`: the section's first card's place in the list (its `data-row-index`, DSP-4).
1949
- const mobileGroups: { key: string; rows: T[]; start: number }[] | null = mobileKey
1950
- ? mobileSlice.reduce<{ key: string; rows: T[]; start: number }[]>((acc, row, i) => {
1951
- const key = mobileKey(row);
1952
- const last = acc[acc.length - 1];
1953
- if (last && last.key === key) last.rows.push(row);
1954
- else acc.push({ key, rows: [row], start: i });
1955
- return acc;
1956
- }, [])
1957
- : null;
1958
-
1959
- // `frame={false}` swaps the Card for a bare wrapper carrying the same hooks and
1960
- // layout classes, so everything inside renders identically either way.
1961
- const Root = frame ? FramedRoot : PlainRoot;
1962
-
1963
- return (
1964
- <Root
1965
- // A styling and testing hook: a host's own cell content can follow the table's
1966
- // density (`group-data-[density=compact]/table:…`) without being told twice.
1967
- data-density={density}
1968
- data-frame={frame ? undefined : "none"}
1969
- // The frame clips (`overflow-clip`), so it is a clipping container for a
1970
- // Tooltip's auto-portal — said as an attribute as well, because jsdom computes no
1971
- // classes and a table-cell tooltip must portal under test just as it does in the
1972
- // browser (see `CLIPS_ATTRIBUTE`). The bare root clips nothing and is not marked.
1973
- data-clips={frame ? "" : undefined}
1974
- // The fill's marker (0.40, docs/page-layout-harmonization.md §1.3): AppShell's
1975
- // `<main>` and every `FillHeight` between it and this table key off it with
1976
- // `has-[[data-fill-height]]`, so a page asks for the fill by passing the prop.
1977
- data-fill-height={fillHeight ? "" : undefined}
1978
- // The focused row's note (DSP-4): cleared on the way down to whatever takes the
1979
- // focus, and set again on the way up by the row that holds it. Focus leaving for
1980
- // something real outside the table drops it — unless that is an overlay (a confirm,
1981
- // a menu), which hands the focus back, or closes with the row it asked about.
1982
- onFocusCapture={() => {
1983
- heldRow.current = null;
1984
- overlayWatch.current?.stop();
1985
- }}
1986
- onBlur={(e) => {
1987
- const to = e.relatedTarget;
1988
- if (to instanceof Element && !to.closest(OVERLAY_SELECTOR)) heldRow.current = null;
1989
- // Focus that left for nothing — a click on the page (0.52 code review): no
1990
- // element to say where, and a later removal of the row is no longer the user's.
1991
- const held = heldRow.current;
1992
- whenFocusLeftForNothing(e, () => {
1993
- if (heldRow.current === held) heldRow.current = null;
1994
- });
1995
- }}
1996
- className={cn(
1997
- "group/table",
1998
- frame ? "overflow-clip" : "min-w-0",
1999
- // Both layouts, not just the table: the phone card list overflowing a bounded
2000
- // pane drew itself over whatever followed it, and in a viewport-locked shell
2001
- // the rows below the fold could not be reached at all. From `md` the minimum is
2002
- // the content's again (`md:min-h-auto`): the body's floor below then holds the
2003
- // whole chain up to `<main>`, which scrolls instead of leaving a slit.
2004
- fillHeight && "flex flex-1 flex-col min-h-0 md:min-h-auto",
2005
- className,
2006
- )}
2007
- >
2008
- {/* Outside both viewport branches on purpose. A screen reader subscribes to a
2009
- live region when it encounters it, so one that appears together with its
2010
- first message is usually missed — and crossing the md breakpoint swaps the
2011
- card list for the table wholesale, which would remount a region living
2012
- inside either one. */}
2013
- <span {...regionProps} />
2014
- {toolbar != null && toolbar !== false && (
2015
- <div
2016
- data-table-toolbar=""
2017
- className={cn(
2018
- "flex flex-wrap items-center gap-2",
2019
- frame ? cn("border-b border-subtle", compact ? "px-2 py-1.5" : "px-3 py-2") : "pb-3",
2020
- )}
2021
- >
2022
- {toolbar}
2023
- </div>
2024
- )}
2025
- {!isMdUp && (
2026
- <div ref={mobileRootRef} className={cn(fillHeight && "flex min-h-0 flex-1 flex-col")}>
2027
- {/* Mobile filter access (feedback #299): the per-column filter popovers
2028
- live in the desktop header, which the card list doesn't render — so
2029
- expose the same filters through a bottom sheet here. */}
2030
- {(!serverRows || onFiltersChange) && (
2031
- <MobileFilters
2032
- columns={columns}
2033
- filters={filters}
2034
- selectOptionsByKey={selectOptionsByKey}
2035
- isServer={serverRows}
2036
- hasFilterCallback={!!onFiltersChange}
2037
- onSetFilter={setFilterValue}
2038
- onClearFilter={clearFilter}
2039
- onClearAll={clearAllFilters}
2040
- labels={labels}
2041
- locale={locale}
2042
- />
2043
- )}
2044
- {mobileSort && (
2045
- <MobileSort
2046
- columns={visibleColumns.filter((c) => (!serverRows || !!onSortsChange) && !!c.sortBy)}
2047
- sorts={sorts}
2048
- sortCycle={sortCycle}
2049
- compact={compact}
2050
- labels={labels}
2051
- onPick={(key) => {
2052
- if (key === null) {
2053
- if (sorts.length > 0) commitSorts([], sorts[0].key);
2054
- return;
2055
- }
2056
- if (sorts[0]?.key === key) return;
2057
- // A newly picked column starts where its header's first click would.
2058
- const col = columns.find((c) => c.key === key);
2059
- commitSorts([{ key, dir: col?.firstSort ?? "asc" }], key);
2060
- }}
2061
- onFlip={() => {
2062
- if (sorts.length === 0) return;
2063
- commitSorts(
2064
- sorts.map((s, i) => (i === 0 ? { ...s, dir: flipDir(s.dir) } : s)),
2065
- sorts[0].key,
2066
- );
2067
- }}
2068
- />
2069
- )}
2070
- {selection && (
2071
- <MobileSelectBar
2072
- active={phoneSelecting}
2073
- allSelected={selection.allSelected}
2074
- someSelected={selection.someSelected}
2075
- compact={compact}
2076
- labels={labels}
2077
- onEnter={() => setPhoneSelectMode(true)}
2078
- onToggleAll={selection.onToggleAll}
2079
- onCancel={() => {
2080
- setPhoneSelectMode(false);
2081
- if (anySelected) selection.onToggleAll(false);
2082
- }}
2083
- />
2084
- )}
2085
- <ul
2086
- ref={phoneListRef}
2087
- // Named as the table is, and focusable while empty: where the focus goes when
2088
- // the last card leaves (DSP-4).
2089
- aria-label={labels.table}
2090
- tabIndex={mobileSlice.length === 0 ? -1 : undefined}
2091
- className={cn(
2092
- "divide-y divide-subtle",
2093
- // Under `fillHeight` the list takes the room its parent gives it, and in a
2094
- // BOUNDED parent it is the scroller, as the table body is on a wide screen:
2095
- // the filter button stays above it and a server pager below. Only then —
2096
- // see `phoneListScrolls` (live #420). The endless-scroll sentinel still
2097
- // fires in here — an IntersectionObserver on the viewport sees through the
2098
- // clipping ancestor.
2099
- fillHeight && "min-h-0 flex-1",
2100
- fillHeight && phoneListScrolls && "overflow-y-auto overscroll-contain",
2101
- // Same busy treatment as the desktop body; see `isLoading` below.
2102
- isLoading && mobileSlice.length > 0 && "opacity-60 transition-opacity",
2103
- )}
2104
- aria-busy={isLoading || undefined}
2105
- >
2106
- {/* The desktop's leading row, as the list's first item (0.51.7, audit
2107
- 2026-10-30 DSP-2; MobileLeadingRow says what it is and is not). */}
2108
- {leadingRow ? (
2109
- <MobileLeadingRow sticky={leadingRowSticky} onHeight={setPhoneLeadHeight}>
2110
- {leadingRow}
2111
- </MobileLeadingRow>
2112
- ) : null}
2113
- {mobileGroups
2114
- ? mobileGroups.map((g) => (
2115
- <Fragment key={`hb-group:${g.key}`}>
2116
- <li
2117
- className="sticky top-0 z-10 flex flex-wrap items-baseline justify-between gap-x-3 bg-surface-2 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted backdrop-blur"
2118
- style={leadingRow && leadingRowSticky ? { top: phoneLeadHeight } : undefined}
2119
- >
2120
- <span className="min-w-0">
2121
- {mobileGroupLabel
2122
- ? mobileGroupLabel(g.key)
2123
- : mobileLevel?.label
2124
- ? mobileLevel.label(g.key, g.rows)
2125
- : g.key}
2126
- </span>
2127
- {mobileLevel?.end && (
2128
- <span className="ms-auto tabular-nums">{mobileLevel.end(g.key, g.rows)}</span>
2129
- )}
2130
- </li>
2131
- {g.rows.map((r, i) => renderMobileRow(r, g.start + i))}
2132
- </Fragment>
2133
- ))
2134
- : mobileSlice.map(renderMobileRow)}
2135
- {mobileSlice.length === 0 && (
2136
- <li className={cn("text-center text-muted", compact ? "px-3 py-3 text-xs" : "px-4 py-6 text-sm")}>
2137
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
2138
- </li>
2139
- )}
2140
- {/* Endless-scroll sentinel: observed by IntersectionObserver to pull in
2141
- the next chunk as it nears the viewport (feedback #232). */}
2142
- {canRevealMoreMobile && (
2143
- <li ref={loadMoreRef} className="px-4 py-4 text-center text-xs text-placeholder">
2144
- {/* Isolated for the reason spelled out on LoadingText. */}
2145
- <bdi>{labels.loading}</bdi>
2146
- </li>
2147
- )}
2148
- {/* The owner's next slice (0.48), once every row held is revealed. */}
2149
- {loadMore && !canRevealMoreMobile && (
2150
- <DataTableLoadMoreFooter
2151
- as="li"
2152
- loadMore={loadMore}
2153
- rowCount={sorted.length}
2154
- labels={labels}
2155
- compact={compact}
2156
- />
2157
- )}
2158
- </ul>
2159
- {/* The phone's totals: a summary card after the list rather than one more
2160
- `<li>` in it. The list reveals rows in chunks as it is scrolled, so a last
2161
- item would sit mid-list until the end was reached; outside it, and under
2162
- `fillHeight` outside the scroller too, the totals stay in view the way the
2163
- sticky `<tfoot>` keeps them in view on a wide screen. Named by
2164
- `footerLabel` so it is announced as what it is, not as a stray list of
2165
- numbers; `mobileHidden` columns stay hidden here as on the cards. */}
2166
- {showFooter && mobileColumns.some((c) => c.footer) && (
2167
- <section
2168
- data-table-footer=""
2169
- aria-labelledby={footerLabel != null ? footerHeadingId : undefined}
2170
- className={cn(
2171
- "border-t-2 border-strong bg-surface-2",
2172
- compact ? "px-3 py-2" : "px-4 py-3",
2173
- isLoading && "opacity-60 transition-opacity",
2174
- )}
2175
- >
2176
- {footerLabel != null && (
2177
- <div id={footerHeadingId} className={cn("font-semibold", compact ? "mb-1 text-sm" : "mb-2")}>
2178
- {footerLabel}
2179
- </div>
2180
- )}
2181
- <dl
2182
- className={cn("grid grid-cols-[auto_1fr] gap-x-3", compact ? "gap-y-0.5 text-xs" : "gap-y-1 text-sm")}
2183
- >
2184
- {mobileColumns
2185
- .filter((c) => c.footer)
2186
- .map((col) => (
2187
- <Fragment key={col.key}>
2188
- <dt className="self-center text-xs uppercase tracking-wide text-muted">{col.header}</dt>
2189
- <dd className="min-w-0 self-center font-semibold text-primary">{footerValue(col)}</dd>
2190
- </Fragment>
2191
- ))}
2192
- </dl>
2193
- </section>
2194
- )}
2195
- {/* Client-side mobile lists scroll endlessly (sentinel above); only
2196
- server-paginated tables keep the pager here. */}
2197
- {isServer && !loadMore && (
2198
- <Pagination
2199
- page={safePage}
2200
- totalPages={totalPages}
2201
- pageSize={paginationPageSize}
2202
- total={paginationTotal}
2203
- onPage={goToPage}
2204
- onPageSize={serverPagination!.onPageSizeChange}
2205
- labels={labels}
2206
- locale={locale}
2207
- density={density}
2208
- />
2209
- )}
2210
- {/* The row's own first column is this dialog's title — the same cell the card
2211
- behind it shows, so the panel names the row it opened from. */}
2212
- <FullBleedDialog
2213
- open={dialogOpen}
2214
- // The open row's editor is the row's focus too: a Delete in it takes the row,
2215
- // and the dialog with it (DSP-4).
2216
- onFocus={() => {
2217
- if (mobileDialogRow) noteRowFocus(rowKey(mobileDialogRow));
2218
- }}
2219
- onClose={closeDialog}
2220
- closeLabel={labels.close}
2221
- header={mobileDialogRow ? mobilePrimaryCol?.cell(mobileDialogRow) : null}
2222
- backCloses={mobileDialogBackCloses}
2223
- >
2224
- {mobileDialogContent}
2225
- </FullBleedDialog>
2226
- </div>
2227
- )}
2228
- {isMdUp && (
2229
- <div className={cn("flex", fillHeight && "flex-1")}>
2230
- {/* Under `fillHeight` neither wrapper is `min-h-0`: a flex item's automatic
2231
- minimum is its content's, and the content's height counts the body at its
2232
- basis (`basis-48`, below), not at its rows — so the floor carries up. */}
2233
- <div className={cn("relative min-w-0 flex-1", fillHeight && "flex flex-col")}>
2234
- {/* scrollbar-gutter:stable reserves the vertical-scrollbar space up
2235
- front. Expanding a row adds height, which can bring the scrollbar
2236
- in, which narrows the content box — and in an `auto` table layout
2237
- that re-computes every column width. Reserving the gutter is the
2238
- other half of the `w-0 min-w-full` fix on the expansion cell below
2239
- (feedback #104: "expanding an item resizes the columns"). */}
2240
- <div
2241
- ref={desktopScroller}
2242
- // The scroller: marked for the Tooltip's auto-portal (see the root above).
2243
- data-clips=""
2244
- // Under `fillHeight` the body grows into the room from a 12rem basis (0.40,
2245
- // §1.3): about five rows is its floor. A window too short for the page's
2246
- // header and that much body scrolls `<main>` instead (keksdose's review).
2247
- className={cn("overflow-auto [scrollbar-gutter:stable]", fillHeight && "min-h-0 grow basis-48")}
2248
- // `edgeFade`: the mask, `data-overflow` and the focus handling; nothing
2249
- // while off. The height bound is merged over the mask, not replaced by it.
2250
- {...edgeFadeProps}
2251
- style={fillHeight ? edgeFadeProps.style : { ...edgeFadeProps.style, maxHeight: maxBodyHeight }}
2252
- >
2253
- {/* A table with no name is "table" in a screen reader's list of tables, and
2254
- an app renders several. `labels.table` is how a call site says which. */}
2255
- {/* `aria-busy` while a server page is in flight: a reader holds off announcing
2256
- the rows that are about to be replaced, and the dimmed body says the same
2257
- to the eye. The pager stays live — see `ServerPagination.isLoading`. */}
2258
- <table
2259
- ref={tableRef}
2260
- className={cn("w-full", compact ? "text-xs" : "text-sm")}
2261
- data-fit-grow={growDrawn ? "" : undefined}
2262
- aria-label={labels.table}
2263
- // Focusable while empty: where the focus goes when the last row leaves (DSP-4).
2264
- tabIndex={slice.length === 0 ? -1 : undefined}
2265
- aria-busy={isLoading || undefined}
2266
- // `virtualize` (0.48.2): every row counted, rendered or not.
2267
- aria-rowcount={rowWindow?.rowCount}
2268
- data-virtualized={rowWindow ? "" : undefined}
2269
- >
2270
- {/* The locked column widths (0.48.2, `virtualize`): rows coming and going no
2271
- longer move the columns. */}
2272
- {rowWindow && (
2273
- <colgroup>
2274
- {virtualColKeys.map((key) => {
2275
- const w = rowWindow.colWidths?.[key];
2276
- return <col key={key} style={w !== undefined ? { width: w } : undefined} />;
2277
- })}
2278
- </colgroup>
2279
- )}
2280
- {/* Sticky header. position:sticky pins to the nearest scroll-container
2281
- ancestor — so the header can only stick to THIS wrapper, never the
2282
- page. For that to actually hold the header in place, the wrapper
2283
- must be the thing that scrolls vertically: hence overflow-auto +
2284
- a bounded max-height (rather than overflow-x-auto, which scrolls
2285
- only sideways and lets the header ride away on page scroll). The
2286
- header then stays put as you scroll the rows. position:sticky on
2287
- <thead> isn't reliable across browsers, so we put it on each <th>
2288
- (see DataTableHead, data-table-head.tsx). */}
2289
- <DataTableHead
2290
- visibleColumns={visibleColumns}
2291
- selection={selection}
2292
- compact={compact}
2293
- labels={labels}
2294
- locale={locale}
2295
- isServer={serverRows}
2296
- hasSortCallback={!!onSortsChange}
2297
- hasFilterCallback={!!onFiltersChange}
2298
- sorts={sorts}
2299
- filters={filters}
2300
- selectOptionsByKey={selectOptionsByKey}
2301
- multiSort={multiSort}
2302
- resizable={resizable}
2303
- widths={widths}
2304
- setWidths={setWidths}
2305
- headRefs={headRefs}
2306
- toggleSort={toggleSort}
2307
- setFilterValue={setFilterValue}
2308
- clearFilter={clearFilter}
2309
- startResize={startResize}
2310
- moveResize={moveResize}
2311
- finishResize={finishResize}
2312
- ariaRowIndex={rowWindow ? 1 : undefined}
2313
- lockedWidths={rowWindow?.colWidths}
2314
- selectionLockKey={VIRTUAL_SELECT_COL}
2315
- />
2316
- {(() => {
2317
- const bodyClass = cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity");
2318
- const leading = leadingRow ? (
2319
- <DataTableLeadingRow
2320
- colSpan={totalColSpan}
2321
- sticky={leadingRowSticky}
2322
- ariaRowIndex={rowWindow ? 2 : undefined}
2323
- >
2324
- {leadingRow}
2325
- </DataTableLeadingRow>
2326
- ) : null;
2327
- // One memoised row each (0.48.2, K-052-2; data-table-row.tsx): the table
2328
- // derives each row's own inputs here, as primitives, so a tick re-renders
2329
- // only the rows whose inputs changed.
2330
- const renderRow = (row: T, rowIndex: number) => {
2331
- const expanded = isExpanded?.(row) ?? false;
2332
- const key = rowKey(row);
2333
- return (
2334
- <DataTableRow<T>
2335
- key={key}
2336
- row={row}
2337
- rowIndex={rowIndex}
2338
- rowKey={key}
2339
- ctx={rowCtx}
2340
- columns={visibleColumns}
2341
- widths={widths}
2342
- growDrawn={growDrawn}
2343
- linkColumn={linkColumn}
2344
- href={rowHref?.(row)}
2345
- selectionColumn={!!selection}
2346
- selectable={canSelect(row)}
2347
- selected={!!selection && selection.isSelected(row)}
2348
- expanded={expanded}
2349
- expansion={expanded ? expandedRow?.(row) : null}
2350
- disclosure={rowsFocusable && !!expandedRow}
2351
- detailId={`${detailIdBase}-${rowIndex}`}
2352
- rowsFocusable={rowsFocusable}
2353
- rowInteractive={!!onRowClick || !!selection}
2354
- tabbable={key === tabRowKey}
2355
- className={rowClassName?.(row)}
2356
- attrs={rowAttributes?.(row)}
2357
- compact={compact}
2358
- totalColSpan={totalColSpan}
2359
- rowDetailsLabel={labels.rowDetails}
2360
- selectRowLabel={labels.selectRow}
2361
- ariaRowIndex={rowWindow?.ariaRow(rowIndex)}
2362
- />
2363
- );
2364
- };
2365
- const emptyRow =
2366
- slice.length === 0 ? (
2367
- <tr>
2368
- <td
2369
- colSpan={totalColSpan}
2370
- className={cn("text-center text-muted", compact ? "px-2 py-2" : "px-3 py-4")}
2371
- >
2372
- {/* Nothing is not the same as not-yet: "no results" while the first
2373
- page is still in flight is a claim the table cannot make. */}
2374
- {isLoading ? <LoadingText label={labels.loading} /> : (empty ?? labels.empty)}
2375
- </td>
2376
- </tr>
2377
- ) : null;
2378
- const band = (
2379
- level: 1 | 2,
2380
- def: DataTableGroupBy<T>,
2381
- g: { key: string; rows: T[] },
2382
- ariaRowIndex?: number,
2383
- ) => (
2384
- <DataTableGroupBand
2385
- level={level}
2386
- colSpan={totalColSpan}
2387
- top={level === 1 ? bandTops[0] : bandTops[1]}
2388
- compact={compact}
2389
- label={def.label ? def.label(g.key, g.rows) : g.key}
2390
- end={def.end?.(g.key, g.rows)}
2391
- ariaRowIndex={ariaRowIndex}
2392
- />
2393
- );
2394
- if (rowWindow) {
2395
- // Windowed (0.48.2): each group's own `<tbody>` with its bands and the
2396
- // rendered rows, spacer rows between; one spacer `<tbody>` per run of groups
2397
- // with nothing rendered. Spacers are hidden from the accessibility tree.
2398
- const spacer = (key: string, height: number) => (
2399
- <tr key={key} aria-hidden="true" data-vt-spacer="">
2400
- <td colSpan={totalColSpan} style={{ height, padding: 0, border: 0 }} />
2401
- </tr>
2402
- );
2403
- return (
2404
- <>
2405
- {leading && <tbody className={bodyClass}>{leading}</tbody>}
2406
- {rowWindow.bodies.map((b) => {
2407
- if (b.kind === "spacer")
2408
- return (
2409
- <tbody key={b.key} data-vt-body="" aria-hidden="true">
2410
- {spacer("s", b.height)}
2411
- </tbody>
2412
- );
2413
- const g = virtualGroups[b.group];
2414
- const vb = virtualBands[b.group];
2415
- return (
2416
- <tbody
2417
- key={vb ? `vg:${vb.g1.key}:${vb.g2?.key ?? ""}:${g.start}` : "vg"}
2418
- data-vt-body=""
2419
- className={bodyClass}
2420
- >
2421
- {vb && g.bands.includes(1) && band(1, levels[0], vb.g1, rowWindow.ariaBand(b.group, 1))}
2422
- {vb?.g2 && band(2, levels[1], vb.g2, rowWindow.ariaBand(b.group, 2))}
2423
- {b.items.map((it) =>
2424
- it.kind === "gap" ? spacer(it.key, it.height) : renderRow(slice[it.index], it.index),
2425
- )}
2426
- </tbody>
2427
- );
2428
- })}
2429
- </>
2430
- );
2431
- }
2432
- if (!desktopGroups) {
2433
- return (
2434
- <tbody className={bodyClass}>
2435
- {leading}
2436
- {slice.map(renderRow)}
2437
- {emptyRow}
2438
- </tbody>
2439
- );
2440
- }
2441
- // Grouped (0.48, keksdose dev #605): one `<tbody>` per innermost group, its
2442
- // band the row header; the leading row (and the empty state) in their own
2443
- // before the first group.
2444
- const rowsOf = (g: { rows: T[]; start: number }) => g.rows.map((r, i) => renderRow(r, g.start + i));
2445
- return (
2446
- <>
2447
- {(leading || emptyRow) && (
2448
- <tbody className={bodyClass}>
2449
- {leading}
2450
- {emptyRow}
2451
- </tbody>
2452
- )}
2453
- {desktopGroups.map((g1) =>
2454
- g1.children ? (
2455
- g1.children.map((g2, j) => (
2456
- <tbody key={`g:${g1.key}:${g2.key}:${g2.start}`} className={bodyClass}>
2457
- {j === 0 && band(1, levels[0], g1)}
2458
- {band(2, levels[1], g2)}
2459
- {rowsOf(g2)}
2460
- </tbody>
2461
- ))
2462
- ) : (
2463
- <tbody key={`g:${g1.key}:${g1.start}`} className={bodyClass}>
2464
- {band(1, levels[0], g1)}
2465
- {rowsOf(g1)}
2466
- </tbody>
2467
- ),
2468
- )}
2469
- </>
2470
- );
2471
- })()}
2472
- {showFooter && (
2473
- // Sticky to the scroller's bottom edge for the same reason the header is
2474
- // sticky to its top: the body scrolls inside a bounded wrapper, and a
2475
- // total that scrolls away with the last row is a total nobody sees on a
2476
- // 25-row page. Each cell carries its own background and its own `sticky`,
2477
- // as the header's do — `position: sticky` on a `<tfoot>` is not reliable.
2478
- <tfoot
2479
- data-table-footer=""
2480
- className={cn("font-semibold text-primary", isLoading && "opacity-60 transition-opacity")}
2481
- >
2482
- <tr aria-rowindex={rowWindow?.rowCount}>
2483
- {/* The selection column has nothing to total: an empty cell keeps the
2484
- values under their own headers. */}
2485
- {selection && <td className={cn(FOOTER_CELL, compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2")} />}
2486
- {visibleColumns.map((col) => {
2487
- const width = widths[col.key];
2488
- const pinned = width ? { width, minWidth: width, maxWidth: width } : undefined;
2489
- const isLabel = col === footerLabelCol;
2490
- const Cell = isLabel ? "th" : "td";
2491
- return (
2492
- <Cell
2493
- key={col.key}
2494
- data-col={col.key}
2495
- scope={isLabel ? "row" : undefined}
2496
- style={pinned}
2497
- className={cn(
2498
- FOOTER_CELL,
2499
- "align-top",
2500
- compact ? "px-2 py-1" : "px-3 py-2",
2501
- width && "overflow-hidden text-ellipsis",
2502
- // A `<th>` centres by default; the label reads from the start
2503
- // like the cells above it, unless the column says otherwise.
2504
- isLabel && "text-start",
2505
- col.grow ? GROW_CELL_CLASS : growDrawn && GROW_PEER_CELL_CLASS,
2506
- logicalAlign(col.className),
2507
- )}
2508
- >
2509
- {col.footer ? footerValue(col) : isLabel ? footerLabel : null}
2510
- </Cell>
2511
- );
2512
- })}
2513
- </tr>
2514
- </tfoot>
2515
- )}
2516
- </table>
2517
- {/* In the scroller, after the rows (0.48): it comes into view as the last row
2518
- does, under `fillHeight` too, where the body is the scroller. */}
2519
- {loadMore && (
2520
- <DataTableLoadMoreFooter
2521
- loadMore={loadMore}
2522
- rowCount={sorted.length}
2523
- labels={labels}
2524
- compact={compact}
2525
- />
2526
- )}
2527
- </div>
2528
- {!unpaged && !loadMore && (
2529
- <Pagination
2530
- page={safePage}
2531
- totalPages={totalPages}
2532
- pageSize={paginationPageSize}
2533
- total={paginationTotal}
2534
- onPage={goToPage}
2535
- onPageSize={
2536
- isServer
2537
- ? serverPagination!.onPageSizeChange
2538
- : (n) => {
2539
- // To the owner when it holds the page size, as `commitSorts` does.
2540
- if (onPageSizeChange) onPageSizeChange(n);
2541
- else setPageSize(n);
2542
- setPage(0);
2543
- }
2544
- }
2545
- labels={labels}
2546
- locale={locale}
2547
- density={density}
2548
- />
2549
- )}
2550
- </div>
2551
- {columnSettings && (
2552
- <ColumnSettingsPanel
2553
- columns={columns}
2554
- hiddenCols={hiddenCols}
2555
- setHiddenCols={setHiddenCols}
2556
- autoHidden={autoHidden}
2557
- keepColumn={keepColumn}
2558
- showSettings={showSettings}
2559
- setShowSettings={setShowSettings}
2560
- columnsCountLabel={columnsCountLabel}
2561
- labels={labels}
2562
- autoSizeAll={autoSizeAll}
2563
- />
2564
- )}
2565
- </div>
2566
- )}
2567
- </Root>
2568
- );
2569
- }
2570
-
2571
- /** The framed root: the kit's flush Card, as every table had before `frame`. */
2572
- function FramedRoot({ children, ...rest }: React.HTMLAttributes<HTMLDivElement>) {
2573
- return (
2574
- <Card flush {...rest}>
2575
- {children}
2576
- </Card>
2577
- );
2578
- }
2579
-
2580
- /** The unframed root (`frame={false}`): no border, surface, radius or shadow — the
2581
- * host's own card supplies them. */
2582
- function PlainRoot(props: React.HTMLAttributes<HTMLDivElement>) {
2583
- return <div {...props} />;
2584
- }
2585
-
2586
- /** The loading row's content: a spinner beside the word, the word for everyone. */
2587
- function LoadingText({ label }: { label: string }) {
2588
- // `<bdi>`: the label takes its direction from its own letters, not the table's. The
2589
- // English default inside a `dir="rtl"` table otherwise had its trailing "…" — a
2590
- // neutral with no letter after it — resolved right-to-left and drawn in front of
2591
- // the word: "…Loading". An Arabic or Hebrew label still reads right-to-left.
2592
- return (
2593
- <span className="inline-flex items-center gap-2">
2594
- <Spinner label={null} className="size-4" />
2595
- <bdi>{label}</bdi>
2596
- </span>
2597
- );
2598
- }