From f94aa4ee910e875aa80b473901e0a2992f4f4692 Mon Sep 17 00:00:00 2001 From: Aman Mahajan Date: Wed, 15 Jul 2020 12:47:20 -0500 Subject: [PATCH] Remove InteractionMask (#2061) * Initial commit * Copy Tab logic * Initial cell drag implementation * Initial cell editing implementation * Move editorContainer to the DataGrid component * Update src/utils/selectedCellUtils.ts Co-authored-by: Nicolas Stepien <567105+nstepien@users.noreply.github.com> * Cleanup * Remove masks components * Cancel copying * Remove edit check * Cleanup * Address comments * Move DragHandle to the parent DataGrid component * Do not paste on the copied cell * Remove unnecessary class * Fix copy/dragged cell styles * Address dragging issues * Pass down dragHandle component * Fix styles * Remove unused function * Move getNextPosition to selectedCellUtils * Use ref to get the latest draggedOverRowIdx * Revert EventBus changes * Fix type errors * Specify return type * Update changelog * Add selectedCellProps props Select cell only on update * Remove isMouted check * Add the row containing the selected cell if not included in the vertical range * Update src/DataGrid.tsx Co-authored-by: Nicolas Stepien <567105+nstepien@users.noreply.github.com> * Address comments * Set focus in useLayoutEffect and set tabIndex to -1 * setFocus -> shouldFocus * Address comments * Cleanup * use event.buttons * Better focus handling * Remove comments * Check valid selection * Even better focus implementation * Cleanup handleKeyDown usage * Remove drag cell borders Co-authored-by: Nicolas Stepien <567105+nstepien@users.noreply.github.com> Co-authored-by: Nicolas Stepien --- src/Cell.test.tsx | 8 +- src/Cell.tsx | 97 +++- src/DataGrid.tsx | 382 ++++++++++++-- src/EventBus.ts | 3 +- src/Row.tsx | 33 +- src/common/types.ts | 44 +- src/editors/EditorContainer.tsx | 14 +- src/editors/index.ts | 3 + src/hooks/index.ts | 1 + src/hooks/useCombinedRefs.ts | 10 + src/index.ts | 2 +- src/masks/CellMask.test.tsx | 38 -- src/masks/CellMask.tsx | 22 - src/masks/DragMask.test.tsx | 63 --- src/masks/DragMask.tsx | 33 -- src/masks/InteractionMasks.test.tsx | 765 ---------------------------- src/masks/InteractionMasks.tsx | 383 -------------- src/utils/index.ts | 14 + src/utils/selectedCellUtils.ts | 32 +- stories/demos/AllFeatures.less | 4 +- style/cell.less | 33 +- style/core.less | 15 +- style/index.less | 1 - style/interaction-masks.less | 46 -- 24 files changed, 547 insertions(+), 1499 deletions(-) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useCombinedRefs.ts delete mode 100644 src/masks/CellMask.test.tsx delete mode 100644 src/masks/CellMask.tsx delete mode 100644 src/masks/DragMask.test.tsx delete mode 100644 src/masks/DragMask.tsx delete mode 100644 src/masks/InteractionMasks.test.tsx delete mode 100644 src/masks/InteractionMasks.tsx delete mode 100644 style/interaction-masks.less diff --git a/src/Cell.test.tsx b/src/Cell.test.tsx index 3ebf587eba..8bd8abc895 100644 --- a/src/Cell.test.tsx +++ b/src/Cell.test.tsx @@ -22,7 +22,9 @@ const testProps: CellRendererProps = { lastFrozenColumnIndex: -1, row: { id: 1, description: 'Wicklow' }, isRowSelected: false, - eventBus: new EventBus() + eventBus: new EventBus(), + isCopied: false, + isDraggedOver: false }; const renderComponent = (extraProps?: PropsWithChildren>>) => { @@ -60,7 +62,9 @@ describe('Cell', () => { lastFrozenColumnIndex: -1, row: helpers.rows[11], isRowSelected: false, - eventBus: new EventBus() + eventBus: new EventBus(), + isCopied: false, + isDraggedOver: false }; it('passes classname property', () => { diff --git a/src/Cell.tsx b/src/Cell.tsx index f9a22b250b..51e435677b 100644 --- a/src/Cell.tsx +++ b/src/Cell.tsx @@ -1,38 +1,61 @@ -import React, { forwardRef, memo } from 'react'; +import React, { forwardRef, memo, useRef } from 'react'; import clsx from 'clsx'; +import { EditorContainer, EditorPortal } from './editors'; import { CellRendererProps } from './common/types'; -import { preventDefault, wrapEvent } from './utils'; +import { wrapEvent } from './utils'; +import { useCombinedRefs } from './hooks'; function Cell({ className, column, + isCopied, + isDraggedOver, isRowSelected, lastFrozenColumnIndex, row, rowIdx, eventBus, + selectedCellProps, onRowClick, + onKeyDown, onClick, onDoubleClick, onContextMenu, - onDragOver, ...props }: CellRendererProps, ref: React.Ref) { + const cellRef = useRef(null); + const isSelected = selectedCellProps !== undefined; + const isEditing = selectedCellProps?.mode === 'EDIT'; + + const { cellClass } = column; + className = clsx( + 'rdg-cell', + { + 'rdg-cell-frozen': column.frozen, + 'rdg-cell-frozen-last': column.idx === lastFrozenColumnIndex, + 'rdg-cell-selected': isSelected, + 'rdg-cell-copied': isCopied, + 'rdg-cell-dragged-over': isDraggedOver + }, + typeof cellClass === 'function' ? cellClass(row) : cellClass, + className + ); + function selectCell(openEditor?: boolean) { eventBus.dispatch('SELECT_CELL', { idx: column.idx, rowIdx }, openEditor); } - function handleCellClick() { + function handleClick() { selectCell(); onRowClick?.(rowIdx, row, column); } - function handleCellContextMenu() { + function handleContextMenu() { selectCell(); } - function handleCellDoubleClick() { + function handleDoubleClick() { selectCell(true); } @@ -40,38 +63,56 @@ function Cell({ eventBus.dispatch('SELECT_ROW', { rowIdx, checked, isShiftClick }); } - const { cellClass } = column; - className = clsx( - 'rdg-cell', - { - 'rdg-cell-frozen': column.frozen, - 'rdg-cell-frozen-last': column.idx === lastFrozenColumnIndex - }, - typeof cellClass === 'function' ? cellClass(row) : cellClass, - className - ); + function getCellContent() { + if (selectedCellProps && selectedCellProps.mode === 'EDIT') { + const { editorPortalTarget, ...editorProps } = selectedCellProps.editorContainerProps; + const { left, top } = cellRef.current!.getBoundingClientRect(); + + return ( + + + {...editorProps} + rowIdx={rowIdx} + row={row} + column={column} + left={left} + top={top} + /> + + ); + } + + return ( + <> + + {selectedCellProps?.dragHandleProps && ( +
+ )} + + ); + } return (
- + {getCellContent()}
); } diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 5b25afccf3..4f8dba8d28 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -9,14 +9,15 @@ import React, { useCallback, createElement } from 'react'; +import clsx from 'clsx'; import EventBus from './EventBus'; -import InteractionMasks from './masks/InteractionMasks'; import HeaderRow from './HeaderRow'; import FilterRow from './FilterRow'; import Row from './Row'; import SummaryRow from './SummaryRow'; import { ValueFormatter } from './formatters'; +import { legacyCellInput } from './editors'; import { assertIsValidKey, getColumnMetrics, @@ -24,7 +25,11 @@ import { getHorizontalRangeToRender, getScrollbarSize, getVerticalRangeToRender, - getViewportColumns + getViewportColumns, + getNextSelectedCellPosition, + isSelectedCellEditable, + canExitGrid, + isCtrlKeyHeldDown } from './utils'; import { @@ -36,9 +41,20 @@ import { Position, RowRendererProps, RowsUpdateEvent, - SelectRowEvent + SelectRowEvent, + CommitEvent, + SelectedCellProps } from './common/types'; -import { CellNavigationMode, SortDirection } from './common/enums'; +import { CellNavigationMode, SortDirection, UpdateActions } from './common/enums'; + +interface SelectCellState extends Position { + mode: 'SELECT'; +} + +interface EditCellState extends Position { + mode: 'EDIT'; + key: string | null; +} export interface DataGridHandle { scrollToColumn: (colIdx: number) => void; @@ -189,12 +205,6 @@ function DataGrid({ editorPortalTarget = document.body, rowClass }: DataGridProps, ref: React.Ref) { - /** - * refs - * */ - const gridRef = useRef(null); - const lastSelectedRowIdx = useRef(-1); - /** * states */ @@ -203,6 +213,24 @@ function DataGrid({ const [scrollTop, setScrollTop] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); const [columnWidths, setColumnWidths] = useState>(() => new Map()); + const [selectedPosition, setSelectedPosition] = useState({ idx: -1, rowIdx: -1, mode: 'SELECT' }); + const [copiedPosition, setCopiedPosition] = useState(null); + const [isDragging, setDragging] = useState(false); + const [draggedOverRowIdx, setOverRowIdx] = useState(undefined); + + const setDraggedOverRowIdx = useCallback((rowIdx?: number) => { + setOverRowIdx(rowIdx); + latestDraggedOverRowIdx.current = rowIdx; + }, []); + + /** + * refs + */ + const gridRef = useRef(null); + const focusSinkRef = useRef(null); + const prevSelectedPosition = useRef(selectedPosition); + const latestDraggedOverRowIdx = useRef(draggedOverRowIdx); + const lastSelectedRowIdx = useRef(-1); /** * computed values @@ -268,6 +296,13 @@ function DataGrid({ }; }, [width]); + useLayoutEffect(() => { + if (selectedPosition === prevSelectedPosition.current || selectedPosition.mode === 'EDIT' || !isCellWithinBounds(selectedPosition)) return; + prevSelectedPosition.current = selectedPosition; + scrollToCell(selectedPosition); + focusSinkRef.current!.focus(); + }); + useEffect(() => { if (!onSelectedRowsChange) return; @@ -297,6 +332,10 @@ function DataGrid({ return eventBus.subscribe('SELECT_ROW', handleRowSelectionChange); }, [eventBus, onSelectedRowsChange, rows, rowKey, selectedRows]); + useEffect(() => { + return eventBus.subscribe('SELECT_CELL', selectCell); + }); + useImperativeHandle(ref, () => ({ scrollToColumn(idx: number) { scrollToCell({ idx }); @@ -306,15 +345,50 @@ function DataGrid({ if (!current) return; current.scrollTop = rowIdx * rowHeight; }, - selectCell(position: Position, openEditor?: boolean) { - eventBus.dispatch('SELECT_CELL', position, openEditor); - } + selectCell })); /** * event handlers */ - function onGridScroll(event: React.UIEvent) { + function handleKeyDown(event: React.KeyboardEvent) { + if (enableCellCopyPaste && isCtrlKeyHeldDown(event) && isCellWithinBounds(selectedPosition)) { + // event.key may be uppercase `C` or `V` + const lowerCaseKey = event.key.toLowerCase(); + if (lowerCaseKey === 'c') { + handleCopy(); + return; + } + if (lowerCaseKey === 'v') { + handlePaste(); + return; + } + } + + switch (event.key) { + case 'Escape': + setCopiedPosition(null); + return; + case 'ArrowUp': + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': + case 'Tab': + case 'Home': + case 'End': + case 'PageUp': + case 'PageDown': + navigate(event); + break; + default: + if (isCellWithinBounds(selectedPosition)) { + handleCellInput(event); + } + break; + } + } + + function handleScroll(event: React.UIEvent) { const { scrollTop, scrollLeft } = event.currentTarget; setScrollTop(scrollTop); setScrollLeft(scrollLeft); @@ -329,20 +403,151 @@ function DataGrid({ onColumnResize?.(column.idx, width); }, [columnWidths, onColumnResize]); - function handleRowsUpdate(event: RowsUpdateEvent) { - onRowsUpdate?.(event); + function handleCommit({ cellKey, rowIdx, updated }: CommitEvent) { + onRowsUpdate?.({ + cellKey, + fromRow: rowIdx, + toRow: rowIdx, + updated, + action: UpdateActions.CELL_UPDATE + }); + + closeEditor(); + } + + function handleCopy() { + const { idx, rowIdx } = selectedPosition; + const value = rows[rowIdx][columns[idx].key as keyof R]; + setCopiedPosition({ idx, rowIdx, value }); + } + + function handlePaste() { + if ( + copiedPosition === null + || !isCellEditable(selectedPosition) + || (copiedPosition.idx === selectedPosition.idx && copiedPosition.rowIdx === selectedPosition.rowIdx) + ) { + return; + } + + const { rowIdx: toRow } = selectedPosition; + + const cellKey = columns[selectedPosition.idx].key; + const { rowIdx: fromRow, idx, value } = copiedPosition; + const fromCellKey = columns[idx].key; + + onRowsUpdate?.({ + cellKey, + fromRow, + toRow, + updated: { [cellKey]: value } as unknown as never, + action: UpdateActions.COPY_PASTE, + fromCellKey + }); + } + + function handleCellInput(event: React.KeyboardEvent) { + const { key } = event; + const column = columns[selectedPosition.idx]; + const row = rows[selectedPosition.rowIdx]; + const canOpenEditor = selectedPosition.mode === 'SELECT' && isCellEditable(selectedPosition); + const isActivatedByUser = (column.unsafe_onCellInput ?? legacyCellInput)(event, row) === true; + + if (canOpenEditor && (key === 'Enter' || isActivatedByUser)) { + setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, key, mode: 'EDIT' })); + } + } + + function handleDragEnd() { + if (latestDraggedOverRowIdx.current === undefined) return; + + const { idx, rowIdx } = selectedPosition; + const column = columns[idx]; + const cellKey = column.key; + const value = rows[rowIdx][cellKey as keyof R]; + + onRowsUpdate?.({ + cellKey, + fromRow: rowIdx, + toRow: latestDraggedOverRowIdx.current, + updated: { [cellKey]: value } as unknown as never, + action: UpdateActions.CELL_DRAG + }); + + setDraggedOverRowIdx(undefined); + } + + function handleMouseDown(event: React.MouseEvent) { + if (event.buttons !== 1) return; + setDragging(true); + window.addEventListener('mouseover', onMouseOver); + window.addEventListener('mouseup', onMouseUp); + + function onMouseOver(event: MouseEvent) { + // Trigger onMouseup in edge cases where we release the mouse button but `mouseup` isn't triggered, + // for example when releasing the mouse button outside the iframe the grid is rendered in. + // https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons + if (event.buttons !== 1) onMouseUp(); + } + + function onMouseUp() { + window.removeEventListener('mouseover', onMouseOver); + window.removeEventListener('mouseup', onMouseUp); + setDragging(false); + handleDragEnd(); + } + } + + function handleDoubleClick(event: React.MouseEvent) { + event.stopPropagation(); + + const column = columns[selectedPosition.idx]; + const cellKey = column.key; + const value = rows[selectedPosition.rowIdx][cellKey as keyof R]; + + onRowsUpdate?.({ + cellKey, + fromRow: selectedPosition.rowIdx, + toRow: rows.length - 1, + updated: { [cellKey]: value } as unknown as never, + action: UpdateActions.COLUMN_FILL + }); } /** * utils */ - function getFrozenColumnsWidth() { + function isCellWithinBounds({ idx, rowIdx }: Position): boolean { + return rowIdx >= 0 && rowIdx < rows.length && idx >= 0 && idx < columns.length; + } + + function isCellEditable(position: Position): boolean { + return isCellWithinBounds(position) + && isSelectedCellEditable({ columns, rows, selectedPosition: position, onCheckCellIsEditable }); + } + + function selectCell(position: Position, enableEditor = false): void { + if (!isCellWithinBounds(position)) return; + + if (enableEditor && isCellEditable(position)) { + setSelectedPosition({ ...position, mode: 'EDIT', key: null }); + } else { + setSelectedPosition({ ...position, mode: 'SELECT' }); + } + onSelectedCellChange?.({ ...position }); + } + + function closeEditor() { + setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'SELECT' })); + } + + function getFrozenColumnsWidth(): number { if (lastFrozenColumnIndex === -1) return 0; const lastFrozenCol = columns[lastFrozenColumnIndex]; return lastFrozenCol.left + lastFrozenCol.width; } - function scrollToCell({ idx, rowIdx }: Partial) { + function scrollToCell({ idx, rowIdx }: Partial): void { const { current } = gridRef; if (!current) return; @@ -368,6 +573,106 @@ function DataGrid({ } } + function getNextPosition(key: string, ctrlKey: boolean, shiftKey: boolean): Position { + const { idx, rowIdx } = selectedPosition; + switch (key) { + case 'ArrowUp': + return { idx, rowIdx: rowIdx - 1 }; + case 'ArrowDown': + return { idx, rowIdx: rowIdx + 1 }; + case 'ArrowLeft': + return { idx: idx - 1, rowIdx }; + case 'ArrowRight': + return { idx: idx + 1, rowIdx }; + case 'Tab': + if (selectedPosition.idx === -1 && selectedPosition.rowIdx === -1) { + return shiftKey ? { idx: columns.length - 1, rowIdx: rows.length - 1 } : { idx: 0, rowIdx: 0 }; + } + return { idx: idx + (shiftKey ? -1 : 1), rowIdx }; + case 'Home': + return ctrlKey ? { idx: 0, rowIdx: 0 } : { idx: 0, rowIdx }; + case 'End': + return ctrlKey ? { idx: columns.length - 1, rowIdx: rows.length - 1 } : { idx: columns.length - 1, rowIdx }; + case 'PageUp': + return { idx, rowIdx: rowIdx - Math.floor(clientHeight / rowHeight) }; + case 'PageDown': + return { idx, rowIdx: rowIdx + Math.floor(clientHeight / rowHeight) }; + default: + return selectedPosition; + } + } + + function navigate(event: React.KeyboardEvent) { + const { key, shiftKey } = event; + const ctrlKey = isCtrlKeyHeldDown(event); + let nextPosition = getNextPosition(key, ctrlKey, shiftKey); + let mode = cellNavigationMode; + if (key === 'Tab') { + // If we are in a position to leave the grid, stop editing but stay in that cell + if (canExitGrid({ shiftKey, cellNavigationMode, columns, rowsCount: rows.length, selectedPosition })) { + // Allow focus to leave the grid so the next control in the tab order can be focused + return; + } + + mode = cellNavigationMode === CellNavigationMode.NONE + ? CellNavigationMode.CHANGE_ROW + : cellNavigationMode; + } + + // Do not allow focus to leave + event.preventDefault(); + + nextPosition = getNextSelectedCellPosition({ + columns, + rowsCount: rows.length, + cellNavigationMode: mode, + nextPosition + }); + + selectCell(nextPosition); + } + + function getDraggedOverCellIdx(currentRowIdx: number): number | undefined { + if (draggedOverRowIdx === undefined) return; + const { rowIdx } = selectedPosition; + + const isDraggedOver = rowIdx < draggedOverRowIdx + ? rowIdx < currentRowIdx && currentRowIdx <= draggedOverRowIdx + : rowIdx > currentRowIdx && currentRowIdx >= draggedOverRowIdx; + + return isDraggedOver ? selectedPosition.idx : undefined; + } + + function getSelectedCellProps(rowIdx: number): SelectedCellProps | undefined { + if (selectedPosition.rowIdx !== rowIdx) return; + + if (selectedPosition.mode === 'EDIT') { + return { + mode: 'EDIT', + idx: selectedPosition.idx, + onKeyDown: handleKeyDown, + editorContainerProps: { + editorPortalTarget, + rowHeight, + scrollLeft, + scrollTop, + firstEditorKeyPress: selectedPosition.key, + onCommit: handleCommit, + onCommitCancel: closeEditor + } + }; + } + + return { + mode: 'SELECT', + idx: selectedPosition.idx, + onKeyDown: handleKeyDown, + dragHandleProps: enableCellDragAndDrop && isCellEditable(selectedPosition) + ? { onMouseDown: handleMouseDown, onDoubleClick: handleDoubleClick } + : undefined + }; + } + function getViewportRows() { const rowElements = []; @@ -395,6 +700,10 @@ function DataGrid({ onRowClick={onRowClick} rowClass={rowClass} top={rowIdx * rowHeight + totalHeaderHeight} + copiedCellIdx={copiedPosition?.rowIdx === rowIdx ? copiedPosition.idx : undefined} + draggedOverCellIdx={getDraggedOverCellIdx(rowIdx)} + setDraggedOverRowIdx={isDragging ? setDraggedOverRowIdx : undefined} + selectedCellProps={getSelectedCellProps(rowIdx)} /> ); } @@ -402,9 +711,16 @@ function DataGrid({ return rowElements; } + // Reset the positions if the current values are no longer valid. This can happen if a column or row is removed + if (selectedPosition.idx >= columns.length || selectedPosition.rowIdx >= rows.length) { + setSelectedPosition({ idx: -1, rowIdx: -1, mode: 'SELECT' }); + setCopiedPosition(null); + setDraggedOverRowIdx(undefined); + } + return (
({ '--row-height': `${rowHeight}px` } as unknown as React.CSSProperties} ref={gridRef} - onScroll={onGridScroll} + onScroll={handleScroll} > rowKey={rowKey} @@ -438,26 +754,12 @@ function DataGrid({ )} {rows.length === 0 && emptyRowsRenderer ? createElement(emptyRowsRenderer) : ( <> - {viewportWidth > 0 && ( - - rows={rows} - rowHeight={rowHeight} - columns={columns} - enableCellCopyPaste={enableCellCopyPaste} - enableCellDragAndDrop={enableCellDragAndDrop} - cellNavigationMode={cellNavigationMode} - eventBus={eventBus} - gridRef={gridRef} - totalHeaderHeight={totalHeaderHeight} - scrollLeft={scrollLeft} - scrollTop={scrollTop} - scrollToCell={scrollToCell} - editorPortalTarget={editorPortalTarget} - onCheckCellIsEditable={onCheckCellIsEditable} - onRowsUpdate={handleRowsUpdate} - onSelectedCellChange={onSelectedCellChange} - /> - )} +
{getViewportRows()} {summaryRows?.map((row, rowIdx) => ( diff --git a/src/EventBus.ts b/src/EventBus.ts index 0ca931a62a..7e484edc93 100644 --- a/src/EventBus.ts +++ b/src/EventBus.ts @@ -1,9 +1,8 @@ import { Position, SelectRowEvent } from './common/types'; interface EventMap { - SELECT_CELL: (position: Position, enableEditor?: boolean) => void; + SELECT_CELL: (position: Position, openEditor?: boolean) => void; SELECT_ROW: (event: SelectRowEvent) => void; - DRAG_ENTER: (overRowIdx: number) => void; } type EventName = keyof EventMap; diff --git a/src/Row.tsx b/src/Row.tsx index 08782154ac..2ff87be0ba 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx'; import Cell from './Cell'; import { RowRendererProps } from './common/types'; -import { preventDefault, wrapEvent } from './utils'; +import { wrapEvent } from './utils'; function Row({ cellRenderer: CellRenderer = Cell, @@ -12,25 +12,20 @@ function Row({ rowIdx, isRowSelected, lastFrozenColumnIndex, - onRowClick, + copiedCellIdx, + draggedOverCellIdx, row, viewportColumns, - onDragEnter, - onDragOver, - onDrop, + selectedCellProps, + onRowClick, rowClass, + setDraggedOverRowIdx, + onMouseEnter, top, ...props }: RowRendererProps) { - function handleDragEnter(event: React.DragEvent) { - // Prevent default to allow drop - event.preventDefault(); - eventBus.dispatch('DRAG_ENTER', rowIdx); - } - - function handleDragOver(event: React.DragEvent) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; + function handleDragEnter() { + setDraggedOverRowIdx?.(rowIdx); } className = clsx( @@ -41,15 +36,10 @@ function Row({ className ); - // Regarding onDrop: the default in Firefox is to treat data in dataTransfer as a URL, - // and perform navigation on it, even if the data type used is 'text'. - // To bypass this, we need to capture and prevent the drop event. return (
@@ -60,8 +50,11 @@ function Row({ column={column} lastFrozenColumnIndex={lastFrozenColumnIndex} row={row} + isCopied={copiedCellIdx === column.idx} + isDraggedOver={draggedOverCellIdx === column.idx} isRowSelected={isRowSelected} eventBus={eventBus} + selectedCellProps={selectedCellProps?.idx === column.idx ? selectedCellProps : undefined} onRowClick={onRowClick} /> ))} diff --git a/src/common/types.ts b/src/common/types.ts index f6381880e4..44be661330 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -56,14 +56,6 @@ export interface Position { rowIdx: number; } -export interface Dimension { - width: number; - height: number; - top: number; - left: number; - zIndex: number; -} - export interface Editor { getInputNode: () => Element | Text | undefined | null; getValue: () => TValue; @@ -103,13 +95,43 @@ export interface HeaderRendererProps { onAllRowsSelectionChange: (checked: boolean) => void; } +export interface SharedEditorContainerProps { + editorPortalTarget: Element; + firstEditorKeyPress: string | null; + scrollLeft: number; + scrollTop: number; + rowHeight: number; + onCommit: (e: CommitEvent) => void; + onCommitCancel: () => void; +} + +interface SelectedCellPropsBase { + idx: number; + onKeyDown: (event: React.KeyboardEvent) => void; +} + +interface SelectedCellPropsEdit extends SelectedCellPropsBase { + mode: 'EDIT'; + editorContainerProps: SharedEditorContainerProps; +} + +interface SelectedCellPropsSelect extends SelectedCellPropsBase { + mode: 'SELECT'; + dragHandleProps?: Pick, 'onMouseDown' | 'onDoubleClick'>; +} + +export type SelectedCellProps = SelectedCellPropsEdit | SelectedCellPropsSelect; + export interface CellRendererProps extends Omit, 'style' | 'children'> { rowIdx: number; column: CalculatedColumn; lastFrozenColumnIndex: number; row: TRow; isRowSelected: boolean; + isCopied: boolean; + isDraggedOver: boolean; eventBus: EventBus; + selectedCellProps?: SelectedCellProps; onRowClick?: (rowIdx: number, row: TRow, column: CalculatedColumn) => void; } @@ -119,11 +141,15 @@ export interface RowRendererProps extends Omit>; rowIdx: number; lastFrozenColumnIndex: number; + copiedCellIdx?: number; + draggedOverCellIdx?: number; isRowSelected: boolean; eventBus: EventBus; + top: number; + selectedCellProps?: SelectedCellProps; onRowClick?: (rowIdx: number, row: TRow, column: CalculatedColumn) => void; rowClass?: (row: TRow) => string | undefined; - top: number; + setDraggedOverRowIdx?: (overRowIdx: number) => void; } export interface FilterRendererProps { diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx index 49a7e4f9da..5bae587fa3 100644 --- a/src/editors/EditorContainer.tsx +++ b/src/editors/EditorContainer.tsx @@ -1,25 +1,15 @@ import React, { KeyboardEvent, useRef, useState, useLayoutEffect, useCallback, useEffect } from 'react'; import clsx from 'clsx'; -import { CalculatedColumn, Editor, CommitEvent } from '../common/types'; +import { CalculatedColumn, Editor, Omit, SharedEditorContainerProps } from '../common/types'; import SimpleTextEditor from './SimpleTextEditor'; import ClickOutside from './ClickOutside'; -import { InteractionMasksProps } from '../masks/InteractionMasks'; import { preventDefault } from '../utils'; -type SharedInteractionMasksProps = Pick, - | 'scrollLeft' - | 'scrollTop' - | 'rowHeight' ->; - -export interface EditorContainerProps extends SharedInteractionMasksProps { +export interface EditorContainerProps extends Omit { rowIdx: number; row: R; column: CalculatedColumn; - onCommit: (e: CommitEvent) => void; - onCommitCancel: () => void; - firstEditorKeyPress: string | null; top: number; left: number; } diff --git a/src/editors/index.ts b/src/editors/index.ts index d58276ced9..f0538e005f 100644 --- a/src/editors/index.ts +++ b/src/editors/index.ts @@ -1 +1,4 @@ export { default as SimpleTextEditor } from './SimpleTextEditor'; +export { default as EditorPortal } from './EditorPortal'; +export { default as EditorContainer } from './EditorContainer'; +export * from './CellInputHandlers'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000000..0a613bf24b --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useCombinedRefs'; diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts new file mode 100644 index 0000000000..d9b985cfcf --- /dev/null +++ b/src/hooks/useCombinedRefs.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react'; +import { wrapRefs } from '../utils'; + +export function useCombinedRefs(...refs: readonly React.Ref[]) { + return useMemo( + () => wrapRefs(...refs), + // eslint-disable-next-line react-hooks/exhaustive-deps + refs + ); +} diff --git a/src/index.ts b/src/index.ts index 4d0736eaa4..0ab9bef505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,6 @@ export { default as Cell } from './Cell'; export { default as Row } from './Row'; export * from './Columns'; export * from './formatters'; -export * from './editors'; +export { SimpleTextEditor } from './editors'; export * from './common/enums'; export * from './common/types'; diff --git a/src/masks/CellMask.test.tsx b/src/masks/CellMask.test.tsx deleted file mode 100644 index 932eb3c41b..0000000000 --- a/src/masks/CellMask.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import { sel } from '../test/utils'; -import CellMask from './CellMask'; - -describe('CellMask', () => { - const setup = (children?: JSX.Element) => { - const props = { - height: 30, - width: 50, - left: 5, - top: 10, - zIndex: 1 - }; - - const wrapper = shallow({children}); - return wrapper.find(sel('cell-mask')); - }; - - it('should render the mask with correct style', () => { - const mask = setup(); - - expect(mask.prop('style')).toMatchObject({ - height: 30, - width: 50, - zIndex: 1, - transform: 'translate(5px, 10px)' - }); - }); - - it('should render any children', () => { - const FakeChild =
test
; - const mask = setup(FakeChild); - - expect(mask.contains(FakeChild)).toBe(true); - }); -}); diff --git a/src/masks/CellMask.tsx b/src/masks/CellMask.tsx deleted file mode 100644 index 0441d77c41..0000000000 --- a/src/masks/CellMask.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { forwardRef } from 'react'; -import clsx from 'clsx'; -import { Dimension } from '../common/types'; - -export type CellMaskProps = React.HTMLAttributes & Dimension; - -export default forwardRef(function CellMask({ width, height, top, left, zIndex, className, ...props }, ref) { - return ( -
- ); -}); diff --git a/src/masks/DragMask.test.tsx b/src/masks/DragMask.test.tsx deleted file mode 100644 index b4ab6a63fc..0000000000 --- a/src/masks/DragMask.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import { Position } from '../common/types'; -import CellMask from './CellMask'; -import DragMask, { DraggedPosition } from './DragMask'; - -describe('DragMask', () => { - const setup = (draggedPosition: DraggedPosition) => { - const props = { - getSelectedDimensions({ rowIdx }: Position) { - const heights: { [key: number]: number } = { - 2: 20, - 3: 30, - 4: 40, - 5: 50, - 6: 60 - }; - const height = heights[rowIdx]; - - return { - height, - width: 50, - left: 5, - top: 90, - zIndex: 1 - }; - }, - draggedPosition - }; - - const wrapper = shallow(); - return wrapper.find(CellMask); - }; - - it('should not render the CellMask component if the drag handle is on the same row as the dragged cell', () => { - const mask = setup({ idx: 0, rowIdx: 2, overRowIdx: 2 }); - - expect(mask).toHaveLength(0); - }); - - it('should render the CellMask component with correct position for the dragged down cell', () => { - const mask = setup({ idx: 0, rowIdx: 2, overRowIdx: 4 }); - - expect(mask.props()).toMatchObject({ - height: 70, - width: 50, - left: 5, - top: 90 - }); - }); - - it('should render the CellMask component with correct position for the dragged up cell', () => { - const mask = setup({ idx: 0, rowIdx: 6, overRowIdx: 4 }); - - expect(mask.props()).toMatchObject({ - height: 90, - width: 50, - left: 5, - top: 90 - }); - }); -}); diff --git a/src/masks/DragMask.tsx b/src/masks/DragMask.tsx deleted file mode 100644 index 3cf1b31b32..0000000000 --- a/src/masks/DragMask.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; - -import { Position, Dimension } from '../common/types'; -import CellMask from './CellMask'; - -export interface DraggedPosition extends Position { - overRowIdx: number; -} - -interface Props { - draggedPosition: DraggedPosition; - getSelectedDimensions: (position: Position) => Dimension; -} - -export default function DragMask({ draggedPosition, getSelectedDimensions }: Props) { - const { overRowIdx, idx, rowIdx } = draggedPosition; - if (rowIdx === overRowIdx) return null; - - const isDraggedOverDown = rowIdx < overRowIdx; - const startRowIdx = isDraggedOverDown ? rowIdx + 1 : overRowIdx; - const endRowIdx = isDraggedOverDown ? overRowIdx : rowIdx - 1; - const className = isDraggedOverDown ? 'react-grid-cell-dragged-over-down' : 'react-grid-cell-dragged-over-up'; - - const dimensions = getSelectedDimensions({ idx, rowIdx: startRowIdx }); - for (let currentRowIdx = startRowIdx + 1; currentRowIdx <= endRowIdx; currentRowIdx++) { - const { height } = getSelectedDimensions({ idx, rowIdx: currentRowIdx }); - dimensions.height += height; - } - - return ( - - ); -} diff --git a/src/masks/InteractionMasks.test.tsx b/src/masks/InteractionMasks.test.tsx deleted file mode 100644 index b49f75f1fd..0000000000 --- a/src/masks/InteractionMasks.test.tsx +++ /dev/null @@ -1,765 +0,0 @@ -/* eslint-disable jest/no-commented-out-tests */ -/* eslint-disable sonarjs/no-identical-functions */ -import React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; - -import InteractionMasks, { InteractionMasksProps } from './InteractionMasks'; -// import SelectionRangeMask from '../SelectionRangeMask'; -import DragMask from './DragMask'; -import EventBus from '../EventBus'; -import EditorContainer from '../editors/EditorContainer'; -import { createColumns } from '../test/utils'; -import { CellNavigationMode, UpdateActions } from '../common/enums'; -import { Position } from '../common/types'; - -const NUMBER_OF_COLUMNS = 10; -const ROWS_COUNT = 5; -const columns = createColumns(NUMBER_OF_COLUMNS); - -// Enzyme sets the className on the ForwardRef component also so we need to filter div -const selectionMaskSelector = 'div.rdg-selected'; -const copyMaskSelector = 'div.rdg-cell-copied'; - -interface Row { - [key: string]: React.ReactNode; -} - -describe('InteractionMasks', () => { - function setup(overrideProps?: Partial>, initialPosition?: Position) { - const onSelectedCellChange = jest.fn(); - const props: InteractionMasksProps = { - columns, - rows: Array(ROWS_COUNT).fill({ col1: 1 }), - rowHeight: 30, - totalHeaderHeight: 30, - scrollToCell: jest.fn(), - onSelectedCellChange, - onRowsUpdate: jest.fn(), - enableCellCopyPaste: true, - enableCellDragAndDrop: true, - cellNavigationMode: CellNavigationMode.NONE, - eventBus: new EventBus(), - editorPortalTarget: document.body, - gridRef: React.createRef(), - scrollLeft: 0, - scrollTop: 0, - ...overrideProps - }; - - const wrapper = mount(); - if (initialPosition) { - act(() => { - props.eventBus.dispatch('SELECT_CELL', initialPosition); - }); - wrapper.update(); - onSelectedCellChange.mockReset(); - } - return { wrapper, props }; - } - - const pressKey = (wrapper: ReturnType['wrapper'], key: string, eventData?: Partial) => { - act(() => { - wrapper.simulate('keydown', { key, preventDefault: () => null, ...eventData }); - }); - }; - - const simulateTab = (wrapper: ReturnType['wrapper'], shiftKey = false, preventDefault = () => { }) => { - act(() => { - pressKey(wrapper, 'Tab', { shiftKey, preventDefault }); - }); - }; - - describe('Rendered masks', () => { - describe('When a single cell is selected', () => { - describe('within grid bounds', () => { - it('should render a SelectionMask component', () => { - const { wrapper } = setup({}, { idx: 0, rowIdx: 0 }); - expect(wrapper.find(selectionMaskSelector)).toHaveLength(1); - }); - }); - - describe('outside grid bounds', () => { - it('should not render a SelectionMask component', () => { - const { wrapper } = setup(); - expect(wrapper.find(selectionMaskSelector)).toHaveLength(0); - }); - }); - }); - - // describe('When a cell range is selected', () => { - // it('should render a SelectionRangeMask component', () => { - // const { wrapper } = setup({}, { - // selectedPosition: { idx: 0, rowIdx: 0 }, - // selectedRange: { - // topLeft: { idx: 0, rowIdx: 0 }, - // bottomRight: { idx: 1, rowIdx: 1 }, - // startCell: { idx: 0, rowIdx: 0 }, - // cursorCell: null, - // isDragging: false - // } - // }); - // expect(wrapper.find(SelectionRangeMask).length).toBe(1); - // }); - - // it('should render a SelectionMask component on the range\'s start cell', () => { - // const { wrapper } = setup({}, { - // selectedPosition: { idx: 0, rowIdx: 0 }, - // selectedRange: { - // topLeft: { idx: 0, rowIdx: 0 }, - // bottomRight: { idx: 1, rowIdx: 1 }, - // startCell: { idx: 0, rowIdx: 0 }, - // cursorCell: null, - // isDragging: false - // } - // }); - // expect(wrapper.find(SelectionMask).length).toBe(1); - // expect(wrapper.find(SelectionMask).props()).toEqual({ height: 30, left: 0, top: 0, width: 100, zIndex: 1 }); - // }); - // }); - }); - - // describe('Range selection functionality', () => { - // describe('with the cursor', () => { - // it('should update the single-cell selectedPosition on starting a selection', () => { - // const { props, wrapper } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // expect(wrapper.state('selectedPosition')).toEqual({ idx: 2, rowIdx: 2 }); - // }); - - // it('should update the multi-cell selectedRange on starting a selection', () => { - // const { props, wrapper } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.startCell).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toEqual({ idx: 2, rowIdx: 2 }); - // }); - - // describe('moving the cursor to a new cell, mid-select', () => { - // function innerSetup() { - // const { props, wrapper } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // return { props, wrapper }; - // } - - // it('should update topLeft (and cursor) when moving left', () => { - // const { props, wrapper } = innerSetup(); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 1, rowIdx: 2 }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toEqual({ idx: 1, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.startCell).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toEqual({ idx: 1, rowIdx: 2 }); - // }); - - // it('should update topLeft (and cursor) when moving up', () => { - // const { props, wrapper } = innerSetup(); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 2, rowIdx: 1 }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toEqual({ idx: 2, rowIdx: 1 }); - // expect(selectedRange.bottomRight).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.startCell).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toEqual({ idx: 2, rowIdx: 1 }); - // }); - - // it('should update bottomRight (and cursor) when moving right', () => { - // const { props, wrapper } = innerSetup(); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 3, rowIdx: 2 }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toEqual({ idx: 3, rowIdx: 2 }); - // expect(selectedRange.startCell).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toEqual({ idx: 3, rowIdx: 2 }); - // }); - - // it('should update bottomRight (and cursor) when moving down', () => { - // const { props, wrapper } = innerSetup(); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 2, rowIdx: 3 }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toEqual({ idx: 2, rowIdx: 3 }); - // expect(selectedRange.startCell).toEqual({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toEqual({ idx: 2, rowIdx: 3 }); - // }); - // }); - - // it('should not update state when moving the cursor but not mid-select', () => { - // const { props, wrapper } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 2, rowIdx: 2 }); - // expect(wrapper.state('selectedRange').startCell).toBeNull(); - // }); - - // it('should not update state when moving the cursor after a selection has ended', () => { - // const { props, wrapper } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // props.eventBus.dispatch(EventTypes.SELECT_END); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 3, rowIdx: 3 }); - // expect(wrapper.state('selectedRange').cursorCell).toEqual({ idx: 2, rowIdx: 2 }); - // }); - - // it('should give focus to InteractionMasks once a selection has ended', () => { - // // We have to use mount, rather than shallow, so that InteractionMasks has a ref to it's node, used for focusing - // const { wrapper, props } = setup(undefined, undefined, true); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // jest.spyOn(wrapper.instance(), 'focus').mockImplementation(() => { }); - // props.eventBus.dispatch(EventTypes.SELECT_END); - // expect(wrapper.instance().focus).toHaveBeenCalled(); - // }); - // }); - // }); - - // describe('Keyboard range selection functionality', () => { - // const selectRange = (eventBus: EventBus, from: Position, to: Position) => { - // eventBus.dispatch(EventTypes.SELECT_START, from); - // eventBus.dispatch(EventTypes.SELECT_UPDATE, to); - // eventBus.dispatch(EventTypes.SELECT_END); - // }; - - // describe('when a range is already selected', () => { - // describe('when the cursor cell is not in outer bounds', () => { - // function innerSetup() { - // const setupResult = setup(); - // selectRange(setupResult.props.eventBus, { idx: 2, rowIdx: 2 }, { idx: 3, rowIdx: 3 }); - // return setupResult; - // } - - // it('should shrink the selection upwards on Shift+Up', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowUp', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 3, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 3, rowIdx: 2 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - - // it('should shrink the selection leftwards on Shift+Left', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowLeft', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 2, rowIdx: 3 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 2, rowIdx: 3 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - - // it('should grow the selection downwards on Shift+Down', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowDown', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 3, rowIdx: 4 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 3, rowIdx: 4 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - - // it('should grow the selection rightwards on Shift+Right', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowRight', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 4, rowIdx: 3 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 4, rowIdx: 3 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - // }); - - // describe('when the next cell is out of bounds', () => { - // it('should not grow the selection on Shift+Up', () => { - // const { props, wrapper } = setup(); - // selectRange(props.eventBus, { idx: 1, rowIdx: 1 }, { idx: 0, rowIdx: 0 }); - // pressKey(wrapper, 'ArrowUp', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 0, rowIdx: 0 }); - // }); - - // it('should not grow the selection on Shift+Left', () => { - // const { props, wrapper } = setup(); - // selectRange(props.eventBus, { idx: 1, rowIdx: 1 }, { idx: 0, rowIdx: 0 }); - // pressKey(wrapper, 'ArrowLeft', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.cursorCell).toEqual({ idx: 0, rowIdx: 0 }); - // }); - - // it('should not grow the selection on Shift+Right', () => { - // const { props, wrapper } = setup(); - // selectRange(props.eventBus, { idx: 2, rowIdx: 2 }, { idx: NUMBER_OF_COLUMNS - 1, rowIdx: 3 }); - // pressKey(wrapper, 'ArrowRight', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.cursorCell).toEqual({ idx: NUMBER_OF_COLUMNS - 1, rowIdx: 3 }); - // }); - - // it('should not grow the selection on Shift+Down', () => { - // const { props, wrapper } = setup(); - // selectRange(props.eventBus, { idx: 2, rowIdx: 2 }, { idx: 2, rowIdx: ROWS_COUNT - 1 }); - // pressKey(wrapper, 'ArrowDown', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.cursorCell).toEqual({ idx: 2, rowIdx: ROWS_COUNT - 1 }); - // }); - // }); - // }); - - // describe('when only a single cell is selected', () => { - // function innerSetup() { - // const currentCell = { idx: 2, rowIdx: 2 }; - // const setupResult = setup({}, { selectedPosition: currentCell }); - // setupResult.props.eventBus.dispatch(EventTypes.SELECT_START, currentCell); - // setupResult.props.eventBus.dispatch(EventTypes.SELECT_END); - // return setupResult; - // } - - // it('should grow the selection range left on Shift+Left', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowLeft', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 1, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 1, rowIdx: 2 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - - // it('should grow the selection range right on Shift+Right', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowRight', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 3, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 3, rowIdx: 2 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - - // it('should grow the selection range up on Shift+Up', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowUp', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 1 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 2, rowIdx: 1 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - - // it('should grow the selection range down on Shift+Down', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowDown', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 2, rowIdx: 2 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 2, rowIdx: 3 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 2, rowIdx: 3 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 2, rowIdx: 2 }); - // }); - // }); - - // describe('when no range has ever been selected', () => { - // function innerSetup() { - // const currentCell = { idx: 0, rowIdx: 0 }; - // return setup({}, { selectedPosition: currentCell }); - // } - - // it('should grow the selection range right on Shift+Right', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowRight', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 0, rowIdx: 0 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 1, rowIdx: 0 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 1, rowIdx: 0 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 0, rowIdx: 0 }); - // }); - - // it('should grow the selection range down on Shift+Down', () => { - // const { wrapper } = innerSetup(); - // pressKey(wrapper, 'ArrowDown', { shiftKey: true }); - // const selectedRange = wrapper.state('selectedRange'); - // expect(selectedRange.topLeft).toMatchObject({ idx: 0, rowIdx: 0 }); - // expect(selectedRange.bottomRight).toMatchObject({ idx: 0, rowIdx: 1 }); - // expect(selectedRange.cursorCell).toMatchObject({ idx: 0, rowIdx: 1 }); - // expect(selectedRange.startCell).toMatchObject({ idx: 0, rowIdx: 0 }); - // }); - // }); - // }); - - // describe('Range selection events', () => { - // it('should fire onSelectedCellRangeChange on starting a selection', () => { - // const { props } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // expect(props.onSelectedCellRangeChange).toHaveBeenCalledWith(expect.objectContaining({ - // topLeft: { idx: 2, rowIdx: 2 }, - // bottomRight: { idx: 2, rowIdx: 2 }, - // isDragging: true - // })); - // }); - - // it('should fire onSelectedCellRangeChange on updating a selection', () => { - // const { props } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 3, rowIdx: 3 }); - // expect(props.onSelectedCellRangeChange).toHaveBeenCalledWith(expect.objectContaining({ - // topLeft: { idx: 2, rowIdx: 2 }, - // bottomRight: { idx: 3, rowIdx: 3 }, - // isDragging: true - // })); - // }); - - // it('should fire onSelectedCellRangeChange on completing a selection', () => { - // const { props } = setup(); - // props.eventBus.dispatch(EventTypes.SELECT_START, { idx: 2, rowIdx: 2 }); - // props.eventBus.dispatch(EventTypes.SELECT_UPDATE, { idx: 3, rowIdx: 3 }); - // props.eventBus.dispatch(EventTypes.SELECT_END); - // expect(props.onSelectedCellRangeChange).toHaveBeenCalledWith(expect.objectContaining({ - // topLeft: { idx: 2, rowIdx: 2 }, - // bottomRight: { idx: 3, rowIdx: 3 }, - // isDragging: false - // })); - // }); - - // it('should fire onSelectedCellRangeChange and onCRSCompleted on modifying a selection via they keyboard', () => { - // const currentCell = { idx: 0, rowIdx: 0 }; - // const { wrapper, props } = setup({}, { selectedPosition: currentCell }); - // pressKey(wrapper, 'ArrowRight', { shiftKey: true }); - // expect(props.onSelectedCellRangeChange).toHaveBeenCalledWith(expect.objectContaining({ - // topLeft: { idx: 0, rowIdx: 0 }, - // bottomRight: { idx: 1, rowIdx: 0 } - // })); - // }); - // }); - - describe('Keyboard navigation functionality', () => { - it('Press enter should enable editing', () => { - const { wrapper } = setup({}, { idx: 0, rowIdx: 0 }); - pressKey(wrapper, 'Enter'); - wrapper.update(); - expect(wrapper.find(EditorContainer)).toHaveLength(1); - }); - - describe('When current selected cell is not in outer bounds', () => { - it('Press arrow up should move up', () => { - const { wrapper, props } = setup({}, { idx: 0, rowIdx: 1 }); - pressKey(wrapper, 'ArrowUp'); - expect(props.onSelectedCellChange).toHaveBeenCalledWith({ idx: 0, rowIdx: 0 }); - }); - - it('Press arrow right should move right', () => { - const { wrapper, props } = setup({}, { idx: 0, rowIdx: 0 }); - pressKey(wrapper, 'ArrowRight'); - expect(props.onSelectedCellChange).toHaveBeenCalledWith({ idx: 1, rowIdx: 0 }); - }); - - it('Press arrow down should move down', () => { - const { wrapper, props } = setup({}, { idx: 0, rowIdx: 0 }); - pressKey(wrapper, 'ArrowDown'); - expect(props.onSelectedCellChange).toHaveBeenCalledWith({ idx: 0, rowIdx: 1 }); - }); - - it('Press arrow left should move left', () => { - const { wrapper, props } = setup({}, { idx: 1, rowIdx: 0 }); - pressKey(wrapper, 'ArrowLeft'); - expect(props.onSelectedCellChange).toHaveBeenCalledWith({ idx: 0, rowIdx: 0 }); - }); - - it('Press tab should move right', () => { - const { wrapper, props } = setup({}, { idx: 0, rowIdx: 0 }); - pressKey(wrapper, 'Tab'); - expect(props.onSelectedCellChange).toHaveBeenCalledWith({ idx: 1, rowIdx: 0 }); - }); - - it('Press shiftKey + tab should move left', () => { - const { wrapper, props } = setup({}, { idx: 1, rowIdx: 0 }); - pressKey(wrapper, 'Tab', { shiftKey: true }); - expect(props.onSelectedCellChange).toHaveBeenCalledWith({ idx: 0, rowIdx: 0 }); - }); - }); - - describe('When next cell is out of bounds', () => { - it('Press arrow left should not move left', () => { - const { wrapper, props } = setup({}, { idx: 0, rowIdx: 0 }); - pressKey(wrapper, 'ArrowLeft'); - expect(props.onSelectedCellChange).toHaveBeenCalledTimes(0); - }); - - it('Press arrow right should not move right', () => { - const { wrapper, props } = setup({}, { idx: NUMBER_OF_COLUMNS - 1, rowIdx: 0 }); - pressKey(wrapper, 'ArrowRight'); - expect(props.onSelectedCellChange).toHaveBeenCalledTimes(0); - }); - - it('Press arrow up should not move up', () => { - const { wrapper, props } = setup({}, { idx: 0, rowIdx: 0 }); - pressKey(wrapper, 'ArrowUp'); - expect(props.onSelectedCellChange).toHaveBeenCalledTimes(0); - }); - }); - - describe('using keyboard to navigate through the grid by pressing Tab or Shift+Tab', () => { - // enzyme doesn't allow dom keyboard navigation, but we can assume that if - // prevent default isn't called, it lets the dom do normal navigation - - const assertGridWasExited = (wrapper: ReturnType['wrapper']) => { - expect(wrapper.find(selectionMaskSelector)).toHaveLength(0); - }; - - const tabCell = (props: Partial>, shiftKey?: boolean, state?: { selectedPosition: Position }) => { - const { wrapper, props: { onSelectedCellChange } } = setup(props, state?.selectedPosition); - const preventDefaultSpy = jest.fn(); - simulateTab(wrapper, shiftKey, preventDefaultSpy); - wrapper.update(); - return { wrapper, preventDefaultSpy, onSelectedCellChange }; - }; - - const assertExitGridOnTab = (props: Partial>, shiftKey?: boolean, state?: { selectedPosition: Position }) => { - const { wrapper, preventDefaultSpy } = tabCell(props, shiftKey, state); - expect(preventDefaultSpy).not.toHaveBeenCalled(); - assertGridWasExited(wrapper); - }; - - const assertSelectedCellOnTab = (props: Partial>, shiftKey?: boolean, state?: { selectedPosition: Position }) => { - const { preventDefaultSpy, onSelectedCellChange } = tabCell(props, shiftKey, state); - expect(preventDefaultSpy).toHaveBeenCalled(); - return expect(onSelectedCellChange); - }; - - describe('when cellNavigationMode is changeRow', () => { - const cellNavigationMode = CellNavigationMode.CHANGE_ROW; - // it('allows the user to exit the grid with Tab if there are no rows', () => { - // assertExitGridOnTab({ cellNavigationMode, rows: [] }); - // }); - // it('allows the user to exit the grid with Shift+Tab if there are no rows', () => { - // assertExitGridOnTab({ cellNavigationMode, rows: [] }, true); - // }); - it('allows the user to exit to the grid with Shift+Tab at the first cell of the grid', () => { - const selectedPosition = { rowIdx: 0, idx: 0 }; - assertExitGridOnTab({ cellNavigationMode }, true, { selectedPosition }); - }); - it('allows the user to exit the grid when they press Tab at the last cell in the grid', () => { - const selectedPosition = { rowIdx: ROWS_COUNT - 1, idx: NUMBER_OF_COLUMNS - 1 }; - assertExitGridOnTab({ cellNavigationMode }, false, { selectedPosition }); - }); - it('goes to the next cell when the user presses Tab and they are not at the end of a row', () => { - const selectedPosition = { rowIdx: 3, idx: 3 }; - assertSelectedCellOnTab({ cellNavigationMode }, false, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 3, idx: 4 }); - }); - it('goes to the beginning of the next row when the user presses Tab and they are at the end of a row', () => { - const selectedPosition = { rowIdx: 2, idx: NUMBER_OF_COLUMNS - 1 }; - assertSelectedCellOnTab({ cellNavigationMode }, false, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 3, idx: 0 }); - }); - it('goes to the previous cell when the user presses Shift+Tab and they are not at the beginning of a row', () => { - const selectedPosition = { rowIdx: 2, idx: 2 }; - assertSelectedCellOnTab({ cellNavigationMode }, true, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 2, idx: 1 }); - }); - it('goes to the end of the previous row when the user presses Shift+Tab and they are at the beginning of a row', () => { - const selectedPosition = { rowIdx: 2, idx: 0 }; - assertSelectedCellOnTab({ cellNavigationMode }, true, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 1, idx: NUMBER_OF_COLUMNS - 1 }); - }); - }); - - describe('when cellNavigationMode is none', () => { - const cellNavigationMode = CellNavigationMode.NONE; - // it('allows the user to exit the grid with Tab if there are no rows', () => { - // assertExitGridOnTab({ cellNavigationMode, rows: [] }); - // }); - // it('allows the user to exit the grid with Shift+Tab if there are no rows', () => { - // assertExitGridOnTab({ cellNavigationMode, rows: [] }, true); - // }); - it('allows the user to exit the grid when they press Shift+Tab at the first cell of the grid', () => { - const selectedPosition = { rowIdx: 0, idx: 0 }; - assertExitGridOnTab({ cellNavigationMode }, true, { selectedPosition }); - }); - it('allows the user to exit the grid when they press Tab at the last cell in the grid', () => { - const selectedPosition = { rowIdx: ROWS_COUNT - 1, idx: NUMBER_OF_COLUMNS - 1 }; - assertExitGridOnTab({ cellNavigationMode }, false, { selectedPosition }); - }); - it('goes to the next cell when the user presses Tab and they are not at the end of a row', () => { - const selectedPosition = { rowIdx: 3, idx: 3 }; - assertSelectedCellOnTab({ cellNavigationMode }, false, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 3, idx: 4 }); - }); - it('goes to the first cell of the next row when they press Tab and they are at the end of a row', () => { - const selectedPosition = { rowIdx: 3, idx: NUMBER_OF_COLUMNS - 1 }; - assertSelectedCellOnTab({ cellNavigationMode }, false, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 4, idx: 0 }); - }); - it('goes to the previous cell when the user presses Shift+Tab and they are not at the beginning of a row', () => { - const selectedPosition = { rowIdx: 2, idx: 2 }; - assertSelectedCellOnTab({ cellNavigationMode }, true, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 2, idx: 1 }); - }); - it('goes to the last cell of the previous row when they press Shift+Tab and they are at the beginning of a row', () => { - const selectedPosition = { rowIdx: 3, idx: 0 }; - assertSelectedCellOnTab({ cellNavigationMode }, true, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 2, idx: NUMBER_OF_COLUMNS - 1 }); - }); - }); - - describe('when cellNavigationMode is loopOverRow', () => { - const cellNavigationMode = CellNavigationMode.LOOP_OVER_ROW; - // it('allows the user to exit the grid with Tab if there are no rows', () => { - // assertExitGridOnTab({ cellNavigationMode, rows: [] }); - // }); - // it('allows the user to exit the grid with Shift+Tab if there are no rows', () => { - // assertExitGridOnTab({ cellNavigationMode, rows: [] }, true); - // }); - it('goes to the first cell in the row when the user presses Tab and they are at the end of a row', () => { - const selectedPosition = { rowIdx: ROWS_COUNT - 1, idx: NUMBER_OF_COLUMNS - 1 }; - assertSelectedCellOnTab({ cellNavigationMode }, false, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: ROWS_COUNT - 1, idx: 0 }); - }); - it('goes to the last cell in the row when the user presses Shift+Tab and they are at the beginning of a row', () => { - const selectedPosition = { rowIdx: 0, idx: 0 }; - assertSelectedCellOnTab({ cellNavigationMode }, true, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 0, idx: NUMBER_OF_COLUMNS - 1 }); - }); - it('goes to the next cell when the user presses Tab and they are not at the end of a row', () => { - const selectedPosition = { rowIdx: 3, idx: 3 }; - assertSelectedCellOnTab({ cellNavigationMode }, false, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 3, idx: 4 }); - }); - it('goes to the previous cell when the user presses Shift+Tab and they are not at the beginning of a row', () => { - const selectedPosition = { rowIdx: 2, idx: 2 }; - assertSelectedCellOnTab({ cellNavigationMode }, true, { selectedPosition }) - .toHaveBeenCalledWith({ rowIdx: 2, idx: 1 }); - }); - }); - }); - }); - - describe('Copy functionality', () => { - const setupCopy = () => { - const rows = [ - { Column1: '1' }, - { Column1: '2' }, - { Column1: '3' } - ]; - const { wrapper, props } = setup({ rows }); - act(() => { - props.eventBus.dispatch('SELECT_CELL', { idx: 1, rowIdx: 2 }); - }); - return { wrapper, props }; - }; - - it('should not render a CopyMask component if there is no copied cell', () => { - const { wrapper } = setupCopy(); - expect(wrapper.find(copyMaskSelector)).toHaveLength(0); - }); - - it('should render a CopyMask component when a cell is copied', () => { - const { wrapper } = setupCopy(); - pressKey(wrapper, 'c', { ctrlKey: true }); - wrapper.update(); - expect(wrapper.find(copyMaskSelector)).toHaveLength(1); - expect(wrapper.find(copyMaskSelector).props().style).toStrictEqual({ - height: 30, - width: 100, - transform: 'translate(100px, 60px)', - zIndex: 1 - }); - }); - - it('should remove the CopyMask component on escape', () => { - const { wrapper } = setupCopy(); - pressKey(wrapper, 'c', { ctrlKey: true }); - pressKey(wrapper, 'Escape'); - wrapper.update(); - expect(wrapper.find(copyMaskSelector)).toHaveLength(0); - }); - - it('should update the selected cell with the copied value on paste', () => { - const { wrapper, props } = setupCopy(); - act(() => { - props.eventBus.dispatch('SELECT_CELL', { idx: 1, rowIdx: 2 }); - }); - // Copy selected cell - pressKey(wrapper, 'c', { ctrlKey: true }); - // Move up - pressKey(wrapper, 'ArrowUp'); - // Paste copied cell - pressKey(wrapper, 'v', { ctrlKey: true }); - - expect(props.onRowsUpdate).toHaveBeenCalledWith({ - cellKey: 'Column1', - fromRow: 2, - toRow: 1, - updated: { Column1: '3' }, - action: UpdateActions.COPY_PASTE, - fromCellKey: 'Column1' - }); - }); - }); - - describe('Drag functionality', () => { - const setupDrag = (rowIdx = 2) => { - const rows = [ - { Column1: '1' }, - { Column1: '2' }, - { Column1: '3' }, - { Column1: '4' }, - { Column1: '5' } - ]; - const { wrapper, props } = setup({ rows }, { idx: 1, rowIdx }); - return { wrapper, props }; - }; - - it('should not render the DragMask component if drag has not started', () => { - const { wrapper } = setupDrag(); - - expect(wrapper.find(DragMask)).toHaveLength(0); - }); - - it('should render the DragMask component on cell drag', () => { - const { wrapper } = setupDrag(); - const setData = jest.fn(); - wrapper.find('.drag-handle').simulate('dragstart', { - target: { className: 'test' }, - dataTransfer: { setData } - }); - - expect(wrapper.find(DragMask)).toHaveLength(1); - expect(setData).toHaveBeenCalled(); - }); - - it('should update the dragged over cells on downwards drag end', () => { - const { wrapper, props } = setupDrag(); - const setData = jest.fn(); - wrapper.find('.drag-handle').simulate('dragstart', { - target: { className: 'test' }, - dataTransfer: { setData } - }); - act(() => { - props.eventBus.dispatch('DRAG_ENTER', 6); - }); - wrapper.find('.drag-handle').simulate('dragEnd'); - - expect(props.onRowsUpdate).toHaveBeenCalledWith({ - cellKey: 'Column1', - fromRow: 2, - toRow: 6, - updated: { Column1: '3' }, - action: UpdateActions.CELL_DRAG - }); - }); - - it('should update the dragged over cells on upwards drag end', () => { - const { wrapper, props } = setupDrag(4); - const setData = jest.fn(); - wrapper.find('.drag-handle').simulate('dragstart', { - target: { className: 'test' }, - dataTransfer: { setData } - }); - act(() => { - props.eventBus.dispatch('DRAG_ENTER', 0); - }); - wrapper.find('.drag-handle').simulate('dragEnd'); - - expect(props.onRowsUpdate).toHaveBeenCalledWith({ - cellKey: 'Column1', - fromRow: 4, - toRow: 0, - updated: { Column1: '5' }, - action: UpdateActions.CELL_DRAG - }); - }); - }); -}); diff --git a/src/masks/InteractionMasks.tsx b/src/masks/InteractionMasks.tsx deleted file mode 100644 index 84d0d6c68f..0000000000 --- a/src/masks/InteractionMasks.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; - -// Components -import CellMask from './CellMask'; -import DragMask, { DraggedPosition } from './DragMask'; -import EditorContainer from '../editors/EditorContainer'; -import EditorPortal from '../editors/EditorPortal'; -import { legacyCellInput } from '../editors/CellInputHandlers'; - -// Utils -import { - isCtrlKeyHeldDown, - getSelectedDimensions as getDimensions, - getNextSelectedCellPosition, - canExitGrid, - isSelectedCellEditable -} from '../utils'; - -// Types -import EventBus from '../EventBus'; -import { UpdateActions, CellNavigationMode } from '../common/enums'; -import { CalculatedColumn, Position, Dimension, CommitEvent } from '../common/types'; -import { DataGridProps } from '../DataGrid'; - -type SharedCanvasProps = Pick, - | 'rows' - | 'onCheckCellIsEditable' - | 'onSelectedCellChange' -> & Pick>, - | 'rowHeight' - | 'enableCellCopyPaste' - | 'enableCellDragAndDrop' - | 'cellNavigationMode' - | 'editorPortalTarget' - | 'onRowsUpdate' ->; - -interface SelectCellState extends Position { - status: 'SELECT'; -} - -interface EditCellState extends Position { - status: 'EDIT'; - key: string | null; -} - -export interface InteractionMasksProps extends SharedCanvasProps { - columns: readonly CalculatedColumn[]; - gridRef: React.RefObject; - totalHeaderHeight: number; - scrollLeft: number; - scrollTop: number; - eventBus: EventBus; - scrollToCell: (cell: Position) => void; -} - -export default function InteractionMasks({ - columns, - rows, - rowHeight, - eventBus, - enableCellCopyPaste, - enableCellDragAndDrop, - editorPortalTarget, - cellNavigationMode, - gridRef, - totalHeaderHeight, - scrollLeft, - scrollTop, - onSelectedCellChange, - onCheckCellIsEditable, - onRowsUpdate, - scrollToCell -}: InteractionMasksProps) { - const [selectedPosition, setSelectedPosition] = useState({ idx: -1, rowIdx: -1, status: 'SELECT' }); - const [copiedPosition, setCopiedPosition] = useState(null); - const [draggedPosition, setDraggedPosition] = useState(null); - const selectionMaskRef = useRef(null); - - // Focus on the selection mask when the selected position is changed or the editor is closed - useEffect(() => { - if (selectedPosition.rowIdx === -1 || selectedPosition.idx === -1 || selectedPosition.status === 'EDIT') return; - selectionMaskRef.current?.focus(); - }, [selectedPosition]); - - useEffect(() => { - return eventBus.subscribe('SELECT_CELL', selectCell); - }); - - useEffect(() => { - if (draggedPosition === null) return; - const handleDragEnter = (overRowIdx: number) => { - setDraggedPosition({ ...draggedPosition, overRowIdx }); - }; - return eventBus.subscribe('DRAG_ENTER', handleDragEnter); - }, [draggedPosition, eventBus]); - - const closeEditor = useCallback(() => { - setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, status: 'SELECT' })); - }, []); - - // Reset the positions if the current values are no longer valid. This can happen if a column or row is removed - if (selectedPosition.idx > columns.length || selectedPosition.rowIdx > rows.length) { - setSelectedPosition({ idx: -1, rowIdx: -1, status: 'SELECT' }); - setCopiedPosition(null); - setDraggedPosition(null); - } - - function getEditorPosition() { - if (gridRef.current === null) return { left: 0, top: 0 }; - const { left, top } = gridRef.current.getBoundingClientRect(); - const { scrollTop: docTop, scrollLeft: docLeft } = document.scrollingElement || document.documentElement; - const gridLeft = left + docLeft; - const gridTop = top + docTop; - const column = columns[selectedPosition.idx]; - return { - left: gridLeft + column.left - (column.frozen ? 0 : scrollLeft), - top: gridTop + totalHeaderHeight + selectedPosition.rowIdx * rowHeight - scrollTop - }; - } - - function getNextPosition(key: string, mode = cellNavigationMode, shiftKey = false) { - const { idx, rowIdx } = selectedPosition; - let nextPosition: Position; - switch (key) { - case 'ArrowUp': - nextPosition = { idx, rowIdx: rowIdx - 1 }; - break; - case 'ArrowDown': - nextPosition = { idx, rowIdx: rowIdx + 1 }; - break; - case 'ArrowLeft': - nextPosition = { idx: idx - 1, rowIdx }; - break; - case 'ArrowRight': - nextPosition = { idx: idx + 1, rowIdx }; - break; - case 'Tab': - nextPosition = { idx: idx + (shiftKey ? -1 : 1), rowIdx }; - break; - default: - nextPosition = { idx, rowIdx }; - break; - } - - return getNextSelectedCellPosition({ - columns, - rowsCount: rows.length, - cellNavigationMode: mode, - nextPosition - }); - } - - function onKeyDown(event: React.KeyboardEvent): void { - const column = columns[selectedPosition.idx]; - const row = rows[selectedPosition.rowIdx]; - const isActivatedByUser = (column.unsafe_onCellInput ?? legacyCellInput)(event, row) === true; - - const { key } = event; - if (enableCellCopyPaste && isCtrlKeyHeldDown(event)) { - // event.key may be uppercase `C` or `V` - const lowerCaseKey = event.key.toLowerCase(); - if (lowerCaseKey === 'c') return handleCopy(); - if (lowerCaseKey === 'v') return handlePaste(); - } - - const canOpenEditor = selectedPosition.status === 'SELECT' && isCellEditable(selectedPosition); - - switch (key) { - case 'Enter': - if (canOpenEditor) { - setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, status: 'EDIT', key: 'Enter' })); - } else if (selectedPosition.status === 'EDIT') { - setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, status: 'SELECT' })); - } - break; - case 'Escape': - closeEditor(); - setCopiedPosition(null); - break; - case 'Tab': - onPressTab(event); - break; - case 'ArrowUp': - case 'ArrowDown': - case 'ArrowLeft': - case 'ArrowRight': - event.preventDefault(); - selectCell(getNextPosition(key)); - break; - default: - if (canOpenEditor && isActivatedByUser) { - setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, status: 'EDIT', key })); - } - break; - } - } - - function onPressTab(e: React.KeyboardEvent): void { - // If we are in a position to leave the grid, stop editing but stay in that cell - if (canExitGrid(e, { cellNavigationMode, columns, rowsCount: rows.length, selectedPosition })) { - if (selectedPosition.status === 'EDIT') { - closeEditor(); - return; - } - - // Reset the selected position before exiting - setSelectedPosition({ idx: -1, rowIdx: -1, status: 'SELECT' }); - return; - } - - e.preventDefault(); - const tabCellNavigationMode = cellNavigationMode === CellNavigationMode.NONE - ? CellNavigationMode.CHANGE_ROW - : cellNavigationMode; - const nextPosition = getNextPosition('Tab', tabCellNavigationMode, e.shiftKey); - selectCell(nextPosition); - } - - function handleCopy(): void { - const { idx, rowIdx } = selectedPosition; - const value = rows[rowIdx][columns[idx].key as keyof R]; - setCopiedPosition({ idx, rowIdx, value }); - } - - function handlePaste(): void { - if (copiedPosition === null || !isCellEditable(selectedPosition)) { - return; - } - - const { rowIdx: toRow } = selectedPosition; - - const cellKey = columns[selectedPosition.idx].key; - const { rowIdx: fromRow, idx, value } = copiedPosition; - const fromCellKey = columns[idx].key; - - onRowsUpdate({ - cellKey, - fromRow, - toRow, - updated: { [cellKey]: value } as unknown as never, - action: UpdateActions.COPY_PASTE, - fromCellKey - }); - } - - function isCellWithinBounds({ idx, rowIdx }: Position): boolean { - return rowIdx >= 0 && rowIdx < rows.length && idx >= 0 && idx < columns.length; - } - - function isCellEditable(position: Position) { - return isCellWithinBounds(position) - && isSelectedCellEditable({ columns, rows, selectedPosition: position, onCheckCellIsEditable }); - } - - function selectCell(position: Position, enableEditor = false): void { - if (!isCellWithinBounds(position)) return; - - if (enableEditor && isCellEditable(position)) { - setSelectedPosition({ ...position, status: 'EDIT', key: null }); - } else { - setSelectedPosition({ ...position, status: 'SELECT' }); - } - scrollToCell(position); - onSelectedCellChange?.({ ...position }); - } - - function isDragEnabled(): boolean { - return enableCellDragAndDrop && isCellEditable(selectedPosition); - } - - function handleDragStart(e: React.DragEvent): void { - e.dataTransfer.effectAllowed = 'copy'; - // Setting data is required to make an element draggable in FF - const transferData = JSON.stringify(selectedPosition); - try { - e.dataTransfer.setData('text/plain', transferData); - } catch (ex) { - // IE only supports 'text' and 'URL' for the 'type' argument - e.dataTransfer.setData('text', transferData); - } - setDraggedPosition({ ...selectedPosition, overRowIdx: selectedPosition.rowIdx }); - } - - function handleDragEnd() { - if (draggedPosition === null) return; - - const { rowIdx, overRowIdx } = draggedPosition; - const column = columns[draggedPosition.idx]; - const cellKey = column.key; - const value = rows[rowIdx][cellKey as keyof R]; - - onRowsUpdate({ - cellKey, - fromRow: rowIdx, - toRow: overRowIdx, - updated: { [cellKey]: value } as unknown as never, - action: UpdateActions.CELL_DRAG - }); - - setDraggedPosition(null); - } - - function onDragHandleDoubleClick(): void { - const column = columns[selectedPosition.idx]; - const cellKey = column.key; - const value = rows[selectedPosition.rowIdx][cellKey as keyof R]; - - onRowsUpdate({ - cellKey, - fromRow: selectedPosition.rowIdx, - toRow: rows.length - 1, - updated: { [cellKey]: value } as unknown as never, - action: UpdateActions.COLUMN_FILL - }); - } - - function onCommit({ cellKey, rowIdx, updated }: CommitEvent): void { - onRowsUpdate({ - cellKey, - fromRow: rowIdx, - toRow: rowIdx, - updated, - action: UpdateActions.CELL_UPDATE - }); - closeEditor(); - } - - function getSelectedDimensions(selectedPosition: Position): Dimension { - return getDimensions({ selectedPosition, columns, scrollLeft, rowHeight }); - } - - return ( -
- {copiedPosition && isCellWithinBounds(copiedPosition) && ( - - )} - {draggedPosition && isCellWithinBounds(draggedPosition) && ( - - )} - {selectedPosition.status === 'SELECT' && isCellWithinBounds(selectedPosition) && ( - - {isDragEnabled() && ( -
- )} - - )} - {selectedPosition.status === 'EDIT' && isCellWithinBounds(selectedPosition) && ( - - - firstEditorKeyPress={selectedPosition.key} - onCommit={onCommit} - onCommitCancel={closeEditor} - rowIdx={selectedPosition.rowIdx} - row={rows[selectedPosition.rowIdx]} - rowHeight={rowHeight} - column={columns[selectedPosition.idx]} - scrollLeft={scrollLeft} - scrollTop={scrollTop} - {...getEditorPosition()} - /> - - )} -
- ); -} diff --git a/src/utils/index.ts b/src/utils/index.ts index f07e14fdf2..07f4f17083 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,17 @@ export function assertIsValidKey(key: unknown): asserts key is keyof R { throw new Error('Please specify the rowKey prop to use selection'); } } + +export function wrapRefs(...refs: readonly React.Ref[]) { + return (handle: T | null) => { + for (const ref of refs) { + if (typeof ref === 'function') { + ref(handle); + } else if (ref !== null) { + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 + // @ts-expect-error + ref.current = handle; + } + } + }; +} diff --git a/src/utils/selectedCellUtils.ts b/src/utils/selectedCellUtils.ts index eb7a7c12fd..b9aa18046f 100644 --- a/src/utils/selectedCellUtils.ts +++ b/src/utils/selectedCellUtils.ts @@ -1,30 +1,6 @@ import { CellNavigationMode } from '../common/enums'; import { canEdit } from './columnUtils'; -import { CalculatedColumn, Position, Dimension } from '../common/types'; - -// above unfrozen cells, below frozen cells -const zCellMask = 1; -// above frozen cells, below header/filter/summary rows -const zFrozenCellMask = 2; - -interface GetSelectedDimensionsOpts { - selectedPosition: Position; - columns: readonly CalculatedColumn[]; - rowHeight: number; - scrollLeft: number; -} - -export function getSelectedDimensions({ selectedPosition: { idx, rowIdx }, columns, rowHeight, scrollLeft }: GetSelectedDimensionsOpts): Dimension { - if (idx < 0) { - return { width: 0, left: 0, top: 0, height: rowHeight, zIndex: 1 }; - } - const column = columns[idx]; - const { width } = column; - const left = column.frozen ? column.left + scrollLeft : column.left; - const top = rowIdx * rowHeight; - const zIndex = column.frozen ? zFrozenCellMask : zCellMask; - return { width, left, top, height: rowHeight, zIndex }; -} +import { CalculatedColumn, Position } from '../common/types'; interface IsSelectedCellEditableOpts { selectedPosition: Position; @@ -95,9 +71,10 @@ interface CanExitGridOpts { columns: readonly CalculatedColumn[]; rowsCount: number; selectedPosition: Position; + shiftKey: boolean; } -export function canExitGrid(event: React.KeyboardEvent, { cellNavigationMode, columns, rowsCount, selectedPosition: { rowIdx, idx } }: CanExitGridOpts): boolean { +export function canExitGrid({ cellNavigationMode, columns, rowsCount, selectedPosition: { rowIdx, idx }, shiftKey }: CanExitGridOpts): boolean { // When the cellNavigationMode is 'none' or 'changeRow', you can exit the grid if you're at the first or last cell of the grid // When the cellNavigationMode is 'loopOverRow', there is no logical exit point so you can't exit the grid if (cellNavigationMode === CellNavigationMode.NONE || cellNavigationMode === CellNavigationMode.CHANGE_ROW) { @@ -105,9 +82,8 @@ export function canExitGrid(event: React.KeyboardEvent, { cellNavigationM const atFirstCellInRow = idx === 0; const atLastRow = rowIdx === rowsCount - 1; const atFirstRow = rowIdx === 0; - const shift = event.shiftKey === true; - return shift ? atFirstCellInRow && atFirstRow : atLastCellInRow && atLastRow; + return shiftKey ? atFirstCellInRow && atFirstRow : atLastCellInRow && atLastRow; } return false; diff --git a/stories/demos/AllFeatures.less b/stories/demos/AllFeatures.less index 672d908a65..b5c899e28e 100644 --- a/stories/demos/AllFeatures.less +++ b/stories/demos/AllFeatures.less @@ -1,8 +1,8 @@ -.highlight { +.highlight .rdg-cell { background-color: #9370DB; color: white; } -.rdg-row.highlight:hover { +.highlight:hover .rdg-cell { background-color: #800080; } diff --git a/style/cell.less b/style/cell.less index f08dbe58fe..9111a9da99 100644 --- a/style/cell.less +++ b/style/cell.less @@ -23,8 +23,35 @@ box-shadow: 2px 0 5px -2px rgba(136, 136, 136, .3); } -.rdg-cell-mask { +.rdg-cell-selected { + box-shadow: inset 0 0 0 2px #66afe9; +} + +.rdg-cell-copied { + background-color:#ccccff; +} + +.rdg-cell-drag-handle { + cursor: move; position: absolute; - pointer-events: none; - outline: none; + right: 0; + bottom: 0; + width: 8px; + height: 8px; + background: #66afe9; + + &:hover { + width: 16px; + height: 16px; + border: 2px solid #66afe9; + background: #fff; + } +} + +.rdg-cell-dragged-over { + background-color:#ccccff; +} + +.rdg-cell-copied.rdg-cell-dragged-over { + background-color:#9999ff; } diff --git a/style/core.less b/style/core.less index 091321b1d9..aefbea42bb 100644 --- a/style/core.less +++ b/style/core.less @@ -1,4 +1,4 @@ -@import './variables.less'; +@import "./variables.less"; .rdg { // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context @@ -26,3 +26,16 @@ z-index: 0; } } + +.rdg-focus-sink { + position: sticky; + top: 0; + left: 0; + height: 0; + width: 0; + outline: 0; +} + +.rdg-viewport-dragging .rdg-row { + cursor: move; +} diff --git a/style/index.less b/style/index.less index f542087f21..0a1d19fdd1 100644 --- a/style/index.less +++ b/style/index.less @@ -3,5 +3,4 @@ @import 'core.less'; @import 'editor.less'; @import 'header.less'; -@import 'interaction-masks.less'; @import 'row.less'; diff --git a/style/interaction-masks.less b/style/interaction-masks.less deleted file mode 100644 index 2efe01992c..0000000000 --- a/style/interaction-masks.less +++ /dev/null @@ -1,46 +0,0 @@ -.rdg-selected { - border: 2px solid #66afe9; -} - -.rdg-selected .drag-handle { - pointer-events: auto; - position: absolute; - bottom: -5px; - right: -4px; - background: #66afe9; - width: 8px; - height: 8px; - border: 1px solid #fff; - border-right: 0px; - border-bottom: 0px; - cursor: crosshair; - cursor: -moz-grab; - cursor: -webkit-grab; - cursor: grab; -} - -.rdg-selected:hover .drag-handle { - bottom: -8px; - right: -7px; - background: white; - width: 16px; - height: 16px; - border: 1px solid #66afe9; -} - -.react-grid-cell-dragged-over-up, .react-grid-cell-dragged-over-down { - border: 1px dashed black; - background: rgba(0, 0, 255, 0.2) !important; -} - -.react-grid-cell-dragged-over-up { - border-bottom-width: 0; -} - -.react-grid-cell-dragged-over-down { - border-top-width: 0; -} - -.rdg-cell-copied { - background: rgba(0, 0, 255, 0.2) !important; -}