@eifi1/ui-kit 0.48.1 → 0.49.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 (318) hide show
  1. package/README.md +28 -13
  2. package/dist/admin/admin-action-confirm.js +1 -1
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/admin/invitations-panel.js +5 -1
  5. package/dist/admin/invitations-panel.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +11 -5
  7. package/dist/admin/user-roster.js +15 -4
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/accept-invitation.js +2 -2
  10. package/dist/auth/accept-invitation.js.map +1 -1
  11. package/dist/auth/not-found-page.js +1 -1
  12. package/dist/auth/not-found-page.js.map +1 -1
  13. package/dist/auth/reset-password-form.js +2 -2
  14. package/dist/auth/reset-password-form.js.map +1 -1
  15. package/dist/auth/verify-email.js +2 -2
  16. package/dist/auth/verify-email.js.map +1 -1
  17. package/dist/billing/plan-card.js +2 -2
  18. package/dist/billing/plan-card.js.map +1 -1
  19. package/dist/components/breadcrumbs.d.ts +19 -1
  20. package/dist/components/breadcrumbs.js +11 -4
  21. package/dist/components/breadcrumbs.js.map +1 -1
  22. package/dist/components/button-look.d.ts +37 -7
  23. package/dist/components/button-look.js +33 -6
  24. package/dist/components/button-look.js.map +1 -1
  25. package/dist/components/button.d.ts +4 -1
  26. package/dist/components/button.js +2 -2
  27. package/dist/components/button.js.map +1 -1
  28. package/dist/components/calendar-heatmap.js +2 -1
  29. package/dist/components/calendar-heatmap.js.map +1 -1
  30. package/dist/components/chart-edge.d.ts +10 -0
  31. package/dist/components/chart-edge.js +57 -0
  32. package/dist/components/chart-edge.js.map +1 -0
  33. package/dist/components/chart.js +2 -1
  34. package/dist/components/chart.js.map +1 -1
  35. package/dist/components/chip.d.ts +8 -1
  36. package/dist/components/chip.js +4 -1
  37. package/dist/components/chip.js.map +1 -1
  38. package/dist/components/confirm-context.d.ts +5 -0
  39. package/dist/components/confirm-context.js +7 -0
  40. package/dist/components/confirm-context.js.map +1 -0
  41. package/dist/components/confirm-dialog.js +3 -3
  42. package/dist/components/confirm-dialog.js.map +1 -1
  43. package/dist/components/danger-confirm.d.ts +2 -2
  44. package/dist/components/danger-confirm.js +1 -1
  45. package/dist/components/danger-confirm.js.map +1 -1
  46. package/dist/components/data-table-fit.d.ts +8 -1
  47. package/dist/components/data-table-fit.js +28 -4
  48. package/dist/components/data-table-fit.js.map +1 -1
  49. package/dist/components/data-table-groups.d.ts +3 -1
  50. package/dist/components/data-table-groups.js +11 -5
  51. package/dist/components/data-table-groups.js.map +1 -1
  52. package/dist/components/data-table-head.d.ts +8 -1
  53. package/dist/components/data-table-head.js +11 -3
  54. package/dist/components/data-table-head.js.map +1 -1
  55. package/dist/components/data-table-leading-row.d.ts +3 -1
  56. package/dist/components/data-table-leading-row.js +2 -1
  57. package/dist/components/data-table-leading-row.js.map +1 -1
  58. package/dist/components/data-table-mobile.d.ts +48 -2
  59. package/dist/components/data-table-mobile.js +87 -10
  60. package/dist/components/data-table-mobile.js.map +1 -1
  61. package/dist/components/data-table-row-actions.d.ts +2 -1
  62. package/dist/components/data-table-row-actions.js +9 -2
  63. package/dist/components/data-table-row-actions.js.map +1 -1
  64. package/dist/components/data-table-row.d.ts +84 -0
  65. package/dist/components/data-table-row.js +261 -0
  66. package/dist/components/data-table-row.js.map +1 -0
  67. package/dist/components/data-table-selection.d.ts +3 -0
  68. package/dist/components/data-table-selection.js +12 -1
  69. package/dist/components/data-table-selection.js.map +1 -1
  70. package/dist/components/data-table-virtual.d.ts +98 -0
  71. package/dist/components/data-table-virtual.js +369 -0
  72. package/dist/components/data-table-virtual.js.map +1 -0
  73. package/dist/components/data-table.d.ts +74 -2
  74. package/dist/components/data-table.js +241 -221
  75. package/dist/components/data-table.js.map +1 -1
  76. package/dist/components/error-boundary.js +2 -2
  77. package/dist/components/error-boundary.js.map +1 -1
  78. package/dist/components/floating-panel.d.ts +36 -13
  79. package/dist/components/floating-panel.js +27 -7
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +1 -1
  82. package/dist/components/form-actions.js +1 -1
  83. package/dist/components/form-actions.js.map +1 -1
  84. package/dist/components/icon-button.d.ts +11 -0
  85. package/dist/components/icon-button.js +7 -5
  86. package/dist/components/icon-button.js.map +1 -1
  87. package/dist/components/nav-pills.d.ts +8 -1
  88. package/dist/components/nav-pills.js +61 -38
  89. package/dist/components/nav-pills.js.map +1 -1
  90. package/dist/components/page-header-context.d.ts +8 -0
  91. package/dist/components/page-header-context.js +7 -0
  92. package/dist/components/page-header-context.js.map +1 -0
  93. package/dist/components/page-header.d.ts +14 -2
  94. package/dist/components/page-header.js +10 -1
  95. package/dist/components/page-header.js.map +1 -1
  96. package/dist/components/pie-chart.js +2 -1
  97. package/dist/components/pie-chart.js.map +1 -1
  98. package/dist/components/progress-bar.js +5 -4
  99. package/dist/components/progress-bar.js.map +1 -1
  100. package/dist/components/reauth-dialog.js +1 -1
  101. package/dist/components/reauth-dialog.js.map +1 -1
  102. package/dist/components/row-actions-labels.js +3 -1
  103. package/dist/components/row-actions-labels.js.map +1 -1
  104. package/dist/components/row-actions.d.ts +43 -9
  105. package/dist/components/row-actions.js +78 -15
  106. package/dist/components/row-actions.js.map +1 -1
  107. package/dist/components/section-nav.d.ts +37 -0
  108. package/dist/components/section-nav.js +33 -0
  109. package/dist/components/section-nav.js.map +1 -0
  110. package/dist/components/series-chart.js +8 -3
  111. package/dist/components/series-chart.js.map +1 -1
  112. package/dist/components/share-card.js +1 -1
  113. package/dist/components/share-card.js.map +1 -1
  114. package/dist/components/signature-pad.js +1 -1
  115. package/dist/components/signature-pad.js.map +1 -1
  116. package/dist/components/sparkline.js +2 -1
  117. package/dist/components/sparkline.js.map +1 -1
  118. package/dist/components/stat-tile.js +14 -7
  119. package/dist/components/stat-tile.js.map +1 -1
  120. package/dist/components/status-dot.d.ts +5 -1
  121. package/dist/components/status-dot.js +4 -1
  122. package/dist/components/status-dot.js.map +1 -1
  123. package/dist/components/tooltip.js +1 -1
  124. package/dist/components/tooltip.js.map +1 -1
  125. package/dist/components/translation-review-editor.js +2 -2
  126. package/dist/components/translation-review-editor.js.map +1 -1
  127. package/dist/components/translation-review.js +2 -2
  128. package/dist/components/translation-review.js.map +1 -1
  129. package/dist/components/treemap.d.ts +5 -2
  130. package/dist/components/treemap.js +26 -6
  131. package/dist/components/treemap.js.map +1 -1
  132. package/dist/components/use-table-state.js +37 -6
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/data-table.d.ts +1 -0
  135. package/dist/data-table.js.map +1 -1
  136. package/dist/demo/demo-banner.js +1 -1
  137. package/dist/demo/demo-banner.js.map +1 -1
  138. package/dist/demo/demo-ended.js +1 -1
  139. package/dist/demo/demo-ended.js.map +1 -1
  140. package/dist/demo/demo-start.js +1 -1
  141. package/dist/demo/demo-start.js.map +1 -1
  142. package/dist/eslint/index.js +1 -1
  143. package/dist/feedback/feedback-inbox.js +1 -1
  144. package/dist/feedback/feedback-inbox.js.map +1 -1
  145. package/dist/hooks/use-anchored-panel.js +2 -1
  146. package/dist/hooks/use-anchored-panel.js.map +1 -1
  147. package/dist/hooks/use-dismiss.d.ts +9 -1
  148. package/dist/hooks/use-dismiss.js +25 -5
  149. package/dist/hooks/use-dismiss.js.map +1 -1
  150. package/dist/hooks/use-document-title.d.ts +14 -0
  151. package/dist/hooks/use-document-title.js +22 -0
  152. package/dist/hooks/use-document-title.js.map +1 -0
  153. package/dist/i18n/german.js +6 -2
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +31 -1
  156. package/dist/i18n/kit-labels.js +43 -2
  157. package/dist/i18n/kit-labels.js.map +1 -1
  158. package/dist/i18n/locales/en.js +6 -2
  159. package/dist/i18n/locales/en.js.map +2 -2
  160. package/dist/i18n/locales/es.js +6 -2
  161. package/dist/i18n/locales/es.js.map +1 -1
  162. package/dist/i18n/locales/fr.js +6 -2
  163. package/dist/i18n/locales/fr.js.map +1 -1
  164. package/dist/i18n/locales/hu.js +6 -2
  165. package/dist/i18n/locales/hu.js.map +1 -1
  166. package/dist/i18n/locales/it.js +6 -2
  167. package/dist/i18n/locales/it.js.map +1 -1
  168. package/dist/i18n/locales/zh.js +6 -2
  169. package/dist/i18n/locales/zh.js.map +1 -1
  170. package/dist/i18n/review.js +2 -0
  171. package/dist/i18n/review.js.map +1 -1
  172. package/dist/i18n/shared-labels.js +1 -0
  173. package/dist/i18n/shared-labels.js.map +1 -1
  174. package/dist/index.d.ts +8 -1
  175. package/dist/index.js +11 -0
  176. package/dist/index.js.map +1 -1
  177. package/dist/landing/landing-actions.js +2 -2
  178. package/dist/landing/landing-actions.js.map +1 -1
  179. package/dist/landing/public-header.js +2 -2
  180. package/dist/landing/public-header.js.map +1 -1
  181. package/dist/lib/clipboard-minus.d.ts +27 -0
  182. package/dist/lib/clipboard-minus.js +63 -0
  183. package/dist/lib/clipboard-minus.js.map +1 -0
  184. package/dist/lib/document-title.d.ts +17 -0
  185. package/dist/lib/document-title.js +29 -0
  186. package/dist/lib/document-title.js.map +1 -0
  187. package/dist/lib/float-clearance.js +8 -2
  188. package/dist/lib/float-clearance.js.map +1 -1
  189. package/dist/lib/format.d.ts +35 -10
  190. package/dist/lib/format.js +55 -11
  191. package/dist/lib/format.js.map +1 -1
  192. package/dist/lib/minus.d.ts +22 -0
  193. package/dist/lib/minus.js +14 -0
  194. package/dist/lib/minus.js.map +1 -0
  195. package/dist/lib/status-tone.d.ts +30 -0
  196. package/dist/lib/status-tone.js +17 -0
  197. package/dist/lib/status-tone.js.map +1 -0
  198. package/dist/settings/settings-layout.js +3 -2
  199. package/dist/settings/settings-layout.js.map +1 -1
  200. package/dist/shell/account-menu-language.d.ts +1 -1
  201. package/dist/shell/account-menu-language.js +9 -1
  202. package/dist/shell/account-menu-language.js.map +1 -1
  203. package/dist/shell/app-shell-labels.js +2 -1
  204. package/dist/shell/app-shell-labels.js.map +1 -1
  205. package/dist/shell/app-shell.d.ts +58 -16
  206. package/dist/shell/app-shell.js +158 -44
  207. package/dist/shell/app-shell.js.map +1 -1
  208. package/dist/shell/language-code.d.ts +3 -0
  209. package/dist/shell/language-code.js +8 -0
  210. package/dist/shell/language-code.js.map +1 -0
  211. package/dist/shell/topbar-controls.d.ts +14 -6
  212. package/dist/shell/topbar-controls.js +14 -17
  213. package/dist/shell/topbar-controls.js.map +1 -1
  214. package/dist/testing/css-variables.generated.d.ts +1 -1
  215. package/dist/testing/css-variables.generated.js +1 -1
  216. package/dist/testing/css-variables.generated.js.map +1 -1
  217. package/dist/theme/chart-palette.d.ts +82 -15
  218. package/dist/theme/chart-palette.js +51 -24
  219. package/dist/theme/chart-palette.js.map +1 -1
  220. package/dist/theme/palette-presets.d.ts +9 -0
  221. package/dist/theme/palette-presets.js +77 -62
  222. package/dist/theme/palette-presets.js.map +1 -1
  223. package/dist/tour/tour.js +1 -1
  224. package/dist/tour/tour.js.map +1 -1
  225. package/dist/wizard/stepper-nav.d.ts +1 -1
  226. package/dist/wizard/stepper-nav.js +3 -3
  227. package/dist/wizard/stepper-nav.js.map +1 -1
  228. package/package.json +1 -1
  229. package/src/admin/admin-action-confirm.tsx +1 -1
  230. package/src/admin/invitations-panel.tsx +7 -3
  231. package/src/admin/user-roster.tsx +23 -6
  232. package/src/auth/accept-invitation.tsx +3 -3
  233. package/src/auth/not-found-page.tsx +1 -1
  234. package/src/auth/reset-password-form.tsx +3 -3
  235. package/src/auth/verify-email.tsx +3 -3
  236. package/src/billing/plan-card.tsx +2 -2
  237. package/src/components/breadcrumbs.tsx +32 -4
  238. package/src/components/button-look.ts +86 -15
  239. package/src/components/button.tsx +6 -3
  240. package/src/components/calendar-heatmap.tsx +2 -1
  241. package/src/components/chart-edge.ts +73 -0
  242. package/src/components/chart.tsx +3 -1
  243. package/src/components/chip.tsx +10 -1
  244. package/src/components/confirm-context.ts +7 -0
  245. package/src/components/confirm-dialog.tsx +3 -3
  246. package/src/components/danger-confirm.tsx +3 -3
  247. package/src/components/data-table-fit.ts +58 -8
  248. package/src/components/data-table-groups.tsx +15 -4
  249. package/src/components/data-table-head.tsx +25 -2
  250. package/src/components/data-table-leading-row.tsx +4 -1
  251. package/src/components/data-table-mobile.tsx +162 -11
  252. package/src/components/data-table-row-actions.tsx +12 -3
  253. package/src/components/data-table-row.tsx +485 -0
  254. package/src/components/data-table-selection.ts +17 -1
  255. package/src/components/data-table-virtual.ts +547 -0
  256. package/src/components/data-table.tsx +367 -326
  257. package/src/components/error-boundary.tsx +2 -2
  258. package/src/components/floating-panel.tsx +67 -16
  259. package/src/components/form-actions.tsx +2 -2
  260. package/src/components/icon-button.tsx +21 -8
  261. package/src/components/nav-pills.tsx +29 -2
  262. package/src/components/page-header-context.ts +8 -0
  263. package/src/components/page-header.tsx +34 -2
  264. package/src/components/pie-chart.tsx +2 -1
  265. package/src/components/progress-bar.tsx +6 -3
  266. package/src/components/reauth-dialog.tsx +1 -1
  267. package/src/components/row-actions-labels.ts +2 -0
  268. package/src/components/row-actions.tsx +154 -33
  269. package/src/components/section-nav.tsx +65 -0
  270. package/src/components/series-chart.tsx +12 -3
  271. package/src/components/share-card.tsx +1 -1
  272. package/src/components/signature-pad.tsx +1 -1
  273. package/src/components/sparkline.tsx +2 -1
  274. package/src/components/stat-tile.tsx +19 -7
  275. package/src/components/status-dot.tsx +7 -1
  276. package/src/components/tooltip.tsx +3 -1
  277. package/src/components/translation-review-editor.tsx +2 -2
  278. package/src/components/translation-review.tsx +2 -2
  279. package/src/components/treemap.tsx +34 -8
  280. package/src/components/use-table-state.ts +63 -8
  281. package/src/data-table.ts +5 -3
  282. package/src/demo/demo-banner.tsx +1 -1
  283. package/src/demo/demo-ended.tsx +1 -1
  284. package/src/demo/demo-start.tsx +1 -1
  285. package/src/feedback/feedback-inbox.tsx +1 -1
  286. package/src/hooks/use-anchored-panel.ts +9 -1
  287. package/src/hooks/use-dismiss.ts +43 -4
  288. package/src/hooks/use-document-title.ts +31 -0
  289. package/src/i18n/german.ts +4 -0
  290. package/src/i18n/kit-labels.tsx +76 -1
  291. package/src/i18n/locales/es.ts +4 -0
  292. package/src/i18n/locales/fr.ts +4 -0
  293. package/src/i18n/locales/hu.ts +4 -0
  294. package/src/i18n/locales/it.ts +4 -0
  295. package/src/i18n/locales/zh.ts +4 -0
  296. package/src/i18n/review.ts +2 -0
  297. package/src/i18n/shared-labels.ts +1 -0
  298. package/src/index.ts +13 -0
  299. package/src/landing/landing-actions.tsx +2 -2
  300. package/src/landing/public-header.tsx +2 -2
  301. package/src/lib/clipboard-minus.ts +94 -0
  302. package/src/lib/document-title.ts +41 -0
  303. package/src/lib/float-clearance.ts +19 -3
  304. package/src/lib/format.ts +115 -30
  305. package/src/lib/minus.ts +40 -0
  306. package/src/lib/status-tone.ts +43 -0
  307. package/src/settings/settings-layout.tsx +3 -2
  308. package/src/shell/account-menu-language.tsx +14 -2
  309. package/src/shell/app-shell-labels.ts +1 -0
  310. package/src/shell/app-shell.tsx +298 -69
  311. package/src/shell/language-code.ts +5 -0
  312. package/src/shell/topbar-controls.tsx +35 -15
  313. package/src/testing/css-variables.generated.ts +6 -6
  314. package/src/theme/chart-palette.ts +102 -20
  315. package/src/theme/palette-presets.ts +121 -84
  316. package/src/tour/tour.tsx +1 -1
  317. package/src/wizard/stepper-nav.tsx +4 -4
  318. package/tokens.css +28 -18
@@ -5,17 +5,17 @@ import { Spinner } from "./spinner";
5
5
  import { isTableOwnColumn, useRowActionColumn } from "./data-table-row-actions";
6
6
  import { useColumnResize } from "./data-table-resize";
7
7
  import { useRangeSelection } from "./data-table-selection";
8
+ import { columnLabel, logicalAlign, resolveDataTableLabels } from "./data-table-shared";
8
9
  import {
9
- RowLink,
10
- cleanAttrs,
11
- columnLabel,
12
- fromOutside,
13
- fromOwnControl,
14
- logicalAlign,
15
- resolveDataTableLabels,
16
- splitCellProps,
17
- } from "./data-table-shared";
18
- import { MobileFilters, MobileRow, MobileSelectBar, MobileSort, StackedFields } from "./data-table-mobile";
10
+ MobileFilters,
11
+ MobileSelectBar,
12
+ MobileSort,
13
+ StackedFields,
14
+ keepsOwnClicks,
15
+ mobileCardColumns,
16
+ useCardColumnWarnings,
17
+ } from "./data-table-mobile";
18
+ import { DataTableMobileRow, DataTableRow, type DataTableRowCtx } from "./data-table-row";
19
19
  import { DataTableHead } from "./data-table-head";
20
20
  import { DataTableLeadingRow } from "./data-table-leading-row";
21
21
  import {
@@ -26,8 +26,11 @@ import {
26
26
  useGroupBandTops,
27
27
  type DataTableGroupBy,
28
28
  type DataTableLoadMore,
29
+ type RowGroup,
29
30
  } from "./data-table-groups";
30
31
  export type { DataTableGroupBy, DataTableLoadMore } from "./data-table-groups";
32
+ import { VIRTUAL_SELECT_COL, rowNavTarget, useRowWindow, type DataTableVirtualize, type VirtualGroup } from "./data-table-virtual";
33
+ export type { DataTableVirtualize } from "./data-table-virtual";
31
34
  import { ColumnSettingsPanel } from "./data-table-column-settings";
32
35
  import { useLargeText } from "../hooks/use-large-text";
33
36
  import { cn } from "../lib/cn";
@@ -66,6 +69,36 @@ export interface DataTableColumn<T> {
66
69
  // bookkeeping) that would crowd a card without aiding scanning.
67
70
  mobilePrimary?: boolean;
68
71
  mobileHidden?: boolean;
72
+ /**
73
+ * This column on the phone's default card (0.49 KIT-7; ignored with `mobileCard`):
74
+ * `"primary"` — the card's title, as `mobilePrimary`; `"secondary"` — one of its
75
+ * fields; `"hidden"` — not on the card, as `mobileHidden`. The card shows at most
76
+ * three fields: the columns marked `"secondary"` when any
77
+ * is, else the first three visible columns after the title. A field whose value is
78
+ * empty — the cell returns `null`, `undefined`, `""` or the formatters' empty "—" —
79
+ * is left out of that card. The columns the card does not show go into the opened
80
+ * row with `mobileDetailsInRow`. A `noRowLink` column (the cell's own buttons or link)
81
+ * counts as a field: past the third it leaves the card with its action, and a
82
+ * development build says so once per table and column key. Give a column of row
83
+ * actions `role: "actions"` instead: it is then no field, and the card draws it after
84
+ * them.
85
+ */
86
+ mobile?: "primary" | "secondary" | "hidden";
87
+ /**
88
+ * What the column is (0.49 KIT-7), for the phone's default card; the desktop table is
89
+ * unchanged.
90
+ *
91
+ * - `"status"`: its value sits as a chip beside the title instead of as a label/value
92
+ * line, and takes none of the card's three fields. A cell that returns text is drawn
93
+ * in a neutral `Chip`; one that returns an element (a `Chip` with its own tone, a
94
+ * `StatusDot`) is drawn as it is.
95
+ * - `"actions"`: the row's buttons (`rowActionsColumn` sets it). Never the card's title
96
+ * and never one of its three fields: the card draws it after them, at its end,
97
+ * whether or not other columns are marked `mobile: "secondary"`; only `mobileHidden`
98
+ * / `mobile: "hidden"` take it off. Its cell keeps its own clicks, as `noRowLink`:
99
+ * no row link wraps it, and a card that draws it is not a link.
100
+ */
101
+ role?: "status" | "actions";
69
102
  /**
70
103
  * Set on a column whose cell renders its own `<a>` (or anything else that must
71
104
  * keep its own click): the row anchor of `rowHref` then skips it and lands on
@@ -280,7 +313,10 @@ export interface DataTableProps<T> {
280
313
  * When true, mirror filter/sort/page state into URL search params so views are
281
314
  * shareable and bookmarkable. URL params used: `f.<column>`, `sort`, `p`, `ps`.
282
315
  * URL state takes precedence over localStorage on initial load. Requires the
283
- * consuming app to render inside a react-router Router.
316
+ * consuming app to render inside a react-router Router (0.49: only with `urlSync` — a
317
+ * table without it needs none). With `urlSync` and no Router, a development build
318
+ * throws "DataTable urlSync requires a Router"; a production build keeps the view in
319
+ * local state instead.
284
320
  *
285
321
  * `{ prefix }` namespaces them, for two URL-synced tables on one page:
286
322
  * `urlSync={{ prefix: "inv." }}` uses `inv.f.<column>`, `inv.sort`, `inv.p`, `inv.ps`.
@@ -607,6 +643,43 @@ export interface DataTableProps<T> {
607
643
  * {@link DataTableLoadMore}.
608
644
  */
609
645
  loadMore?: DataTableLoadMore;
646
+ /**
647
+ * 0.48.2 (keksdose dev #632, Marcel: "The table is extremely slow. Is it virtualised…?"):
648
+ * render only the desktop rows near the visible part of the scroller, plus `overscan`
649
+ * rows each side ({@link DataTableVirtualize}), with spacer rows standing in for the
650
+ * rest. For long lists — keksdose's register under `loadMore` holds 1000+ rows. Opt-in;
651
+ * without it every row renders, as before.
652
+ *
653
+ * - **Scroller:** the bounded body (`fillHeight` or `maxBodyHeight`), or the page when the
654
+ * body is unbounded (`maxBodyHeight="none"`): the window is what of the table both the
655
+ * scroller and the browser window show.
656
+ * - **Kept rendered:** the header, the `leadingRow`, the group bands (sticky and stacked
657
+ * as before; a period's band while any of its rows is in view), every OPEN row (so an
658
+ * inline editor in `expandedRow` keeps its state), the roving tab stop and a row
659
+ * holding the focus. The `loadMore` footer follows the last spacer and loads once.
660
+ * - **Heights:** each rendered row is measured and remembered by its key; a row not yet
661
+ * seen counts as the measured average (`estimateRowHeight` before any). The scroll
662
+ * height settles as the rows are seen; the browser's scroll anchoring holds the rows in
663
+ * view while a spacer above is corrected (Safari has none: a jump of the difference
664
+ * is possible there on the first pass).
665
+ * - **Columns:** the header widths are measured once and locked in a `<colgroup>`, so the
666
+ * columns do not move as rows come and go; a wider cell scrolling in widens its column
667
+ * (once). Re-measured when the rows, the columns, a width or the scroller's width
668
+ * change. A `grow` column and a resized one are not locked.
669
+ * - **Keyboard and screen reader:** Arrow/Home/End move over every row — the target is
670
+ * rendered, then focused (End reaches the last row); Shift+click ranges and select-all
671
+ * work on the data, not the DOM. The table has `aria-rowcount`, every rendered row its
672
+ * `aria-rowindex` (bands, details and the totals row counted); spacers are
673
+ * `aria-hidden`.
674
+ * - **Trade-offs:** the browser's find-in-page sees only the rendered rows, and so does
675
+ * print; an `id` from `rowAttributes` (an anchor, an `aria-controls` target, a
676
+ * `scrollIntoView` on a fresh row) exists only while its row is rendered. Memoise
677
+ * `columns` (and `mobileCard`): the rows bail out of a render only then, and a scroll
678
+ * renders the rows that come in, not the whole window.
679
+ * - **Phone:** desktop only. The card list renders every row it holds (`loadMore`,
680
+ * `paginated={false}`) or reveals them in chunks as it is scrolled (client paging).
681
+ */
682
+ virtualize?: boolean | DataTableVirtualize;
610
683
  /**
611
684
  * Row height. `"compact"` is lenkbank's in-card tables: five `text-xs` tables with
612
685
  * `px-2 py-1` cells sitting inside cards, which at the default padding came out at
@@ -862,35 +935,14 @@ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
862
935
 
863
936
  const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
864
937
 
865
- /** Arrow/Home/End between the focusable rows of the table's bodies — the roving half of
866
- * the keyboard-activatable rows (see the row's `onKeyDown`). */
867
- function moveRowFocus(from: HTMLElement, key: string): boolean {
868
- if (!(key in ROW_NAV_KEYS)) return false;
869
- // Every body of the table: a grouped table (`groupBy`, 0.48) has one per group.
870
- const table = from.closest("table");
871
- if (!table) return false;
872
- const rows = Array.from(table.tBodies).flatMap((body) =>
873
- Array.from(body.children).filter(
874
- (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute("data-row-nav"),
875
- ),
876
- );
877
- const i = rows.indexOf(from);
878
- const target =
879
- key === "ArrowDown"
880
- ? rows[i + 1]
881
- : key === "ArrowUp"
882
- ? rows[i - 1]
883
- : key === "Home"
884
- ? rows[0]
885
- : key === "End"
886
- ? rows[rows.length - 1]
887
- : undefined;
888
- // At either end the key is still consumed, or ArrowDown on the last row would
889
- // scroll the page out from under a focused row.
890
- target?.focus();
891
- return true;
892
- }
893
- const ROW_NAV_KEYS = { ArrowDown: 1, ArrowUp: 1, Home: 1, End: 1 } as const;
938
+ // `virtualize`'s constants (0.48.2): the band levels a group's `<tbody>` opens with.
939
+ const NO_BANDS: readonly (1 | 2)[] = [];
940
+ const BAND_1: readonly (1 | 2)[] = [1];
941
+ const BAND_2: readonly (1 | 2)[] = [2];
942
+ const BAND_1_2: readonly (1 | 2)[] = [1, 2];
943
+ const NO_VIRTUAL_OPTIONS: DataTableVirtualize = {};
944
+
945
+ // `moveRowFocus` (the roving rows' Arrow/Home/End) lives with the row in data-table-row.tsx.
894
946
 
895
947
  // ---------- Main DataTable ----------
896
948
 
@@ -943,6 +995,7 @@ export function DataTable<T>({
943
995
  leadingRowSticky = false,
944
996
  groupBy,
945
997
  loadMore,
998
+ virtualize = false,
946
999
  density = "comfortable",
947
1000
  sortCycle = "tri",
948
1001
  chrome = "full",
@@ -1271,12 +1324,16 @@ export function DataTable<T>({
1271
1324
 
1272
1325
  // 0.48 (keksdose live #451): the `fitPriority` columns the table's width leaves out
1273
1326
  // (data-table-fit.ts). Desktop only; the scroller is read after render.
1327
+ // Large and Extra large: read here, as the fit re-measures when it changes.
1328
+ const large = useLargeText();
1274
1329
  const { autoHidden, keep: keepColumn } = useColumnFit({
1275
1330
  columns,
1276
1331
  hiddenCols,
1277
1332
  headRefs,
1278
1333
  scroller: () => desktopScroller.current,
1279
1334
  enabled: !usePhoneLayout(),
1335
+ // Measured only when one of these changed (0.48.2): not on a ticked box.
1336
+ content: [slice, columns, hiddenCols, widths, density, large],
1280
1337
  });
1281
1338
  const visibleColumns = useMemo(
1282
1339
  () => columns.filter((c) => !hiddenCols.has(c.key) && !autoHidden.has(c.key)),
@@ -1293,14 +1350,14 @@ export function DataTable<T>({
1293
1350
  // column panel. Resolved from `visibleColumns` for exactly that reason — off a
1294
1351
  // raw `columns` lookup a hidden headline would leave the row with no link at all.
1295
1352
  //
1296
- // `noRowLink` columns are skipped in BOTH steps: wrapping a cell that owns an
1297
- // anchor nests two links and disables the inner one (see the prop's note). If
1298
- // nothing visible is left to wrap, the row gets NO anchor — an anchor over the
1299
- // one cell there is would cancel that cell's own click and hand back nothing,
1300
- // and a row without a link still behaves exactly as it did before #451.
1353
+ // `noRowLink` (and `role: "actions"`) columns are skipped in BOTH steps: wrapping a
1354
+ // cell that owns an anchor nests two links and disables the inner one (see the prop's
1355
+ // note). If nothing visible is left to wrap, the row gets NO anchor — an anchor
1356
+ // over the one cell there is would cancel that cell's own click and hand back
1357
+ // nothing, and a row without a link still behaves exactly as it did before #451.
1301
1358
  const linkColumn = rowHref
1302
- ? (visibleColumns.find((c) => c.mobilePrimary && !c.noRowLink) ??
1303
- visibleColumns.find((c) => !c.noRowLink))
1359
+ ? (visibleColumns.find((c) => c.mobilePrimary && !keepsOwnClicks(c)) ??
1360
+ visibleColumns.find((c) => !keepsOwnClicks(c)))
1304
1361
  : undefined;
1305
1362
 
1306
1363
  // ---- Column resize ---- (data-table-resize.ts: pointer capture, one `setWidths` per
@@ -1370,17 +1427,41 @@ export function DataTable<T>({
1370
1427
  // of a horizontally-scrolling table. Column settings stay desktop-only; the
1371
1428
  // filters come through a bottom sheet (feedback #299) and the sort through the
1372
1429
  // opt-in `mobileSort` row (keksdose K10), both on the table's own state.
1373
- const mobileColumns = visibleColumns.filter((c) => !c.mobileHidden);
1374
- const mobilePrimaryCol =
1375
- mobileColumns.find((c) => c.mobilePrimary) ?? mobileColumns[0] ?? null;
1376
- const mobileSecondaryColumns = mobileColumns.filter((c) => c !== mobilePrimaryCol);
1377
- // The columns the card leaves out, for the opened row (`mobileDetailsInRow`).
1378
- const mobileDetailColumns = mobileDetailsInRow ? visibleColumns.filter((c) => c.mobileHidden) : [];
1430
+ // Memoised on `visibleColumns`: the cards compare their columns by identity (0.48.2).
1431
+ const mobileColumns = useMemo(
1432
+ () => visibleColumns.filter((c) => !c.mobileHidden && c.mobile !== "hidden"),
1433
+ [visibleColumns],
1434
+ );
1435
+ // The default card (0.49 KIT-7): the title, the status chips beside it, at most three
1436
+ // fields — the ones marked `mobile: "secondary"`, else the first after the title — and
1437
+ // the `role: "actions"` columns after them.
1438
+ const {
1439
+ primary: mobilePrimaryCol,
1440
+ status: mobileStatusColumns,
1441
+ fields: mobileSecondaryColumns,
1442
+ actions: mobileActionColumns,
1443
+ } = useMemo(() => mobileCardColumns(mobileColumns), [mobileColumns]);
1444
+ // A development build names a `noRowLink` column (a per-row delete) the card drops.
1445
+ useCardColumnWarnings(
1446
+ columns,
1447
+ !mobileCard && !mobileDetailsInRow,
1448
+ storageKey ?? (typeof urlSync === "object" ? urlSync.prefix : undefined),
1449
+ );
1450
+ // The columns the card leaves out, for the opened row (`mobileDetailsInRow`): the
1451
+ // hidden ones and, without a `mobileCard`, the ones past the card's three fields.
1452
+ const onCard = new Set<DataTableColumn<T>>([
1453
+ ...(mobilePrimaryCol ? [mobilePrimaryCol] : []),
1454
+ ...mobileStatusColumns,
1455
+ ...mobileSecondaryColumns,
1456
+ ...mobileActionColumns,
1457
+ ]);
1458
+ const mobileDetailColumns = mobileDetailsInRow
1459
+ ? visibleColumns.filter((c) => (mobileCard ? c.mobileHidden || c.mobile === "hidden" : !onCard.has(c)))
1460
+ : [];
1379
1461
  // Large and Extra large (§4 "DataTable phone card"): the card's labels go ABOVE their
1380
1462
  // values, nothing in it truncates, and the rows get more room. Most of it is a
1381
1463
  // `large:` class; the label/value grid needs a different STRUCTURE (each pair in a
1382
- // box of its own), which a class cannot write.
1383
- const large = useLargeText();
1464
+ // box of its own), which a class cannot write. (`large` is read above, by the fit.)
1384
1465
  // What an opened phone row shows: the card's left-out columns first (when asked
1385
1466
  // for), then the app's own `expandedRow`. Desktop keeps `expandedRow` alone.
1386
1467
  const opensRow = !!expandedRow || mobileDetailColumns.length > 0;
@@ -1401,7 +1482,7 @@ export function DataTable<T>({
1401
1482
  // opens the row and leaves the cell's own link working. A caller's `mobileCard`
1402
1483
  // renderer is opaque to us; a link inside one is the caller's to declare by
1403
1484
  // marking the column it comes from.
1404
- const mobileCardLinkable = mobileColumns.every((c) => !c.noRowLink);
1485
+ const mobileCardLinkable = (mobileCard ? mobileColumns : [...onCard]).every((c) => !keepsOwnClicks(c));
1405
1486
  // The card list's reading direction, for the one thing on it CSS cannot flip: which
1406
1487
  // physical drag a logical `start`/`end` swipe action means. Read off the DOM through
1407
1488
  // a ref callback (it runs at commit, like a layout effect) because `dir` is an
@@ -1465,7 +1546,7 @@ export function DataTable<T>({
1465
1546
  const phoneSelecting = !!selection && !isMdUp && (phoneSelectMode || anySelected);
1466
1547
  // ---- Shift+click range selection ---- (data-table-selection.ts; feedback #289,
1467
1548
  // anchored on the row's KEY so a sort, filter or page change cannot move it)
1468
- const { selectionAnchor, rangeClick, canSelect, selectRange } = useRangeSelection({
1549
+ const { canSelect, selectRange, setAnchor, markRangeClick, takeRangeClick } = useRangeSelection({
1469
1550
  selection,
1470
1551
  slice,
1471
1552
  rowKey,
@@ -1498,6 +1579,42 @@ export function DataTable<T>({
1498
1579
  ? rowKey(slice[0])
1499
1580
  : null;
1500
1581
 
1582
+ // What the memoised rows' handlers read at event time (0.48.2, K-052-2): the latest
1583
+ // props, refreshed every commit, so a row that bailed out of a render still toggles
1584
+ // through the current `selection` and opens through the current `onRowClick`.
1585
+ // `virtualize` (0.48.2): desktop only, and only with rows to window. Arrow/Home/End then
1586
+ // move over every row, rendered or not: the target is rendered, then focused.
1587
+ const virtualOn = !!virtualize && isMdUp && slice.length > 0;
1588
+ const focusRowRef = useRef<((index: number) => void) | undefined>(undefined);
1589
+ const latestRowCtx: DataTableRowCtx<T> = {
1590
+ moveFocus: virtualOn
1591
+ ? (from, key) => {
1592
+ const target = rowNavTarget(from, key, slice.length);
1593
+ if (target === null) return false;
1594
+ focusRowRef.current?.(target);
1595
+ return true;
1596
+ }
1597
+ : undefined,
1598
+ onRowClick,
1599
+ onToggle: selection?.onToggle,
1600
+ isSelected: (row) => !!selection?.isSelected(row),
1601
+ canSelect,
1602
+ selectRange,
1603
+ setAnchor,
1604
+ markRangeClick,
1605
+ takeRangeClick,
1606
+ setFocusRowKey,
1607
+ onLongPress: (row) => {
1608
+ setPhoneSelectMode(true);
1609
+ setAnchor(rowKey(row));
1610
+ if (selection && !selection.isSelected(row)) selection.onToggle(row, true);
1611
+ },
1612
+ };
1613
+ const rowCtx = useRef(latestRowCtx);
1614
+ useLayoutEffect(() => {
1615
+ rowCtx.current = latestRowCtx;
1616
+ });
1617
+
1501
1618
  // When dialog-mode is on, the expanded row's detail goes into a bottom-sheet
1502
1619
  // modal instead of unfolding inline. Only one row is ever expanded at a time.
1503
1620
  const mobileDialogRow = mobileExpandAsDialog && !isMdUp ? mobileSlice.find((r) => isExpanded?.(r)) : undefined;
@@ -1514,46 +1631,40 @@ export function DataTable<T>({
1514
1631
 
1515
1632
  // One mobile card (`MobileRow`, data-table-mobile.tsx). A function so the flat list
1516
1633
  // and the grouped list (below) share identical row markup.
1517
- const renderMobileRow = (row: T) => (
1518
- <MobileRow
1519
- key={rowKey(row)}
1520
- row={row}
1521
- isExpanded={isExpanded}
1522
- openedRowContent={openedRowContent}
1523
- opensRow={opensRow}
1524
- rowClassName={rowClassName}
1525
- rowAttributes={rowAttributes}
1526
- onRowClick={onRowClick}
1527
- rowHref={rowHref}
1528
- swipeActionsFor={swipeActionsFor}
1529
- mobileDir={mobileDir}
1530
- mobileCardLinkable={mobileCardLinkable}
1531
- mobileCard={mobileCard}
1532
- mobilePrimaryCol={mobilePrimaryCol}
1533
- mobileSecondaryColumns={mobileSecondaryColumns}
1534
- mobileExpandAsDialog={mobileExpandAsDialog}
1535
- compact={compact}
1536
- large={large}
1537
- select={
1538
- selection
1539
- ? {
1540
- active: phoneSelecting,
1541
- selectable: canSelect(row),
1542
- selected: selection.isSelected(row),
1543
- onToggle: (checked) => {
1544
- selectionAnchor.current = rowKey(row);
1545
- selection.onToggle(row, checked);
1546
- },
1547
- onLongPress: () => {
1548
- setPhoneSelectMode(true);
1549
- selectionAnchor.current = rowKey(row);
1550
- if (!selection.isSelected(row)) selection.onToggle(row, true);
1551
- },
1552
- }
1553
- : null
1554
- }
1555
- />
1556
- );
1634
+ const renderMobileRow = (row: T) => {
1635
+ const key = rowKey(row);
1636
+ const expanded = isExpanded?.(row) ?? false;
1637
+ return (
1638
+ <DataTableMobileRow<T>
1639
+ key={key}
1640
+ ctx={rowCtx}
1641
+ rowKey={key}
1642
+ row={row}
1643
+ expanded={expanded}
1644
+ expansion={expanded ? openedRowContent(row) : null}
1645
+ opensRow={opensRow}
1646
+ className={rowClassName?.(row)}
1647
+ attrs={rowAttributes?.(row)}
1648
+ interactive={!!onRowClick}
1649
+ href={mobileCardLinkable ? rowHref?.(row) : undefined}
1650
+ swipeActionsFor={swipeActionsFor}
1651
+ mobileDir={mobileDir}
1652
+ mobileCardLinkable={mobileCardLinkable}
1653
+ mobileCard={mobileCard}
1654
+ mobilePrimaryCol={mobilePrimaryCol}
1655
+ mobileStatusColumns={mobileStatusColumns}
1656
+ mobileSecondaryColumns={mobileSecondaryColumns}
1657
+ mobileActionColumns={mobileActionColumns}
1658
+ mobileExpandAsDialog={mobileExpandAsDialog}
1659
+ compact={compact}
1660
+ large={large}
1661
+ selectionOn={!!selection}
1662
+ selectActive={phoneSelecting}
1663
+ selectable={canSelect(row)}
1664
+ selected={!!selection && selection.isSelected(row)}
1665
+ />
1666
+ );
1667
+ };
1557
1668
 
1558
1669
  // Consecutive grouping of the (already-sorted) mobile slice into labelled
1559
1670
  // sections for the "assistance"-style list (feedback #317). Null unless the
@@ -1566,6 +1677,57 @@ export function DataTable<T>({
1566
1677
  !!desktopGroups,
1567
1678
  `${levels.length}:${leadingRowSticky && !!leadingRow}:${desktopGroups?.[0]?.key ?? ""}`,
1568
1679
  );
1680
+ // ---- Row windowing ---- (0.48.2, keksdose dev #632; data-table-virtual.ts)
1681
+ // One run per innermost group, with its bands (`virtualOn` is above, with the rows' context).
1682
+ const virtualGroups: VirtualGroup[] = [];
1683
+ const virtualBands: { g1: RowGroup<T>; g2: RowGroup<T> | null }[] = [];
1684
+ if (virtualOn) {
1685
+ if (!desktopGroups) virtualGroups.push({ start: 0, end: slice.length, bands: NO_BANDS, first: 0 });
1686
+ else
1687
+ for (const g1 of desktopGroups) {
1688
+ const first = virtualGroups.length;
1689
+ if (!g1.children) {
1690
+ virtualGroups.push({ start: g1.start, end: g1.start + g1.rows.length, bands: BAND_1, first });
1691
+ virtualBands.push({ g1, g2: null });
1692
+ continue;
1693
+ }
1694
+ g1.children.forEach((g2, j) => {
1695
+ virtualGroups.push({ start: g2.start, end: g2.start + g2.rows.length, bands: j === 0 ? BAND_1_2 : BAND_2, first });
1696
+ virtualBands.push({ g1, g2 });
1697
+ });
1698
+ }
1699
+ }
1700
+ const expandedIndices: number[] = [];
1701
+ if (virtualOn && isExpanded && expandedRow) slice.forEach((r, i) => isExpanded(r) && expandedIndices.push(i));
1702
+ const virtualColKeys = [...(selection ? [VIRTUAL_SELECT_COL] : []), ...visibleColumns.map((c) => c.key)];
1703
+ const virtualLockable = new Set(
1704
+ virtualColKeys.filter((k) => !widths[k] && !visibleColumns.some((c) => c.key === k && c.grow)),
1705
+ );
1706
+ const rowWindow = useRowWindow({
1707
+ enabled: virtualOn,
1708
+ rows: slice,
1709
+ rowKey,
1710
+ groups: virtualGroups,
1711
+ expanded: expandedIndices,
1712
+ pinnedKeys: [tabRowKey],
1713
+ scrollerRef: desktopScroller,
1714
+ tableRef,
1715
+ options: typeof virtualize === "object" ? virtualize : NO_VIRTUAL_OPTIONS,
1716
+ compact,
1717
+ rowsBefore: 1 + (leadingRow ? 1 : 0),
1718
+ rowsAfter: showFooter ? 1 : 0,
1719
+ colKeys: virtualColKeys,
1720
+ lockable: virtualLockable,
1721
+ resetKey: `${Object.entries(widths).join(";")}|${density}|${large}|${slice.length}:${
1722
+ slice.length ? `${rowKey(slice[0])}:${rowKey(slice[slice.length - 1])}` : ""
1723
+ }`,
1724
+ });
1725
+ // What `moveFocus` (in the rows' context, above) reaches: the window's focus mover.
1726
+ const focusRowAt = rowWindow?.focusRow;
1727
+ useLayoutEffect(() => {
1728
+ focusRowRef.current = focusRowAt;
1729
+ }, [focusRowAt]);
1730
+
1569
1731
  // `mobileGroupBy` may be a desktop `groupBy` level (0.48), its label and end slot too.
1570
1732
  const mobileLevel = typeof mobileGroupBy === "function" ? null : (mobileGroupBy ?? null);
1571
1733
  const mobileKey = typeof mobileGroupBy === "function" ? mobileGroupBy : mobileGroupBy?.key;
@@ -1866,7 +2028,20 @@ export function DataTable<T>({
1866
2028
  data-fit-grow={growDrawn ? "" : undefined}
1867
2029
  aria-label={labels.table}
1868
2030
  aria-busy={isLoading || undefined}
2031
+ // `virtualize` (0.48.2): every row counted, rendered or not.
2032
+ aria-rowcount={rowWindow?.rowCount}
2033
+ data-virtualized={rowWindow ? "" : undefined}
1869
2034
  >
2035
+ {/* The locked column widths (0.48.2, `virtualize`): rows coming and going no
2036
+ longer move the columns. */}
2037
+ {rowWindow && (
2038
+ <colgroup>
2039
+ {virtualColKeys.map((key) => {
2040
+ const w = rowWindow.colWidths?.[key];
2041
+ return <col key={key} style={w !== undefined ? { width: w } : undefined} />;
2042
+ })}
2043
+ </colgroup>
2044
+ )}
1870
2045
  {/* Sticky header. position:sticky pins to the nearest scroll-container
1871
2046
  ancestor — so the header can only stick to THIS wrapper, never the
1872
2047
  page. For that to actually hold the header in place, the wrapper
@@ -1899,235 +2074,57 @@ export function DataTable<T>({
1899
2074
  startResize={startResize}
1900
2075
  moveResize={moveResize}
1901
2076
  finishResize={finishResize}
2077
+ ariaRowIndex={rowWindow ? 1 : undefined}
2078
+ lockedWidths={rowWindow?.colWidths}
2079
+ selectionLockKey={VIRTUAL_SELECT_COL}
1902
2080
  />
1903
2081
  {(() => {
1904
2082
  const bodyClass = cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity");
1905
2083
  const leading = leadingRow ? (
1906
- <DataTableLeadingRow colSpan={totalColSpan} sticky={leadingRowSticky}>
2084
+ <DataTableLeadingRow
2085
+ colSpan={totalColSpan}
2086
+ sticky={leadingRowSticky}
2087
+ ariaRowIndex={rowWindow ? 2 : undefined}
2088
+ >
1907
2089
  {leadingRow}
1908
2090
  </DataTableLeadingRow>
1909
2091
  ) : null;
2092
+ // One memoised row each (0.48.2, K-052-2; data-table-row.tsx): the table
2093
+ // derives each row's own inputs here, as primitives, so a tick re-renders
2094
+ // only the rows whose inputs changed.
1910
2095
  const renderRow = (row: T, rowIndex: number) => {
1911
2096
  const expanded = isExpanded?.(row) ?? false;
1912
- const expansion = expanded ? expandedRow?.(row) : null;
1913
- const rowInteractive = !!onRowClick || !!selection;
1914
- const href = rowHref?.(row);
1915
2097
  const key = rowKey(row);
1916
- const detailId = `${detailIdBase}-${rowIndex}`;
1917
- // Whether the row is open, said where ARIA allows it (round 0.34, the
1918
- // showcase's axe check): `aria-expanded` belongs to a treegrid's row, not a
1919
- // table's, and the <tr> wore it. A disclosure button in the first cell carries
1920
- // it now, with `aria-controls` on the detail row while that is there.
1921
- // Visually hidden and not a tab stop: the row stays the one roving stop, with
1922
- // Enter/Space and the whole-row click — this is the screen reader cursor's
1923
- // way in, and where it hears the state. Named by `aria-label`, so the cell's
1924
- // text stays the cell's own.
1925
- const disclosure =
1926
- rowsFocusable && expandedRow ? (
1927
- <button
1928
- type="button"
1929
- tabIndex={-1}
1930
- className="sr-only-fixed"
1931
- aria-label={labels.rowDetails}
1932
- aria-expanded={expanded}
1933
- aria-controls={expansion ? detailId : undefined}
1934
- // The row's own handler leaves a click on a control inside it alone.
1935
- onClick={() => onRowClick!(row)}
1936
- />
1937
- ) : null;
1938
2098
  return (
1939
- <Fragment key={key}>
1940
- <tr
1941
- {...cleanAttrs(rowAttributes?.(row))}
1942
- data-row-nav={rowsFocusable ? "" : undefined}
1943
- tabIndex={rowsFocusable ? (key === tabRowKey ? 0 : -1) : undefined}
1944
- onFocus={
1945
- rowsFocusable
1946
- ? (e) => {
1947
- if (e.target === e.currentTarget) setFocusRowKey(key);
1948
- }
1949
- : undefined
1950
- }
1951
- onKeyDown={
1952
- rowsFocusable
1953
- ? (e) => {
1954
- // Only the row's own key presses: Enter on a nested button
1955
- // or link is that control's, and Space in a nested input
1956
- // is a space.
1957
- if (e.target !== e.currentTarget) return;
1958
- if (e.altKey || e.ctrlKey || e.metaKey) return;
1959
- if (e.key === "Enter" || e.key === " ") {
1960
- // Space would otherwise scroll the table.
1961
- e.preventDefault();
1962
- onRowClick!(row);
1963
- return;
1964
- }
1965
- if (e.shiftKey) return;
1966
- if (moveRowFocus(e.currentTarget, e.key)) e.preventDefault();
1967
- }
1968
- : undefined
1969
- }
1970
- className={cn(
1971
- "border-t border-subtle",
1972
- rowInteractive && "cursor-pointer hover:bg-hover",
1973
- // The kit's focus frame width (§5, `--focus-ring-width`), drawn as an
1974
- // OUTLINE rather than FOCUS_RING's box-shadow: a shadow on a `<tr>`
1975
- // is not painted by every engine, an outline is.
1976
- rowsFocusable &&
1977
- "focus-visible:bg-hover focus-visible:outline-focus focus-visible:-outline-offset-2 focus-visible:outline-brand",
1978
- rowClassName?.(row),
1979
- )}
1980
- onClick={
1981
- rowInteractive
1982
- ? (e) => {
1983
- // A click on a control inside the row (a button, a link,
1984
- // an input the cell renders) is that control's — it must
1985
- // not also open the row. Cells no longer have to
1986
- // `stopPropagation` to get that.
1987
- if (fromOwnControl(e.target, e.currentTarget)) return;
1988
- if (fromOutside(e.target, e.currentTarget)) return;
1989
- // Modifier-clicks anywhere in the row drive
1990
- // selection instead of expanding it (feedback #289):
1991
- // Ctrl/Cmd toggles a single row, Shift selects the
1992
- // range from the last-toggled row (inclusive of this
1993
- // one) — the same as the checkbox, but for the whole
1994
- // row, which is what the checkbox-only version was
1995
- // missing.
1996
- if (selection && canSelect(row)) {
1997
- if (e.shiftKey) {
1998
- // Drop the text selection a shift-click would
1999
- // otherwise smear across the rows.
2000
- window.getSelection?.()?.removeAllRanges();
2001
- if (selectRange(rowIndex)) return;
2002
- // No anchor yet: treat it as the first pick.
2003
- selectionAnchor.current = rowKey(row);
2004
- selection.onToggle(row, !selection.isSelected(row));
2005
- return;
2006
- }
2007
- if (e.metaKey || e.ctrlKey) {
2008
- selectionAnchor.current = rowKey(row);
2009
- selection.onToggle(row, !selection.isSelected(row));
2010
- return;
2011
- }
2012
- }
2013
- onRowClick?.(row);
2014
- }
2015
- : undefined
2016
- }
2017
- >
2018
- {selection && (
2019
- <td
2020
- className={cn("align-top", compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2")}
2021
- // Don't let selecting a row also trigger the row click
2022
- // (which expands/edits it).
2023
- onClick={(e) => e.stopPropagation()}
2024
- >
2025
- {canSelect(row) && (
2026
- <input
2027
- type="checkbox"
2028
- className={cn("accent-indigo-600", compact ? "size-3.5" : "size-4")}
2029
- checked={selection.isSelected(row)}
2030
- // Shift+click selects the range from the last toggled
2031
- // row. `preventDefault` puts the box back, but it does NOT
2032
- // stop React's `onChange`: React derives a checkbox's
2033
- // change from the `click` itself, after the browser has
2034
- // already flipped `checked` and before the cancellation
2035
- // reverts it — so the same click also reported itself as
2036
- // a single toggle, and the owner got `onToggleMany` and
2037
- // then `onToggle` for the clicked row. The click is
2038
- // remembered by identity and its change is dropped; a
2039
- // boolean flag would outlive a click whose change never
2040
- // came and swallow the user's next real one.
2041
- onClick={(e) => {
2042
- if (e.shiftKey && selectRange(rowIndex)) {
2043
- e.preventDefault();
2044
- rangeClick.current = e.nativeEvent;
2045
- }
2046
- }}
2047
- onChange={(e) => {
2048
- if (rangeClick.current && e.nativeEvent === rangeClick.current) {
2049
- rangeClick.current = null;
2050
- return;
2051
- }
2052
- selectionAnchor.current = rowKey(row);
2053
- selection.onToggle(row, e.target.checked);
2054
- }}
2055
- aria-label={labels.selectRow}
2056
- />
2057
- )}
2058
- </td>
2059
- )}
2060
- {visibleColumns.map((col, colIndex) => {
2061
- const width = widths[col.key];
2062
- // The caller's first, the table's over it — see `cellProps`.
2063
- const own = splitCellProps(col.cellProps?.(row));
2064
- const pinned = width ? { width, minWidth: width, maxWidth: width } : undefined;
2065
- return (
2066
- <td
2067
- {...own.attrs}
2068
- key={col.key}
2069
- data-col={col.key}
2070
- style={own.style || pinned ? { ...own.style, ...pinned } : undefined}
2071
- className={cn(
2072
- "align-top",
2073
- compact ? "px-2 py-1" : "px-3 py-2",
2074
- width && "overflow-hidden text-ellipsis",
2075
- col.grow ? GROW_CELL_CLASS : growDrawn && GROW_PEER_CELL_CLASS,
2076
- logicalAlign(own.className),
2077
- logicalAlign(col.className),
2078
- )}
2079
- >
2080
- {colIndex === 0 && disclosure}
2081
- {col === linkColumn && href ? (
2082
- // No `onActivate`: the click is left to bubble to the
2083
- // <tr> handler above, which already owns expand-vs-select.
2084
- // Calling it here as well would toggle the row twice.
2085
- <RowLink href={href} className="block" data-row-link="">
2086
- {col.cell(row)}
2087
- </RowLink>
2088
- ) : (
2089
- col.cell(row)
2090
- )}
2091
- </td>
2092
- );
2093
- })}
2094
- </tr>
2095
- {expansion && (
2096
- <tr id={disclosure ? detailId : undefined} className="bg-surface-2 border-t border-subtle">
2097
- {/* The detail panel spans every column. In an `auto` table
2098
- layout (the default here — columns only get fixed style
2099
- widths once the user resizes/auto-sizes them) the cell's
2100
- content preferred-width is redistributed across the
2101
- spanned columns, so a long expansion visibly re-sizes the
2102
- columns above it — and one-line content does not, since
2103
- its preferred width is small (feedback #104). Wrapping the
2104
- content in `w-0 min-w-full` pins the cell's preferred
2105
- width to ~0 (the explicit width:0 child), so it can never
2106
- perturb the columns; the div then fills to the full row
2107
- width only at paint time, and long text wraps within it.
2108
- Inside it the content is pinned to what the scroller SHOWS
2109
- (0.47, keksdose S4): on a table wider than its scroller a
2110
- table-wide panel put an inline editor's Save/Cancel off-screen.
2111
- `sticky start-0` holds it at the scroller's start edge (the
2112
- right one in RTL) as the row scrolls sideways, and
2113
- `--dt-visible-w` (the scroller's clientWidth, measured above)
2114
- sizes it; before the first measure, and in a table no wider
2115
- than its scroller, it is the full width as before. */}
2116
- <td colSpan={totalColSpan} className="p-0">
2117
- <div className="w-0 min-w-full">
2118
- <div
2119
- className={cn(
2120
- "sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
2121
- compact ? "px-2 py-2" : "px-3 py-3",
2122
- )}
2123
- >
2124
- {expansion}
2125
- </div>
2126
- </div>
2127
- </td>
2128
- </tr>
2129
- )}
2130
- </Fragment>
2099
+ <DataTableRow<T>
2100
+ key={key}
2101
+ row={row}
2102
+ rowIndex={rowIndex}
2103
+ rowKey={key}
2104
+ ctx={rowCtx}
2105
+ columns={visibleColumns}
2106
+ widths={widths}
2107
+ growDrawn={growDrawn}
2108
+ linkColumn={linkColumn}
2109
+ href={rowHref?.(row)}
2110
+ selectionColumn={!!selection}
2111
+ selectable={canSelect(row)}
2112
+ selected={!!selection && selection.isSelected(row)}
2113
+ expanded={expanded}
2114
+ expansion={expanded ? expandedRow?.(row) : null}
2115
+ disclosure={rowsFocusable && !!expandedRow}
2116
+ detailId={`${detailIdBase}-${rowIndex}`}
2117
+ rowsFocusable={rowsFocusable}
2118
+ rowInteractive={!!onRowClick || !!selection}
2119
+ tabbable={key === tabRowKey}
2120
+ className={rowClassName?.(row)}
2121
+ attrs={rowAttributes?.(row)}
2122
+ compact={compact}
2123
+ totalColSpan={totalColSpan}
2124
+ rowDetailsLabel={labels.rowDetails}
2125
+ selectRowLabel={labels.selectRow}
2126
+ ariaRowIndex={rowWindow?.ariaRow(rowIndex)}
2127
+ />
2131
2128
  );
2132
2129
  };
2133
2130
  const emptyRow =
@@ -2143,6 +2140,60 @@ export function DataTable<T>({
2143
2140
  </td>
2144
2141
  </tr>
2145
2142
  ) : null;
2143
+ const band = (
2144
+ level: 1 | 2,
2145
+ def: DataTableGroupBy<T>,
2146
+ g: { key: string; rows: T[] },
2147
+ ariaRowIndex?: number,
2148
+ ) => (
2149
+ <DataTableGroupBand
2150
+ level={level}
2151
+ colSpan={totalColSpan}
2152
+ top={level === 1 ? bandTops[0] : bandTops[1]}
2153
+ compact={compact}
2154
+ label={def.label ? def.label(g.key, g.rows) : g.key}
2155
+ end={def.end?.(g.key, g.rows)}
2156
+ ariaRowIndex={ariaRowIndex}
2157
+ />
2158
+ );
2159
+ if (rowWindow) {
2160
+ // Windowed (0.48.2): each group's own `<tbody>` with its bands and the
2161
+ // rendered rows, spacer rows between; one spacer `<tbody>` per run of groups
2162
+ // with nothing rendered. Spacers are hidden from the accessibility tree.
2163
+ const spacer = (key: string, height: number) => (
2164
+ <tr key={key} aria-hidden="true" data-vt-spacer="">
2165
+ <td colSpan={totalColSpan} style={{ height, padding: 0, border: 0 }} />
2166
+ </tr>
2167
+ );
2168
+ return (
2169
+ <>
2170
+ {leading && <tbody className={bodyClass}>{leading}</tbody>}
2171
+ {rowWindow.bodies.map((b) => {
2172
+ if (b.kind === "spacer")
2173
+ return (
2174
+ <tbody key={b.key} data-vt-body="" aria-hidden="true">
2175
+ {spacer("s", b.height)}
2176
+ </tbody>
2177
+ );
2178
+ const g = virtualGroups[b.group];
2179
+ const vb = virtualBands[b.group];
2180
+ return (
2181
+ <tbody
2182
+ key={vb ? `vg:${vb.g1.key}:${vb.g2?.key ?? ""}:${g.start}` : "vg"}
2183
+ data-vt-body=""
2184
+ className={bodyClass}
2185
+ >
2186
+ {vb && g.bands.includes(1) && band(1, levels[0], vb.g1, rowWindow.ariaBand(b.group, 1))}
2187
+ {vb?.g2 && band(2, levels[1], vb.g2, rowWindow.ariaBand(b.group, 2))}
2188
+ {b.items.map((it) =>
2189
+ it.kind === "gap" ? spacer(it.key, it.height) : renderRow(slice[it.index], it.index),
2190
+ )}
2191
+ </tbody>
2192
+ );
2193
+ })}
2194
+ </>
2195
+ );
2196
+ }
2146
2197
  if (!desktopGroups) {
2147
2198
  return (
2148
2199
  <tbody className={bodyClass}>
@@ -2155,16 +2206,6 @@ export function DataTable<T>({
2155
2206
  // Grouped (0.48, keksdose dev #605): one `<tbody>` per innermost group, its
2156
2207
  // band the row header; the leading row (and the empty state) in their own
2157
2208
  // before the first group.
2158
- const band = (level: 1 | 2, def: DataTableGroupBy<T>, g: { key: string; rows: T[] }) => (
2159
- <DataTableGroupBand
2160
- level={level}
2161
- colSpan={totalColSpan}
2162
- top={level === 1 ? bandTops[0] : bandTops[1]}
2163
- compact={compact}
2164
- label={def.label ? def.label(g.key, g.rows) : g.key}
2165
- end={def.end?.(g.key, g.rows)}
2166
- />
2167
- );
2168
2209
  const rowsOf = (g: { rows: T[]; start: number }) => g.rows.map((r, i) => renderRow(r, g.start + i));
2169
2210
  return (
2170
2211
  <>
@@ -2206,7 +2247,7 @@ export function DataTable<T>({
2206
2247
  isLoading && "opacity-60 transition-opacity",
2207
2248
  )}
2208
2249
  >
2209
- <tr>
2250
+ <tr aria-rowindex={rowWindow?.rowCount}>
2210
2251
  {/* The selection column has nothing to total: an empty cell keeps the
2211
2252
  values under their own headers. */}
2212
2253
  {selection && (