diff --git a/src/Cell.tsx b/src/Cell.tsx index f41b01d1d1..128cdd113e 100644 --- a/src/Cell.tsx +++ b/src/Cell.tsx @@ -5,21 +5,9 @@ import { useRovingTabIndex } from './hooks'; import { createCellEvent, getCellClassname, getCellStyle, isCellEditableUtil } from './utils'; import type { CellRendererProps } from './types'; -const cellCopied = css` - @layer rdg.Cell { - background-color: #ccccff; - } -`; - -const cellCopiedClassname = `rdg-cell-copied ${cellCopied}`; - const cellDraggedOver = css` @layer rdg.Cell { background-color: #ccccff; - - &.${cellCopied} { - background-color: #9999ff; - } } `; @@ -29,7 +17,6 @@ function Cell({ column, colSpan, isCellSelected, - isCopied, isDraggedOver, row, rowIdx, @@ -48,7 +35,6 @@ function Cell({ className = getCellClassname( column, { - [cellCopiedClassname]: isCopied, [cellDraggedOverClassname]: isDraggedOver }, typeof cellClass === 'function' ? cellClass(row) : cellClass, diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index d73e568ad5..d8047136c7 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -39,18 +39,19 @@ import { import type { CalculatedColumn, CellClickArgs, + CellClipboardEvent, + CellCopyEvent, CellKeyboardEvent, CellKeyDownArgs, CellMouseEvent, CellNavigationMode, + CellPasteEvent, CellSelectArgs, Column, ColumnOrColumnGroup, - CopyEvent, Direction, FillEvent, Maybe, - PasteEvent, Position, Renderers, RowsChangeData, @@ -175,8 +176,6 @@ export interface DataGridProps extends Sha onSortColumnsChange?: Maybe<(sortColumns: SortColumn[]) => void>; defaultColumnOptions?: Maybe, NoInfer>>; onFill?: Maybe<(event: FillEvent>) => NoInfer>; - onCopy?: Maybe<(event: CopyEvent>) => void>; - onPaste?: Maybe<(event: PasteEvent>) => NoInfer>; /** * Event props @@ -196,6 +195,12 @@ export interface DataGridProps extends Sha onCellKeyDown?: Maybe< (args: CellKeyDownArgs, NoInfer>, event: CellKeyboardEvent) => void >; + onCellCopy?: Maybe< + (args: CellCopyEvent, NoInfer>, event: CellClipboardEvent) => void + >; + onCellPaste?: Maybe< + (args: CellPasteEvent, NoInfer>, event: CellClipboardEvent) => NoInfer + >; /** Function called whenever cell selection is changed */ onSelectedCellChange?: Maybe<(args: CellSelectArgs, NoInfer>) => void>; /** Called when the grid is scrolled */ @@ -260,8 +265,8 @@ export function DataGrid(props: DataGridPr onColumnResize, onColumnsReorder, onFill, - onCopy, - onPaste, + onCellCopy, + onCellPaste, // Toggles and modes enableVirtualization: rawEnableVirtualization, // Miscellaneous @@ -310,7 +315,6 @@ export function DataGrid(props: DataGridPr const [measuredColumnWidths, setMeasuredColumnWidths] = useState( (): ReadonlyMap => new Map() ); - const [copiedCell, setCopiedCell] = useState<{ row: R; columnKey: string } | null>(null); const [isDragging, setDragging] = useState(false); const [draggedOverRowIdx, setOverRowIdx] = useState(undefined); const [scrollToPosition, setScrollToPosition] = useState(null); @@ -608,39 +612,13 @@ export function DataGrid(props: DataGridPr ); if (cellEvent.isGridDefaultPrevented()) return; } + if (!(event.target instanceof Element)) return; const isCellEvent = event.target.closest('.rdg-cell') !== null; const isRowEvent = isTreeGrid && event.target === focusSinkRef.current; if (!isCellEvent && !isRowEvent) return; - // eslint-disable-next-line @typescript-eslint/no-deprecated - const { keyCode } = event; - - if ( - selectedCellIsWithinViewportBounds && - (onPaste != null || onCopy != null) && - isCtrlKeyHeldDown(event) - ) { - // event.key may differ by keyboard input language, so we use event.keyCode instead - // event.nativeEvent.code cannot be used either as it would break copy/paste for the DVORAK layout - const cKey = 67; - const vKey = 86; - if (keyCode === cKey) { - // copy highlighted text only - if (window.getSelection()?.isCollapsed === false) return; - handleCopy(); - return; - } - if (keyCode === vKey) { - handlePaste(); - return; - } - } - switch (event.key) { - case 'Escape': - setCopiedCell(null); - return; case 'ArrowUp': case 'ArrowDown': case 'ArrowLeft': @@ -684,31 +662,21 @@ export function DataGrid(props: DataGridPr updateRow(columns[selectedPosition.idx], selectedPosition.rowIdx, selectedPosition.row); } - function handleCopy() { + function handleCellCopy(event: CellClipboardEvent) { + if (!selectedCellIsWithinViewportBounds) return; const { idx, rowIdx } = selectedPosition; - const sourceRow = rows[rowIdx]; - const sourceColumnKey = columns[idx].key; - setCopiedCell({ row: sourceRow, columnKey: sourceColumnKey }); - onCopy?.({ sourceRow, sourceColumnKey }); + onCellCopy?.({ row: rows[rowIdx], column: columns[idx] }, event); } - function handlePaste() { - if (!onPaste || !onRowsChange || copiedCell === null || !isCellEditable(selectedPosition)) { + function handleCellPaste(event: CellClipboardEvent) { + if (!onCellPaste || !onRowsChange || !isCellEditable(selectedPosition)) { return; } const { idx, rowIdx } = selectedPosition; - const targetColumn = columns[idx]; - const targetRow = rows[rowIdx]; - - const updatedTargetRow = onPaste({ - sourceRow: copiedCell.row, - sourceColumnKey: copiedCell.columnKey, - targetRow, - targetColumnKey: targetColumn.key - }); - - updateRow(targetColumn, rowIdx, updatedTargetRow); + const column = columns[idx]; + const updatedRow = onCellPaste({ row: rows[rowIdx], column }, event); + updateRow(column, rowIdx, updatedRow); } function handleCellInput(event: KeyboardEvent) { @@ -726,7 +694,7 @@ export function DataGrid(props: DataGridPr return; } - if (isCellEditable(selectedPosition) && isDefaultCellInput(event)) { + if (isCellEditable(selectedPosition) && isDefaultCellInput(event, onCellPaste != null)) { setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, @@ -1051,11 +1019,6 @@ export function DataGrid(props: DataGridPr onCellContextMenu: onCellContextMenuLatest, rowClass, gridRowStart, - copiedCellIdx: - copiedCell !== null && copiedCell.row === row - ? columns.findIndex((c) => c.key === copiedCell.columnKey) - : undefined, - selectedCellIdx: selectedRowIdx === rowIdx ? selectedIdx : undefined, draggedOverCellIdx: getDraggedOverCellIdx(rowIdx), setDraggedOverRowIdx: isDragging ? setDraggedOverRowIdx : undefined, @@ -1135,6 +1098,8 @@ export function DataGrid(props: DataGridPr ref={gridRef} onScroll={handleScroll} onKeyDown={handleKeyDown} + onCopy={handleCellCopy} + onPaste={handleCellPaste} data-testid={testId} data-cy={dataCy} > diff --git a/src/Row.tsx b/src/Row.tsx index 11628b6251..6ee25eeaf4 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -14,7 +14,6 @@ function Row({ selectedCellIdx, isRowSelectionDisabled, isRowSelected, - copiedCellIdx, draggedOverCellIdx, lastFrozenColumnIndex, row, @@ -72,7 +71,6 @@ function Row({ colSpan, row, rowIdx, - isCopied: copiedCellIdx === idx, isDraggedOver: draggedOverCellIdx === idx, isCellSelected, onClick: onCellClick, diff --git a/src/TreeDataGrid.tsx b/src/TreeDataGrid.tsx index a888e4ae55..8fd9abaa1f 100644 --- a/src/TreeDataGrid.tsx +++ b/src/TreeDataGrid.tsx @@ -2,10 +2,13 @@ import { useCallback, useMemo } from 'react'; import type { Key } from 'react'; import { useLatestFunc } from './hooks'; -import { assertIsValidKeyGetter, isCtrlKeyHeldDown } from './utils'; +import { assertIsValidKeyGetter } from './utils'; import type { + CellClipboardEvent, + CellCopyEvent, CellKeyboardEvent, CellKeyDownArgs, + CellPasteEvent, Column, GroupRow, Maybe, @@ -53,6 +56,8 @@ export function TreeDataGrid({ rowHeight: rawRowHeight, rowKeyGetter: rawRowKeyGetter, onCellKeyDown: rawOnCellKeyDown, + onCellCopy: rawOnCellCopy, + onCellPaste: rawOnCellPaste, onRowsChange, selectedRows: rawSelectedRows, onSelectedRowsChange: rawOnSelectedRowsChange, @@ -318,14 +323,25 @@ export function TreeDataGrid({ selectCell({ idx, rowIdx: parentRowAndIndex[1] }); } } + } - // Prevent copy/paste on group rows - // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isCtrlKeyHeldDown(event) && (event.keyCode === 67 || event.keyCode === 86)) { - event.preventGridDefault(); + // Prevent copy/paste on group rows + function handleCellCopy( + { row, column }: CellCopyEvent, NoInfer>, + event: CellClipboardEvent + ) { + if (!isGroupRow(row)) { + rawOnCellCopy?.({ row, column }, event); } } + function handleCellPaste( + { row, column }: CellPasteEvent, NoInfer>, + event: CellClipboardEvent + ) { + return isGroupRow(row) ? row : rawOnCellPaste!({ row, column }, event); + } + function handleRowsChange(updatedRows: R[], { indexes, column }: RowsChangeData) { if (!onRowsChange) return; const updatedRawRows = [...rawRows]; @@ -361,7 +377,6 @@ export function TreeDataGrid({ onCellContextMenu, onRowChange, lastFrozenColumnIndex, - copiedCellIdx, draggedOverCellIdx, setDraggedOverRowIdx, selectedCellEditor, @@ -400,7 +415,6 @@ export function TreeDataGrid({ onCellContextMenu, onRowChange, lastFrozenColumnIndex, - copiedCellIdx, draggedOverCellIdx, setDraggedOverRowIdx, selectedCellEditor @@ -422,6 +436,8 @@ export function TreeDataGrid({ selectedRows={selectedRows} onSelectedRowsChange={onSelectedRowsChange} onCellKeyDown={handleKeyDown} + onCellCopy={handleCellCopy} + onCellPaste={rawOnCellPaste ? handleCellPaste : undefined} renderers={{ ...renderers, renderRow diff --git a/src/index.ts b/src/index.ts index 69d59371f9..6d111cacfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,6 @@ export type { SelectHeaderRowEvent, SelectRowEvent, FillEvent, - CopyEvent, - PasteEvent, SortDirection, SortColumn, ColSpanArgs, @@ -49,5 +47,7 @@ export type { CellClickArgs, CellKeyDownArgs, CellKeyboardEvent, + CellCopyEvent, + CellPasteEvent, CellSelectArgs } from './types'; diff --git a/src/types.ts b/src/types.ts index 192421625d..82b22178df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -153,7 +153,6 @@ export interface CellRendererProps Omit, 'children' | 'onClick' | 'onDoubleClick' | 'onContextMenu'> { column: CalculatedColumn; colSpan: number | undefined; - isCopied: boolean; isDraggedOver: boolean; isCellSelected: boolean; onClick: RenderRowProps['onCellClick']; @@ -171,25 +170,27 @@ export type CellMouseEvent = CellEvent>; export type CellKeyboardEvent = CellEvent>; +export type CellClipboardEvent = React.ClipboardEvent; + export interface CellClickArgs { - rowIdx: number; - row: TRow; column: CalculatedColumn; + row: TRow; + rowIdx: number; selectCell: (enableEditor?: boolean) => void; } interface SelectCellKeyDownArgs { mode: 'SELECT'; - row: TRow; column: CalculatedColumn; + row: TRow; rowIdx: number; selectCell: (position: Position, enableEditor?: Maybe) => void; } export interface EditCellKeyDownArgs { mode: 'EDIT'; - row: TRow; column: CalculatedColumn; + row: TRow; rowIdx: number; navigate: () => void; onClose: (commitChanges?: boolean, shouldFocusCell?: boolean) => void; @@ -224,7 +225,6 @@ export interface RenderRowProps extends BaseRenderRowProps { row: TRow; lastFrozenColumnIndex: number; - copiedCellIdx: number | undefined; draggedOverCellIdx: number | undefined; selectedCellEditor: ReactElement> | undefined; onRowChange: (column: CalculatedColumn, rowIdx: number, newRow: TRow) => void; @@ -253,17 +253,13 @@ export interface FillEvent { targetRow: TRow; } -export interface CopyEvent { - sourceColumnKey: string; - sourceRow: TRow; +interface CellCopyPasteEvent { + column: CalculatedColumn; + row: TRow; } -export interface PasteEvent { - sourceColumnKey: string; - sourceRow: TRow; - targetColumnKey: string; - targetRow: TRow; -} +export type CellCopyEvent = CellCopyPasteEvent; +export type CellPasteEvent = CellCopyPasteEvent; export interface GroupRow { readonly childRows: readonly TRow[]; diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index e12609eb4f..5bb352521e 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -52,10 +52,16 @@ export function isCtrlKeyHeldDown(e: React.KeyboardEvent): boolean { return (e.ctrlKey || e.metaKey) && e.key !== 'Control'; } -export function isDefaultCellInput(event: React.KeyboardEvent): boolean { - const vKey = 86; +// event.key may differ by keyboard input language, so we use event.keyCode instead +// event.nativeEvent.code cannot be used either as it would break copy/paste for the DVORAK layout +const vKey = 86; + +export function isDefaultCellInput( + event: React.KeyboardEvent, + isUserHandlingPaste: boolean +): boolean { // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isCtrlKeyHeldDown(event) && event.keyCode !== vKey) return false; + if (isCtrlKeyHeldDown(event) && (event.keyCode !== vKey || isUserHandlingPaste)) return false; return !nonInputKeys.has(event.key); } diff --git a/test/browser/TreeDataGrid.test.tsx b/test/browser/TreeDataGrid.test.tsx index 02a2e25b6d..883719fda4 100644 --- a/test/browser/TreeDataGrid.test.tsx +++ b/test/browser/TreeDataGrid.test.tsx @@ -5,16 +5,7 @@ import type { Column } from '../../src'; import { SelectColumn, textEditor, TreeDataGrid } from '../../src'; import { focusSinkClassname } from '../../src/style/core'; import { rowSelected } from '../../src/style/row'; -import type { PasteEvent } from '../../src/types'; -import { - copySelectedCell, - getCellsAtRowIndex, - getHeaderCells, - getRows, - getSelectedCell, - getTreeGrid, - pasteSelectedCell -} from './utils'; +import { getCellsAtRowIndex, getHeaderCells, getRows, getSelectedCell, getTreeGrid } from './utils'; const rowSelectedClassname = 'rdg-row-selected'; @@ -87,6 +78,9 @@ const initialRows: readonly Row[] = [ } ]; +const onCellCopySpy = vi.fn(); +const onCellPasteSpy = vi.fn(({ row }: { row: Row }) => row); + function rowKeyGetter(row: Row) { return row.id; } @@ -98,13 +92,6 @@ function TestGrid({ groupBy }: { groupBy: string[] }) { (): ReadonlySet => new Set([]) ); - function onPaste(event: PasteEvent) { - return { - ...event.targetRow, - [event.targetColumnKey]: event.sourceRow[event.sourceColumnKey as keyof Row] - }; - } - return ( ); } @@ -131,6 +119,8 @@ function rowGrouper(rows: readonly Row[], columnKey: string) { } function setup(groupBy: string[]) { + onCellCopySpy.mockClear(); + onCellPasteSpy.mockClear(); page.render(); } @@ -380,13 +370,36 @@ test('cell navigation in a treegrid', async () => { test('copy/paste when grouping is enabled', async () => { setup(['year']); await userEvent.click(page.getByRole('gridcell', { name: '2021' })); + await userEvent.copy(); + expect(onCellCopySpy).not.toHaveBeenCalled(); + await userEvent.paste(); + expect(onCellPasteSpy).not.toHaveBeenCalled(); + await userEvent.click(page.getByRole('gridcell', { name: 'USA' })); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass('rdg-cell-copied'); - await userEvent.keyboard('{arrowdown}'); - await expect.element(getSelectedCell()).toHaveTextContent('Canada'); - await pasteSelectedCell(); - await expect.element(getSelectedCell()).toHaveTextContent('USA'); + await userEvent.copy(); + expect(onCellCopySpy).toHaveBeenCalledExactlyOnceWith( + { + column: expect.objectContaining(columns[2]), + row: { + country: 'USA', + id: 2, + year: 2021 + } + }, + expect.anything() + ); + await userEvent.paste(); + expect(onCellPasteSpy).toHaveBeenCalledExactlyOnceWith( + { + column: expect.objectContaining(columns[2]), + row: { + country: 'USA', + id: 2, + year: 2021 + } + }, + expect.anything() + ); }); test('update row using cell renderer', async () => { diff --git a/test/browser/copyPaste.test.tsx b/test/browser/copyPaste.test.tsx index 509d7f8d32..e7bf3bbc49 100644 --- a/test/browser/copyPaste.test.tsx +++ b/test/browser/copyPaste.test.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { page, userEvent } from '@vitest/browser/context'; import { DataGrid } from '../../src'; -import type { Column, PasteEvent } from '../../src'; -import { copySelectedCell, getCellsAtRowIndex, getSelectedCell, pasteSelectedCell } from './utils'; +import type { CellPasteEvent, Column } from '../../src'; +import { getCellsAtRowIndex, getSelectedCell } from './utils'; interface Row { col: string; @@ -41,23 +41,14 @@ const bottomSummaryRows: readonly Row[] = [ } ]; -const copyCellClassName = 'rdg-cell-copied'; -const onPasteSpy = vi.fn(); -const onCopySpy = vi.fn(); - -function CopyPasteTest({ - onPasteCallback = true, - onCopyCallback = false -}: { - onPasteCallback?: boolean; - onCopyCallback?: boolean; -}) { - const [rows, setRows] = useState(initialRows); +const onCellCopySpy = vi.fn(); +const onCellPasteSpy = vi.fn(({ column, row }: CellPasteEvent) => { + const columnKey = column.key; + return { ...row, [columnKey]: row[columnKey as keyof Row] }; +}); - function onPaste({ sourceColumnKey, sourceRow, targetColumnKey, targetRow }: PasteEvent) { - onPasteSpy(); - return { ...targetRow, [targetColumnKey]: sourceRow[sourceColumnKey as keyof Row] }; - } +function CopyPasteTest() { + const [rows, setRows] = useState(initialRows); return ( ); } -function setup(onPasteCallback = true, onCopyCallback = false) { - onPasteSpy.mockReset(); - onCopySpy.mockReset(); - page.render(); +function setup() { + onCellCopySpy.mockClear(); + onCellPasteSpy.mockClear(); + page.render(); } -test('should not allow copy/paste if onPaste & onCopy is undefined', async () => { - setup(false, false); - await userEvent.click(getCellsAtRowIndex(0)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).not.toHaveClass(copyCellClassName); - expect(onCopySpy).not.toHaveBeenCalled(); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - await userEvent.keyboard('{escape}'); - expect(getCellsAtRowIndex(1)[0]).toHaveTextContent('a2'); - expect(onPasteSpy).not.toHaveBeenCalled(); -}); - -test('should allow copy if only onCopy is specified', async () => { - setup(false, true); - await userEvent.click(getCellsAtRowIndex(0)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass(copyCellClassName); - expect(onCopySpy).toHaveBeenCalledExactlyOnceWith({ - sourceRow: initialRows[0], - sourceColumnKey: 'col' - }); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - expect(getCellsAtRowIndex(1)[0]).toHaveTextContent('a2'); - expect(onPasteSpy).not.toHaveBeenCalled(); -}); - -test('should allow copy/paste if only onPaste is specified', async () => { - setup(true, false); +test('should call onCellCopy on cell copy', async () => { + setup(); await userEvent.click(getCellsAtRowIndex(0)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass(copyCellClassName); - expect(onCopySpy).not.toHaveBeenCalled(); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - expect(getCellsAtRowIndex(1)[0]).toHaveTextContent('a1'); - expect(onPasteSpy).toHaveBeenCalledOnce(); + await userEvent.copy(); + expect(onCellCopySpy).toHaveBeenCalledExactlyOnceWith( + { + row: initialRows[0], + column: expect.objectContaining(columns[0]) + }, + expect.anything() + ); }); -test('should allow copy/paste if both onPaste & onCopy is specified', async () => { - setup(true, true); +test('should call onCellPaste on cell paste', async () => { + setup(); await userEvent.click(getCellsAtRowIndex(0)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass(copyCellClassName); - expect(onCopySpy).toHaveBeenCalledExactlyOnceWith({ - sourceRow: initialRows[0], - sourceColumnKey: 'col' - }); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - expect(getCellsAtRowIndex(1)[0]).toHaveTextContent('a1'); - expect(onPasteSpy).toHaveBeenCalledOnce(); + await userEvent.paste(); + expect(onCellPasteSpy).toHaveBeenCalledExactlyOnceWith( + { + row: initialRows[0], + column: expect.objectContaining(columns[0]) + }, + expect.anything() + ); }); test('should not allow paste on readonly cells', async () => { - setup(); - await userEvent.click(getCellsAtRowIndex(1)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass(copyCellClassName); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - expect(getCellsAtRowIndex(2)[0]).toHaveTextContent('a3'); -}); - -test('should allow copying a readonly cell, and pasting the value into a writable cell', async () => { setup(); await userEvent.click(getCellsAtRowIndex(2)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass(copyCellClassName); - await userEvent.keyboard('{arrowup}'); - await pasteSelectedCell(); - expect(getCellsAtRowIndex(1)[0]).toHaveTextContent('a3'); + await userEvent.paste(); + expect(onCellPasteSpy).not.toHaveBeenCalled(); }); -test('should cancel copy/paste on escape', async () => { +test('should allow copying a readonly cell', async () => { setup(); - await userEvent.click(getCellsAtRowIndex(0)[0]); - await copySelectedCell(); - await expect.element(getSelectedCell()).toHaveClass(copyCellClassName); - await userEvent.keyboard('{escape}'); - await expect.element(getSelectedCell()).not.toHaveClass(copyCellClassName); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - expect(getCellsAtRowIndex(1)[0]).toHaveTextContent('a2'); + await userEvent.click(getCellsAtRowIndex(2)[0]); + await userEvent.copy(); + expect(onCellCopySpy).toHaveBeenCalledExactlyOnceWith( + { + row: initialRows[2], + column: expect.objectContaining(columns[0]) + }, + expect.anything() + ); }); -test('should not allow copy on header or summary cells', async () => { +test('should not allow copy/paste on header or summary cells', async () => { setup(); await userEvent.tab(); - await copySelectedCell(); - await expect.element(getSelectedCell()).not.toHaveClass(copyCellClassName); - await userEvent.keyboard('{arrowdown}'); - await pasteSelectedCell(); - await expect.element(getSelectedCell()).toHaveTextContent('a1'); - expect(onPasteSpy).not.toHaveBeenCalled(); - await userEvent.keyboard('{Control>}{end}'); - await copySelectedCell(); - await expect.element(getSelectedCell()).not.toHaveClass(copyCellClassName); - await userEvent.keyboard('{arrowup}'); - await pasteSelectedCell(); - await expect.element(getSelectedCell()).toHaveTextContent('a3'); - expect(onPasteSpy).not.toHaveBeenCalled(); -}); + await userEvent.copy(); + expect(onCellCopySpy).not.toHaveBeenCalled(); + await userEvent.paste(); + expect(onCellPasteSpy).not.toHaveBeenCalled(); -test('should not allow paste on header or summary cells', async () => { - setup(); - await userEvent.click(getCellsAtRowIndex(0)[0]); - await copySelectedCell(); - await userEvent.keyboard('{arrowup}'); - await pasteSelectedCell(); - await expect.element(getSelectedCell()).toHaveTextContent('Col'); - expect(onPasteSpy).not.toHaveBeenCalled(); await userEvent.keyboard('{Control>}{end}'); - await pasteSelectedCell(); - await expect.element(getSelectedCell()).toHaveTextContent('s1'); - expect(onPasteSpy).not.toHaveBeenCalled(); + await userEvent.copy(); + expect(onCellCopySpy).not.toHaveBeenCalled(); + await userEvent.paste(); + expect(onCellPasteSpy).not.toHaveBeenCalled(); }); test('should not start editing when pressing ctrl+', async () => { diff --git a/test/browser/utils.tsx b/test/browser/utils.tsx index c25ab87cbe..5c476acefd 100644 --- a/test/browser/utils.tsx +++ b/test/browser/utils.tsx @@ -1,4 +1,4 @@ -import { page, userEvent } from '@vitest/browser/context'; +import { page } from '@vitest/browser/context'; import { css } from '@linaria/core'; import { DataGrid } from '../../src'; @@ -55,14 +55,6 @@ export function validateCellPosition(columnIdx: number, rowIdx: number) { expect(cell.parentNode).toHaveAttribute('aria-rowindex', `${rowIdx + 1}`); } -export function copySelectedCell() { - return userEvent.keyboard('{Control>}c{/Control}'); -} - -export function pasteSelectedCell() { - return userEvent.keyboard('{Control>}v{/Control}'); -} - export async function scrollGrid({ scrollLeft, scrollTop diff --git a/website/routes/AllFeatures.tsx b/website/routes/AllFeatures.tsx index 5b3b1f471e..21b16e10ed 100644 --- a/website/routes/AllFeatures.tsx +++ b/website/routes/AllFeatures.tsx @@ -2,9 +2,10 @@ import { useState } from 'react'; import { faker } from '@faker-js/faker'; import { createFileRoute } from '@tanstack/react-router'; import { css } from '@linaria/core'; +import clsx from 'clsx'; import { DataGrid, SelectColumn, textEditor } from '../../src'; -import type { Column, CopyEvent, FillEvent, PasteEvent } from '../../src'; +import type { CalculatedColumn, CellCopyEvent, CellPasteEvent, Column, FillEvent } from '../../src'; import { textEditorClassname } from '../../src/editors/textEditor'; import { useDirection } from '../directionContext'; @@ -54,6 +55,8 @@ const highlightClassname = css` } `; +const copiedRowClassname = css``; + export interface Row { id: string; avatar: string; @@ -203,61 +206,108 @@ function AllFeatures() { const initialRows = Route.useLoaderData(); const [rows, setRows] = useState(initialRows); const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set()); + const [copiedCell, setCopiedCell] = useState<{ + readonly row: Row; + readonly column: CalculatedColumn; + } | null>(null); function handleFill({ columnKey, sourceRow, targetRow }: FillEvent): Row { return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; } - function handlePaste({ - sourceColumnKey, - sourceRow, - targetColumnKey, - targetRow - }: PasteEvent): Row { - const incompatibleColumns = ['email', 'zipCode', 'date']; - if ( - sourceColumnKey === 'avatar' || - ['id', 'avatar'].includes(targetColumnKey) || - ((incompatibleColumns.includes(targetColumnKey) || - incompatibleColumns.includes(sourceColumnKey)) && - sourceColumnKey !== targetColumnKey) - ) { - return targetRow; + function handleCellPaste( + { row, column }: CellCopyEvent, + event: React.ClipboardEvent + ): Row { + const targetColumnKey = column.key; + + if (copiedCell !== null) { + const sourceColumnKey = copiedCell.column.key; + const sourceRow = copiedCell.row; + + const incompatibleColumns = ['email', 'zipCode', 'date']; + if ( + sourceColumnKey === 'avatar' || + ['id', 'avatar'].includes(targetColumnKey) || + ((incompatibleColumns.includes(targetColumnKey) || + incompatibleColumns.includes(sourceColumnKey)) && + sourceColumnKey !== targetColumnKey) + ) { + return row; + } + + return { ...row, [targetColumnKey]: sourceRow[sourceColumnKey as keyof Row] }; } - return { ...targetRow, [targetColumnKey]: sourceRow[sourceColumnKey as keyof Row] }; + const copiedText = event.clipboardData.getData('text/plain'); + if (copiedText !== '') { + return { ...row, [targetColumnKey]: copiedText }; + } + + return row; } - function handleCopy({ sourceRow, sourceColumnKey }: CopyEvent): void { - if (window.isSecureContext) { - navigator.clipboard.writeText(sourceRow[sourceColumnKey as keyof Row]); + function handleCellCopy( + { row, column }: CellPasteEvent, + event: React.ClipboardEvent + ): void { + // copy highlighted text only + if (window.getSelection()?.isCollapsed === false) { + setCopiedCell(null); + return; } + + setCopiedCell({ row, column }); + event.clipboardData.setData('text/plain', row[column.key as keyof Row]); + event.preventDefault(); } return ( - row.id === 'id_2'} - onSelectedRowsChange={setSelectedRows} - className="fill-grid" - rowClass={(row, index) => - row.id.includes('7') || index === 0 ? highlightClassname : undefined - } - direction={direction} - onCellClick={(args, event) => { - if (args.column.key === 'title') { - event.preventGridDefault(); - args.selectCell(true); - } - }} - /> + <> + {copiedCell && ( + + )} + row.id === 'id_2'} + onSelectedRowsChange={setSelectedRows} + className="fill-grid" + rowClass={(row, index) => { + return clsx({ + [highlightClassname]: row.id.includes('7') || index === 0, + [copiedRowClassname]: copiedCell?.row === row + }); + }} + direction={direction} + onCellClick={(args, event) => { + if (args.column.key === 'title') { + event.preventGridDefault(); + args.selectCell(true); + } + }} + onCellKeyDown={(_, event) => { + if (event.key === 'Escape') { + setCopiedCell(null); + } + }} + /> + ); }