diff --git a/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts b/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts index da560bd9f257..6178bf298131 100644 --- a/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts +++ b/packages/grid/_modules_/grid/components/styled-wrappers/GridRootStyles.ts @@ -124,7 +124,15 @@ export const useStyles = makeStyles( }, '& .MuiDataGrid-columnSeparatorResizable': { cursor: 'col-resize', - '&:hover, &.Mui-resizing': { + touchAction: 'none', + '&:hover': { + color: theme.palette.text.primary, + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + color: borderColor, + }, + }, + '&.Mui-resizing': { color: theme.palette.text.primary, }, }, diff --git a/packages/grid/_modules_/grid/constants/cssClassesConstants.ts b/packages/grid/_modules_/grid/constants/cssClassesConstants.ts index dd62b6933a9a..92a4b926a9d9 100644 --- a/packages/grid/_modules_/grid/constants/cssClassesConstants.ts +++ b/packages/grid/_modules_/grid/constants/cssClassesConstants.ts @@ -2,6 +2,7 @@ export const CELL_CSS_CLASS = 'MuiDataGrid-cell'; export const ROW_CSS_CLASS = 'MuiDataGrid-row'; export const HEADER_CELL_CSS_CLASS = 'MuiDataGrid-colCell'; +export const HEADER_CELL_SEPARATOR_RESIZABLE_CSS_CLASS = 'MuiDataGrid-columnSeparatorResizable'; export const DATA_CONTAINER_CSS_CLASS = 'data-container'; export const HEADER_CELL_DROP_ZONE_CSS_CLASS = 'MuiDataGrid-colCell-dropZone'; export const HEADER_CELL_DRAGGING_CSS_CLASS = 'MuiDataGrid-colCell-dragging'; diff --git a/packages/grid/_modules_/grid/hooks/features/useColumnResize.tsx b/packages/grid/_modules_/grid/hooks/features/useColumnResize.tsx index 8e71971e713a..614c13cc5573 100644 --- a/packages/grid/_modules_/grid/hooks/features/useColumnResize.tsx +++ b/packages/grid/_modules_/grid/hooks/features/useColumnResize.tsx @@ -4,11 +4,59 @@ import { ColDef } from '../../models/colDef'; import { useLogger } from '../utils'; import { useEventCallback } from '../../utils/material-ui-utils'; import { COL_RESIZE_START, COL_RESIZE_STOP } from '../../constants/eventsConstants'; -import { HEADER_CELL_CSS_CLASS } from '../../constants/cssClassesConstants'; -import { findCellElementsFromCol } from '../../utils'; +import { + HEADER_CELL_CSS_CLASS, + HEADER_CELL_SEPARATOR_RESIZABLE_CSS_CLASS, +} from '../../constants/cssClassesConstants'; +import { + findCellElementsFromCol, + findParentElementFromClassName, + getFieldFromHeaderElem, + findHeaderElementFromField, +} from '../../utils/domUtils'; import { ApiRef } from '../../models'; +import { CursorCoordinates } from '../../models/api/columnReorderApi'; const MIN_COL_WIDTH = 50; +let cachedSupportsTouchActionNone = false; + +// TODO: remove support for Safari < 13. +// https://caniuse.com/#search=touch-action +// +// Safari, on iOS, supports touch action since v13. +// Over 80% of the iOS phones are compatible +// in August 2020. +function doesSupportTouchActionNone(): boolean { + if (!cachedSupportsTouchActionNone) { + const element = document.createElement('div'); + element.style.touchAction = 'none'; + document.body.appendChild(element); + cachedSupportsTouchActionNone = window.getComputedStyle(element).touchAction === 'none'; + element.parentElement!.removeChild(element); + } + return cachedSupportsTouchActionNone; +} + +function trackFinger(event, currentTouchId): CursorCoordinates | boolean { + if (currentTouchId !== undefined && event.changedTouches) { + for (let i = 0; i < event.changedTouches.length; i += 1) { + const touch = event.changedTouches[i]; + if (touch.identifier === currentTouchId) { + return { + x: touch.clientX, + y: touch.clientY, + }; + } + } + + return false; + } + + return { + x: event.clientX, + y: event.clientY, + }; +} // TODO improve experience for last column export const useColumnResize = (columnsRef: React.RefObject, apiRef: ApiRef) => { @@ -18,6 +66,8 @@ export const useColumnResize = (columnsRef: React.RefObject, api const colCellElementsRef = React.useRef>(); const initialOffset = React.useRef(); const stopResizeEventTimeout = React.useRef(); + const touchId = React.useRef(); + const columnsHeaderElement = columnsRef.current; const updateWidth = (newWidth: number) => { logger.debug(`Updating width to ${newWidth} for col ${colDefRef.current!.field}`); @@ -81,8 +131,9 @@ export const useColumnResize = (columnsRef: React.RefObject, api // Avoid text selection event.preventDefault(); - colElementRef.current = event.currentTarget.closest( - `.${HEADER_CELL_CSS_CLASS}`, + colElementRef.current = findParentElementFromClassName( + event.currentTarget, + HEADER_CELL_CSS_CLASS, ) as HTMLDivElement; const field = colElementRef.current.getAttribute('data-field') as string; const colDef = apiRef.current.getColumnFromField(field); @@ -91,7 +142,7 @@ export const useColumnResize = (columnsRef: React.RefObject, api apiRef.current.publishEvent(COL_RESIZE_START, { field }); colDefRef.current = colDef; - colElementRef.current = columnsRef.current!.querySelector( + colElementRef.current = columnsHeaderElement!.querySelector( `[data-field="${colDef.field}"]`, ) as HTMLDivElement; @@ -110,19 +161,121 @@ export const useColumnResize = (columnsRef: React.RefObject, api doc.addEventListener('mouseup', handleResizeMouseUp); }); + const handleTouchEnd = useEventCallback((nativeEvent) => { + const finger = trackFinger(nativeEvent, touchId.current); + + if (!finger) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-use-before-define + stopListening(); + + apiRef.current!.updateColumn(colDefRef.current as ColDef); + + clearTimeout(stopResizeEventTimeout.current); + stopResizeEventTimeout.current = setTimeout(() => { + apiRef.current.publishEvent(COL_RESIZE_STOP); + }); + + logger.debug( + `Updating col ${colDefRef.current!.field} with new width: ${colDefRef.current!.width}`, + ); + }); + + const handleTouchMove = useEventCallback((nativeEvent) => { + const finger = trackFinger(nativeEvent, touchId.current); + if (!finger) { + return; + } + + // Cancel move in case some other element consumed a touchmove event and it was not fired. + if (nativeEvent.type === 'mousemove' && nativeEvent.buttons === 0) { + handleTouchEnd(nativeEvent); + return; + } + + let newWidth = + initialOffset.current! + + (finger as CursorCoordinates).x - + colElementRef.current!.getBoundingClientRect().left; + newWidth = Math.max(MIN_COL_WIDTH, newWidth); + + updateWidth(newWidth); + }); + + const handleTouchStart = useEventCallback((event) => { + const cellSeparator = findParentElementFromClassName( + event.target, + HEADER_CELL_SEPARATOR_RESIZABLE_CSS_CLASS, + ); + // Let the event bubble if the target is not a col separator + if (!cellSeparator) return; + // If touch-action: none; is not supported we need to prevent the scroll manually. + if (!doesSupportTouchActionNone()) { + event.preventDefault(); + } + + const touch = event.changedTouches[0]; + if (touch != null) { + // A number that uniquely identifies the current finger in the touch session. + touchId.current = touch.identifier; + } + + colElementRef.current = findParentElementFromClassName( + event.target, + HEADER_CELL_CSS_CLASS, + ) as HTMLDivElement; + const field = getFieldFromHeaderElem(colElementRef.current!); + const colDef = apiRef.current.getColumnFromField(field); + + logger.debug(`Start Resize on col ${colDef.field}`); + apiRef.current.publishEvent(COL_RESIZE_START, { field }); + + colDefRef.current = colDef; + colElementRef.current = findHeaderElementFromField( + columnsHeaderElement!, + colDef.field, + ) as HTMLDivElement; + colCellElementsRef.current = findCellElementsFromCol(colElementRef.current) as NodeListOf< + Element + >; + + initialOffset.current = + (colDefRef.current.width as number) - + (touch.clientX - colElementRef.current!.getBoundingClientRect().left); + + const doc = ownerDocument(event.currentTarget as HTMLElement); + doc.addEventListener('touchmove', handleTouchMove); + doc.addEventListener('touchend', handleTouchEnd); + }); + const stopListening = React.useCallback(() => { const doc = ownerDocument(apiRef.current.rootElementRef!.current as HTMLElement); doc.body.style.removeProperty('cursor'); doc.removeEventListener('mousemove', handleResizeMouseMove); doc.removeEventListener('mouseup', handleResizeMouseUp); - }, [apiRef, handleResizeMouseMove, handleResizeMouseUp]); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + }, [apiRef, handleResizeMouseMove, handleResizeMouseUp, handleTouchMove, handleTouchEnd]); React.useEffect(() => { + columnsHeaderElement?.addEventListener('touchstart', handleTouchStart, { + passive: doesSupportTouchActionNone(), + }); + return () => { + columnsHeaderElement?.removeEventListener('touchstart', handleTouchStart); + clearTimeout(stopResizeEventTimeout.current); stopListening(); }; - }, [stopListening]); + }, [columnsHeaderElement, handleTouchStart, stopListening]); - return React.useMemo(() => ({ onMouseDown: handleMouseDown }), [handleMouseDown]); + return React.useMemo( + () => ({ + onMouseDown: handleMouseDown, + }), + [handleMouseDown], + ); }; diff --git a/packages/grid/_modules_/grid/hooks/features/virtualization/useVirtualRows.ts b/packages/grid/_modules_/grid/hooks/features/virtualization/useVirtualRows.ts index 5c833dc2adea..652534281067 100644 --- a/packages/grid/_modules_/grid/hooks/features/virtualization/useVirtualRows.ts +++ b/packages/grid/_modules_/grid/hooks/features/virtualization/useVirtualRows.ts @@ -37,7 +37,7 @@ export const useVirtualRows = ( const paginationState = useGridSelector(apiRef, paginationSelector); const totalRowCount = useGridSelector(apiRef, rowCountSelector); - const [scrollTo] = useScrollFn(apiRef, renderingZoneRef, colRef); + const [scrollTo] = useScrollFn(renderingZoneRef, colRef); const [renderedColRef, updateRenderedCols] = useVirtualColumns(options, apiRef); const setRenderingState = React.useCallback( diff --git a/packages/grid/_modules_/grid/hooks/utils/useScrollFn.ts b/packages/grid/_modules_/grid/hooks/utils/useScrollFn.ts index a9a5d331c9fb..ff546cd1ba35 100644 --- a/packages/grid/_modules_/grid/hooks/utils/useScrollFn.ts +++ b/packages/grid/_modules_/grid/hooks/utils/useScrollFn.ts @@ -4,7 +4,6 @@ import { ScrollFn, ScrollParams } from '../../models/params/scrollParams'; import { useLogger } from './useLogger'; export function useScrollFn( - apiRef: any, renderingZoneElementRef: React.RefObject, columnHeadersElementRef: React.RefObject, ): [ScrollFn] { diff --git a/packages/grid/_modules_/grid/utils/domUtils.ts b/packages/grid/_modules_/grid/utils/domUtils.ts index 825020c494d7..b414cd88427e 100644 --- a/packages/grid/_modules_/grid/utils/domUtils.ts +++ b/packages/grid/_modules_/grid/utils/domUtils.ts @@ -28,6 +28,11 @@ export function getIdFromRowElem(rowEl: Element): string { export function getFieldFromHeaderElem(colCellEl: Element): string { return colCellEl.getAttribute('data-field')!; } + +export function findHeaderElementFromField(elem: Element, field: string): Element | null { + return elem.querySelector(`[data-field="${field}"]`); +} + export function findCellElementsFromCol(col: HTMLElement): NodeListOf | null { const field = col.getAttribute('data-field'); const root = findParentElementFromClassName(col, 'MuiDataGrid-root'); diff --git a/packages/storybook/src/stories/grid-resize.stories.tsx b/packages/storybook/src/stories/grid-resize.stories.tsx index e26a6e61ed6a..ae3a8f73e874 100644 --- a/packages/storybook/src/stories/grid-resize.stories.tsx +++ b/packages/storybook/src/stories/grid-resize.stories.tsx @@ -30,7 +30,7 @@ export const ResizeSmallDataset = () => { Switch sizes -
+