Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ interface Renderers<TRow, TSummaryRow> {
interface SelectCellOptions {
enableEditor?: Maybe<boolean>;
shouldFocusCell?: Maybe<boolean>;
/** Extend the selected range from the selected cell instead of moving the selection */
extendRange?: Maybe<boolean>;
}
interface ColumnWidth {
readonly type: 'resized' | 'measured';
Expand Down
31 changes: 28 additions & 3 deletions lib/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/index.js.map

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions src/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,13 @@ function Cell<R, SR>({
function handleMouseDown(event: MouseEvent<HTMLDivElement>) {
onMouseDown?.(event);
if (!handleMouseEvent(event, onCellMouseDown)) {
// select cell if the event is not prevented
selectCellWrapper();
// select cell if the event is not prevented, or extend the
// selected range up to this cell on shift + click
if (rangeSelectionMode && event.shiftKey) {
selectCell({ rowIdx, idx: column.idx }, { extendRange: true });
} else {
selectCellWrapper();
}
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,31 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
if (!isCellWithinSelectionBounds(position)) return;
commitEditorChanges();

if (
options?.extendRange &&
enableRangeSelection &&
selectedPosition.idx >= 0 &&
isRowIdxWithinViewportBounds(selectedPosition.rowIdx) &&
isRowIdxWithinViewportBounds(position.rowIdx)
) {
// Keep the selected cell as the anchor, as shift + arrow keys do
if (selectedPosition.mode === 'EDIT') {
// close the editor, its changes have been committed above
setSelectedPosition({
idx: selectedPosition.idx,
rowIdx: selectedPosition.rowIdx,
mode: 'SELECT'
});
}
setSelectedRangeWithBoundary({
startColumnIdx: selectedPosition.idx,
startRowIdx: selectedPosition.rowIdx,
endColumnIdx: position.idx,
endRowIdx: position.rowIdx
});
return;
}

const samePosition = isSamePosition(selectedPosition, position);

if (options?.enableEditor && isCellEditable(position)) {
Expand All @@ -974,6 +999,20 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
} else if (samePosition) {
// Avoid re-renders if the selected cell state is the same
scrollIntoView(getCellToScroll(gridRef.current!));
// Clicking the selected cell collapses an extended range back to that cell
setSelectedRange((range) =>
range.startColumnIdx === position.idx &&
range.endColumnIdx === position.idx &&
range.startRowIdx === position.rowIdx &&
range.endRowIdx === position.rowIdx
? range
: {
startColumnIdx: position.idx,
startRowIdx: position.rowIdx,
endColumnIdx: position.idx,
endRowIdx: position.rowIdx
}
);
} else {
setShouldFocusCell(options?.shouldFocusCell === true);
setSelectedPosition({ ...position, mode: 'SELECT' });
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ export interface Renderers<TRow, TSummaryRow> {
export interface SelectCellOptions {
enableEditor?: Maybe<boolean>;
shouldFocusCell?: Maybe<boolean>;
/** Extend the selected range from the selected cell instead of moving the selection */
extendRange?: Maybe<boolean>;
}

export interface ColumnWidth {
Expand Down
116 changes: 100 additions & 16 deletions test/browser/rangeSelection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { userEvent } from '@vitest/browser/context';
import { page, userEvent } from '@vitest/browser/context';

import type { Column } from '../../src';
import type { CellsRange } from '../../src/types';
Expand All @@ -16,25 +16,50 @@ const columns: readonly Column<Row>[] = [
{ key: 'c', name: 'C' }
];

const rows: readonly Row[] = [
{ a: 'a1', b: 'b1', c: 'c1' },
{ a: 'a2', b: 'b2', c: 'c2' },
{ a: 'a3', b: 'b3', c: 'c3' }
];
const rows: readonly Row[] = Array.from({ length: 5 }, (_, rowIdx) => ({
a: `a${rowIdx + 1}`,
b: `b${rowIdx + 1}`,
c: `c${rowIdx + 1}`
}));

const onSelectedRangeChange = vi.fn<(range: CellsRange) => void>();

function setupGrid(enableRangeSelection = true) {
onSelectedRangeChange.mockClear();
setup<Row, unknown>({ columns, rows, enableRangeSelection, onSelectedRangeChange });
}

async function shiftClick(name: string) {
await userEvent.keyboard('{Shift>}');
await userEvent.click(getCell(name));
await userEvent.keyboard('{/Shift}');
}

function getSelectedCells() {
return page.getByRole('gridcell', { selected: true });
}

function expectRange(
startRowIdx: number,
startColumnIdx: number,
endRowIdx: number,
endColumnIdx: number
) {
expect(onSelectedRangeChange).toHaveBeenLastCalledWith({
startRowIdx,
startColumnIdx,
endRowIdx,
endColumnIdx
});
}

test('shift + arrow keys extend the selected range when range selection is enabled', async () => {
const onSelectedRangeChange = vi.fn<(range: CellsRange) => void>();
setup<Row, unknown>({ columns, rows, enableRangeSelection: true, onSelectedRangeChange });
setupGrid();

await userEvent.click(getCell('a1'));
await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowRight}{/Shift}');

expect(onSelectedRangeChange).toHaveBeenLastCalledWith({
startRowIdx: 0,
startColumnIdx: 0,
endRowIdx: 1,
endColumnIdx: 1
});
expectRange(0, 0, 1, 1);
await expect.element(getCell('b2')).toHaveAttribute('aria-selected', 'true');
await expect.element(getCell('c3')).toHaveAttribute('aria-selected', 'false');

Expand All @@ -45,11 +70,70 @@ test('shift + arrow keys extend the selected range when range selection is enabl
});

test('shift + arrow keys navigate when range selection is disabled', async () => {
const onSelectedRangeChange = vi.fn<(range: CellsRange) => void>();
setup<Row, unknown>({ columns, rows, onSelectedRangeChange });
setupGrid(false);

await userEvent.click(getCell('a1'));
await userEvent.keyboard('{Shift>}{ArrowDown}{ArrowRight}{/Shift}');
await validateCellPosition(1, 2);
await expect.element(getCell('a1')).toHaveAttribute('aria-selected', 'false');
});

test('shift + click extends the range from the selected cell', async () => {
setupGrid();
await userEvent.click(getCell('a2'));
expectRange(1, 0, 1, 0);
await expect.poll(() => getSelectedCells().elements()).toHaveLength(1);

await shiftClick('c4');
expectRange(1, 0, 3, 2);
await expect.poll(() => getSelectedCells().elements()).toHaveLength(9);
await expect.element(getCell('a2')).toHaveAttribute('aria-selected', 'true');
await expect.element(getCell('c4')).toHaveAttribute('aria-selected', 'true');
await expect.element(getCell('a5')).toHaveAttribute('aria-selected', 'false');
});

test('shift + click extends the range upwards and leftwards', async () => {
setupGrid();
await userEvent.click(getCell('c4'));
await shiftClick('b2');
expectRange(1, 1, 3, 2);
await expect.poll(() => getSelectedCells().elements()).toHaveLength(6);
await expect.element(getCell('a2')).toHaveAttribute('aria-selected', 'false');
});

test('shift + click again keeps the selected cell as the anchor', async () => {
setupGrid();
await userEvent.click(getCell('b2'));
await shiftClick('c4');
expectRange(1, 1, 3, 2);
await shiftClick('a1');
expectRange(0, 0, 1, 1);
await expect.poll(() => getSelectedCells().elements()).toHaveLength(4);
});

test('plain click resets the extended range', async () => {
setupGrid();
await userEvent.click(getCell('a2'));
await shiftClick('c4');
await expect.poll(() => getSelectedCells().elements()).toHaveLength(9);

await userEvent.click(getCell('b5'));
expectRange(4, 1, 4, 1);
await expect.poll(() => getSelectedCells().elements()).toHaveLength(1);
await expect.element(getCell('b5')).toHaveAttribute('aria-selected', 'true');

// clicking the selected cell itself also collapses the range
await shiftClick('c3');
await expect.poll(() => getSelectedCells().elements()).toHaveLength(6);
await userEvent.click(getCell('b5'));
expectRange(4, 1, 4, 1);
await expect.poll(() => getSelectedCells().elements()).toHaveLength(1);
});

test('shift + click moves the selected cell when range selection is disabled', async () => {
setupGrid(false);
await userEvent.click(getCell('a2'));
await shiftClick('c4');
await expect.poll(() => getSelectedCells().elements()).toHaveLength(1);
await expect.element(getCell('c4')).toHaveAttribute('aria-selected', 'true');
});
Loading