diff --git a/docs/pages/api-docs/data-grid/grid-api.md b/docs/pages/api-docs/data-grid/grid-api.md
index ea39eac9082cd..2ab49a3b88047 100644
--- a/docs/pages/api-docs/data-grid/grid-api.md
+++ b/docs/pages/api-docs/data-grid/grid-api.md
@@ -19,6 +19,7 @@ import { GridApi } from '@material-ui/x-grid';
| commitCellChange | (params: GridEditCellPropsParams) => void | Commits a cell change. Used to update the value when editing a cell. |
| components | GridApiRefComponentsProperty | The set of overridable components used in the grid. |
| componentsProps? | GridSlotsComponentsProps | Overrideable components props dynamically passed to the component at rendering. |
+| copySelectedRowsToClipboard | (includeHeaders?: boolean) => void | Copies the selected rows to the clipboard.
The fields will separated by the TAB character. |
| deleteFilter | (item: GridFilterItem) => void | Deletes a GridFilterItem. |
| exportDataAsCsv | (options?: GridExportCsvOptions) => void | Downloads and exports a CSV of the grid's data. |
| forceUpdate | Dispatch<any> | Forces the grid to rerender. It's often used after a state update. |
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 0000000000000..173fe0945ad60
--- /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 0000000000000..669aa86252994
--- /dev/null
+++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts
@@ -0,0 +1,80 @@
+import * as React from 'react';
+import { GridApiRef } from '../../../models/api/gridApiRef';
+import { useGridApiEventHandler } from '../../root/useGridApiEventHandler';
+import { GRID_KEYDOWN } from '../../../constants/eventsConstants';
+import { serialiseRow, serialiseCellValue } 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';
+
+export const useGridClipboard = (apiRef: GridApiRef): void => {
+ const visibleColumns = useGridSelector(apiRef, visibleGridColumnsSelector);
+
+ const writeToClipboardPolyfill = React.useCallback(
+ (data: string) => {
+ const textarea = document.createElement('textarea');
+ textarea.style.opacity = '0px';
+ textarea.value = data;
+ apiRef.current.rootElementRef?.current?.appendChild(textarea);
+ textarea.select();
+ document.execCommand('copy');
+ apiRef.current.rootElementRef?.current?.removeChild(textarea);
+ },
+ [apiRef],
+ );
+
+ 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 headers = `${filteredColumns
+ .map((column) => serialiseCellValue(column.headerName || column.field, '\t'))
+ .join('\t')}\r\n`;
+
+ const data = [...selectedRows.keys()].reduce(
+ (acc, id) =>
+ `${acc}${serialiseRow(id, filteredColumns, apiRef.current.getCellParams, '\t').join(
+ '\t',
+ )}\r\n`,
+ includeHeaders ? headers : '',
+ );
+
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(data).catch(() => {
+ writeToClipboardPolyfill(data);
+ });
+ } else {
+ writeToClipboardPolyfill(data);
+ }
+ },
+ [apiRef, visibleColumns, writeToClipboardPolyfill],
+ );
+
+ const handleKeydown = React.useCallback(
+ (event: KeyboardEvent) => {
+ const isCtrlPressed = event.ctrlKey || event.metaKey;
+ if (event.key.toLowerCase() !== 'c' || !isCtrlPressed) {
+ return;
+ }
+ apiRef.current.copySelectedRowsToClipboard(event.shiftKey);
+ },
+ [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 9738ff87b849e..ddd84530d33fc 100644
--- a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts
+++ b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts
@@ -7,7 +7,7 @@ import {
} from '../../../../models';
import { GridExportCsvDelimiter } from '../../../../models/gridExport';
-const serialiseCellValue = (value: any, delimiterCharacter: GridExportCsvDelimiter) => {
+export const serialiseCellValue = (value: any, delimiterCharacter: GridExportCsvDelimiter) => {
if (typeof value === 'string') {
const formattedValue = value.replace(/"/g, '""');
return formattedValue.includes(delimiterCharacter) ? `"${formattedValue}"` : formattedValue;
diff --git a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts
index 6e6cd254bb3f3..cbd7cb7f1073b 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/root/useEvents.ts b/packages/grid/_modules_/grid/hooks/root/useEvents.ts
index 4bd4c7216e044..3b746d605ec41 100644
--- a/packages/grid/_modules_/grid/hooks/root/useEvents.ts
+++ b/packages/grid/_modules_/grid/hooks/root/useEvents.ts
@@ -34,6 +34,7 @@ import {
GRID_CELL_BLUR,
} from '../../constants/eventsConstants';
import { useGridApiOptionHandler } from './useGridApiEventHandler';
+import { useNativeEventListener } from './useNativeEventListener';
export function useEvents(apiRef: GridApiRef): void {
const logger = useLogger('useEvents');
@@ -88,6 +89,13 @@ export function useEvents(apiRef: GridApiRef): void {
useGridApiOptionHandler(apiRef, GRID_COMPONENT_ERROR, options.onError);
useGridApiOptionHandler(apiRef, GRID_STATE_CHANGE, options.onStateChange);
+ useNativeEventListener(
+ apiRef,
+ apiRef.current.rootElementRef!,
+ GRID_KEYDOWN,
+ getHandler(GRID_KEYDOWN),
+ );
+
React.useEffect(() => {
if (apiRef.current.rootElementRef?.current) {
logger.debug('Binding events listeners');
diff --git a/packages/grid/_modules_/grid/models/api/gridApi.ts b/packages/grid/_modules_/grid/models/api/gridApi.ts
index 647bd2db792df..48fc530901d0c 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 0000000000000..e3a7d857671c1
--- /dev/null
+++ b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts
@@ -0,0 +1,11 @@
+/**
+ * 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`.
+ */
+ 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 ecf8a468cea46..7599251c833d5 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 6c826695b9597..b67df51608560 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 = ',' | ';' | '\t';
/**
* 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 2de2f23bba407..bd60d0fa9d84c 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 0000000000000..59b4f52e7bf07
--- /dev/null
+++ b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx
@@ -0,0 +1,102 @@
+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();
+ return;
+ }
+
+ writeText = stub().resolves();
+
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ });
+ });
+
+ after(function afterHook() {
+ 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'));
+ });
+ });
+ });
+});