diff --git a/cypress/e2e/list.cy.js b/cypress/e2e/list.cy.js
index d6b7ab0a121..15648cae0f7 100644
--- a/cypress/e2e/list.cy.js
+++ b/cypress/e2e/list.cy.js
@@ -224,6 +224,64 @@ describe('List Page', () => {
.click({ shiftKey: true });
cy.contains('6 items selected');
});
+
+ it('should allow to deselect a range with shift key', () => {
+ cy.contains('1-10 of 13'); // wait for data
+ cy.get(ListPagePosts.elements.selectItem).eq(0).click();
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(4)
+ .click({ shiftKey: true });
+ cy.contains('5 items selected');
+ cy.get(ListPagePosts.elements.selectedItem).should(els =>
+ expect(els).to.have.length(5)
+ );
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(2)
+ .click({ shiftKey: true });
+ cy.contains('2 items selected');
+ cy.get(ListPagePosts.elements.selectedItem).should(els =>
+ expect(els).to.have.length(2)
+ );
+ });
+
+ it('should allow alternating shift-select and shift-deselect', () => {
+ cy.contains('1-10 of 13'); // wait for data
+ cy.get(ListPagePosts.elements.selectItem).eq(0).click();
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(3)
+ .click({ shiftKey: true });
+ cy.contains('4 items selected');
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(4)
+ .click({ shiftKey: true });
+ cy.contains('5 items selected');
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(2)
+ .click({ shiftKey: true });
+ cy.contains('2 items selected');
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(4)
+ .click({ shiftKey: true });
+ cy.contains('5 items selected');
+ cy.get(ListPagePosts.elements.selectedItem).should(els =>
+ expect(els).to.have.length(5)
+ );
+ });
+
+ it('should support shift-deselect after select all then manual deselect', () => {
+ cy.contains('1-10 of 13'); // wait for data
+ ListPagePosts.toggleSelectAll();
+ cy.contains('10 items selected');
+ cy.get(ListPagePosts.elements.selectItem).eq(1).click();
+ cy.contains('9 items selected');
+ cy.get(ListPagePosts.elements.selectItem)
+ .eq(3)
+ .click({ shiftKey: true });
+ cy.contains('7 items selected');
+ cy.get(ListPagePosts.elements.selectedItem).should(els =>
+ expect(els).to.have.length(7)
+ );
+ });
});
describe('rowClick', () => {
diff --git a/packages/ra-core/src/dataTable/DataTableBase.tsx b/packages/ra-core/src/dataTable/DataTableBase.tsx
index 1453a08da99..82fe8671c87 100644
--- a/packages/ra-core/src/dataTable/DataTableBase.tsx
+++ b/packages/ra-core/src/dataTable/DataTableBase.tsx
@@ -1,20 +1,20 @@
+import difference from 'lodash/difference';
+import union from 'lodash/union';
import * as React from 'react';
import { useEffect, useMemo, useRef, type FC, type ReactNode } from 'react';
-import union from 'lodash/union';
-import difference from 'lodash/difference';
-import { OptionalResourceContextProvider, useResourceContext } from '../core';
-import { useEvent } from '../util';
import { useListContextWithProps } from '../controller/list/useListContextWithProps';
import { type ListControllerResult } from '../controller/list/useListController';
-import { type RowClickFunctionBase } from './types';
+import { OptionalResourceContextProvider, useResourceContext } from '../core';
import { type Identifier, type RaRecord, type SortPayload } from '../types';
-import { DataTableConfigContext } from './DataTableConfigContext';
+import { useEvent } from '../util';
import { DataTableCallbacksContext } from './DataTableCallbacksContext';
+import { DataTableConfigContext } from './DataTableConfigContext';
import { DataTableDataContext } from './DataTableDataContext';
import { DataTableSelectedIdsContext } from './DataTableSelectedIdsContext';
import { DataTableSortContext } from './DataTableSortContext';
import { DataTableStoreContext } from './DataTableStoreContext';
+import { type RowClickFunctionBase } from './types';
export const DataTableBase = function DataTable<
RecordType extends RaRecord = any,
@@ -76,8 +76,6 @@ export const DataTableBase = function DataTable<
if (!data) return;
const ids = data.map(record => record.id);
const lastSelectedIndex = ids.indexOf(lastSelected.current);
- // @ts-ignore FIXME useEvent prevents using event.currentTarget
- lastSelected.current = event.target.checked ? id : null;
if (event.shiftKey && lastSelectedIndex !== -1) {
const index = ids.indexOf(id);
@@ -86,10 +84,10 @@ export const DataTableBase = function DataTable<
Math.max(lastSelectedIndex, index) + 1
);
- // @ts-ignore FIXME useEvent prevents using event.currentTarget
- const newSelectedIds = event.target.checked
- ? union(selectedIds, idsBetweenSelections)
- : difference(selectedIds, idsBetweenSelections);
+ const isClickedItemSelected = selectedIds?.includes(id);
+ const newSelectedIds = isClickedItemSelected
+ ? difference(selectedIds, idsBetweenSelections)
+ : union(selectedIds, idsBetweenSelections);
onSelect?.(
isRowSelectable
@@ -103,6 +101,8 @@ export const DataTableBase = function DataTable<
} else {
onToggleItem?.(id);
}
+
+ lastSelected.current = id;
}
);
diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx
index 1b7d20aacb5..219ed3a535d 100644
--- a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx
+++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx
@@ -1,16 +1,16 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import * as React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import {
Basic,
Columns,
Empty,
- StandaloneStatic,
- StandaloneDynamic,
Expand,
ExpandSingle,
IsRowExpandable,
IsRowSelectable,
NonPrimitiveData,
+ StandaloneDynamic,
+ StandaloneStatic,
} from './DataTable.stories';
describe('DataTable', () => {
@@ -245,6 +245,31 @@ describe('DataTable', () => {
fireEvent.click(checkboxes[0]);
await screen.findByText('2 items selected');
});
+ it('should support alternating shift-select and shift-deselect', async () => {
+ render();
+ const checkboxes = await screen.findAllByRole('checkbox');
+ fireEvent.click(checkboxes[1]);
+ fireEvent.click(checkboxes[4], { shiftKey: true });
+ await screen.findByText('4 items selected');
+ fireEvent.click(checkboxes[5], { shiftKey: true });
+ await screen.findByText('5 items selected');
+ fireEvent.click(checkboxes[3], { shiftKey: true });
+ await screen.findByText('2 items selected');
+ fireEvent.click(checkboxes[5], { shiftKey: true });
+ await screen.findByText('5 items selected');
+ });
+ it('should support shift-deselect after select all then deselect one', async () => {
+ render();
+ const checkboxes = await screen.findAllByRole('checkbox');
+ fireEvent.click(checkboxes[0]);
+ const selectAllButton = await screen.findByText('Select all');
+ selectAllButton.click();
+ await screen.findByText('7 items selected');
+ fireEvent.click(checkboxes[2]);
+ await screen.findByText('6 items selected');
+ fireEvent.click(checkboxes[4], { shiftKey: true });
+ await screen.findByText('4 items selected');
+ });
});
describe('isRowSelectable', () => {
it('should allow to disable row selection', async () => {