diff --git a/docs/src/pages/components/data-grid/accessibility/accessibility.md b/docs/src/pages/components/data-grid/accessibility/accessibility.md
index b01dc79b5857..b85c2011a049 100644
--- a/docs/src/pages/components/data-grid/accessibility/accessibility.md
+++ b/docs/src/pages/components/data-grid/accessibility/accessibility.md
@@ -65,16 +65,17 @@ Use the arrow keys to move the focus.
### Selection
-| Keys | Description |
-| -------------------------------------------------------------------------------------------------: | :------------------------------------------------ |
-| Shift + Space | Select the current row |
-| Shift + Space + Arrow Up/Down | Select the current row and the row above or below |
-| CTRL + A | Select all rows |
-| CTRL + C | Copy the currently selected row(s) |
-| CTRL + Click on cell | Enable multi-selection |
-| CTRL + Click on a selected row | Deselect the row |
-| Enter | Sort column when column header is focused |
-| CTRL + Enter | Open column menu when column header is focused |
+| Keys | Description |
+| -------------------------------------------------------------------------------------------------: | :--------------------------------------------------- |
+| Shift + Space | Select the current row |
+| Shift + Space + Arrow Up/Down | Select the current row and the row above or below |
+| CTRL + A | Select all rows |
+| CTRL + C | Copy the currently selected row(s) |
+| ALT + C | Copy the currently selected row(s) including headers |
+| CTRL + Click on cell | Enable multi-selection |
+| CTRL + Click on a selected row | Deselect the row |
+| Enter | Sort column when column header is focused |
+| CTRL + Enter | Open column menu when column header is focused |
### Sorting
diff --git a/packages/grid/_modules_/grid/constants/eventsConstants.ts b/packages/grid/_modules_/grid/constants/eventsConstants.ts
index 53d00deadcec..ede2f02117f0 100644
--- a/packages/grid/_modules_/grid/constants/eventsConstants.ts
+++ b/packages/grid/_modules_/grid/constants/eventsConstants.ts
@@ -16,6 +16,11 @@ export const GRID_DEBOUNCED_RESIZE = 'debouncedResize';
*/
export const GRID_SCROLL = 'scroll';
+/**
+ * @ignore - do not document.
+ */
+export const GRID_KEYDOWN = 'keydown';
+
// GRID events
/**
diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/index.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/index.ts
new file mode 100644
index 000000000000..173fe0945ad6
--- /dev/null
+++ b/packages/grid/_modules_/grid/hooks/features/clipboard/index.ts
@@ -0,0 +1 @@
+export * from './useGridClipboard';
diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts
new file mode 100644
index 000000000000..dc49737ab5df
--- /dev/null
+++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts
@@ -0,0 +1,91 @@
+import * as React from 'react';
+import { GridApiRef } from '../../../models/api/gridApiRef';
+import { useGridApiEventHandler } from '../../root/useGridApiEventHandler';
+import { GRID_KEYDOWN } from '../../../constants/eventsConstants';
+import { buildCSV } from '../export/serializers/csvSerializer';
+import { useGridSelector } from '../core/useGridSelector';
+import { visibleGridColumnsSelector } from '../columns/gridColumnsSelector';
+import { gridCheckboxSelectionColDef } from '../../../models/colDef';
+import { GridClipboardApi } from '../../../models/api';
+import { useGridApiMethod } from '../../root/useGridApiMethod';
+
+function writeToClipboardPolyfill(data: string) {
+ const span = document.createElement('span');
+ span.style.whiteSpace = 'pre';
+ span.style.userSelect = 'all';
+ span.style.opacity = '0px';
+ span.textContent = data;
+
+ document.body.appendChild(span);
+
+ const range = document.createRange();
+ range.selectNode(span);
+ const selection = window.getSelection();
+ selection!.removeAllRanges();
+ selection!.addRange(range);
+
+ try {
+ document.execCommand('copy');
+ } finally {
+ document.body.removeChild(span);
+ }
+}
+
+export const useGridClipboard = (apiRef: GridApiRef): void => {
+ const visibleColumns = useGridSelector(apiRef, visibleGridColumnsSelector);
+
+ const copySelectedRowsToClipboard = React.useCallback(
+ (includeHeaders = false) => {
+ const selectedRows = apiRef.current.getSelectedRows();
+ const filteredColumns = visibleColumns.filter(
+ (column) => column.field !== gridCheckboxSelectionColDef.field,
+ );
+
+ if (selectedRows.size === 0 || filteredColumns.length === 0) {
+ return;
+ }
+
+ const data = buildCSV({
+ columns: visibleColumns,
+ rows: selectedRows,
+ includeHeaders,
+ getCellParams: apiRef.current.getCellParams,
+ delimiterCharacter: '\t',
+ });
+
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(data).catch(() => {
+ writeToClipboardPolyfill(data);
+ });
+ } else {
+ writeToClipboardPolyfill(data);
+ }
+ },
+ [apiRef, visibleColumns],
+ );
+
+ const handleKeydown = React.useCallback(
+ (event: KeyboardEvent) => {
+ const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey;
+ if (event.key.toLowerCase() !== 'c' || !isModifierKeyPressed) {
+ return;
+ }
+
+ // Do nothing if there's a native selection
+ if (window.getSelection()?.toString() !== '') {
+ return;
+ }
+
+ apiRef.current.copySelectedRowsToClipboard(event.altKey);
+ },
+ [apiRef],
+ );
+
+ useGridApiEventHandler(apiRef, GRID_KEYDOWN, handleKeydown);
+
+ const clipboardApi: GridClipboardApi = {
+ copySelectedRowsToClipboard,
+ };
+
+ useGridApiMethod(apiRef, clipboardApi, 'GridClipboardApi');
+};
diff --git a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts
index 9738ff87b849..7cc108784475 100644
--- a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts
+++ b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts
@@ -36,24 +36,30 @@ export function serialiseRow(
interface BuildCSVOptions {
columns: GridColumns;
rows: Map;
- selectedRows: Record;
+ selectedRows?: Record;
getCellParams: (id: GridRowId, field: string) => GridCellParams;
delimiterCharacter: GridExportCsvDelimiter;
+ includeHeaders?: boolean;
}
export function buildCSV(options: BuildCSVOptions): string {
- const { columns, rows, selectedRows, getCellParams, delimiterCharacter } = options;
+ const {
+ columns,
+ rows,
+ selectedRows,
+ getCellParams,
+ delimiterCharacter,
+ includeHeaders = true,
+ } = options;
let rowIds = [...rows.keys()];
- const selectedRowIds = Object.keys(selectedRows);
- if (selectedRowIds.length) {
- rowIds = rowIds.filter((id) => selectedRowIds.includes(`${id}`));
+ if (selectedRows) {
+ const selectedRowIds = Object.keys(selectedRows);
+ if (selectedRowIds.length) {
+ rowIds = rowIds.filter((id) => selectedRowIds.includes(`${id}`));
+ }
}
- const CSVHead = `${columns
- .filter((column) => column.field !== gridCheckboxSelectionColDef.field)
- .map((column) => serialiseCellValue(column.headerName || column.field, delimiterCharacter))
- .join(delimiterCharacter)}\r\n`;
const CSVBody = rowIds
.reduce(
(acc, id) =>
@@ -63,7 +69,15 @@ export function buildCSV(options: BuildCSVOptions): string {
'',
)
.trim();
- const csv = `${CSVHead}${CSVBody}`.trim();
- return csv;
+ if (!includeHeaders) {
+ return CSVBody;
+ }
+
+ const CSVHead = `${columns
+ .filter((column) => column.field !== gridCheckboxSelectionColDef.field)
+ .map((column) => serialiseCellValue(column.headerName || column.field, delimiterCharacter))
+ .join(delimiterCharacter)}\r\n`;
+
+ return `${CSVHead}${CSVBody}`.trim();
}
diff --git a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts
index e0df772b2a58..06b7e8197ef2 100644
--- a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts
+++ b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts
@@ -10,20 +10,15 @@ import { GridApiRef } from '../../../models/api/gridApiRef';
import { GridCellParams } from '../../../models/params/gridCellParams';
import {
findParentElementFromClassName,
- getIdFromRowElem,
- getRowEl,
isGridCellRoot,
isGridHeaderCellRoot,
} from '../../../utils/domUtils';
import { isEnterKey, isNavigationKey, isSpaceKey } from '../../../utils/keyboardUtils';
-import { useGridSelector } from '../core/useGridSelector';
import { useLogger } from '../../utils/useLogger';
import { useGridApiEventHandler } from '../../root/useGridApiEventHandler';
-import { gridSelectionStateSelector } from '../selection/gridSelectionSelector';
export const useGridKeyboard = (apiRef: GridApiRef): void => {
const logger = useLogger('useGridKeyboard');
- const selectionState = useGridSelector(apiRef, gridSelectionStateSelector);
const expandSelection = React.useCallback(
(params: GridCellParams, event: React.KeyboardEvent) => {
@@ -63,22 +58,6 @@ export const useGridKeyboard = (apiRef: GridApiRef): void => {
[logger, apiRef],
);
- const handleCopy = React.useCallback(
- (target: HTMLElement) => {
- const rowEl = getRowEl(target)!;
- const rowId = getIdFromRowElem(rowEl);
- const isRowSelected = selectionState[rowId];
-
- if (isRowSelected) {
- window?.getSelection()?.selectAllChildren(rowEl);
- } else {
- window?.getSelection()?.selectAllChildren(target);
- }
- document.execCommand('copy');
- },
- [selectionState],
- );
-
const handleCellKeyDown = React.useCallback(
(params: GridCellParams, event: React.KeyboardEvent) => {
// The target is not an element when triggered by a Select inside the cell
@@ -115,7 +94,6 @@ export const useGridKeyboard = (apiRef: GridApiRef): void => {
}
if (event.key.toLowerCase() === 'c' && (event.ctrlKey || event.metaKey)) {
- handleCopy(event.target as HTMLElement);
return;
}
@@ -124,7 +102,7 @@ export const useGridKeyboard = (apiRef: GridApiRef): void => {
apiRef.current.selectRows(apiRef.current.getAllRowIds(), true);
}
},
- [apiRef, expandSelection, handleCopy],
+ [apiRef, expandSelection],
);
const handleColumnHeaderKeyDown = React.useCallback(
diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts
index 9fe2c5ad410c..99a0d1ca9c19 100644
--- a/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts
+++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts
@@ -287,7 +287,8 @@ export function useGridEditRows(apiRef: GridApiRef) {
const isEditMode = params.cellMode === 'edit';
- if (!isEditMode && isCellEnterEditModeKeys(event.key)) {
+ const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey;
+ if (!isEditMode && isCellEnterEditModeKeys(event.key) && !isModifierKeyPressed) {
apiRef.current.publishEvent(GRID_CELL_EDIT_ENTER, params, event);
}
if (!isEditMode && isDeleteKeys(event.key)) {
diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts
index a9bfd5889c79..5b2727da16b3 100644
--- a/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts
+++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts
@@ -78,9 +78,17 @@ export function useGridParamsApi(apiRef: GridApiRef) {
(id: GridRowId, field: string) => {
const colDef = apiRef.current.getColumn(field);
const value = apiRef.current.getCellValue(id, field);
- const baseParams = getBaseCellParams(id, field);
+ const row = apiRef.current.getRow(id);
const params: GridCellParams = {
- ...baseParams,
+ id,
+ field,
+ row,
+ colDef,
+ cellMode: apiRef.current.getCellMode(id, field),
+ getValue: apiRef.current.getCellValue,
+ api: apiRef.current,
+ hasFocus: cellFocus !== null && cellFocus.field === field && cellFocus.id === id,
+ tabIndex: cellTabIndex && cellTabIndex.field === field && cellTabIndex.id === id ? 0 : -1,
value,
formattedValue: value,
};
@@ -91,7 +99,7 @@ export function useGridParamsApi(apiRef: GridApiRef) {
return params;
},
- [apiRef, getBaseCellParams],
+ [apiRef, cellFocus, cellTabIndex],
);
const getCellValue = React.useCallback(
diff --git a/packages/grid/_modules_/grid/hooks/root/useEvents.ts b/packages/grid/_modules_/grid/hooks/root/useEvents.ts
index 4cbe67c503a6..abec8d45dade 100644
--- a/packages/grid/_modules_/grid/hooks/root/useEvents.ts
+++ b/packages/grid/_modules_/grid/hooks/root/useEvents.ts
@@ -1,3 +1,4 @@
+import * as React from 'react';
import { GridApiRef } from '../../models/api/gridApiRef';
import { useGridSelector } from '../features/core/useGridSelector';
import { optionsSelector } from '../utils/optionsSelector';
@@ -26,8 +27,10 @@ import {
GRID_CELL_KEY_DOWN,
GRID_CELL_FOCUS_OUT,
GRID_CELL_BLUR,
+ GRID_KEYDOWN,
} from '../../constants/eventsConstants';
import { useGridApiOptionHandler } from './useGridApiEventHandler';
+import { useNativeEventListener } from './useNativeEventListener';
export function useEvents(apiRef: GridApiRef): void {
const options = useGridSelector(apiRef, optionsSelector);
@@ -63,4 +66,18 @@ export function useEvents(apiRef: GridApiRef): void {
useGridApiOptionHandler(apiRef, GRID_COMPONENT_ERROR, options.onError);
useGridApiOptionHandler(apiRef, GRID_STATE_CHANGE, options.onStateChange);
+
+ const getHandler = React.useCallback(
+ (name: string) =>
+ (...args: any[]) =>
+ apiRef.current.publishEvent(name, ...args),
+ [apiRef],
+ );
+
+ useNativeEventListener(
+ apiRef,
+ apiRef.current.rootElementRef!,
+ GRID_KEYDOWN,
+ getHandler(GRID_KEYDOWN),
+ );
}
diff --git a/packages/grid/_modules_/grid/models/api/gridApi.ts b/packages/grid/_modules_/grid/models/api/gridApi.ts
index 647bd2db792d..48fc530901d0 100644
--- a/packages/grid/_modules_/grid/models/api/gridApi.ts
+++ b/packages/grid/_modules_/grid/models/api/gridApi.ts
@@ -17,6 +17,7 @@ import { GridEventsApi } from './gridEventsApi';
import { GridDensityApi } from './gridDensityApi';
import { GridLocaleTextApi } from './gridLocaleTextApi';
import { GridCsvExportApi } from './gridCsvExportApi';
+import { GridClipboardApi } from './gridClipboardApi';
/**
* The full grid API.
@@ -40,4 +41,5 @@ export interface GridApi
GridFilterApi,
GridColumnMenuApi,
GridPreferencesPanelApi,
- GridLocaleTextApi {}
+ GridLocaleTextApi,
+ GridClipboardApi {}
diff --git a/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts
new file mode 100644
index 000000000000..da2af48940df
--- /dev/null
+++ b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts
@@ -0,0 +1,12 @@
+/**
+ * The Clipboard API interface that is available in the grid [[apiRef]].
+ */
+export interface GridClipboardApi {
+ /**
+ * Copies the selected rows to the clipboard.
+ * The fields will separated by the TAB character.
+ * @param {boolean} includeHeaders Whether to include the headers or not. Default is `false`.
+ * @ignore - do not document.
+ */
+ copySelectedRowsToClipboard: (includeHeaders?: boolean) => void;
+}
diff --git a/packages/grid/_modules_/grid/models/api/index.ts b/packages/grid/_modules_/grid/models/api/index.ts
index ecf8a468cea4..7599251c833d 100644
--- a/packages/grid/_modules_/grid/models/api/index.ts
+++ b/packages/grid/_modules_/grid/models/api/index.ts
@@ -19,3 +19,4 @@ export * from './gridFocusApi';
export * from './gridFilterApi';
export * from './gridColumnMenuApi';
export * from './gridPreferencesPanelApi';
+export * from './gridClipboardApi';
diff --git a/packages/grid/_modules_/grid/models/gridExport.ts b/packages/grid/_modules_/grid/models/gridExport.ts
index 6c826695b959..fbd0fee21f3f 100644
--- a/packages/grid/_modules_/grid/models/gridExport.ts
+++ b/packages/grid/_modules_/grid/models/gridExport.ts
@@ -1,7 +1,7 @@
/**
* Available CSV delimiter characters used to separate fields.
*/
-export type GridExportCsvDelimiter = ',' | ';';
+export type GridExportCsvDelimiter = string;
/**
* The options to apply on the CSV export.
diff --git a/packages/grid/_modules_/grid/useGridComponent.tsx b/packages/grid/_modules_/grid/useGridComponent.tsx
index 2de2f23bba40..bd60d0fa9d84 100644
--- a/packages/grid/_modules_/grid/useGridComponent.tsx
+++ b/packages/grid/_modules_/grid/useGridComponent.tsx
@@ -21,6 +21,7 @@ import { useGridSelection } from './hooks/features/selection/useGridSelection';
import { useGridSorting } from './hooks/features/sorting/useGridSorting';
import { useGridComponents } from './hooks/features/useGridComponents';
import { useGridVirtualRows } from './hooks/features/virtualization/useGridVirtualRows';
+import { useGridClipboard } from './hooks/features/clipboard/useGridClipboard';
import { useApi } from './hooks/root/useApi';
import { useEvents } from './hooks/root/useEvents';
import { useGridContainerProps } from './hooks/root/useGridContainerProps';
@@ -63,6 +64,7 @@ export const useGridComponent = (apiRef: GridApiRef, props: GridComponentProps)
useGridPagination(apiRef);
useGridCsvExport(apiRef);
useGridInfiniteLoader(apiRef);
+ useGridClipboard(apiRef);
useGridComponents(apiRef, props);
useStateProp(apiRef, props);
useRenderInfoLog(apiRef);
diff --git a/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx
new file mode 100644
index 000000000000..d1953c9ea9c7
--- /dev/null
+++ b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+import { GridApiRef, useGridApiRef, XGrid } from '@material-ui/x-grid';
+import { expect } from 'chai';
+import { stub } from 'sinon';
+import {
+ createClientRenderStrictMode,
+ // @ts-expect-error need to migrate helpers to TypeScript
+ fireEvent,
+} from 'test/utils';
+import { getCell } from 'test/utils/helperFn';
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+describe(' - Clipboard', () => {
+ // TODO v5: replace with createClientRender
+ const render = createClientRenderStrictMode();
+
+ const baselineProps = {
+ autoHeight: isJSDOM,
+ };
+
+ const columns = [{ field: 'id' }, { field: 'brand', headerName: 'Brand' }];
+
+ let apiRef: GridApiRef;
+
+ function Test() {
+ apiRef = useGridApiRef();
+ return (
+
+
+
+ );
+ }
+
+ describe('copySelectedRowsToClipboard', () => {
+ let writeText;
+
+ before(function beforeHook() {
+ if (!isJSDOM) {
+ // Needs permission to read the clipboard
+ this.skip();
+ }
+ });
+
+ beforeEach(function beforeEachHook() {
+ writeText = stub().resolves();
+
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ });
+ });
+
+ afterEach(function afterEachHook() {
+ Object.defineProperty(navigator, 'clipboard', { value: undefined });
+ });
+
+ it('should copy the selected rows to the clipboard', () => {
+ render();
+ apiRef.current.selectRows([0, 1]);
+ apiRef.current.copySelectedRowsToClipboard();
+ expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas'].join('\r\n'));
+ });
+
+ it('should include the headers when includeHeaders=true', () => {
+ render();
+ apiRef.current.selectRows([0, 1]);
+ apiRef.current.copySelectedRowsToClipboard(true);
+ expect(writeText.firstCall.args[0]).to.equal(
+ ['id\tBrand', '0\tNike', '1\tAdidas'].join('\r\n'),
+ );
+ });
+
+ ['ctrlKey', 'metaKey'].forEach((key) => {
+ it(`should copy the selected rows to the clipboard when ${key} + C is pressed`, () => {
+ render();
+ apiRef.current.selectRows([0, 1]);
+ const cell = getCell(0, 0);
+ cell.focus();
+ fireEvent.keyDown(cell, { key: 'c', [key]: true });
+ expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas'].join('\r\n'));
+ });
+ });
+ });
+});