Skip to content

Commit

Permalink
[DataGrid] Allow to copy the selected rows to the clipboard (#1929)
Browse files Browse the repository at this point in the history
  • Loading branch information
m4theushw committed Jun 29, 2021
1 parent 2b0daf2 commit a3248d1
Show file tree
Hide file tree
Showing 15 changed files with 286 additions and 50 deletions.
21 changes: 11 additions & 10 deletions docs/src/pages/components/data-grid/accessibility/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ Use the arrow keys to move the focus.

### Selection

| Keys | Description |
| -------------------------------------------------------------------------------------------------: | :------------------------------------------------ |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> | Select the current row |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> + <kbd class="key">Arrow Up/Down</kbd> | Select the current row and the row above or below |
| <kbd class="key">CTRL</kbd> + <kbd class="key">A</kbd> | Select all rows |
| <kbd class="key">CTRL</kbd> + <kbd class="key">C</kbd> | Copy the currently selected row(s) |
| <kbd class="key">CTRL</kbd> + Click on cell | Enable multi-selection |
| <kbd class="key">CTRL</kbd> + Click on a selected row | Deselect the row |
| <kbd class="key">Enter</kbd> | Sort column when column header is focused |
| <kbd class="key">CTRL</kbd> + <kbd class="key">Enter</kbd> | Open column menu when column header is focused |
| Keys | Description |
| -------------------------------------------------------------------------------------------------: | :--------------------------------------------------- |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> | Select the current row |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> + <kbd class="key">Arrow Up/Down</kbd> | Select the current row and the row above or below |
| <kbd class="key">CTRL</kbd> + <kbd class="key">A</kbd> | Select all rows |
| <kbd class="key">CTRL</kbd> + <kbd class="key">C</kbd> | Copy the currently selected row(s) |
| <kbd class="key">ALT</kbd> + <kbd class="key">C</kbd> | Copy the currently selected row(s) including headers |
| <kbd class="key">CTRL</kbd> + Click on cell | Enable multi-selection |
| <kbd class="key">CTRL</kbd> + Click on a selected row | Deselect the row |
| <kbd class="key">Enter</kbd> | Sort column when column header is focused |
| <kbd class="key">CTRL</kbd> + <kbd class="key">Enter</kbd> | Open column menu when column header is focused |

### Sorting

Expand Down
5 changes: 5 additions & 0 deletions packages/grid/_modules_/grid/constants/eventsConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useGridClipboard';
Original file line number Diff line number Diff line change
@@ -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');
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,30 @@ export function serialiseRow(
interface BuildCSVOptions {
columns: GridColumns;
rows: Map<GridRowId, GridRowModel>;
selectedRows: Record<string, GridRowId>;
selectedRows?: Record<string, GridRowId>;
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<string>(
(acc, id) =>
Expand All @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -91,7 +99,7 @@ export function useGridParamsApi(apiRef: GridApiRef) {

return params;
},
[apiRef, getBaseCellParams],
[apiRef, cellFocus, cellTabIndex],
);

const getCellValue = React.useCallback(
Expand Down
17 changes: 17 additions & 0 deletions packages/grid/_modules_/grid/hooks/root/useEvents.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
);
}
4 changes: 3 additions & 1 deletion packages/grid/_modules_/grid/models/api/gridApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -40,4 +41,5 @@ export interface GridApi
GridFilterApi,
GridColumnMenuApi,
GridPreferencesPanelApi,
GridLocaleTextApi {}
GridLocaleTextApi,
GridClipboardApi {}
12 changes: 12 additions & 0 deletions packages/grid/_modules_/grid/models/api/gridClipboardApi.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/grid/_modules_/grid/models/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * from './gridFocusApi';
export * from './gridFilterApi';
export * from './gridColumnMenuApi';
export * from './gridPreferencesPanelApi';
export * from './gridClipboardApi';
2 changes: 1 addition & 1 deletion packages/grid/_modules_/grid/models/gridExport.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/grid/_modules_/grid/useGridComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit a3248d1

Please sign in to comment.