Skip to content

Commit

Permalink
[DataGrid] Add touch support on column resize (mui#537)
Browse files Browse the repository at this point in the history
* Add support for touch column resize

* Change the touch start event listner from the separator to the document

* Allow resizing on mobile only when column separator is touched

* fix hover

* Attach touchStart event to the columns header element, not the document

* Add support for touch column resize

* Change the touch start event listner from the separator to the document

* Allow resizing on mobile only when column separator is touched

* Attach touchStart event to the columns header element, not the document

* fix hover

* minimize git diff

* Use findParentElementFromClassName dom util

* Add new findHeaderElementFromField dom util

Co-authored-by: Olivier Tassinari <olivier.tassinari@gmail.com>
  • Loading branch information
2 people authored and dtassone committed Nov 9, 2020
1 parent a92a543 commit f668150
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 12 deletions.
Expand Up @@ -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,
},
},
Expand Down
Expand Up @@ -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';
169 changes: 161 additions & 8 deletions packages/grid/_modules_/grid/hooks/features/useColumnResize.tsx
Expand Up @@ -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<HTMLDivElement>, apiRef: ApiRef) => {
Expand All @@ -18,6 +66,8 @@ export const useColumnResize = (columnsRef: React.RefObject<HTMLDivElement>, api
const colCellElementsRef = React.useRef<NodeListOf<Element>>();
const initialOffset = React.useRef<number>();
const stopResizeEventTimeout = React.useRef<number>();
const touchId = React.useRef<number>();
const columnsHeaderElement = columnsRef.current;

const updateWidth = (newWidth: number) => {
logger.debug(`Updating width to ${newWidth} for col ${colDefRef.current!.field}`);
Expand Down Expand Up @@ -81,8 +131,9 @@ export const useColumnResize = (columnsRef: React.RefObject<HTMLDivElement>, 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);
Expand All @@ -91,7 +142,7 @@ export const useColumnResize = (columnsRef: React.RefObject<HTMLDivElement>, 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;

Expand All @@ -110,19 +161,121 @@ export const useColumnResize = (columnsRef: React.RefObject<HTMLDivElement>, 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],
);
};
Expand Up @@ -37,7 +37,7 @@ export const useVirtualRows = (
const paginationState = useGridSelector<PaginationState>(apiRef, paginationSelector);
const totalRowCount = useGridSelector<number>(apiRef, rowCountSelector);

const [scrollTo] = useScrollFn(apiRef, renderingZoneRef, colRef);
const [scrollTo] = useScrollFn(renderingZoneRef, colRef);
const [renderedColRef, updateRenderedCols] = useVirtualColumns(options, apiRef);

const setRenderingState = React.useCallback(
Expand Down
1 change: 0 additions & 1 deletion packages/grid/_modules_/grid/hooks/utils/useScrollFn.ts
Expand Up @@ -4,7 +4,6 @@ import { ScrollFn, ScrollParams } from '../../models/params/scrollParams';
import { useLogger } from './useLogger';

export function useScrollFn(
apiRef: any,
renderingZoneElementRef: React.RefObject<HTMLDivElement>,
columnHeadersElementRef: React.RefObject<HTMLDivElement>,
): [ScrollFn] {
Expand Down
5 changes: 5 additions & 0 deletions packages/grid/_modules_/grid/utils/domUtils.ts
Expand Up @@ -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<Element> | null {
const field = col.getAttribute('data-field');
const root = findParentElementFromClassName(col, 'MuiDataGrid-root');
Expand Down
2 changes: 1 addition & 1 deletion packages/storybook/src/stories/grid-resize.stories.tsx
Expand Up @@ -30,7 +30,7 @@ export const ResizeSmallDataset = () => {
Switch sizes
</button>
</div>
<div style={{ width: size.width, height: size.height, display: 'flex' }}>
<div style={{ width: size.width, height: size.height }}>
<XGrid rows={data.rows} columns={data.columns} />
</div>
</React.Fragment>
Expand Down

0 comments on commit f668150

Please sign in to comment.