Skip to content

Commit

Permalink
[Dashboard][Collapsable Panels] Respond to touch events (#204225)
Browse files Browse the repository at this point in the history
## Summary

Adds support to touch events. The difference between these ones and
mouse events is that once they are active, the scroll is off (just like
in the current Dashboard)


https://github.com/user-attachments/assets/4cdcc850-7391-441e-ab9a-0abbe70e4e56

Fixes #202014
  • Loading branch information
mbondyra authored Jan 8, 2025
1 parent c398818 commit ea6d7be
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 70 deletions.
62 changes: 52 additions & 10 deletions packages/kbn-grid-layout/grid/grid_layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GridLayoutProps> = {}) => {
const defaultProps: GridLayoutProps = {
Expand All @@ -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
Expand Down Expand Up @@ -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
});

Expand All @@ -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',
Expand Down
56 changes: 40 additions & 16 deletions packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import React, { useCallback, useEffect, useImperativeHandle, useRef, useState }
import { EuiIcon, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
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<HTMLElement | null>) => void;
Expand All @@ -24,7 +31,7 @@ export const DragHandle = React.forwardRef<
gridLayoutStateManager: GridLayoutStateManager;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
e: UserInteractionEvent
) => void;
}
>(({ gridLayoutStateManager, interactionStart }, ref) => {
Expand All @@ -35,13 +42,20 @@ export const DragHandle = React.forwardRef<
const dragHandleRefs = useRef<Array<HTMLElement | null>>([]);

/**
* 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<HTMLButtonElement, 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();
Expand All @@ -50,24 +64,36 @@ 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<HTMLElement | null>) => {
setDragHandleCount(dragHandles.length);
dragHandleRefs.current = dragHandles;

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(() => {
Expand Down Expand Up @@ -124,12 +150,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}
>
<EuiIcon type="grabOmnidirectional" />
</button>
Expand Down
7 changes: 2 additions & 5 deletions packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { combineLatest, skip } from 'rxjs';
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';

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';
Expand All @@ -25,10 +25,7 @@ export interface GridPanelProps {
panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode;
interactionStart: (
type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
gridLayoutStateManager: GridLayoutStateManager;
}

Expand Down
13 changes: 8 additions & 5 deletions packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,12 @@ import { css } from '@emotion/react';
import { useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
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<HTMLButtonElement, MouseEvent>
) => void;
interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
}) => {
const { euiTheme } = useEuiTheme();
return (
Expand All @@ -32,6 +29,12 @@ export const ResizeHandle = ({
onMouseUp={(e) => {
interactionStart('drop', e);
}}
onTouchStart={(e) => {
interactionStart('resize', e);
}}
onTouchEnd={(e) => {
interactionStart('drop', e);
}}
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel',
})}
Expand Down
48 changes: 40 additions & 8 deletions packages/kbn-grid-layout/grid/grid_row/grid_row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ import { css } from '@emotion/react';
import { cloneDeep } from 'lodash';
import { DragPreview } from '../drag_preview';
import { GridPanel } from '../grid_panel';
import { GridLayoutStateManager, GridRowData, PanelInteractionEvent } from '../types';
import {
GridLayoutStateManager,
GridRowData,
UserInteractionEvent,
PanelInteractionEvent,
} from '../types';
import { getKeysInOrder } from '../utils/resolve_grid_row';
import { GridRowHeader } from './grid_row_header';
import { isTouchEvent, isMouseEvent } from '../utils/sensors';

export interface GridRowProps {
rowIndex: number;
Expand Down Expand Up @@ -214,7 +220,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
if (!panelRef) return;

const panelRect = panelRef.getBoundingClientRect();
if (type === 'drop') {
setInteractionEvent(undefined);
/**
Expand All @@ -226,17 +231,15 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels)
);
} else {
const panelRect = panelRef.getBoundingClientRect();
const pointerOffsets = getPointerOffsets(e, panelRect);

setInteractionEvent({
type,
id: panelId,
panelDiv: panelRef,
targetRowIndex: rowIndex,
mouseOffsets: {
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
},
pointerOffsets,
});
}
}}
Expand Down Expand Up @@ -285,3 +288,32 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
);
}
);

const defaultPointerOffsets = {
top: 0,
left: 0,
right: 0,
bottom: 0,
};

function getPointerOffsets(e: UserInteractionEvent, panelRect: DOMRect) {
if (isTouchEvent(e)) {
if (e.touches.length > 1) return defaultPointerOffsets;
const touch = e.touches[0];
return {
top: touch.clientY - panelRect.top,
left: touch.clientX - panelRect.left,
right: touch.clientX - panelRect.right,
bottom: touch.clientY - panelRect.bottom,
};
}
if (isMouseEvent(e)) {
return {
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
};
}
throw new Error('Invalid event type');
}
8 changes: 7 additions & 1 deletion packages/kbn-grid-layout/grid/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export interface PanelInteractionEvent {
* The pixel offsets from where the mouse was at drag start to the
* edges of the panel
*/
mouseOffsets: {
pointerOffsets: {
top: number;
left: number;
right: number;
Expand All @@ -122,3 +122,9 @@ export interface PanelPlacementSettings {
}

export type GridAccessMode = 'VIEW' | 'EDIT';

export type UserMouseEvent = MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>;

export type UserTouchEvent = TouchEvent | React.TouchEvent<HTMLButtonElement>;

export type UserInteractionEvent = React.UIEvent<HTMLElement> | Event;
Loading

0 comments on commit ea6d7be

Please sign in to comment.