diff --git a/src/components/Grid/ColumnResizer.tsx b/src/components/Grid/ColumnResizer.tsx index 13c36fd9..9b00e275 100644 --- a/src/components/Grid/ColumnResizer.tsx +++ b/src/components/Grid/ColumnResizer.tsx @@ -1,17 +1,20 @@ -import { - MouseEventHandler, - PointerEventHandler, - useCallback, - useRef, - useState, -} from "react"; +import { PointerEventHandler, useCallback, useEffect, useRef } from "react"; import { styled } from "styled-components"; -import { ColumnResizeFn, SetResizeCursorPositionFn } from "./types"; +import { ColumnResizeFn, GetResizerPositionFn } from "./types"; import throttle from "lodash/throttle"; +import { initialPosition, ResizingState } from "./useResizingState"; +const DOUBLE_CLICK_THRESHOLD_MSEC = 300; + +/** + * Styled component for the resizer span element. + * @type {StyledComponent} + * @param {number} $height - Height of the resizer element in pixels. + * @param {boolean} $isPressed - Indicates if the resizer is currently pressed. + */ const ResizeSpan = styled.div<{ $height: number; $isPressed: boolean }>` - top: 0; - left: calc(100% - 4px); + top: ${initialPosition.top}; + left: ${initialPosition.left}; z-index: 1; position: absolute; height: ${({ $height }) => $height}px; @@ -30,91 +33,129 @@ const ResizeSpan = styled.div<{ $height: number; $isPressed: boolean }>` position: fixed; `} `; -type PointerRefType = { - width: number; - pointerId: number; - initialClientX: number; -}; +/** + * Properties for the ColumnResizer component. + * @typedef {Object} Props + * @property {number} height - Height of the resizer. + * @property {ColumnResizeFn} onColumnResize - Function to handle column resize. + * @property {number} columnIndex - Index of the column being resized. + * @property {GetResizerPositionFn} getResizerPosition - Function to get the position of the resizer. + * @property {number} columnWidth - Initial width of the column. + * @property {ResizingState} resizingState - State management object for resizing interactions. + */ interface Props { height: number; onColumnResize: ColumnResizeFn; columnIndex: number; - setResizeCursorPosition: SetResizeCursorPositionFn; + getResizerPosition: GetResizerPositionFn; + columnWidth: number; + resizingState: ResizingState; } + +/** + * Component for rendering a column resizer with pointer events and resizing state management. + * @param {Props} props - Properties passed to the component. + * @returns {JSX.Element} The ColumnResizer component. + */ const ColumnResizer = ({ height, onColumnResize: onColumnResizeProp, columnIndex, - setResizeCursorPosition, + getResizerPosition, + columnWidth, + resizingState, }: Props) => { const resizeRef = useRef(null); - const pointerRef = useRef(null); - const [isPressed, setIsPressed] = useState(false); + const { + pointer, + setPointer, + getIsPressed, + setIsPressed, + getPosition, + setPosition, + lastPressedTimestamp, + } = resizingState; + const isPressed = getIsPressed(columnIndex); + const position = getPosition(columnIndex); const onColumnResize = throttle(onColumnResizeProp, 1000); - const onMouseDown: MouseEventHandler = useCallback( - e => { - e.preventDefault(); - e.stopPropagation(); - setIsPressed(true); - - if (e.detail > 1) { - onColumnResize(columnIndex, 0, "auto"); - } - }, - [columnIndex, onColumnResize, setIsPressed] - ); - - const onMouseUp: MouseEventHandler = useCallback( - e => { - e.stopPropagation(); + useEffect(() => { + // Capture the pointer when pressed to ensure we receive move events outside the element. + // Capturing must be properly handled when the component unmounts and mounts again, + // based on its pressed state. + const control = resizeRef.current; + if (!isPressed || !control || !pointer) { + return; + } + const pointerId = pointer.pointerId; + try { + control.setPointerCapture(pointerId); + return () => { + if (control.hasPointerCapture(pointerId)) { + control.releasePointerCapture(pointerId); + } + }; + } catch (e) { + console.error(e); + } + }, [pointer, isPressed, columnIndex]); - setIsPressed(false); - }, - [setIsPressed] - ); + /** + * Handler for pointer down events to initiate resizing or auto-sizing on double-click. + * @type {PointerEventHandler} + */ const onPointerDown: PointerEventHandler = useCallback( e => { e.stopPropagation(); + e.preventDefault(); if (resizeRef.current) { - resizeRef.current.setPointerCapture(e.pointerId); - const header = resizeRef.current.closest(`[data-header="${columnIndex}"]`); - if (header) { - pointerRef.current = { - pointerId: e.pointerId, - initialClientX: e.clientX, - width: header.clientWidth, - }; - - setResizeCursorPosition( - resizeRef.current, - e.clientX, - header.clientWidth, - columnIndex - ); + // We cannot detect double-click with onDoubleClick event, + // because this component might be unmounted before the second click and mounted again. + // We keep track of the last click timestamp and check if it was a double-click. + if (lastPressedTimestamp > Date.now() - DOUBLE_CLICK_THRESHOLD_MSEC) { + // Auto-size the column on double click. + onColumnResize(columnIndex, 0, "auto"); } + setPointer({ + pointerId: e.pointerId, + initialClientX: e.clientX, + width: columnWidth, + }); + setIsPressed(columnIndex, true); + const pos = getResizerPosition(e.clientX, columnWidth, columnIndex); + setPosition(pos); } }, - [columnIndex, setResizeCursorPosition] + [ + lastPressedTimestamp, + setPointer, + columnWidth, + setIsPressed, + columnIndex, + getResizerPosition, + setPosition, + onColumnResize, + ] ); - const onMouseMove: MouseEventHandler = useCallback( + /** + * Handler for pointer move events to update the column width as the user drags. + * @type {PointerEventHandler} + */ + const onPointerMove: PointerEventHandler = useCallback( e => { e.stopPropagation(); - if (resizeRef.current && pointerRef.current) { - const header = resizeRef.current.closest(`[data-header="${columnIndex}"]`); - if (header) { - resizeRef.current.setPointerCapture(pointerRef.current.pointerId); - const width = - header.clientWidth + (e.clientX - pointerRef.current.initialClientX); - - setResizeCursorPosition(resizeRef.current, e.clientX, width, columnIndex); - pointerRef.current.width = Math.max(width, 50); - } + e.preventDefault(); + if (isPressed && pointer) { + const width = columnWidth + (e.clientX - pointer.initialClientX); + const pos = getResizerPosition(e.clientX, width, columnIndex); + setPosition(pos); + // Ensure minimum width of 50px + pointer.width = Math.max(width, 50); } }, - [columnIndex, setResizeCursorPosition] + [pointer, isPressed, columnWidth, getResizerPosition, columnIndex, setPosition] ); return ( @@ -126,27 +167,31 @@ const ColumnResizer = ({ onPointerUp={e => { e.preventDefault(); e.stopPropagation(); - if ( resizeRef.current && // 0 is a valid pointerId in Firefox - (pointerRef.current?.pointerId || pointerRef.current?.pointerId === 0) + (pointer?.pointerId || pointer?.pointerId === 0) ) { - resizeRef.current.releasePointerCapture(pointerRef.current.pointerId); - const shouldCallResize = e.clientX !== pointerRef.current.initialClientX; + const shouldCallResize = e.clientX !== pointer.initialClientX; if (shouldCallResize) { - onColumnResize(columnIndex, pointerRef.current.width, "manual"); + onColumnResize(columnIndex, pointer.width, "manual"); } - resizeRef.current.style.top = "0"; - resizeRef.current.style.left = "calc(100% - 4px)"; - pointerRef.current = null; + setPosition(initialPosition); + setPointer(null); + setIsPressed(columnIndex, false); } }} - onMouseMove={onMouseMove} - onMouseDown={onMouseDown} + onPointerMove={onPointerMove} + onPointerCancel={e => { + e.preventDefault(); + e.stopPropagation(); + setPosition(initialPosition); + setPointer(null); + setIsPressed(columnIndex, false); + }} onClick={e => e.stopPropagation()} - onMouseUp={onMouseUp} data-resize + style={position} /> ); }; diff --git a/src/components/Grid/Grid.tsx b/src/components/Grid/Grid.tsx index a6e1a632..e1fe99b6 100644 --- a/src/components/Grid/Grid.tsx +++ b/src/components/Grid/Grid.tsx @@ -22,14 +22,15 @@ import RowNumberColumn from "./RowNumberColumn"; import Header from "./Header"; import { styled } from "styled-components"; import { + GetResizerPositionFn, GridContextMenuItemProps, GridProps, ItemDataType, + ResizerPosition, RoundedType, SelectedRegion, SelectionAction, SelectionFocus, - SetResizeCursorPositionFn, onSelectFn, } from "./types"; import { useSelectionActions } from "./useSelectionActions"; @@ -38,6 +39,7 @@ import { Cell } from "./Cell"; import { ContextMenu, createToast } from "@/components"; import copyGridElements from "./copyGridElements"; import useColumns from "./useColumns"; +import useResizingState from "./useResizingState"; const NO_BUTTONS_PRESSED = 0; const LEFT_BUTTON_PRESSED = 1; @@ -257,6 +259,7 @@ export const Grid = forwardRef( }, [onSelectProp] ); + const resizingState = useResizingState(); const onFocusChange = useCallback( (row: number, column: number) => { @@ -343,17 +346,16 @@ export const Grid = forwardRef( [getColumnHorizontalPosition, rowNumberWidth] ); - const setResizeCursorPosition: SetResizeCursorPositionFn = useCallback( - (element, clientX, width, columnIndex) => { - element.style.left = `${getFixedResizerLeftPosition( - clientX, - width, - columnIndex - )}px`; + const getResizerPosition: GetResizerPositionFn = useCallback( + (clientX, width, columnIndex) => { + const result: ResizerPosition = { + left: `${getFixedResizerLeftPosition(clientX, width, columnIndex)}px`, + }; if (outerRef.current) { - element.style.top = `${outerRef.current.scrollTop}px`; + result.top = `${outerRef.current.scrollTop}px`; } + return result; }, [getFixedResizerLeftPosition] ); @@ -444,15 +446,16 @@ export const Grid = forwardRef( minColumn={minColumn} maxColumn={maxColumn} height={headerHeight} - columnWidth={columnWidth} + getColumnWidth={columnWidth} cell={cell} rowNumberWidth={rowNumberWidth} getSelectionType={getSelectionType} columnCount={columnCount} onColumnResize={onColumnResize} getColumnHorizontalPosition={getColumnHorizontalPosition} - setResizeCursorPosition={setResizeCursorPosition} + getResizerPosition={getResizerPosition} showBorder={showBorder} + resizingState={resizingState} /> )} diff --git a/src/components/Grid/Header.tsx b/src/components/Grid/Header.tsx index 211d877f..b5d1265c 100644 --- a/src/components/Grid/Header.tsx +++ b/src/components/Grid/Header.tsx @@ -2,11 +2,12 @@ import { styled } from "styled-components"; import { CellProps, ColumnResizeFn, + GetResizerPositionFn, SelectionTypeFn, - SetResizeCursorPositionFn, } from "./types"; import { StyledCell } from "./StyledCell"; import ColumnResizer from "./ColumnResizer"; +import { ResizingState } from "./useResizingState"; interface HeaderProps { showRowNumber: boolean; @@ -14,16 +15,17 @@ interface HeaderProps { minColumn: number; maxColumn: number; height: number; - columnWidth: (index: number) => number; + getColumnWidth: (index: number) => number; cell: CellProps; getSelectionType: SelectionTypeFn; columnCount: number; onColumnResize: ColumnResizeFn; getColumnHorizontalPosition: (columnIndex: number) => number; scrolledVertical: boolean; - setResizeCursorPosition: SetResizeCursorPositionFn; + getResizerPosition: GetResizerPositionFn; showBorder: boolean; scrolledHorizontal: boolean; + resizingState: ResizingState; } const HeaderContainer = styled.div<{ $height: number; $scrolledVertical: boolean }>` @@ -52,11 +54,12 @@ interface ColumnProps | "cell" | "getSelectionType" | "onColumnResize" - | "columnWidth" + | "getColumnWidth" | "height" - | "setResizeCursorPosition" + | "getResizerPosition" | "showBorder" | "getColumnHorizontalPosition" + | "resizingState" > { columnIndex: number; isFirstColumn: boolean; @@ -101,15 +104,16 @@ const RowColumn = styled(StyledCell)` const Column = ({ columnIndex, cell, - columnWidth, + getColumnWidth, getColumnHorizontalPosition, getSelectionType, isFirstColumn, isLastColumn, onColumnResize, height, - setResizeCursorPosition, + getResizerPosition, showBorder, + resizingState, }: ColumnProps) => { const selectionType = getSelectionType({ column: columnIndex, @@ -126,9 +130,10 @@ const Column = ({ (leftSelectionType === "selectDirect" || isSelected) && leftSelectionType !== selectionType; + const columnWidth = getColumnWidth(columnIndex) return ( ); @@ -171,14 +178,15 @@ const Header = ({ minColumn, maxColumn, height, - columnWidth, + getColumnWidth, cell, columnCount, getSelectionType, onColumnResize, getColumnHorizontalPosition, - setResizeCursorPosition, + getResizerPosition, showBorder, + resizingState }: HeaderProps) => { const selectedAllType = getSelectionType({ type: "all", @@ -197,15 +205,16 @@ const Header = ({ key={`header-${columnIndex}`} getSelectionType={getSelectionType} columnIndex={columnIndex} - columnWidth={columnWidth} + getColumnWidth={getColumnWidth} getColumnHorizontalPosition={getColumnHorizontalPosition} cell={cell} isFirstColumn={columnIndex === 0 && !showRowNumber} isLastColumn={columnIndex + 1 === columnCount} onColumnResize={onColumnResize} height={height} - setResizeCursorPosition={setResizeCursorPosition} + getResizerPosition={getResizerPosition} showBorder={showBorder} + resizingState={resizingState} /> ))} diff --git a/src/components/Grid/types.ts b/src/components/Grid/types.ts index a8eb598d..722f5523 100644 --- a/src/components/Grid/types.ts +++ b/src/components/Grid/types.ts @@ -206,9 +206,13 @@ export interface GridProps forwardedGridRef?: MutableRefObject; } -export type SetResizeCursorPositionFn = ( - element: HTMLSpanElement, +export type ResizerPosition = { + left: string; + top?: string; +}; + +export type GetResizerPositionFn = ( clientX: number, width: number, columnIndex: number -) => void; +) => ResizerPosition; diff --git a/src/components/Grid/useResizingState.test.ts b/src/components/Grid/useResizingState.test.ts new file mode 100644 index 00000000..a1af0449 --- /dev/null +++ b/src/components/Grid/useResizingState.test.ts @@ -0,0 +1,83 @@ +import { renderHook, act } from "@testing-library/react"; +import useResizingState, { initialPosition, PointerType } from "./useResizingState"; + +describe("useResizingState hook", () => { + it("initializes with default values", () => { + const { result } = renderHook(() => useResizingState()); + expect(result.current.pointer).toBeNull(); + expect(result.current.getIsPressed(0)).toBe(false); + expect(result.current.getPosition(0)).toEqual(initialPosition); + expect(result.current.lastPressedTimestamp).toBe(0); + }); + + it("sets pointer correctly", () => { + const { result } = renderHook(() => useResizingState()); + const pointer: PointerType = { width: 100, pointerId: 1, initialClientX: 150 }; + + act(() => { + result.current.setPointer(pointer); + }); + + expect(result.current.pointer).toEqual(pointer); + }); + + it("sets and retrieves pressed state for a column", () => { + const { result } = renderHook(() => useResizingState()); + + act(() => { + result.current.setIsPressed(1, true); + }); + + expect(result.current.getIsPressed(1)).toBe(true); + expect(result.current.getIsPressed(0)).toBe(false); + + act(() => { + result.current.setIsPressed(1, false); + }); + + expect(result.current.getIsPressed(1)).toBe(false); + }); + + it("updates lastPressedTimestamp when a column is pressed", () => { + const { result } = renderHook(() => useResizingState()); + + const timestampBeforePress = Date.now(); + act(() => { + result.current.setIsPressed(1, true); + }); + + expect(result.current.lastPressedTimestamp).toBeGreaterThanOrEqual( + timestampBeforePress + ); + }); + + it("gets and sets position correctly", () => { + const { result } = renderHook(() => useResizingState()); + const customPosition = { left: "50px", top: "10px" }; + + act(() => { + result.current.setIsPressed(1, true); + result.current.setPosition(customPosition); + }); + + expect(result.current.getPosition(1)).toEqual(customPosition); + + expect(result.current.getPosition(0)).toEqual(initialPosition); + }); + + it("resets pressed column index when another column is pressed", () => { + const { result } = renderHook(() => useResizingState()); + + act(() => { + result.current.setIsPressed(1, true); + }); + expect(result.current.getIsPressed(1)).toBe(true); + + act(() => { + result.current.setIsPressed(2, true); + }); + + expect(result.current.getIsPressed(1)).toBe(false); + expect(result.current.getIsPressed(2)).toBe(true); + }); +}); diff --git a/src/components/Grid/useResizingState.ts b/src/components/Grid/useResizingState.ts new file mode 100644 index 00000000..b589624f --- /dev/null +++ b/src/components/Grid/useResizingState.ts @@ -0,0 +1,109 @@ +import { useState, useCallback } from "react"; +import { ResizerPosition } from "./types"; + +/** + * Defines the type for pointer information used in resizing. + * @typedef {Object} PointerType + * @property {number} width - The width of the pointer component. + * @property {number} pointerId - Unique identifier for the pointer from a touch event. + * @property {number} initialClientX - The initial X coordinate of the pointer when resizing started. + */ +export type PointerType = { + width: number; + pointerId: number; + initialClientX: number; +}; + +/** + * Defines the state and methods used for managing the column resizing. + * @typedef {Object} ResizingState + * @property {PointerType | null} pointer - The current pointer data, or null if no pointer is active. + * @property {(pointer: PointerType | null) => void} setPointer - Setter to update the pointer. + * @property {(columnIndex: number) => boolean} getIsPressed - Indicates if a resizer for the given column is currently pressed/dragged. + * @property {(columnIndex: number, pressed: boolean) => void} setIsPressed - Sets the pressed state for a given column. + * @property {(columnIndex: number) => ResizerPosition} getPosition - Gets the position of the resizer for the specified column. + * @property {(position: ResizerPosition) => void} setPosition - Updates the position of the resizer. + * @property {number} lastPressedTimestamp - Timestamp of the last time a column was pressed, used to detect double-clicks. + */ +export interface ResizingState { + pointer: PointerType | null; + setPointer: (pointer: PointerType | null) => void; + getIsPressed: (columnIndex: number) => boolean; + setIsPressed: (columnIndex: number, pressed: boolean) => void; + getPosition: (columnIndex: number) => ResizerPosition; + setPosition: (position: ResizerPosition) => void; + lastPressedTimestamp: number; +} + +/** + * The initial position of the resizer element. + * @type {ResizerPosition} + */ +export const initialPosition = { + left: "calc(100% - 4px)", + top: "0", +}; + +/** + * Custom hook that provides the state and methods needed to manage a resizing operation on columns. + * @returns {ResizingState} The resizing state and methods for controlling resizing behavior. + */ +const useResizingState = (): ResizingState => { + const [pressedColumnIndex, setPressedColumnIndex] = useState(-1); + const [pointer, setPointer] = useState(null); + const [position, setPosition] = useState(initialPosition); + const [lastPressedTimestamp, setLastPressedTimestamp] = useState(0); + + /** + * Checks if the specified column index is currently in a pressed state. + * @param {number} columnIndex - The index of the column to check. + * @returns {boolean} True if the column is pressed, false otherwise. + */ + const getIsPressed = useCallback( + (columnIndex: number) => { + return pressedColumnIndex === columnIndex; + }, + [pressedColumnIndex] + ); + + /** + * Updates the pressed state for a specified column. + * @param {number} columnIndex - The index of the column. + * @param {boolean} pressed - True to set the column as pressed, false to release it. + */ + const setIsPressed = useCallback((columnIndex: number, pressed: boolean) => { + if (pressed) { + setPressedColumnIndex(columnIndex); + setLastPressedTimestamp(Date.now()); + } else { + setPressedColumnIndex(-1); + } + }, []); + + /** + * Gets the position of the resizer for the specified column index. + * @param {number} columnIndex - The index of the column to retrieve the position for. + * @returns {ResizerPosition} The position of the resizer. + */ + const getPosition = useCallback( + (columnIndex: number) => { + if (pressedColumnIndex !== columnIndex) { + return initialPosition; + } + return position; + }, + [position, pressedColumnIndex] + ); + + return { + pointer, + setPointer, + getIsPressed, + setIsPressed, + getPosition, + setPosition, + lastPressedTimestamp, + }; +}; + +export default useResizingState; diff --git a/src/examples/GridExample.tsx b/src/examples/GridExample.tsx index 931d879f..7edeacbf 100644 --- a/src/examples/GridExample.tsx +++ b/src/examples/GridExample.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Grid, CellProps, @@ -6,9 +6,17 @@ import { SelectionFocus, GridContextMenuItemProps, Pagination, + Switch, } from ".."; -const Cell: CellProps = ({ type, rowIndex, columnIndex, isScrolling, width, ...props }) => { +const Cell: CellProps = ({ + type, + rowIndex, + columnIndex, + isScrolling, + width, + ...props +}) => { return ( { column: 17460, }); const [currentPage, setCurrentPage] = useState(1); - const rowCount = 20000, - columnCount = 20000; + const columnCount = 20000; + const initialRowCount = 20000; + + const [rowCount, setRowCount] = useState(initialRowCount); + const [dynamicUpdates, setDynamicUpdates] = useState(false); const getMenuOptions = useCallback( (selection: SelectedRegion, focus: SelectionFocus): GridContextMenuItemProps[] => { @@ -41,6 +52,23 @@ const GridExample = () => { [] ); + useEffect(() => { + if (!dynamicUpdates) { + // Reset the row count when the switch is off. + setRowCount(initialRowCount); + return; + } + + const interval = setInterval(() => { + // Add a row every 200ms + setRowCount(v => v + 1); + }, 200); + + return () => { + clearInterval(interval); + }; + }, [dynamicUpdates]); + return (
@@ -60,6 +88,14 @@ const GridExample = () => { onChange={setCurrentPage} totalPages={5} /> + { + setDynamicUpdates(v => !v); + }} + label="Add a row every 200ms" + checked={dynamicUpdates} + orientation="horizontal" + />
); };