@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.
- package/README.md +4 -3
- package/dist/components/data-table-fit.d.ts +8 -1
- package/dist/components/data-table-fit.js +7 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-groups.d.ts +3 -1
- package/dist/components/data-table-groups.js +11 -5
- package/dist/components/data-table-groups.js.map +1 -1
- package/dist/components/data-table-head.d.ts +8 -1
- package/dist/components/data-table-head.js +11 -3
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-leading-row.d.ts +3 -1
- package/dist/components/data-table-leading-row.js +2 -1
- package/dist/components/data-table-leading-row.js.map +1 -1
- package/dist/components/data-table-row-actions.js +2 -2
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-row.d.ts +84 -0
- package/dist/components/data-table-row.js +261 -0
- package/dist/components/data-table-row.js.map +1 -0
- package/dist/components/data-table-selection.d.ts +3 -0
- package/dist/components/data-table-selection.js +12 -1
- package/dist/components/data-table-selection.js.map +1 -1
- package/dist/components/data-table-virtual.d.ts +98 -0
- package/dist/components/data-table-virtual.js +369 -0
- package/dist/components/data-table-virtual.js.map +1 -0
- package/dist/components/data-table.d.ts +40 -1
- package/dist/components/data-table.js +213 -218
- package/dist/components/data-table.js.map +1 -1
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/hooks/use-anchored-panel.js +2 -1
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/shell/app-shell.js +7 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/package.json +1 -1
- package/src/components/data-table-fit.ts +17 -2
- package/src/components/data-table-groups.tsx +15 -4
- package/src/components/data-table-head.tsx +25 -2
- package/src/components/data-table-leading-row.tsx +4 -1
- package/src/components/data-table-row-actions.tsx +3 -2
- package/src/components/data-table-row.tsx +483 -0
- package/src/components/data-table-selection.ts +17 -1
- package/src/components/data-table-virtual.ts +547 -0
- package/src/components/data-table.tsx +291 -314
- package/src/data-table.ts +2 -0
- package/src/hooks/use-anchored-panel.ts +9 -1
- 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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
|
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
|
-
<
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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 && (
|