[Dashboard][Collapsable Panels] Respond to touch events (#204225)

## 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 https://github.com/elastic/kibana/issues/202014
This commit is contained in:
Marta Bondyra 2025-01-09 00:59:46 +01:00 committed by GitHub
parent c398818d72
commit ea6d7bef93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 220 additions and 70 deletions

View file

@ -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 = {
@ -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);
moveTo({ clientX: 256, clientY: 128 });
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop
mouseMoveTo({ clientX: 256, clientY: 128 });
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we mouseDrop
drop(panel1DragHandle);
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
touchEnd(panel1DragHandle);
expect(getAllThePanelIds()).toEqual([
'panel2',
'panel5',

View file

@ -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;
@ -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) => {
@ -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();
@ -50,6 +64,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<HTMLElement | null>) => {
setDragHandleCount(dragHandles.length);
@ -57,17 +79,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(() => {
@ -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>

View file

@ -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';
@ -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;
}

View file

@ -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 (
@ -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',
})}

View file

@ -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;
@ -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);
/**
@ -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,
});
}
}}
@ -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');
}

View file

@ -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;
@ -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;

View file

@ -12,6 +12,7 @@ import { useEffect, useRef } from 'react';
import { resolveGridRow } from './utils/resolve_grid_row';
import { GridPanelData, GridLayoutStateManager } from './types';
import { isGridDataEqual } from './utils/equality_checks';
import { isMouseEvent, isTouchEvent } from './utils/sensors';
const MIN_SPEED = 50;
const MAX_SPEED = 150;
@ -57,7 +58,7 @@ export const useGridLayoutEvents = ({
}: {
gridLayoutStateManager: GridLayoutStateManager;
}) => {
const mouseClientPosition = useRef({ x: 0, y: 0 });
const pointerClientPosition = useRef({ x: 0, y: 0 });
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
const scrollInterval = useRef<NodeJS.Timeout | null>(null);
@ -73,18 +74,22 @@ export const useGridLayoutEvents = ({
scrollInterval.current = null;
}
};
const calculateUserEvent = (e: Event) => {
if (!interactionEvent$.value) {
const interactionEvent = interactionEvent$.value;
if (!interactionEvent) {
// if no interaction event, stop auto scroll (if necessary) and return early
stopAutoScrollIfNecessary();
return;
}
e.stopPropagation();
// make sure when the user is dragging through touchmove, the page doesn't scroll
if (isTouchEvent(e)) {
e.preventDefault();
}
const gridRowElements = gridLayoutStateManager.rowRefs.current;
const interactionEvent = interactionEvent$.value;
const isResize = interactionEvent?.type === 'resize';
const currentLayout = gridLayout$.value;
@ -99,16 +104,22 @@ export const useGridLayoutEvents = ({
return;
}
const mouseTargetPixel = {
x: mouseClientPosition.current.x,
y: mouseClientPosition.current.y,
const pointerClientPixel = {
x: pointerClientPosition.current.x,
y: pointerClientPosition.current.y,
};
const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings$.value;
const gridWidth = (gutterSize + columnPixelWidth) * columnCount + gutterSize * 2;
const previewRect = {
left: isResize ? panelRect.left : mouseTargetPixel.x - interactionEvent.mouseOffsets.left,
top: isResize ? panelRect.top : mouseTargetPixel.y - interactionEvent.mouseOffsets.top,
bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom,
right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right,
left: isResize
? panelRect.left
: pointerClientPixel.x - interactionEvent.pointerOffsets.left,
top: isResize ? panelRect.top : pointerClientPixel.y - interactionEvent.pointerOffsets.top,
bottom: pointerClientPixel.y - interactionEvent.pointerOffsets.bottom,
right: Math.min(pointerClientPixel.x - interactionEvent.pointerOffsets.right, gridWidth),
};
gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect });
@ -119,6 +130,10 @@ export const useGridLayoutEvents = ({
const lastRowIndex = interactionEvent?.targetRowIndex;
const targetRowIndex = (() => {
if (isResize) return lastRowIndex;
// TODO: a temporary workaround for the issue where the panel moves to a different row when the user uses touch events.
// Touch events don't work properly when the DOM element is removed and replaced (which is how we handle moving to another row) so we blocked the ability to move panels to another row.
// Reference: https://stackoverflow.com/questions/33298828/touch-move-event-dont-fire-after-touch-start-target-is-removed
if (isTouchEvent(e)) return lastRowIndex;
let highestOverlap = -Infinity;
let highestOverlapRowIndex = -1;
@ -145,7 +160,6 @@ export const useGridLayoutEvents = ({
}
// calculate the requested grid position
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings$.value;
const targetedGridRow = gridRowElements[targetRowIndex];
const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0;
const targetedGridTop = targetedGridRow?.getBoundingClientRect().top ?? 0;
@ -176,19 +190,21 @@ export const useGridLayoutEvents = ({
// auto scroll when an event is happening close to the top or bottom of the screen
const heightPercentage =
100 - ((window.innerHeight - mouseTargetPixel.y) / window.innerHeight) * 100;
100 - ((window.innerHeight - pointerClientPixel.y) / window.innerHeight) * 100;
const atTheTop = window.scrollY <= 0;
const atTheBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight;
const startScrollingUp = !isResize && heightPercentage < 5 && !atTheTop; // don't scroll up when resizing
const startScrollingDown = heightPercentage > 95 && !atTheBottom;
if (startScrollingUp || startScrollingDown) {
if (!scrollInterval.current) {
// only start scrolling if it's not already happening
scrollInterval.current = scrollOnInterval(startScrollingUp ? 'up' : 'down');
if (!isTouchEvent(e)) {
const startScrollingUp = !isResize && heightPercentage < 5 && !atTheTop; // don't scroll up when resizing
const startScrollingDown = heightPercentage > 95 && !atTheBottom;
if (startScrollingUp || startScrollingDown) {
if (!scrollInterval.current) {
// only start scrolling if it's not already happening
scrollInterval.current = scrollOnInterval(startScrollingUp ? 'up' : 'down');
}
} else {
stopAutoScrollIfNecessary();
}
} else {
stopAutoScrollIfNecessary();
}
// resolve the new grid layout
@ -221,20 +237,32 @@ export const useGridLayoutEvents = ({
}
};
const onMouseMove = (e: MouseEvent) => {
const onPointerMove = (e: Event) => {
// Note: When an item is being interacted with, `mousemove` events continue to be fired, even when the
// mouse moves out of the window (i.e. when a panel is being dragged around outside the window).
mouseClientPosition.current = { x: e.clientX, y: e.clientY };
pointerClientPosition.current = getPointerClientPosition(e);
calculateUserEvent(e);
};
document.addEventListener('mousemove', onMouseMove, { passive: true });
document.addEventListener('mousemove', onPointerMove, { passive: true });
document.addEventListener('scroll', calculateUserEvent, { passive: true });
document.addEventListener('touchmove', onPointerMove, { passive: false });
return () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mousemove', onPointerMove);
document.removeEventListener('scroll', calculateUserEvent);
document.removeEventListener('touchmove', onPointerMove);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};
function getPointerClientPosition(e: Event) {
if (isTouchEvent(e)) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
if (isMouseEvent(e)) {
return { x: e.clientX, y: e.clientY };
}
throw new Error('Unknown event type');
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { UserMouseEvent, UserTouchEvent } from '../types';
export const isTouchEvent = (e: Event | React.UIEvent<HTMLElement>): e is UserTouchEvent => {
return 'touches' in e;
};
export const isMouseEvent = (e: Event | React.UIEvent<HTMLElement>): e is UserMouseEvent => {
return 'clientX' in e;
};