Skip to content

Commit c995452

Browse files
committed
feat: add table range selection
1 parent d062970 commit c995452

6 files changed

Lines changed: 259 additions & 17 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
ColumnDef,
3+
getCoreRowModel,
4+
getExpandedRowModel,
5+
getGroupedRowModel,
6+
GroupingState,
7+
Row,
8+
RowSelectionState,
9+
Table,
10+
useReactTable,
11+
} from '@tanstack/react-table';
12+
import { act, renderHook } from '@testing-library/react';
13+
import { useState } from 'react';
14+
import { describe, expect, test } from 'vitest';
15+
import { selectRowRange } from '../grid-row-utils';
16+
17+
interface TestRow {
18+
_key: string;
19+
Name: string;
20+
Dept: string;
21+
}
22+
23+
const data: TestRow[] = [
24+
{ _key: '1', Name: 'A', Dept: 'X' },
25+
{ _key: '2', Name: 'B', Dept: 'X' },
26+
{ _key: '3', Name: 'C', Dept: 'Y' },
27+
{ _key: '4', Name: 'D', Dept: 'Y' },
28+
{ _key: '5', Name: 'E', Dept: 'Y' },
29+
];
30+
31+
const columns: ColumnDef<TestRow>[] = [
32+
{ id: 'Name', accessorKey: 'Name' },
33+
{ id: 'Dept', accessorKey: 'Dept' },
34+
];
35+
36+
function makeTable(options: { enableRowSelection?: boolean | ((row: Row<TestRow>) => boolean); grouping?: GroupingState } = {}) {
37+
const { enableRowSelection = true, grouping } = options;
38+
const { result } = renderHook(() => {
39+
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
40+
return useReactTable<TestRow>({
41+
data,
42+
columns,
43+
state: { rowSelection, grouping: grouping ?? [], expanded: grouping ? true : {} },
44+
getRowId: (row) => row._key,
45+
enableRowSelection,
46+
onRowSelectionChange: setRowSelection,
47+
getCoreRowModel: getCoreRowModel(),
48+
...(grouping ? { getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel() } : {}),
49+
});
50+
});
51+
return result;
52+
}
53+
54+
/** Toggle a single row's selection (sets the anchor's starting state for a range test). */
55+
function toggle(table: Table<TestRow>, rowId: string, selected: boolean) {
56+
act(() => table.getRow(rowId, true).toggleSelected(selected));
57+
}
58+
59+
function selectedKeys(table: Table<TestRow>) {
60+
return Object.keys(table.getState().rowSelection)
61+
.filter((key) => table.getState().rowSelection[key])
62+
.sort();
63+
}
64+
65+
describe('selectRowRange', () => {
66+
test('fills a forward range (anchor above target) with the anchor selected value', () => {
67+
const result = makeTable();
68+
toggle(result.current, '2', true);
69+
70+
let applied = false;
71+
act(() => {
72+
applied = selectRowRange(result.current, '2', '4');
73+
});
74+
75+
expect(applied).toBe(true);
76+
expect(selectedKeys(result.current)).toEqual(['2', '3', '4']);
77+
});
78+
79+
test('fills a backward range (target above anchor) inclusively', () => {
80+
const result = makeTable();
81+
toggle(result.current, '4', true);
82+
83+
act(() => {
84+
selectRowRange(result.current, '4', '2');
85+
});
86+
87+
expect(selectedKeys(result.current)).toEqual(['2', '3', '4']);
88+
});
89+
90+
test('clears the range when the anchor is unselected', () => {
91+
const result = makeTable();
92+
// Pre-select 2,3,4 then unselect the anchor (2) so its value is "unchecked".
93+
toggle(result.current, '2', true);
94+
toggle(result.current, '3', true);
95+
toggle(result.current, '4', true);
96+
toggle(result.current, '2', false);
97+
98+
act(() => {
99+
selectRowRange(result.current, '2', '4');
100+
});
101+
102+
expect(selectedKeys(result.current)).toEqual([]);
103+
});
104+
105+
test('skips non-selectable rows in the range', () => {
106+
const result = makeTable({ enableRowSelection: (row) => row.id !== '3' });
107+
toggle(result.current, '2', true);
108+
109+
act(() => {
110+
selectRowRange(result.current, '2', '4');
111+
});
112+
113+
expect(result.current.getRow('3', true).getIsSelected()).toBe(false);
114+
expect(selectedKeys(result.current)).toEqual(['2', '4']);
115+
});
116+
117+
test('skips group header rows in the range', () => {
118+
const result = makeTable({ grouping: ['Dept'] });
119+
// Rendered order with groups expanded: [group X, 1, 2, group Y, 3, 4, 5]. A range from leaf 2 to
120+
// leaf 3 spans the "Y" group header, which must not be added to the selection.
121+
toggle(result.current, '2', true);
122+
123+
act(() => {
124+
selectRowRange(result.current, '2', '3');
125+
});
126+
127+
expect(selectedKeys(result.current)).toEqual(['2', '3']);
128+
// No group-row id (e.g. "Dept:Y") leaked into the selection.
129+
expect(selectedKeys(result.current).every((key) => !key.includes(':'))).toBe(true);
130+
});
131+
132+
test('returns false and leaves selection untouched when the anchor is not in the row model', () => {
133+
const result = makeTable();
134+
toggle(result.current, '2', true);
135+
136+
let applied = true;
137+
act(() => {
138+
applied = selectRowRange(result.current, 'missing-anchor', '4');
139+
});
140+
141+
expect(applied).toBe(false);
142+
expect(selectedKeys(result.current)).toEqual(['2']);
143+
});
144+
145+
test('returns false when the target is not in the row model', () => {
146+
const result = makeTable();
147+
toggle(result.current, '2', true);
148+
149+
let applied = true;
150+
act(() => {
151+
applied = selectRowRange(result.current, '2', 'missing-target');
152+
});
153+
154+
expect(applied).toBe(false);
155+
expect(selectedKeys(result.current)).toEqual(['2']);
156+
});
157+
});

libs/ui/src/lib/data-table/grid/components/GridContainer.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,9 +333,17 @@ export function GridContainer<TRow = RowWithKey>({
333333
// eslint-disable-next-line react-hooks/exhaustive-deps
334334
}, [keyboardNav.activeCell?.columnId]);
335335

336+
// Anchor for shift-click range selection — the last row whose checkbox was toggled without Shift.
337+
// A private ref exposed via stable getter/setter (a context value can't be mutated directly), so
338+
// every select cell's range-select handler agrees on the anchor.
339+
const rowSelectionAnchorRef = useRef<string | null>(null);
340+
const getRowSelectionAnchor = useCallback(() => rowSelectionAnchorRef.current, []);
341+
const setRowSelectionAnchor = useCallback((rowId: string | null) => {
342+
rowSelectionAnchorRef.current = rowId;
343+
}, []);
336344
const runtime: GridRuntime<TRow> = useMemo(
337-
() => ({ table, gridId, getRowKey, columns: orderedColumns }),
338-
[table, gridId, getRowKey, orderedColumns],
345+
() => ({ table, gridId, getRowKey, columns: orderedColumns, getRowSelectionAnchor, setRowSelectionAnchor }),
346+
[table, gridId, getRowKey, orderedColumns, getRowSelectionAnchor, setRowSelectionAnchor],
339347
);
340348

341349
const rowModelRows = table.getRowModel().rows;

libs/ui/src/lib/data-table/grid/grid-context.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export interface GridRuntime<TRow = RowWithKey> {
4747
getRowKey: (row: TRow) => string;
4848
/** Ordered, visible author-facing columns (kept in sync with TanStack column order). */
4949
columns: ColumnWithFilter<TRow>[];
50+
/** Read the shift-click range-selection anchor — the last row whose checkbox was toggled without
51+
* Shift, or null. Backed by a stable ref in GridContainer so every select cell agrees on it. */
52+
getRowSelectionAnchor?: () => string | null;
53+
/** Set the shift-click range-selection anchor (or clear it with null). */
54+
setRowSelectionAnchor?: (rowId: string | null) => void;
5055
}
5156

5257
export const GridRuntimeContext = createContext<GridRuntime | undefined>(undefined);

libs/ui/src/lib/data-table/grid/grid-row-utils.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,41 @@ export function getSortedFilteredLeafRows<TRow>(table: Table<TRow>): Row<TRow>[]
3232
return leafRows;
3333
}
3434

35+
/**
36+
* Shift-click range selection: set every row between `anchorRowId` and `targetRowId` (inclusive, in the
37+
* current filtered/sorted/flattened display order) to the SAME selected state as the anchor row. Group
38+
* header rows and non-selectable rows are skipped. Returns `false` (without mutating selection) when
39+
* either id is no longer in the row model (e.g. the anchor was filtered out) so the caller can fall back
40+
* to a plain single-row toggle.
41+
*/
42+
export function selectRowRange<TRow>(table: Table<TRow>, anchorRowId: string, targetRowId: string): boolean {
43+
const rows = table.getRowModel().rows;
44+
const anchorIndex = rows.findIndex((row) => row.id === anchorRowId);
45+
const targetIndex = rows.findIndex((row) => row.id === targetRowId);
46+
if (anchorIndex === -1 || targetIndex === -1) {
47+
return false;
48+
}
49+
const selected = rows[anchorIndex].getIsSelected();
50+
const start = Math.min(anchorIndex, targetIndex);
51+
const end = Math.max(anchorIndex, targetIndex);
52+
table.setRowSelection((prev) => {
53+
const next = { ...prev };
54+
for (let i = start; i <= end; i++) {
55+
const row = rows[i];
56+
if (row.getIsGrouped() || !row.getCanSelect()) {
57+
continue;
58+
}
59+
if (selected) {
60+
next[row.id] = true;
61+
} else {
62+
delete next[row.id];
63+
}
64+
}
65+
return next;
66+
});
67+
return true;
68+
}
69+
3570
const SFDC_EMPTY_ID = '000000000000000AAA';
3671

3772
// TanStack Table calls getRowId on every render to re-resolve its row model. Without this cache, rows

libs/ui/src/lib/data-table/grid/keyboard/useGridKeyboardNavigation.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { copyRecordsToClipboard } from '@jetstream/shared/ui-utils';
33
import type { Column, Row, Table } from '@tanstack/react-table';
44
import { FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
55
import { ActiveCell } from '../components/GridRow';
6-
import { getSummaryRowId, getSummaryRowIndex, HEADER_ROW_ID, isSummaryRowId } from '../grid-constants';
6+
import { getSummaryRowId, getSummaryRowIndex, HEADER_ROW_ID, isSummaryRowId, SELECT_COLUMN_KEY } from '../grid-constants';
77
import { ColSpanArgs } from '../grid-types';
88

99
export type GridMode = 'navigation' | 'actionable';
@@ -516,7 +516,10 @@ export function useGridKeyboardNavigation<TRow>({
516516
return;
517517
}
518518
interactionSourceRef.current = 'mouse';
519-
if (shiftKey) {
519+
// The select column owns Shift for checkbox range selection (SelectFormatter); never let a
520+
// Shift-click there extend the rectangular cell selection. Still set the active cell so keyboard
521+
// navigation continues from the checkbox.
522+
if (shiftKey && columnId !== SELECT_COLUMN_KEY) {
520523
applySelection(rowId, columnId, true);
521524
} else {
522525
applySelection(rowId, columnId, false);

libs/ui/src/lib/data-table/grid/renderers/CellRenderers.tsx

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import lodashGet from 'lodash/get';
77
import isBoolean from 'lodash/isBoolean';
88
import isFunction from 'lodash/isFunction';
99
import isString from 'lodash/isString';
10-
import { Fragment, memo, ReactNode, useContext, useState } from 'react';
10+
import { Fragment, memo, ReactNode, useContext, useRef, useState } from 'react';
1111
import Checkbox from '../../../form/checkbox/Checkbox';
1212
import Modal from '../../../modal/Modal';
1313
import { Popover } from '../../../popover/Popover';
@@ -18,8 +18,8 @@ import Spinner from '../../../widgets/Spinner';
1818
import Tooltip from '../../../widgets/Tooltip';
1919
import { dataTableDateFormatter } from '../../data-table-formatters';
2020
import { ACTION_COLUMN_KEY, DEFAULT_ROW_HEIGHT, SELECT_COLUMN_KEY } from '../grid-constants';
21-
import { GridRecordActionContext } from '../grid-context';
22-
import { getRowId, getSfdcRetUrl } from '../grid-row-utils';
21+
import { GridRecordActionContext, GridRuntimeContext } from '../grid-context';
22+
import { getRowId, getSfdcRetUrl, selectRowRange } from '../grid-row-utils';
2323
import { ColumnWithFilter, DataTableCellProps, DataTableGroupCellProps, RowWithKey } from '../grid-types';
2424
import { getRowErrorMessages } from '../validate-cell-value';
2525

@@ -387,18 +387,52 @@ export function withCellValidation<TRow extends RowWithKey>(
387387
}
388388

389389
/** Row-selection checkbox renderer. The built-in select column (GridCell cellKind) usually handles this;
390-
* kept for compatibility with the spreadable SelectColumn definition. */
390+
* kept for compatibility with the spreadable SelectColumn definition.
391+
*
392+
* Supports shift-click range selection: holding Shift and clicking sets every row between the anchor
393+
* (the last non-shift toggle) and this row to the anchor's selected value. The Shift state is captured
394+
* from the wrapper's `mousedown` rather than the change event, because the visible control is the SLDS
395+
* `<label>`/faux checkbox and label-forwarded clicks drop modifier keys. */
391396
export function SelectFormatter({ row, tanstackRow }: DataTableCellProps<any>): ReactNode {
397+
const runtime = useContext(GridRuntimeContext);
398+
const shiftKeyRef = useRef(false);
399+
400+
const handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
401+
const checked = event.currentTarget.checked;
402+
// `detail > 0` is a real pointer click; keyboard Space yields a synthetic click with detail 0, where
403+
// the shift ref would be stale — so range-select only on genuine mouse interactions.
404+
const wasMouseClick = (event.nativeEvent as MouseEvent).detail > 0;
405+
const anchorRowId = runtime?.getRowSelectionAnchor?.();
406+
if (
407+
wasMouseClick &&
408+
shiftKeyRef.current &&
409+
anchorRowId &&
410+
anchorRowId !== tanstackRow.id &&
411+
runtime?.table &&
412+
selectRowRange(runtime.table, anchorRowId, tanstackRow.id)
413+
) {
414+
// Range applied — keep the anchor fixed so the user can re-shift-click to adjust the range.
415+
return;
416+
}
417+
tanstackRow.toggleSelected(checked);
418+
runtime?.setRowSelectionAnchor?.(tanstackRow.id);
419+
};
420+
392421
return (
393-
<Checkbox
394-
id={`checkbox-select-${getRowId(row)}`}
395-
label="Select row"
396-
hideLabel
397-
tabIndex={-1}
398-
checked={tanstackRow.getIsSelected()}
399-
disabled={!tanstackRow.getCanSelect()}
400-
onChange={(checked) => tanstackRow.toggleSelected(checked)}
401-
/>
422+
<span
423+
className="jgrid-cell-select slds-grid slds-grid_align-center slds-grid_vertical-align-center h-100 w-100"
424+
onMouseDown={(event) => (shiftKeyRef.current = event.shiftKey)}
425+
>
426+
<Checkbox
427+
id={`checkbox-select-${getRowId(row)}`}
428+
label="Select row"
429+
hideLabel
430+
tabIndex={-1}
431+
checked={tanstackRow.getIsSelected()}
432+
disabled={!tanstackRow.getCanSelect()}
433+
onChangeNative={handleChange}
434+
/>
435+
</span>
402436
);
403437
}
404438

0 commit comments

Comments
 (0)