[8.x] [Dashboard][Collapsable Panels] Respond to touch events (#204225) (#205979)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Dashboard][Collapsable Panels] Respond to touch events
(#204225)](https://github.com/elastic/kibana/pull/204225)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Marta
Bondyra","email":"4283304+mbondyra@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-01-08T23:59:46Z","message":"[Dashboard][Collapsable
Panels] Respond to touch events (#204225)\n\n## Summary\r\n\r\nAdds
support to touch events. The difference between these ones and\r\nmouse
events is that once they are active, the scroll is off (just like\r\nin
the current
Dashboard)\r\n\r\n\r\nhttps://github.com/user-attachments/assets/4cdcc850-7391-441e-ab9a-0abbe70e4e56\r\n\r\nFixes
https://github.com/elastic/kibana/issues/202014","sha":"ea6d7bef93154a298a8937c814a65a0d0de6185b","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Presentation","release_note:skip","v9.0.0","backport:prev-minor","Project:Collapsable
Panels"],"title":"[Dashboard][Collapsable Panels] Respond to touch
events","number":204225,"url":"https://github.com/elastic/kibana/pull/204225","mergeCommit":{"message":"[Dashboard][Collapsable
Panels] Respond to touch events (#204225)\n\n## Summary\r\n\r\nAdds
support to touch events. The difference between these ones and\r\nmouse
events is that once they are active, the scroll is off (just like\r\nin
the current
Dashboard)\r\n\r\n\r\nhttps://github.com/user-attachments/assets/4cdcc850-7391-441e-ab9a-0abbe70e4e56\r\n\r\nFixes
https://github.com/elastic/kibana/issues/202014","sha":"ea6d7bef93154a298a8937c814a65a0d0de6185b"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/204225","number":204225,"mergeCommit":{"message":"[Dashboard][Collapsable
Panels] Respond to touch events (#204225)\n\n## Summary\r\n\r\nAdds
support to touch events. The difference between these ones and\r\nmouse
events is that once they are active, the scroll is off (just like\r\nin
the current
Dashboard)\r\n\r\n\r\nhttps://github.com/user-attachments/assets/4cdcc850-7391-441e-ab9a-0abbe70e4e56\r\n\r\nFixes
https://github.com/elastic/kibana/issues/202014","sha":"ea6d7bef93154a298a8937c814a65a0d0de6185b"}}]}]
BACKPORT-->

Co-authored-by: Marta Bondyra <4283304+mbondyra@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-01-09 18:59:00 +11:00 committed by GitHub
parent 1e6f754b35
commit c678d6139b
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 { gridSettings, mockRenderPanelContents } from './test_utils/mocks';
import { cloneDeep } from 'lodash'; 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', () => { describe('GridLayout', () => {
const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => { const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
const defaultProps: GridLayoutProps = { const defaultProps: GridLayoutProps = {
@ -38,17 +45,30 @@ describe('GridLayout', () => {
.getAllByRole('button', { name: /panelId:panel/i }) .getAllByRole('button', { name: /panelId:panel/i })
.map((el) => el.getAttribute('aria-label')?.replace(/panelId:/g, '')); .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); fireEvent.mouseDown(handle, options);
}; };
const moveTo = (options = { clientX: 256, clientY: 128 }) => { const mouseMoveTo = (options = { clientX: 256, clientY: 128 }) => {
fireEvent.mouseMove(document, options); fireEvent.mouseMove(document, options);
}; };
const drop = (handle: HTMLElement) => { const mouseDrop = (handle: HTMLElement) => {
fireEvent.mouseUp(handle); 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) => { const assertTabThroughPanel = async (panelId: string) => {
await userEvent.tab(); // tab to drag handle await userEvent.tab(); // tab to drag handle
@ -81,11 +101,11 @@ describe('GridLayout', () => {
jest.clearAllMocks(); jest.clearAllMocks();
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
startDragging(panel1DragHandle); mouseStartDragging(panel1DragHandle);
moveTo({ clientX: 256, clientY: 128 }); mouseMoveTo({ clientX: 256, clientY: 128 });
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called during dragging 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 expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called after reordering
}); });
@ -107,12 +127,34 @@ describe('GridLayout', () => {
renderGridLayout(); renderGridLayout();
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0]; const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
startDragging(panel1DragHandle); mouseStartDragging(panel1DragHandle);
moveTo({ clientX: 256, clientY: 128 }); mouseMoveTo({ clientX: 256, clientY: 128 });
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop 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([ expect(getAllThePanelIds()).toEqual([
'panel2', 'panel2',
'panel5', 'panel5',

View file

@ -13,7 +13,14 @@ import { EuiIcon, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme'; import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n'; 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 { export interface DragHandleApi {
setDragHandles: (refs: Array<HTMLElement | null>) => void; setDragHandles: (refs: Array<HTMLElement | null>) => void;
@ -25,7 +32,7 @@ export const DragHandle = React.forwardRef<
gridLayoutStateManager: GridLayoutStateManager; gridLayoutStateManager: GridLayoutStateManager;
interactionStart: ( interactionStart: (
type: PanelInteractionEvent['type'] | 'drop', type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent> e: UserInteractionEvent
) => void; ) => void;
} }
>(({ gridLayoutStateManager, interactionStart }, ref) => { >(({ gridLayoutStateManager, interactionStart }, ref) => {
@ -36,13 +43,20 @@ export const DragHandle = React.forwardRef<
const dragHandleRefs = useRef<Array<HTMLElement | null>>([]); 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 * every time `setDragHandles` is called
*/ */
const onMouseDown = useCallback( const onDragStart = useCallback(
(e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>) => { (e: UserMouseEvent | UserTouchEvent) => {
if (gridLayoutStateManager.accessMode$.getValue() !== 'EDIT' || e.button !== 0) { // ignore when not in edit mode
// ignore anything but left clicks, and ignore clicks 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; return;
} }
e.stopPropagation(); e.stopPropagation();
@ -51,6 +65,14 @@ export const DragHandle = React.forwardRef<
[interactionStart, gridLayoutStateManager.accessMode$] [interactionStart, gridLayoutStateManager.accessMode$]
); );
const onDragEnd = useCallback(
(e: UserTouchEvent | UserMouseEvent) => {
e.stopPropagation();
interactionStart('drop', e);
},
[interactionStart]
);
const setDragHandles = useCallback( const setDragHandles = useCallback(
(dragHandles: Array<HTMLElement | null>) => { (dragHandles: Array<HTMLElement | null>) => {
setDragHandleCount(dragHandles.length); setDragHandleCount(dragHandles.length);
@ -58,17 +80,21 @@ export const DragHandle = React.forwardRef<
for (const handle of dragHandles) { for (const handle of dragHandles) {
if (handle === null) return; 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 = () => { removeEventListenersRef.current = () => {
for (const handle of dragHandles) { for (const handle of dragHandles) {
if (handle === null) return; if (handle === null) return;
handle.removeEventListener('mousedown', onMouseDown); handle.removeEventListener('mousedown', onDragStart);
handle.removeEventListener('touchstart', onDragStart);
handle.removeEventListener('touchend', onDragEnd);
} }
}; };
}, },
[onMouseDown] [onDragStart, onDragEnd]
); );
useEffect(() => { useEffect(() => {
@ -125,12 +151,10 @@ export const DragHandle = React.forwardRef<
display: none; display: none;
} }
`} `}
onMouseDown={(e) => { onMouseDown={onDragStart}
interactionStart('drag', e); onMouseUp={onDragEnd}
}} onTouchStart={onDragStart}
onMouseUp={(e) => { onTouchEnd={onDragEnd}
interactionStart('drop', e);
}}
> >
<EuiIcon type="grabOmnidirectional" /> <EuiIcon type="grabOmnidirectional" />
</button> </button>

View file

@ -13,7 +13,7 @@ import { combineLatest, skip } from 'rxjs';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme'; 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 { getKeysInOrder } from '../utils/resolve_grid_row';
import { DragHandle, DragHandleApi } from './drag_handle'; import { DragHandle, DragHandleApi } from './drag_handle';
import { ResizeHandle } from './resize_handle'; import { ResizeHandle } from './resize_handle';
@ -25,10 +25,7 @@ export interface GridPanelProps {
panelId: string, panelId: string,
setDragHandles?: (refs: Array<HTMLElement | null>) => void setDragHandles?: (refs: Array<HTMLElement | null>) => void
) => React.ReactNode; ) => React.ReactNode;
interactionStart: ( interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
gridLayoutStateManager: GridLayoutStateManager; gridLayoutStateManager: GridLayoutStateManager;
} }

View file

@ -12,15 +12,12 @@ import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme'; import { euiThemeVars } from '@kbn/ui-theme';
import React from 'react'; import React from 'react';
import { PanelInteractionEvent } from '../types'; import { UserInteractionEvent, PanelInteractionEvent } from '../types';
export const ResizeHandle = ({ export const ResizeHandle = ({
interactionStart, interactionStart,
}: { }: {
interactionStart: ( interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
type: PanelInteractionEvent['type'] | 'drop',
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
) => void;
}) => { }) => {
return ( return (
<button <button
@ -31,6 +28,12 @@ export const ResizeHandle = ({
onMouseUp={(e) => { onMouseUp={(e) => {
interactionStart('drop', e); interactionStart('drop', e);
}} }}
onTouchStart={(e) => {
interactionStart('resize', e);
}}
onTouchEnd={(e) => {
interactionStart('drop', e);
}}
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', { aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
defaultMessage: 'Resize panel', defaultMessage: 'Resize panel',
})} })}

View file

@ -17,9 +17,15 @@ import { euiThemeVars } from '@kbn/ui-theme';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { DragPreview } from '../drag_preview'; import { DragPreview } from '../drag_preview';
import { GridPanel } from '../grid_panel'; 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 { getKeysInOrder } from '../utils/resolve_grid_row';
import { GridRowHeader } from './grid_row_header'; import { GridRowHeader } from './grid_row_header';
import { isTouchEvent, isMouseEvent } from '../utils/sensors';
export interface GridRowProps { export interface GridRowProps {
rowIndex: number; rowIndex: number;
@ -213,7 +219,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId]; const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
if (!panelRef) return; if (!panelRef) return;
const panelRect = panelRef.getBoundingClientRect();
if (type === 'drop') { if (type === 'drop') {
setInteractionEvent(undefined); setInteractionEvent(undefined);
/** /**
@ -225,17 +230,15 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels) getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels)
); );
} else { } else {
const panelRect = panelRef.getBoundingClientRect();
const pointerOffsets = getPointerOffsets(e, panelRect);
setInteractionEvent({ setInteractionEvent({
type, type,
id: panelId, id: panelId,
panelDiv: panelRef, panelDiv: panelRef,
targetRowIndex: rowIndex, targetRowIndex: rowIndex,
mouseOffsets: { pointerOffsets,
top: e.clientY - panelRect.top,
left: e.clientX - panelRect.left,
right: e.clientX - panelRect.right,
bottom: e.clientY - panelRect.bottom,
},
}); });
} }
}} }}
@ -284,3 +287,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 * The pixel offsets from where the mouse was at drag start to the
* edges of the panel * edges of the panel
*/ */
mouseOffsets: { pointerOffsets: {
top: number; top: number;
left: number; left: number;
right: number; right: number;
@ -122,3 +122,9 @@ export interface PanelPlacementSettings {
} }
export type GridAccessMode = 'VIEW' | 'EDIT'; 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 { resolveGridRow } from './utils/resolve_grid_row';
import { GridPanelData, GridLayoutStateManager } from './types'; import { GridPanelData, GridLayoutStateManager } from './types';
import { isGridDataEqual } from './utils/equality_checks'; import { isGridDataEqual } from './utils/equality_checks';
import { isMouseEvent, isTouchEvent } from './utils/sensors';
const MIN_SPEED = 50; const MIN_SPEED = 50;
const MAX_SPEED = 150; const MAX_SPEED = 150;
@ -57,7 +58,7 @@ export const useGridLayoutEvents = ({
}: { }: {
gridLayoutStateManager: GridLayoutStateManager; gridLayoutStateManager: GridLayoutStateManager;
}) => { }) => {
const mouseClientPosition = useRef({ x: 0, y: 0 }); const pointerClientPosition = useRef({ x: 0, y: 0 });
const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined); const lastRequestedPanelPosition = useRef<GridPanelData | undefined>(undefined);
const scrollInterval = useRef<NodeJS.Timeout | null>(null); const scrollInterval = useRef<NodeJS.Timeout | null>(null);
@ -73,18 +74,22 @@ export const useGridLayoutEvents = ({
scrollInterval.current = null; scrollInterval.current = null;
} }
}; };
const calculateUserEvent = (e: Event) => { 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 // if no interaction event, stop auto scroll (if necessary) and return early
stopAutoScrollIfNecessary(); stopAutoScrollIfNecessary();
return; return;
} }
e.stopPropagation(); 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 gridRowElements = gridLayoutStateManager.rowRefs.current;
const interactionEvent = interactionEvent$.value;
const isResize = interactionEvent?.type === 'resize'; const isResize = interactionEvent?.type === 'resize';
const currentLayout = gridLayout$.value; const currentLayout = gridLayout$.value;
@ -99,16 +104,22 @@ export const useGridLayoutEvents = ({
return; return;
} }
const mouseTargetPixel = { const pointerClientPixel = {
x: mouseClientPosition.current.x, x: pointerClientPosition.current.x,
y: mouseClientPosition.current.y, y: pointerClientPosition.current.y,
}; };
const panelRect = interactionEvent.panelDiv.getBoundingClientRect(); const panelRect = interactionEvent.panelDiv.getBoundingClientRect();
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings$.value;
const gridWidth = (gutterSize + columnPixelWidth) * columnCount + gutterSize * 2;
const previewRect = { const previewRect = {
left: isResize ? panelRect.left : mouseTargetPixel.x - interactionEvent.mouseOffsets.left, left: isResize
top: isResize ? panelRect.top : mouseTargetPixel.y - interactionEvent.mouseOffsets.top, ? panelRect.left
bottom: mouseTargetPixel.y - interactionEvent.mouseOffsets.bottom, : pointerClientPixel.x - interactionEvent.pointerOffsets.left,
right: mouseTargetPixel.x - interactionEvent.mouseOffsets.right, 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 }); gridLayoutStateManager.activePanel$.next({ id: interactionEvent.id, position: previewRect });
@ -119,6 +130,10 @@ export const useGridLayoutEvents = ({
const lastRowIndex = interactionEvent?.targetRowIndex; const lastRowIndex = interactionEvent?.targetRowIndex;
const targetRowIndex = (() => { const targetRowIndex = (() => {
if (isResize) return lastRowIndex; 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 highestOverlap = -Infinity;
let highestOverlapRowIndex = -1; let highestOverlapRowIndex = -1;
@ -145,7 +160,6 @@ export const useGridLayoutEvents = ({
} }
// calculate the requested grid position // calculate the requested grid position
const { columnCount, gutterSize, rowHeight, columnPixelWidth } = runtimeSettings$.value;
const targetedGridRow = gridRowElements[targetRowIndex]; const targetedGridRow = gridRowElements[targetRowIndex];
const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0; const targetedGridLeft = targetedGridRow?.getBoundingClientRect().left ?? 0;
const targetedGridTop = targetedGridRow?.getBoundingClientRect().top ?? 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 // auto scroll when an event is happening close to the top or bottom of the screen
const heightPercentage = const heightPercentage =
100 - ((window.innerHeight - mouseTargetPixel.y) / window.innerHeight) * 100; 100 - ((window.innerHeight - pointerClientPixel.y) / window.innerHeight) * 100;
const atTheTop = window.scrollY <= 0; const atTheTop = window.scrollY <= 0;
const atTheBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight; const atTheBottom = window.innerHeight + window.scrollY >= document.body.scrollHeight;
const startScrollingUp = !isResize && heightPercentage < 5 && !atTheTop; // don't scroll up when resizing if (!isTouchEvent(e)) {
const startScrollingDown = heightPercentage > 95 && !atTheBottom; const startScrollingUp = !isResize && heightPercentage < 5 && !atTheTop; // don't scroll up when resizing
if (startScrollingUp || startScrollingDown) { const startScrollingDown = heightPercentage > 95 && !atTheBottom;
if (!scrollInterval.current) { if (startScrollingUp || startScrollingDown) {
// only start scrolling if it's not already happening if (!scrollInterval.current) {
scrollInterval.current = scrollOnInterval(startScrollingUp ? 'up' : 'down'); // only start scrolling if it's not already happening
scrollInterval.current = scrollOnInterval(startScrollingUp ? 'up' : 'down');
}
} else {
stopAutoScrollIfNecessary();
} }
} else {
stopAutoScrollIfNecessary();
} }
// resolve the new grid layout // 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 // 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). // 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); calculateUserEvent(e);
}; };
document.addEventListener('mousemove', onMouseMove, { passive: true }); document.addEventListener('mousemove', onPointerMove, { passive: true });
document.addEventListener('scroll', calculateUserEvent, { passive: true }); document.addEventListener('scroll', calculateUserEvent, { passive: true });
document.addEventListener('touchmove', onPointerMove, { passive: false });
return () => { return () => {
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onPointerMove);
document.removeEventListener('scroll', calculateUserEvent); document.removeEventListener('scroll', calculateUserEvent);
document.removeEventListener('touchmove', onPointerMove);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // 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;
};