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')); + }); + }); + }); +});