@eifi1/ui-kit 0.48.1 → 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 +40 -1
  26. package/dist/components/data-table.js +213 -218
  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 +291 -314
  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";
@@ -607,6 +602,43 @@ export interface DataTableProps<T> {
607
602
  * {@link DataTableLoadMore}.
608
603
  */
609
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;
610
642
  /**
611
643
  * Row height. `"compact"` is lenkbank's in-card tables: five `text-xs` tables with
612
644
  * `px-2 py-1` cells sitting inside cards, which at the default padding came out at
@@ -862,35 +894,14 @@ export const DEFAULT_DATA_TABLE_SORT_LABELS: DataTableSortLabels = {
862
894
 
863
895
  const flipDir = (d: SortDir): SortDir => (d === "asc" ? "desc" : "asc");
864
896
 
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;
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.
894
905
 
895
906
  // ---------- Main DataTable ----------
896
907
 
@@ -943,6 +954,7 @@ export function DataTable<T>({
943
954
  leadingRowSticky = false,
944
955
  groupBy,
945
956
  loadMore,
957
+ virtualize = false,
946
958
  density = "comfortable",
947
959
  sortCycle = "tri",
948
960
  chrome = "full",
@@ -1271,12 +1283,16 @@ export function DataTable<T>({
1271
1283
 
1272
1284
  // 0.48 (keksdose live #451): the `fitPriority` columns the table's width leaves out
1273
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();
1274
1288
  const { autoHidden, keep: keepColumn } = useColumnFit({
1275
1289
  columns,
1276
1290
  hiddenCols,
1277
1291
  headRefs,
1278
1292
  scroller: () => desktopScroller.current,
1279
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],
1280
1296
  });
1281
1297
  const visibleColumns = useMemo(
1282
1298
  () => columns.filter((c) => !hiddenCols.has(c.key) && !autoHidden.has(c.key)),
@@ -1370,17 +1386,20 @@ export function DataTable<T>({
1370
1386
  // of a horizontally-scrolling table. Column settings stay desktop-only; the
1371
1387
  // filters come through a bottom sheet (feedback #299) and the sort through the
1372
1388
  // opt-in `mobileSort` row (keksdose K10), both on the table's own state.
1373
- 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]);
1374
1391
  const mobilePrimaryCol =
1375
1392
  mobileColumns.find((c) => c.mobilePrimary) ?? mobileColumns[0] ?? null;
1376
- const mobileSecondaryColumns = mobileColumns.filter((c) => c !== mobilePrimaryCol);
1393
+ const mobileSecondaryColumns = useMemo(
1394
+ () => mobileColumns.filter((c) => c !== mobilePrimaryCol),
1395
+ [mobileColumns, mobilePrimaryCol],
1396
+ );
1377
1397
  // The columns the card leaves out, for the opened row (`mobileDetailsInRow`).
1378
1398
  const mobileDetailColumns = mobileDetailsInRow ? visibleColumns.filter((c) => c.mobileHidden) : [];
1379
1399
  // Large and Extra large (§4 "DataTable phone card"): the card's labels go ABOVE their
1380
1400
  // values, nothing in it truncates, and the rows get more room. Most of it is a
1381
1401
  // `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();
1402
+ // box of its own), which a class cannot write. (`large` is read above, by the fit.)
1384
1403
  // What an opened phone row shows: the card's left-out columns first (when asked
1385
1404
  // for), then the app's own `expandedRow`. Desktop keeps `expandedRow` alone.
1386
1405
  const opensRow = !!expandedRow || mobileDetailColumns.length > 0;
@@ -1465,7 +1484,7 @@ export function DataTable<T>({
1465
1484
  const phoneSelecting = !!selection && !isMdUp && (phoneSelectMode || anySelected);
1466
1485
  // ---- Shift+click range selection ---- (data-table-selection.ts; feedback #289,
1467
1486
  // anchored on the row's KEY so a sort, filter or page change cannot move it)
1468
- const { selectionAnchor, rangeClick, canSelect, selectRange } = useRangeSelection({
1487
+ const { canSelect, selectRange, setAnchor, markRangeClick, takeRangeClick } = useRangeSelection({
1469
1488
  selection,
1470
1489
  slice,
1471
1490
  rowKey,
@@ -1498,6 +1517,42 @@ export function DataTable<T>({
1498
1517
  ? rowKey(slice[0])
1499
1518
  : null;
1500
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
+
1501
1556
  // When dialog-mode is on, the expanded row's detail goes into a bottom-sheet
1502
1557
  // modal instead of unfolding inline. Only one row is ever expanded at a time.
1503
1558
  const mobileDialogRow = mobileExpandAsDialog && !isMdUp ? mobileSlice.find((r) => isExpanded?.(r)) : undefined;
@@ -1514,46 +1569,38 @@ export function DataTable<T>({
1514
1569
 
1515
1570
  // One mobile card (`MobileRow`, data-table-mobile.tsx). A function so the flat list
1516
1571
  // 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
- );
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
+ };
1557
1604
 
1558
1605
  // Consecutive grouping of the (already-sorted) mobile slice into labelled
1559
1606
  // sections for the "assistance"-style list (feedback #317). Null unless the
@@ -1566,6 +1613,57 @@ export function DataTable<T>({
1566
1613
  !!desktopGroups,
1567
1614
  `${levels.length}:${leadingRowSticky && !!leadingRow}:${desktopGroups?.[0]?.key ?? ""}`,
1568
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
+
1569
1667
  // `mobileGroupBy` may be a desktop `groupBy` level (0.48), its label and end slot too.
1570
1668
  const mobileLevel = typeof mobileGroupBy === "function" ? null : (mobileGroupBy ?? null);
1571
1669
  const mobileKey = typeof mobileGroupBy === "function" ? mobileGroupBy : mobileGroupBy?.key;
@@ -1866,7 +1964,20 @@ export function DataTable<T>({
1866
1964
  data-fit-grow={growDrawn ? "" : undefined}
1867
1965
  aria-label={labels.table}
1868
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}
1869
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
+ )}
1870
1981
  {/* Sticky header. position:sticky pins to the nearest scroll-container
1871
1982
  ancestor — so the header can only stick to THIS wrapper, never the
1872
1983
  page. For that to actually hold the header in place, the wrapper
@@ -1899,235 +2010,57 @@ export function DataTable<T>({
1899
2010
  startResize={startResize}
1900
2011
  moveResize={moveResize}
1901
2012
  finishResize={finishResize}
2013
+ ariaRowIndex={rowWindow ? 1 : undefined}
2014
+ lockedWidths={rowWindow?.colWidths}
2015
+ selectionLockKey={VIRTUAL_SELECT_COL}
1902
2016
  />
1903
2017
  {(() => {
1904
2018
  const bodyClass = cn(isLoading && slice.length > 0 && "opacity-60 transition-opacity");
1905
2019
  const leading = leadingRow ? (
1906
- <DataTableLeadingRow colSpan={totalColSpan} sticky={leadingRowSticky}>
2020
+ <DataTableLeadingRow
2021
+ colSpan={totalColSpan}
2022
+ sticky={leadingRowSticky}
2023
+ ariaRowIndex={rowWindow ? 2 : undefined}
2024
+ >
1907
2025
  {leadingRow}
1908
2026
  </DataTableLeadingRow>
1909
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.
1910
2031
  const renderRow = (row: T, rowIndex: number) => {
1911
2032
  const expanded = isExpanded?.(row) ?? false;
1912
- const expansion = expanded ? expandedRow?.(row) : null;
1913
- const rowInteractive = !!onRowClick || !!selection;
1914
- const href = rowHref?.(row);
1915
2033
  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
2034
  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>
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
+ />
2131
2064
  );
2132
2065
  };
2133
2066
  const emptyRow =
@@ -2143,6 +2076,60 @@ export function DataTable<T>({
2143
2076
  </td>
2144
2077
  </tr>
2145
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
+ }
2146
2133
  if (!desktopGroups) {
2147
2134
  return (
2148
2135
  <tbody className={bodyClass}>
@@ -2155,16 +2142,6 @@ export function DataTable<T>({
2155
2142
  // Grouped (0.48, keksdose dev #605): one `<tbody>` per innermost group, its
2156
2143
  // band the row header; the leading row (and the empty state) in their own
2157
2144
  // 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
2145
  const rowsOf = (g: { rows: T[]; start: number }) => g.rows.map((r, i) => renderRow(r, g.start + i));
2169
2146
  return (
2170
2147
  <>
@@ -2206,7 +2183,7 @@ export function DataTable<T>({
2206
2183
  isLoading && "opacity-60 transition-opacity",
2207
2184
  )}
2208
2185
  >
2209
- <tr>
2186
+ <tr aria-rowindex={rowWindow?.rowCount}>
2210
2187
  {/* The selection column has nothing to total: an empty cell keeps the
2211
2188
  values under their own headers. */}
2212
2189
  {selection && (