@eifi1/ui-kit 0.48.0 → 0.48.2

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 (47) hide show
  1. package/README.md +4 -3
  2. package/dist/components/data-table-fit.d.ts +8 -1
  3. package/dist/components/data-table-fit.js +7 -1
  4. package/dist/components/data-table-fit.js.map +1 -1
  5. package/dist/components/data-table-groups.d.ts +3 -1
  6. package/dist/components/data-table-groups.js +11 -5
  7. package/dist/components/data-table-groups.js.map +1 -1
  8. package/dist/components/data-table-head.d.ts +8 -1
  9. package/dist/components/data-table-head.js +11 -3
  10. package/dist/components/data-table-head.js.map +1 -1
  11. package/dist/components/data-table-leading-row.d.ts +3 -1
  12. package/dist/components/data-table-leading-row.js +2 -1
  13. package/dist/components/data-table-leading-row.js.map +1 -1
  14. package/dist/components/data-table-row-actions.js +2 -2
  15. package/dist/components/data-table-row-actions.js.map +1 -1
  16. package/dist/components/data-table-row.d.ts +84 -0
  17. package/dist/components/data-table-row.js +261 -0
  18. package/dist/components/data-table-row.js.map +1 -0
  19. package/dist/components/data-table-selection.d.ts +3 -0
  20. package/dist/components/data-table-selection.js +12 -1
  21. package/dist/components/data-table-selection.js.map +1 -1
  22. package/dist/components/data-table-virtual.d.ts +98 -0
  23. package/dist/components/data-table-virtual.js +369 -0
  24. package/dist/components/data-table-virtual.js.map +1 -0
  25. package/dist/components/data-table.d.ts +58 -2
  26. package/dist/components/data-table.js +227 -230
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/data-table.d.ts +1 -0
  29. package/dist/data-table.js.map +1 -1
  30. package/dist/eslint/index.js +1 -1
  31. package/dist/hooks/use-anchored-panel.js +2 -1
  32. package/dist/hooks/use-anchored-panel.js.map +1 -1
  33. package/dist/shell/app-shell.js +7 -1
  34. package/dist/shell/app-shell.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/data-table-fit.ts +17 -2
  37. package/src/components/data-table-groups.tsx +15 -4
  38. package/src/components/data-table-head.tsx +25 -2
  39. package/src/components/data-table-leading-row.tsx +4 -1
  40. package/src/components/data-table-row-actions.tsx +3 -2
  41. package/src/components/data-table-row.tsx +483 -0
  42. package/src/components/data-table-selection.ts +17 -1
  43. package/src/components/data-table-virtual.ts +547 -0
  44. package/src/components/data-table.tsx +326 -327
  45. package/src/data-table.ts +2 -0
  46. package/src/hooks/use-anchored-panel.ts +9 -1
  47. package/src/shell/app-shell.tsx +7 -1
@@ -5,17 +5,9 @@ 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 {
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";
8
+ import { columnLabel, logicalAlign, resolveDataTableLabels } from "./data-table-shared";
9
+ import { MobileFilters, MobileSelectBar, MobileSort, StackedFields } from "./data-table-mobile";
10
+ import { DataTableMobileRow, DataTableRow, type DataTableRowCtx } from "./data-table-row";
19
11
  import { DataTableHead } from "./data-table-head";
20
12
  import { DataTableLeadingRow } from "./data-table-leading-row";
21
13
  import {
@@ -26,8 +18,11 @@ import {
26
18
  useGroupBandTops,
27
19
  type DataTableGroupBy,
28
20
  type DataTableLoadMore,
21
+ type RowGroup,
29
22
  } from "./data-table-groups";
30
23
  export type { DataTableGroupBy, DataTableLoadMore } from "./data-table-groups";
24
+ import { VIRTUAL_SELECT_COL, rowNavTarget, useRowWindow, type DataTableVirtualize, type VirtualGroup } from "./data-table-virtual";
25
+ export type { DataTableVirtualize } from "./data-table-virtual";
31
26
  import { ColumnSettingsPanel } from "./data-table-column-settings";
32
27
  import { useLargeText } from "../hooks/use-large-text";
33
28
  import { cn } from "../lib/cn";
@@ -352,6 +347,21 @@ export interface DataTableProps<T> {
352
347
  * that produces `rows`.
353
348
  */
354
349
  serverPagination?: ServerPagination;
350
+ /**
351
+ * 0.48.1 (keksdose K-052-1): the rows are already filtered and sorted by the server —
352
+ * render them as given. No client filter, no client sort, no client paging (every row
353
+ * shows, as with `paginated={false}`). The column filters and sort headers behave as
354
+ * under `serverPagination`: hidden unless the owner takes them over with
355
+ * `onFiltersChange` / `onSortsChange`, and then a click only REPORTS the new state (a
356
+ * header click calls `onSortsChange` with the next sort and the rows keep their order
357
+ * until the owner re-queries and passes new ones).
358
+ *
359
+ * Defaults to `true` when {@link loadMore} is set — the slices it loads come from the
360
+ * server in its order, and re-filtering them dropped rows (the other leg of each
361
+ * transfer). Pass `serverRows={false}` for a client table that loads more chunks but
362
+ * filters and sorts what it holds itself. `serverPagination` implies it regardless.
363
+ */
364
+ serverRows?: boolean;
355
365
  /**
356
366
  * Controlled filter/sort state. When the `on*Change` callback is provided
357
367
  * the table stops owning that piece of state: it renders `filters`/`sorts`
@@ -587,9 +597,48 @@ export interface DataTableProps<T> {
587
597
  * IntersectionObserver, guarded against double calls while one is in flight) and with
588
598
  * a button the keyboard reaches, named by the app ("Load August 2026"). With
589
599
  * `serverPagination` it replaces that pager as well. Both layouts: on a phone it is the
590
- * card list's last item. See {@link DataTableLoadMore}.
600
+ * card list's last item. Since 0.48.1 it implies {@link serverRows} — the rows are
601
+ * rendered in the order given, unfiltered — unless `serverRows={false}`. See
602
+ * {@link DataTableLoadMore}.
591
603
  */
592
604
  loadMore?: DataTableLoadMore;
605
+ /**
606
+ * 0.48.2 (keksdose dev #632, Marcel: "The table is extremely slow. Is it virtualised…?"):
607
+ * render only the desktop rows near the visible part of the scroller, plus `overscan`
608
+ * rows each side ({@link DataTableVirtualize}), with spacer rows standing in for the
609
+ * rest. For long lists — keksdose's register under `loadMore` holds 1000+ rows. Opt-in;
610
+ * without it every row renders, as before.
611
+ *
612
+ * - **Scroller:** the bounded body (`fillHeight` or `maxBodyHeight`), or the page when the
613
+ * body is unbounded (`maxBodyHeight="none"`): the window is what of the table both the
614
+ * scroller and the browser window show.
615
+ * - **Kept rendered:** the header, the `leadingRow`, the group bands (sticky and stacked
616
+ * as before; a period's band while any of its rows is in view), every OPEN row (so an
617
+ * inline editor in `expandedRow` keeps its state), the roving tab stop and a row
618
+ * holding the focus. The `loadMore` footer follows the last spacer and loads once.
619
+ * - **Heights:** each rendered row is measured and remembered by its key; a row not yet
620
+ * seen counts as the measured average (`estimateRowHeight` before any). The scroll
621
+ * height settles as the rows are seen; the browser's scroll anchoring holds the rows in
622
+ * view while a spacer above is corrected (Safari has none: a jump of the difference
623
+ * is possible there on the first pass).
624
+ * - **Columns:** the header widths are measured once and locked in a `<colgroup>`, so the
625
+ * columns do not move as rows come and go; a wider cell scrolling in widens its column
626
+ * (once). Re-measured when the rows, the columns, a width or the scroller's width
627
+ * change. A `grow` column and a resized one are not locked.
628
+ * - **Keyboard and screen reader:** Arrow/Home/End move over every row — the target is
629
+ * rendered, then focused (End reaches the last row); Shift+click ranges and select-all
630
+ * work on the data, not the DOM. The table has `aria-rowcount`, every rendered row its
631
+ * `aria-rowindex` (bands, details and the totals row counted); spacers are
632
+ * `aria-hidden`.
633
+ * - **Trade-offs:** the browser's find-in-page sees only the rendered rows, and so does
634
+ * print; an `id` from `rowAttributes` (an anchor, an `aria-controls` target, a
635
+ * `scrollIntoView` on a fresh row) exists only while its row is rendered. Memoise
636
+ * `columns` (and `mobileCard`): the rows bail out of a render only then, and a scroll
637
+ * renders the rows that come in, not the whole window.
638
+ * - **Phone:** desktop only. The card list renders every row it holds (`loadMore`,
639
+ * `paginated={false}`) or reveals them in chunks as it is scrolled (client paging).
640
+ */
641
+ virtualize?: boolean | DataTableVirtualize;
593
642
  /**
594
643
  * Row height. `"compact"` is lenkbank's in-card tables: five `text-xs` tables with
595
644
  * `px-2 py-1` cells sitting inside cards, which at the default padding came out at
@@ -845,35 +894,14 @@ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
845
894
 
846
895
  const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
847
896
 
848
- /** Arrow/Home/End between the focusable rows of the table's bodies — the roving half of
849
- * the keyboard-activatable rows (see the row's `onKeyDown`). */
850
- function moveRowFocus(from: HTMLElement, key: string): boolean {
851
- if (!(key in ROW_NAV_KEYS)) return false;
852
- // Every body of the table: a grouped table (`groupBy`, 0.48) has one per group.
853
- const table = from.closest("table");
854
- if (!table) return false;
855
- const rows = Array.from(table.tBodies).flatMap((body) =>
856
- Array.from(body.children).filter(
857
- (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute("data-row-nav"),
858
- ),
859
- );
860
- const i = rows.indexOf(from);
861
- const target =
862
- key === "ArrowDown"
863
- ? rows[i + 1]
864
- : key === "ArrowUp"
865
- ? rows[i - 1]
866
- : key === "Home"
867
- ? rows[0]
868
- : key === "End"
869
- ? rows[rows.length - 1]
870
- : undefined;
871
- // At either end the key is still consumed, or ArrowDown on the last row would
872
- // scroll the page out from under a focused row.
873
- target?.focus();
874
- return true;
875
- }
876
- const ROW_NAV_KEYS = { ArrowDown: 1, ArrowUp: 1, Home: 1, End: 1 } as const;
897
+ // `virtualize`'s constants (0.48.2): the band levels a group's `<tbody>` opens with.
898
+ const NO_BANDS: readonly (1 | 2)[] = [];
899
+ const BAND_1: readonly (1 | 2)[] = [1];
900
+ const BAND_2: readonly (1 | 2)[] = [2];
901
+ const BAND_1_2: readonly (1 | 2)[] = [1, 2];
902
+ const NO_VIRTUAL_OPTIONS: DataTableVirtualize = {};
903
+
904
+ // `moveRowFocus` (the roving rows' Arrow/Home/End) lives with the row in data-table-row.tsx.
877
905
 
878
906
  // ---------- Main DataTable ----------
879
907
 
@@ -901,6 +929,7 @@ export function DataTable<T>({
901
929
  fillHeight = false,
902
930
  maxBodyHeight = "calc(100dvh - 12rem)",
903
931
  serverPagination,
932
+ serverRows: serverRowsProp,
904
933
  filters: filtersProp,
905
934
  onFiltersChange,
906
935
  sorts: sortsProp,
@@ -925,6 +954,7 @@ export function DataTable<T>({
925
954
  leadingRowSticky = false,
926
955
  groupBy,
927
956
  loadMore,
957
+ virtualize = false,
928
958
  density = "comfortable",
929
959
  sortCycle = "tri",
930
960
  chrome = "full",
@@ -946,6 +976,10 @@ export function DataTable<T>({
946
976
  const resizable = resizableProp ?? !minimal;
947
977
  const multiSort = multiSortProp ?? !minimal;
948
978
  const isServer = !!serverPagination;
979
+ // The server filtered and sorted these rows (0.48.1, K-052-1): no client filter, sort
980
+ // or paging, and the sort/filter UI only reports to the owner. `isServer` keeps
981
+ // meaning "the server pages" (its pager, its page and page size).
982
+ const serverRows = isServer || (serverRowsProp ?? !!loadMore);
949
983
  const isLoading = !!serverPagination?.isLoading;
950
984
  // prop > provider > English, through the table's own resolver rather than
951
985
  // `useKitLabels`: that resolver derives `columnsCount` from a translated `columns`,
@@ -1077,7 +1111,7 @@ export function DataTable<T>({
1077
1111
  }, [columnSig, selectOptionsSig, rows]);
1078
1112
 
1079
1113
  const filtered = useMemo(() => {
1080
- if (isServer) return rows;
1114
+ if (serverRows) return rows;
1081
1115
  let result = rows;
1082
1116
  for (const col of columns) {
1083
1117
  const state = filters[col.key];
@@ -1086,10 +1120,10 @@ export function DataTable<T>({
1086
1120
  }
1087
1121
  return result;
1088
1122
  // eslint-disable-next-line react-hooks/exhaustive-deps
1089
- }, [rows, columnSig, filters, isServer, locale]);
1123
+ }, [rows, columnSig, filters, serverRows, locale]);
1090
1124
 
1091
1125
  const sorted = useMemo(() => {
1092
- if (isServer) return filtered;
1126
+ if (serverRows) return filtered;
1093
1127
  // Priority chain: the first sort key that distinguishes two rows wins;
1094
1128
  // nulls sort last for that key regardless of direction (as before).
1095
1129
  const chain = sorts
@@ -1114,12 +1148,12 @@ export function DataTable<T>({
1114
1148
  });
1115
1149
  return copy;
1116
1150
  // eslint-disable-next-line react-hooks/exhaustive-deps
1117
- }, [filtered, sorts, columnSig, isServer]);
1151
+ }, [filtered, sorts, columnSig, serverRows]);
1118
1152
 
1119
1153
  // `paginated={false}` is expressed as "page size = everything" rather than as a
1120
1154
  // second code path, so slicing, the page clamp and the range summary all keep
1121
1155
  // exactly one definition.
1122
- const unpaged = !isServer && (!paginated || !!loadMore);
1156
+ const unpaged = !isServer && (!paginated || !!loadMore || serverRows);
1123
1157
  const effectivePageSize = isServer
1124
1158
  ? serverPagination!.pageSize
1125
1159
  : unpaged || pageSize === Infinity
@@ -1202,7 +1236,7 @@ export function DataTable<T>({
1202
1236
  // "clear all"). Only touches keys the table actually exposes as filters.
1203
1237
  const clearAllFilters = () => {
1204
1238
  const keys = columns
1205
- .filter((col) => ((!isServer || onFiltersChange) ? resolveFilter(col) : null))
1239
+ .filter((col) => ((!serverRows || onFiltersChange) ? resolveFilter(col) : null))
1206
1240
  .map((col) => col.key);
1207
1241
  if (!keys.length) return;
1208
1242
  if (onFiltersChange) {
@@ -1236,9 +1270,9 @@ export function DataTable<T>({
1236
1270
  // some later render from the caller, and `serverPagination.total` is ALREADY the
1237
1271
  // filtered total — so the "n of m" sentence has no second number to give here.
1238
1272
  // Saying nothing is better than announcing the page size as a result count.
1239
- if (isServer) return;
1273
+ if (serverRows) return;
1240
1274
  announce(labels.filterResults(filtered.length, rows.length));
1241
- }, [filterKey, filtered.length, rows.length, isServer, announce, labels]);
1275
+ }, [filterKey, filtered.length, rows.length, serverRows, announce, labels]);
1242
1276
 
1243
1277
  // 1-based, because that is what the page strip shows and what a person says.
1244
1278
  const goToPage = (p: number) => {
@@ -1249,12 +1283,16 @@ export function DataTable<T>({
1249
1283
 
1250
1284
  // 0.48 (keksdose live #451): the `fitPriority` columns the table's width leaves out
1251
1285
  // (data-table-fit.ts). Desktop only; the scroller is read after render.
1286
+ // Large and Extra large: read here, as the fit re-measures when it changes.
1287
+ const large = useLargeText();
1252
1288
  const { autoHidden, keep: keepColumn } = useColumnFit({
1253
1289
  columns,
1254
1290
  hiddenCols,
1255
1291
  headRefs,
1256
1292
  scroller: () => desktopScroller.current,
1257
1293
  enabled: !usePhoneLayout(),
1294
+ // Measured only when one of these changed (0.48.2): not on a ticked box.
1295
+ content: [slice, columns, hiddenCols, widths, density, large],
1258
1296
  });
1259
1297
  const visibleColumns = useMemo(
1260
1298
  () => columns.filter((c) => !hiddenCols.has(c.key) && !autoHidden.has(c.key)),
@@ -1348,17 +1386,20 @@ export function DataTable<T>({
1348
1386
  // of a horizontally-scrolling table. Column settings stay desktop-only; the
1349
1387
  // filters come through a bottom sheet (feedback #299) and the sort through the
1350
1388
  // opt-in `mobileSort` row (keksdose K10), both on the table's own state.
1351
- const mobileColumns = visibleColumns.filter((c) => !c.mobileHidden);
1389
+ // Memoised on `visibleColumns`: the cards compare their columns by identity (0.48.2).
1390
+ const mobileColumns = useMemo(() => visibleColumns.filter((c) => !c.mobileHidden), [visibleColumns]);
1352
1391
  const mobilePrimaryCol =
1353
1392
  mobileColumns.find((c) => c.mobilePrimary) ?? mobileColumns[0] ?? null;
1354
- const mobileSecondaryColumns = mobileColumns.filter((c) => c !== mobilePrimaryCol);
1393
+ const mobileSecondaryColumns = useMemo(
1394
+ () => mobileColumns.filter((c) => c !== mobilePrimaryCol),
1395
+ [mobileColumns, mobilePrimaryCol],
1396
+ );
1355
1397
  // The columns the card leaves out, for the opened row (`mobileDetailsInRow`).
1356
1398
  const mobileDetailColumns = mobileDetailsInRow ? visibleColumns.filter((c) => c.mobileHidden) : [];
1357
1399
  // Large and Extra large (§4 "DataTable phone card"): the card's labels go ABOVE their
1358
1400
  // values, nothing in it truncates, and the rows get more room. Most of it is a
1359
1401
  // `large:` class; the label/value grid needs a different STRUCTURE (each pair in a
1360
- // box of its own), which a class cannot write.
1361
- const large = useLargeText();
1402
+ // box of its own), which a class cannot write. (`large` is read above, by the fit.)
1362
1403
  // What an opened phone row shows: the card's left-out columns first (when asked
1363
1404
  // for), then the app's own `expandedRow`. Desktop keeps `expandedRow` alone.
1364
1405
  const opensRow = !!expandedRow || mobileDetailColumns.length > 0;
@@ -1443,7 +1484,7 @@ export function DataTable<T>({
1443
1484
  const phoneSelecting = !!selection && !isMdUp && (phoneSelectMode || anySelected);
1444
1485
  // ---- Shift+click range selection ---- (data-table-selection.ts; feedback #289,
1445
1486
  // anchored on the row's KEY so a sort, filter or page change cannot move it)
1446
- const { selectionAnchor, rangeClick, canSelect, selectRange } = useRangeSelection({
1487
+ const { canSelect, selectRange, setAnchor, markRangeClick, takeRangeClick } = useRangeSelection({
1447
1488
  selection,
1448
1489
  slice,
1449
1490
  rowKey,
@@ -1476,6 +1517,42 @@ export function DataTable<T>({
1476
1517
  ? rowKey(slice[0])
1477
1518
  : null;
1478
1519
 
1520
+ // What the memoised rows' handlers read at event time (0.48.2, K-052-2): the latest
1521
+ // props, refreshed every commit, so a row that bailed out of a render still toggles
1522
+ // through the current `selection` and opens through the current `onRowClick`.
1523
+ // `virtualize` (0.48.2): desktop only, and only with rows to window. Arrow/Home/End then
1524
+ // move over every row, rendered or not: the target is rendered, then focused.
1525
+ const virtualOn = !!virtualize && isMdUp && slice.length > 0;
1526
+ const focusRowRef = useRef<((index: number) => void) | undefined>(undefined);
1527
+ const latestRowCtx: DataTableRowCtx<T> = {
1528
+ moveFocus: virtualOn
1529
+ ? (from, key) => {
1530
+ const target = rowNavTarget(from, key, slice.length);
1531
+ if (target === null) return false;
1532
+ focusRowRef.current?.(target);
1533
+ return true;
1534
+ }
1535
+ : undefined,
1536
+ onRowClick,
1537
+ onToggle: selection?.onToggle,
1538
+ isSelected: (row) => !!selection?.isSelected(row),
1539
+ canSelect,
1540
+ selectRange,
1541
+ setAnchor,
1542
+ markRangeClick,
1543
+ takeRangeClick,
1544
+ setFocusRowKey,
1545
+ onLongPress: (row) => {
1546
+ setPhoneSelectMode(true);
1547
+ setAnchor(rowKey(row));
1548
+ if (selection && !selection.isSelected(row)) selection.onToggle(row, true);
1549
+ },
1550
+ };
1551
+ const rowCtx = useRef(latestRowCtx);
1552
+ useLayoutEffect(() => {
1553
+ rowCtx.current = latestRowCtx;
1554
+ });
1555
+
1479
1556
  // When dialog-mode is on, the expanded row's detail goes into a bottom-sheet
1480
1557
  // modal instead of unfolding inline. Only one row is ever expanded at a time.
1481
1558
  const mobileDialogRow = mobileExpandAsDialog && !isMdUp ? mobileSlice.find((r) => isExpanded?.(r)) : undefined;
@@ -1492,46 +1569,38 @@ export function DataTable<T>({
1492
1569
 
1493
1570
  // One mobile card (`MobileRow`, data-table-mobile.tsx). A function so the flat list
1494
1571
  // and the grouped list (below) share identical row markup.
1495
- const renderMobileRow = (row: T) => (
1496
- <MobileRow
1497
- key={rowKey(row)}
1498
- row={row}
1499
- isExpanded={isExpanded}
1500
- openedRowContent={openedRowContent}
1501
- opensRow={opensRow}
1502
- rowClassName={rowClassName}
1503
- rowAttributes={rowAttributes}
1504
- onRowClick={onRowClick}
1505
- rowHref={rowHref}
1506
- swipeActionsFor={swipeActionsFor}
1507
- mobileDir={mobileDir}
1508
- mobileCardLinkable={mobileCardLinkable}
1509
- mobileCard={mobileCard}
1510
- mobilePrimaryCol={mobilePrimaryCol}
1511
- mobileSecondaryColumns={mobileSecondaryColumns}
1512
- mobileExpandAsDialog={mobileExpandAsDialog}
1513
- compact={compact}
1514
- large={large}
1515
- select={
1516
- selection
1517
- ? {
1518
- active: phoneSelecting,
1519
- selectable: canSelect(row),
1520
- selected: selection.isSelected(row),
1521
- onToggle: (checked) => {
1522
- selectionAnchor.current = rowKey(row);
1523
- selection.onToggle(row, checked);
1524
- },
1525
- onLongPress: () => {
1526
- setPhoneSelectMode(true);
1527
- selectionAnchor.current = rowKey(row);
1528
- if (!selection.isSelected(row)) selection.onToggle(row, true);
1529
- },
1530
- }
1531
- : null
1532
- }
1533
- />
1534
- );
1572
+ const renderMobileRow = (row: T) => {
1573
+ const key = rowKey(row);
1574
+ const expanded = isExpanded?.(row) ?? false;
1575
+ return (
1576
+ <DataTableMobileRow<T>
1577
+ key={key}
1578
+ ctx={rowCtx}
1579
+ rowKey={key}
1580
+ row={row}
1581
+ expanded={expanded}
1582
+ expansion={expanded ? openedRowContent(row) : null}
1583
+ opensRow={opensRow}
1584
+ className={rowClassName?.(row)}
1585
+ attrs={rowAttributes?.(row)}
1586
+ interactive={!!onRowClick}
1587
+ href={mobileCardLinkable ? rowHref?.(row) : undefined}
1588
+ swipeActionsFor={swipeActionsFor}
1589
+ mobileDir={mobileDir}
1590
+ mobileCardLinkable={mobileCardLinkable}
1591
+ mobileCard={mobileCard}
1592
+ mobilePrimaryCol={mobilePrimaryCol}
1593
+ mobileSecondaryColumns={mobileSecondaryColumns}
1594
+ mobileExpandAsDialog={mobileExpandAsDialog}
1595
+ compact={compact}
1596
+ large={large}
1597
+ selectionOn={!!selection}
1598
+ selectActive={phoneSelecting}
1599
+ selectable={canSelect(row)}
1600
+ selected={!!selection && selection.isSelected(row)}
1601
+ />
1602
+ );
1603
+ };
1535
1604
 
1536
1605
  // Consecutive grouping of the (already-sorted) mobile slice into labelled
1537
1606
  // sections for the "assistance"-style list (feedback #317). Null unless the
@@ -1544,6 +1613,57 @@ export function DataTable<T>({
1544
1613
  !!desktopGroups,
1545
1614
  `${levels.length}:${leadingRowSticky && !!leadingRow}:${desktopGroups?.[0]?.key ?? ""}`,
1546
1615
  );
1616
+ // ---- Row windowing ---- (0.48.2, keksdose dev #632; data-table-virtual.ts)
1617
+ // One run per innermost group, with its bands (`virtualOn` is above, with the rows' context).
1618
+ const virtualGroups: VirtualGroup[] = [];
1619
+ const virtualBands: { g1: RowGroup<T>; g2: RowGroup<T> | null }[] = [];
1620
+ if (virtualOn) {
1621
+ if (!desktopGroups) virtualGroups.push({ start: 0, end: slice.length, bands: NO_BANDS, first: 0 });
1622
+ else
1623
+ for (const g1 of desktopGroups) {
1624
+ const first = virtualGroups.length;
1625
+ if (!g1.children) {
1626
+ virtualGroups.push({ start: g1.start, end: g1.start + g1.rows.length, bands: BAND_1, first });
1627
+ virtualBands.push({ g1, g2: null });
1628
+ continue;
1629
+ }
1630
+ g1.children.forEach((g2, j) => {
1631
+ virtualGroups.push({ start: g2.start, end: g2.start + g2.rows.length, bands: j === 0 ? BAND_1_2 : BAND_2, first });
1632
+ virtualBands.push({ g1, g2 });
1633
+ });
1634
+ }
1635
+ }
1636
+ const expandedIndices: number[] = [];
1637
+ if (virtualOn && isExpanded && expandedRow) slice.forEach((r, i) => isExpanded(r) && expandedIndices.push(i));
1638
+ const virtualColKeys = [...(selection ? [VIRTUAL_SELECT_COL] : []), ...visibleColumns.map((c) => c.key)];
1639
+ const virtualLockable = new Set(
1640
+ virtualColKeys.filter((k) => !widths[k] && !visibleColumns.some((c) => c.key === k && c.grow)),
1641
+ );
1642
+ const rowWindow = useRowWindow({
1643
+ enabled: virtualOn,
1644
+ rows: slice,
1645
+ rowKey,
1646
+ groups: virtualGroups,
1647
+ expanded: expandedIndices,
1648
+ pinnedKeys: [tabRowKey],
1649
+ scrollerRef: desktopScroller,
1650
+ tableRef,
1651
+ options: typeof virtualize === "object" ? virtualize : NO_VIRTUAL_OPTIONS,
1652
+ compact,
1653
+ rowsBefore: 1 + (leadingRow ? 1 : 0),
1654
+ rowsAfter: showFooter ? 1 : 0,
1655
+ colKeys: virtualColKeys,
1656
+ lockable: virtualLockable,
1657
+ resetKey: `${Object.entries(widths).join(";")}|${density}|${large}|${slice.length}:${
1658
+ slice.length ? `${rowKey(slice[0])}:${rowKey(slice[slice.length - 1])}` : ""
1659
+ }`,
1660
+ });
1661
+ // What `moveFocus` (in the rows' context, above) reaches: the window's focus mover.
1662
+ const focusRowAt = rowWindow?.focusRow;
1663
+ useLayoutEffect(() => {
1664
+ focusRowRef.current = focusRowAt;
1665
+ }, [focusRowAt]);
1666
+
1547
1667
  // `mobileGroupBy` may be a desktop `groupBy` level (0.48), its label and end slot too.
1548
1668
  const mobileLevel = typeof mobileGroupBy === "function" ? null : (mobileGroupBy ?? null);
1549
1669
  const mobileKey = typeof mobileGroupBy === "function" ? mobileGroupBy : mobileGroupBy?.key;
@@ -1612,12 +1732,12 @@ export function DataTable<T>({
1612
1732
  {/* Mobile filter access (feedback #299): the per-column filter popovers
1613
1733
  live in the desktop header, which the card list doesn't render — so
1614
1734
  expose the same filters through a bottom sheet here. */}
1615
- {(!isServer || onFiltersChange) && (
1735
+ {(!serverRows || onFiltersChange) && (
1616
1736
  <MobileFilters
1617
1737
  columns={columns}
1618
1738
  filters={filters}
1619
1739
  selectOptionsByKey={selectOptionsByKey}
1620
- isServer={isServer}
1740
+ isServer={serverRows}
1621
1741
  hasFilterCallback={!!onFiltersChange}
1622
1742
  onSetFilter={setFilterValue}
1623
1743
  onClearFilter={clearFilter}
@@ -1628,7 +1748,7 @@ export function DataTable<T>({
1628
1748
  )}
1629
1749
  {mobileSort && (
1630
1750
  <MobileSort
1631
- columns={visibleColumns.filter((c) => (!isServer || !!onSortsChange) && !!c.sortBy)}
1751
+ columns={visibleColumns.filter((c) => (!serverRows || !!onSortsChange) && !!c.sortBy)}
1632
1752
  sorts={sorts}
1633
1753
  sortCycle={sortCycle}
1634
1754
  compact={compact}
@@ -1844,7 +1964,20 @@ export function DataTable<T>({
1844
1964
  data-fit-grow={growDrawn ? "" : undefined}
1845
1965
  aria-label={labels.table}
1846
1966
  aria-busy={isLoading || undefined}
1967
+ // `virtualize` (0.48.2): every row counted, rendered or not.
1968
+ aria-rowcount={rowWindow?.rowCount}
1969
+ data-virtualized={rowWindow ? "" : undefined}
1847
1970
  >
1971
+ {/* The locked column widths (0.48.2, `virtualize`): rows coming and going no
1972
+ longer move the columns. */}
1973
+ {rowWindow && (
1974
+ <colgroup>
1975
+ {virtualColKeys.map((key) => {
1976
+ const w = rowWindow.colWidths?.[key];
1977
+ return <col key={key} style={w !== undefined ? { width: w } : undefined} />;
1978
+ })}
1979
+ </colgroup>
1980
+ )}
1848
1981
  {/* Sticky header. position:sticky pins to the nearest scroll-container
1849
1982
  ancestor — so the header can only stick to THIS wrapper, never the
1850
1983
  page. For that to actually hold the header in place, the wrapper
@@ -1860,7 +1993,7 @@ export function DataTable<T>({
1860
1993
  compact={compact}
1861
1994
  labels={labels}
1862
1995
  locale={locale}
1863
- isServer={isServer}
1996
+ isServer={serverRows}
1864
1997
  hasSortCallback={!!onSortsChange}
1865
1998
  hasFilterCallback={!!onFiltersChange}
1866
1999
  sorts={sorts}
@@ -1877,235 +2010,57 @@ export function DataTable<T>({
1877
2010
  startResize={startResize}
1878
2011
  moveResize={moveResize}
1879
2012
  finishResize={finishResize}
2013
+ ariaRowIndex={rowWindow ? 1 : undefined}
2014
+ lockedWidths={rowWindow?.colWidths}
2015
+ selectionLockKey={VIRTUAL_SELECT_COL}
1880
2016
  />
1881
2017
  {(() => {
1882
2018
  const bodyClass = cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity");
1883
2019
  const leading = leadingRow ? (
1884
- <DataTableLeadingRow colSpan={totalColSpan} sticky={leadingRowSticky}>
2020
+ <DataTableLeadingRow
2021
+ colSpan={totalColSpan}
2022
+ sticky={leadingRowSticky}
2023
+ ariaRowIndex={rowWindow ? 2 : undefined}
2024
+ >
1885
2025
  {leadingRow}
1886
2026
  </DataTableLeadingRow>
1887
2027
  ) : null;
2028
+ // One memoised row each (0.48.2, K-052-2; data-table-row.tsx): the table
2029
+ // derives each row's own inputs here, as primitives, so a tick re-renders
2030
+ // only the rows whose inputs changed.
1888
2031
  const renderRow = (row: T, rowIndex: number) => {
1889
2032
  const expanded = isExpanded?.(row) ?? false;
1890
- const expansion = expanded ? expandedRow?.(row) : null;
1891
- const rowInteractive = !!onRowClick || !!selection;
1892
- const href = rowHref?.(row);
1893
2033
  const key = rowKey(row);
1894
- const detailId = `${detailIdBase}-${rowIndex}`;
1895
- // Whether the row is open, said where ARIA allows it (round 0.34, the
1896
- // showcase's axe check): `aria-expanded` belongs to a treegrid's row, not a
1897
- // table's, and the <tr> wore it. A disclosure button in the first cell carries
1898
- // it now, with `aria-controls` on the detail row while that is there.
1899
- // Visually hidden and not a tab stop: the row stays the one roving stop, with
1900
- // Enter/Space and the whole-row click — this is the screen reader cursor's
1901
- // way in, and where it hears the state. Named by `aria-label`, so the cell's
1902
- // text stays the cell's own.
1903
- const disclosure =
1904
- rowsFocusable && expandedRow ? (
1905
- <button
1906
- type="button"
1907
- tabIndex={-1}
1908
- className="sr-only-fixed"
1909
- aria-label={labels.rowDetails}
1910
- aria-expanded={expanded}
1911
- aria-controls={expansion ? detailId : undefined}
1912
- // The row's own handler leaves a click on a control inside it alone.
1913
- onClick={() => onRowClick!(row)}
1914
- />
1915
- ) : null;
1916
2034
  return (
1917
- <Fragment key={key}>
1918
- <tr
1919
- {...cleanAttrs(rowAttributes?.(row))}
1920
- data-row-nav={rowsFocusable ? "" : undefined}
1921
- tabIndex={rowsFocusable ? (key === tabRowKey ? 0 : -1) : undefined}
1922
- onFocus={
1923
- rowsFocusable
1924
- ? (e) => {
1925
- if (e.target === e.currentTarget) setFocusRowKey(key);
1926
- }
1927
- : undefined
1928
- }
1929
- onKeyDown={
1930
- rowsFocusable
1931
- ? (e) => {
1932
- // Only the row's own key presses: Enter on a nested button
1933
- // or link is that control's, and Space in a nested input
1934
- // is a space.
1935
- if (e.target !== e.currentTarget) return;
1936
- if (e.altKey || e.ctrlKey || e.metaKey) return;
1937
- if (e.key === "Enter" || e.key === " ") {
1938
- // Space would otherwise scroll the table.
1939
- e.preventDefault();
1940
- onRowClick!(row);
1941
- return;
1942
- }
1943
- if (e.shiftKey) return;
1944
- if (moveRowFocus(e.currentTarget, e.key)) e.preventDefault();
1945
- }
1946
- : undefined
1947
- }
1948
- className={cn(
1949
- "border-t border-subtle",
1950
- rowInteractive && "cursor-pointer hover:bg-hover",
1951
- // The kit's focus frame width (§5, `--focus-ring-width`), drawn as an
1952
- // OUTLINE rather than FOCUS_RING's box-shadow: a shadow on a `<tr>`
1953
- // is not painted by every engine, an outline is.
1954
- rowsFocusable &&
1955
- "focus-visible:bg-hover focus-visible:outline-focus focus-visible:-outline-offset-2 focus-visible:outline-brand",
1956
- rowClassName?.(row),
1957
- )}
1958
- onClick={
1959
- rowInteractive
1960
- ? (e) => {
1961
- // A click on a control inside the row (a button, a link,
1962
- // an input the cell renders) is that control's — it must
1963
- // not also open the row. Cells no longer have to
1964
- // `stopPropagation` to get that.
1965
- if (fromOwnControl(e.target, e.currentTarget)) return;
1966
- if (fromOutside(e.target, e.currentTarget)) return;
1967
- // Modifier-clicks anywhere in the row drive
1968
- // selection instead of expanding it (feedback #289):
1969
- // Ctrl/Cmd toggles a single row, Shift selects the
1970
- // range from the last-toggled row (inclusive of this
1971
- // one) — the same as the checkbox, but for the whole
1972
- // row, which is what the checkbox-only version was
1973
- // missing.
1974
- if (selection && canSelect(row)) {
1975
- if (e.shiftKey) {
1976
- // Drop the text selection a shift-click would
1977
- // otherwise smear across the rows.
1978
- window.getSelection?.()?.removeAllRanges();
1979
- if (selectRange(rowIndex)) return;
1980
- // No anchor yet: treat it as the first pick.
1981
- selectionAnchor.current = rowKey(row);
1982
- selection.onToggle(row, !selection.isSelected(row));
1983
- return;
1984
- }
1985
- if (e.metaKey || e.ctrlKey) {
1986
- selectionAnchor.current = rowKey(row);
1987
- selection.onToggle(row, !selection.isSelected(row));
1988
- return;
1989
- }
1990
- }
1991
- onRowClick?.(row);
1992
- }
1993
- : undefined
1994
- }
1995
- >
1996
- {selection && (
1997
- <td
1998
- className={cn("align-top", compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2")}
1999
- // Don't let selecting a row also trigger the row click
2000
- // (which expands/edits it).
2001
- onClick={(e) => e.stopPropagation()}
2002
- >
2003
- {canSelect(row) && (
2004
- <input
2005
- type="checkbox"
2006
- className={cn("accent-indigo-600", compact ? "size-3.5" : "size-4")}
2007
- checked={selection.isSelected(row)}
2008
- // Shift+click selects the range from the last toggled
2009
- // row. `preventDefault` puts the box back, but it does NOT
2010
- // stop React's `onChange`: React derives a checkbox's
2011
- // change from the `click` itself, after the browser has
2012
- // already flipped `checked` and before the cancellation
2013
- // reverts it — so the same click also reported itself as
2014
- // a single toggle, and the owner got `onToggleMany` and
2015
- // then `onToggle` for the clicked row. The click is
2016
- // remembered by identity and its change is dropped; a
2017
- // boolean flag would outlive a click whose change never
2018
- // came and swallow the user's next real one.
2019
- onClick={(e) => {
2020
- if (e.shiftKey && selectRange(rowIndex)) {
2021
- e.preventDefault();
2022
- rangeClick.current = e.nativeEvent;
2023
- }
2024
- }}
2025
- onChange={(e) => {
2026
- if (rangeClick.current && e.nativeEvent === rangeClick.current) {
2027
- rangeClick.current = null;
2028
- return;
2029
- }
2030
- selectionAnchor.current = rowKey(row);
2031
- selection.onToggle(row, e.target.checked);
2032
- }}
2033
- aria-label={labels.selectRow}
2034
- />
2035
- )}
2036
- </td>
2037
- )}
2038
- {visibleColumns.map((col, colIndex) => {
2039
- const width = widths[col.key];
2040
- // The caller's first, the table's over it — see `cellProps`.
2041
- const own = splitCellProps(col.cellProps?.(row));
2042
- const pinned = width ? { width, minWidth: width, maxWidth: width } : undefined;
2043
- return (
2044
- <td
2045
- {...own.attrs}
2046
- key={col.key}
2047
- data-col={col.key}
2048
- style={own.style || pinned ? { ...own.style, ...pinned } : undefined}
2049
- className={cn(
2050
- "align-top",
2051
- compact ? "px-2 py-1" : "px-3 py-2",
2052
- width && "overflow-hidden text-ellipsis",
2053
- col.grow ? GROW_CELL_CLASS : growDrawn && GROW_PEER_CELL_CLASS,
2054
- logicalAlign(own.className),
2055
- logicalAlign(col.className),
2056
- )}
2057
- >
2058
- {colIndex === 0 && disclosure}
2059
- {col === linkColumn && href ? (
2060
- // No `onActivate`: the click is left to bubble to the
2061
- // <tr> handler above, which already owns expand-vs-select.
2062
- // Calling it here as well would toggle the row twice.
2063
- <RowLink href={href} className="block" data-row-link="">
2064
- {col.cell(row)}
2065
- </RowLink>
2066
- ) : (
2067
- col.cell(row)
2068
- )}
2069
- </td>
2070
- );
2071
- })}
2072
- </tr>
2073
- {expansion && (
2074
- <tr id={disclosure ? detailId : undefined} className="bg-surface-2 border-t border-subtle">
2075
- {/* The detail panel spans every column. In an `auto` table
2076
- layout (the default here — columns only get fixed style
2077
- widths once the user resizes/auto-sizes them) the cell's
2078
- content preferred-width is redistributed across the
2079
- spanned columns, so a long expansion visibly re-sizes the
2080
- columns above it — and one-line content does not, since
2081
- its preferred width is small (feedback #104). Wrapping the
2082
- content in `w-0 min-w-full` pins the cell's preferred
2083
- width to ~0 (the explicit width:0 child), so it can never
2084
- perturb the columns; the div then fills to the full row
2085
- width only at paint time, and long text wraps within it.
2086
- Inside it the content is pinned to what the scroller SHOWS
2087
- (0.47, keksdose S4): on a table wider than its scroller a
2088
- table-wide panel put an inline editor's Save/Cancel off-screen.
2089
- `sticky start-0` holds it at the scroller's start edge (the
2090
- right one in RTL) as the row scrolls sideways, and
2091
- `--dt-visible-w` (the scroller's clientWidth, measured above)
2092
- sizes it; before the first measure, and in a table no wider
2093
- than its scroller, it is the full width as before. */}
2094
- <td colSpan={totalColSpan} className="p-0">
2095
- <div className="w-0 min-w-full">
2096
- <div
2097
- className={cn(
2098
- "sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
2099
- compact ? "px-2 py-2" : "px-3 py-3",
2100
- )}
2101
- >
2102
- {expansion}
2103
- </div>
2104
- </div>
2105
- </td>
2106
- </tr>
2107
- )}
2108
- </Fragment>
2035
+ <DataTableRow<T>
2036
+ key={key}
2037
+ row={row}
2038
+ rowIndex={rowIndex}
2039
+ rowKey={key}
2040
+ ctx={rowCtx}
2041
+ columns={visibleColumns}
2042
+ widths={widths}
2043
+ growDrawn={growDrawn}
2044
+ linkColumn={linkColumn}
2045
+ href={rowHref?.(row)}
2046
+ selectionColumn={!!selection}
2047
+ selectable={canSelect(row)}
2048
+ selected={!!selection && selection.isSelected(row)}
2049
+ expanded={expanded}
2050
+ expansion={expanded ? expandedRow?.(row) : null}
2051
+ disclosure={rowsFocusable && !!expandedRow}
2052
+ detailId={`${detailIdBase}-${rowIndex}`}
2053
+ rowsFocusable={rowsFocusable}
2054
+ rowInteractive={!!onRowClick || !!selection}
2055
+ tabbable={key === tabRowKey}
2056
+ className={rowClassName?.(row)}
2057
+ attrs={rowAttributes?.(row)}
2058
+ compact={compact}
2059
+ totalColSpan={totalColSpan}
2060
+ rowDetailsLabel={labels.rowDetails}
2061
+ selectRowLabel={labels.selectRow}
2062
+ ariaRowIndex={rowWindow?.ariaRow(rowIndex)}
2063
+ />
2109
2064
  );
2110
2065
  };
2111
2066
  const emptyRow =
@@ -2121,6 +2076,60 @@ export function DataTable<T>({
2121
2076
  </td>
2122
2077
  </tr>
2123
2078
  ) : null;
2079
+ const band = (
2080
+ level: 1 | 2,
2081
+ def: DataTableGroupBy<T>,
2082
+ g: { key: string; rows: T[] },
2083
+ ariaRowIndex?: number,
2084
+ ) => (
2085
+ <DataTableGroupBand
2086
+ level={level}
2087
+ colSpan={totalColSpan}
2088
+ top={level === 1 ? bandTops[0] : bandTops[1]}
2089
+ compact={compact}
2090
+ label={def.label ? def.label(g.key, g.rows) : g.key}
2091
+ end={def.end?.(g.key, g.rows)}
2092
+ ariaRowIndex={ariaRowIndex}
2093
+ />
2094
+ );
2095
+ if (rowWindow) {
2096
+ // Windowed (0.48.2): each group's own `<tbody>` with its bands and the
2097
+ // rendered rows, spacer rows between; one spacer `<tbody>` per run of groups
2098
+ // with nothing rendered. Spacers are hidden from the accessibility tree.
2099
+ const spacer = (key: string, height: number) => (
2100
+ <tr key={key} aria-hidden="true" data-vt-spacer="">
2101
+ <td colSpan={totalColSpan} style={{ height, padding: 0, border: 0 }} />
2102
+ </tr>
2103
+ );
2104
+ return (
2105
+ <>
2106
+ {leading && <tbody className={bodyClass}>{leading}</tbody>}
2107
+ {rowWindow.bodies.map((b) => {
2108
+ if (b.kind === "spacer")
2109
+ return (
2110
+ <tbody key={b.key} data-vt-body="" aria-hidden="true">
2111
+ {spacer("s", b.height)}
2112
+ </tbody>
2113
+ );
2114
+ const g = virtualGroups[b.group];
2115
+ const vb = virtualBands[b.group];
2116
+ return (
2117
+ <tbody
2118
+ key={vb ? `vg:${vb.g1.key}:${vb.g2?.key ?? ""}:${g.start}` : "vg"}
2119
+ data-vt-body=""
2120
+ className={bodyClass}
2121
+ >
2122
+ {vb && g.bands.includes(1) && band(1, levels[0], vb.g1, rowWindow.ariaBand(b.group, 1))}
2123
+ {vb?.g2 && band(2, levels[1], vb.g2, rowWindow.ariaBand(b.group, 2))}
2124
+ {b.items.map((it) =>
2125
+ it.kind === "gap" ? spacer(it.key, it.height) : renderRow(slice[it.index], it.index),
2126
+ )}
2127
+ </tbody>
2128
+ );
2129
+ })}
2130
+ </>
2131
+ );
2132
+ }
2124
2133
  if (!desktopGroups) {
2125
2134
  return (
2126
2135
  <tbody className={bodyClass}>
@@ -2133,16 +2142,6 @@ export function DataTable<T>({
2133
2142
  // Grouped (0.48, keksdose dev #605): one `<tbody>` per innermost group, its
2134
2143
  // band the row header; the leading row (and the empty state) in their own
2135
2144
  // before the first group.
2136
- const band = (level: 1 | 2, def: DataTableGroupBy<T>, g: { key: string; rows: T[] }) => (
2137
- <DataTableGroupBand
2138
- level={level}
2139
- colSpan={totalColSpan}
2140
- top={level === 1 ? bandTops[0] : bandTops[1]}
2141
- compact={compact}
2142
- label={def.label ? def.label(g.key, g.rows) : g.key}
2143
- end={def.end?.(g.key, g.rows)}
2144
- />
2145
- );
2146
2145
  const rowsOf = (g: { rows: T[]; start: number }) => g.rows.map((r, i) => renderRow(r, g.start + i));
2147
2146
  return (
2148
2147
  <>
@@ -2184,7 +2183,7 @@ export function DataTable<T>({
2184
2183
  isLoading && "opacity-60 transition-opacity",
2185
2184
  )}
2186
2185
  >
2187
- <tr>
2186
+ <tr aria-rowindex={rowWindow?.rowCount}>
2188
2187
  {/* The selection column has nothing to total: an empty cell keeps the
2189
2188
  values under their own headers. */}
2190
2189
  {selection && (