diff --git a/packages/kbn-grid-layout/grid/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx index f28703f748bf7..3493c9dec0b10 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.test.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.test.tsx @@ -14,6 +14,13 @@ import { GridLayout, GridLayoutProps } from './grid_layout'; import { gridSettings, mockRenderPanelContents } from './test_utils/mocks'; import { cloneDeep } from 'lodash'; +class TouchEventFake extends Event { + constructor(public touches: Array<{ clientX: number; clientY: number }>) { + super('touchmove'); + this.touches = [{ clientX: 256, clientY: 128 }]; + } +} + describe('GridLayout', () => { const renderGridLayout = (propsOverrides: Partial = {}) => { const defaultProps: GridLayoutProps = { @@ -38,17 +45,30 @@ describe('GridLayout', () => { .getAllByRole('button', { name: /panelId:panel/i }) .map((el) => el.getAttribute('aria-label')?.replace(/panelId:/g, '')); - const startDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => { + const mouseStartDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => { fireEvent.mouseDown(handle, options); }; - const moveTo = (options = { clientX: 256, clientY: 128 }) => { + const mouseMoveTo = (options = { clientX: 256, clientY: 128 }) => { fireEvent.mouseMove(document, options); }; - const drop = (handle: HTMLElement) => { + const mouseDrop = (handle: HTMLElement) => { fireEvent.mouseUp(handle); }; + const touchStart = (handle: HTMLElement, options = { touches: [{ clientX: 0, clientY: 0 }] }) => { + fireEvent.touchStart(handle, options); + }; + const touchMoveTo = (options = { touches: [{ clientX: 256, clientY: 128 }] }) => { + const realTouchEvent = window.TouchEvent; + // @ts-expect-error + window.TouchEvent = TouchEventFake; + fireEvent.touchMove(document, new TouchEventFake(options.touches)); + window.TouchEvent = realTouchEvent; + }; + const touchEnd = (handle: HTMLElement) => { + fireEvent.touchEnd(handle); + }; const assertTabThroughPanel = async (panelId: string) => { await userEvent.tab(); // tab to drag handle @@ -81,11 +101,11 @@ describe('GridLayout', () => { jest.clearAllMocks(); const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; - startDragging(panel1DragHandle); - moveTo({ clientX: 256, clientY: 128 }); + mouseStartDragging(panel1DragHandle); + mouseMoveTo({ clientX: 256, clientY: 128 }); expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called during dragging - drop(panel1DragHandle); + mouseDrop(panel1DragHandle); expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called after reordering }); @@ -107,12 +127,34 @@ describe('GridLayout', () => { renderGridLayout(); const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; - startDragging(panel1DragHandle); + mouseStartDragging(panel1DragHandle); + + mouseMoveTo({ clientX: 256, clientY: 128 }); + expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we mouseDrop - moveTo({ clientX: 256, clientY: 128 }); - expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop + mouseDrop(panel1DragHandle); + expect(getAllThePanelIds()).toEqual([ + 'panel2', + 'panel5', + 'panel3', + 'panel7', + 'panel1', + 'panel8', + 'panel6', + 'panel4', + 'panel9', + 'panel10', + ]); + }); + it('after reordering some panels via touch events', async () => { + renderGridLayout(); + + const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; + touchStart(panel1DragHandle); + touchMoveTo({ touches: [{ clientX: 256, clientY: 128 }] }); + expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we mouseDrop - drop(panel1DragHandle); + touchEnd(panel1DragHandle); expect(getAllThePanelIds()).toEqual([ 'panel2', 'panel5', diff --git a/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx index f175cf227a7e5..c198e44f34460 100644 --- a/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx @@ -13,7 +13,14 @@ import { EuiIcon, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; import { i18n } from '@kbn/i18n'; -import { GridLayoutStateManager, PanelInteractionEvent } from '../types'; +import { + GridLayoutStateManager, + PanelInteractionEvent, + UserInteractionEvent, + UserMouseEvent, + UserTouchEvent, +} from '../types'; +import { isMouseEvent, isTouchEvent } from '../utils/sensors'; export interface DragHandleApi { setDragHandles: (refs: Array) => void; @@ -25,7 +32,7 @@ export const DragHandle = React.forwardRef< gridLayoutStateManager: GridLayoutStateManager; interactionStart: ( type: PanelInteractionEvent['type'] | 'drop', - e: MouseEvent | React.MouseEvent + e: UserInteractionEvent ) => void; } >(({ gridLayoutStateManager, interactionStart }, ref) => { @@ -36,13 +43,20 @@ export const DragHandle = React.forwardRef< const dragHandleRefs = useRef>([]); /** - * We need to memoize the `onMouseDown` callback so that we don't assign a new `onMouseDown` event handler + * We need to memoize the `onDragStart` and `onDragEnd` callbacks so that we don't assign a new event handler * every time `setDragHandles` is called */ - const onMouseDown = useCallback( - (e: MouseEvent | React.MouseEvent) => { - if (gridLayoutStateManager.accessMode$.getValue() !== 'EDIT' || e.button !== 0) { - // ignore anything but left clicks, and ignore clicks when not in edit mode + const onDragStart = useCallback( + (e: UserMouseEvent | UserTouchEvent) => { + // ignore when not in edit mode + if (gridLayoutStateManager.accessMode$.getValue() !== 'EDIT') return; + + // ignore anything but left clicks for mouse events + if (isMouseEvent(e) && e.button !== 0) { + return; + } + // ignore multi-touch events for touch events + if (isTouchEvent(e) && e.touches.length > 1) { return; } e.stopPropagation(); @@ -51,6 +65,14 @@ export const DragHandle = React.forwardRef< [interactionStart, gridLayoutStateManager.accessMode$] ); + const onDragEnd = useCallback( + (e: UserTouchEvent | UserMouseEvent) => { + e.stopPropagation(); + interactionStart('drop', e); + }, + [interactionStart] + ); + const setDragHandles = useCallback( (dragHandles: Array) => { setDragHandleCount(dragHandles.length); @@ -58,17 +80,21 @@ export const DragHandle = React.forwardRef< for (const handle of dragHandles) { if (handle === null) return; - handle.addEventListener('mousedown', onMouseDown, { passive: true }); + handle.addEventListener('mousedown', onDragStart, { passive: true }); + handle.addEventListener('touchstart', onDragStart, { passive: false }); + handle.addEventListener('touchend', onDragEnd, { passive: true }); } removeEventListenersRef.current = () => { for (const handle of dragHandles) { if (handle === null) return; - handle.removeEventListener('mousedown', onMouseDown); + handle.removeEventListener('mousedown', onDragStart); + handle.removeEventListener('touchstart', onDragStart); + handle.removeEventListener('touchend', onDragEnd); } }; }, - [onMouseDown] + [onDragStart, onDragEnd] ); useEffect(() => { @@ -125,12 +151,10 @@ export const DragHandle = React.forwardRef< display: none; } `} - onMouseDown={(e) => { - interactionStart('drag', e); - }} - onMouseUp={(e) => { - interactionStart('drop', e); - }} + onMouseDown={onDragStart} + onMouseUp={onDragEnd} + onTouchStart={onDragStart} + onTouchEnd={onDragEnd} > diff --git a/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx index c30e6ecc996eb..5d56827514356 100644 --- a/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx @@ -13,7 +13,7 @@ import { combineLatest, skip } from 'rxjs'; import { css } from '@emotion/react'; import { euiThemeVars } from '@kbn/ui-theme'; -import { GridLayoutStateManager, PanelInteractionEvent } from '../types'; +import { GridLayoutStateManager, UserInteractionEvent, PanelInteractionEvent } from '../types'; import { getKeysInOrder } from '../utils/resolve_grid_row'; import { DragHandle, DragHandleApi } from './drag_handle'; import { ResizeHandle } from './resize_handle'; @@ -25,10 +25,7 @@ export interface GridPanelProps { panelId: string, setDragHandles?: (refs: Array) => void ) => React.ReactNode; - interactionStart: ( - type: PanelInteractionEvent['type'] | 'drop', - e: MouseEvent | React.MouseEvent - ) => void; + interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void; gridLayoutStateManager: GridLayoutStateManager; } diff --git a/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx index ffee2f2764ed0..62db1c8eb86be 100644 --- a/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx +++ b/packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx @@ -12,15 +12,12 @@ import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-theme'; import React from 'react'; -import { PanelInteractionEvent } from '../types'; +import { UserInteractionEvent, PanelInteractionEvent } from '../types'; export const ResizeHandle = ({ interactionStart, }: { - interactionStart: ( - type: PanelInteractionEvent['type'] | 'drop', - e: MouseEvent | React.MouseEvent - ) => void; + interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void; }) => { return (