Skip to content

Commit c678d61

Browse files
[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)](#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":"[email protected]"},"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 <[email protected]>
1 parent 1e6f754 commit c678d61

File tree

8 files changed

+220
-70
lines changed

8 files changed

+220
-70
lines changed

packages/kbn-grid-layout/grid/grid_layout.test.tsx

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import { GridLayout, GridLayoutProps } from './grid_layout';
1414
import { gridSettings, mockRenderPanelContents } from './test_utils/mocks';
1515
import { cloneDeep } from 'lodash';
1616

17+
class TouchEventFake extends Event {
18+
constructor(public touches: Array<{ clientX: number; clientY: number }>) {
19+
super('touchmove');
20+
this.touches = [{ clientX: 256, clientY: 128 }];
21+
}
22+
}
23+
1724
describe('GridLayout', () => {
1825
const renderGridLayout = (propsOverrides: Partial<GridLayoutProps> = {}) => {
1926
const defaultProps: GridLayoutProps = {
@@ -38,17 +45,30 @@ describe('GridLayout', () => {
3845
.getAllByRole('button', { name: /panelId:panel/i })
3946
.map((el) => el.getAttribute('aria-label')?.replace(/panelId:/g, ''));
4047

41-
const startDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => {
48+
const mouseStartDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => {
4249
fireEvent.mouseDown(handle, options);
4350
};
4451

45-
const moveTo = (options = { clientX: 256, clientY: 128 }) => {
52+
const mouseMoveTo = (options = { clientX: 256, clientY: 128 }) => {
4653
fireEvent.mouseMove(document, options);
4754
};
4855

49-
const drop = (handle: HTMLElement) => {
56+
const mouseDrop = (handle: HTMLElement) => {
5057
fireEvent.mouseUp(handle);
5158
};
59+
const touchStart = (handle: HTMLElement, options = { touches: [{ clientX: 0, clientY: 0 }] }) => {
60+
fireEvent.touchStart(handle, options);
61+
};
62+
const touchMoveTo = (options = { touches: [{ clientX: 256, clientY: 128 }] }) => {
63+
const realTouchEvent = window.TouchEvent;
64+
// @ts-expect-error
65+
window.TouchEvent = TouchEventFake;
66+
fireEvent.touchMove(document, new TouchEventFake(options.touches));
67+
window.TouchEvent = realTouchEvent;
68+
};
69+
const touchEnd = (handle: HTMLElement) => {
70+
fireEvent.touchEnd(handle);
71+
};
5272

5373
const assertTabThroughPanel = async (panelId: string) => {
5474
await userEvent.tab(); // tab to drag handle
@@ -81,11 +101,11 @@ describe('GridLayout', () => {
81101
jest.clearAllMocks();
82102

83103
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
84-
startDragging(panel1DragHandle);
85-
moveTo({ clientX: 256, clientY: 128 });
104+
mouseStartDragging(panel1DragHandle);
105+
mouseMoveTo({ clientX: 256, clientY: 128 });
86106
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called during dragging
87107

88-
drop(panel1DragHandle);
108+
mouseDrop(panel1DragHandle);
89109
expect(mockRenderPanelContents).toHaveBeenCalledTimes(0); // renderPanelContents should not be called after reordering
90110
});
91111

@@ -107,12 +127,34 @@ describe('GridLayout', () => {
107127
renderGridLayout();
108128

109129
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
110-
startDragging(panel1DragHandle);
130+
mouseStartDragging(panel1DragHandle);
131+
132+
mouseMoveTo({ clientX: 256, clientY: 128 });
133+
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we mouseDrop
111134

112-
moveTo({ clientX: 256, clientY: 128 });
113-
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we drop
135+
mouseDrop(panel1DragHandle);
136+
expect(getAllThePanelIds()).toEqual([
137+
'panel2',
138+
'panel5',
139+
'panel3',
140+
'panel7',
141+
'panel1',
142+
'panel8',
143+
'panel6',
144+
'panel4',
145+
'panel9',
146+
'panel10',
147+
]);
148+
});
149+
it('after reordering some panels via touch events', async () => {
150+
renderGridLayout();
151+
152+
const panel1DragHandle = screen.getAllByRole('button', { name: /drag to move/i })[0];
153+
touchStart(panel1DragHandle);
154+
touchMoveTo({ touches: [{ clientX: 256, clientY: 128 }] });
155+
expect(getAllThePanelIds()).toEqual(expectedInitialOrder); // the panels shouldn't be reordered till we mouseDrop
114156

115-
drop(panel1DragHandle);
157+
touchEnd(panel1DragHandle);
116158
expect(getAllThePanelIds()).toEqual([
117159
'panel2',
118160
'panel5',

packages/kbn-grid-layout/grid/grid_panel/drag_handle.tsx

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ import { EuiIcon, useEuiTheme } from '@elastic/eui';
1313
import { css } from '@emotion/react';
1414
import { euiThemeVars } from '@kbn/ui-theme';
1515
import { i18n } from '@kbn/i18n';
16-
import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
16+
import {
17+
GridLayoutStateManager,
18+
PanelInteractionEvent,
19+
UserInteractionEvent,
20+
UserMouseEvent,
21+
UserTouchEvent,
22+
} from '../types';
23+
import { isMouseEvent, isTouchEvent } from '../utils/sensors';
1724

1825
export interface DragHandleApi {
1926
setDragHandles: (refs: Array<HTMLElement | null>) => void;
@@ -25,7 +32,7 @@ export const DragHandle = React.forwardRef<
2532
gridLayoutStateManager: GridLayoutStateManager;
2633
interactionStart: (
2734
type: PanelInteractionEvent['type'] | 'drop',
28-
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
35+
e: UserInteractionEvent
2936
) => void;
3037
}
3138
>(({ gridLayoutStateManager, interactionStart }, ref) => {
@@ -36,13 +43,20 @@ export const DragHandle = React.forwardRef<
3643
const dragHandleRefs = useRef<Array<HTMLElement | null>>([]);
3744

3845
/**
39-
* We need to memoize the `onMouseDown` callback so that we don't assign a new `onMouseDown` event handler
46+
* We need to memoize the `onDragStart` and `onDragEnd` callbacks so that we don't assign a new event handler
4047
* every time `setDragHandles` is called
4148
*/
42-
const onMouseDown = useCallback(
43-
(e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
44-
if (gridLayoutStateManager.accessMode$.getValue() !== 'EDIT' || e.button !== 0) {
45-
// ignore anything but left clicks, and ignore clicks when not in edit mode
49+
const onDragStart = useCallback(
50+
(e: UserMouseEvent | UserTouchEvent) => {
51+
// ignore when not in edit mode
52+
if (gridLayoutStateManager.accessMode$.getValue() !== 'EDIT') return;
53+
54+
// ignore anything but left clicks for mouse events
55+
if (isMouseEvent(e) && e.button !== 0) {
56+
return;
57+
}
58+
// ignore multi-touch events for touch events
59+
if (isTouchEvent(e) && e.touches.length > 1) {
4660
return;
4761
}
4862
e.stopPropagation();
@@ -51,24 +65,36 @@ export const DragHandle = React.forwardRef<
5165
[interactionStart, gridLayoutStateManager.accessMode$]
5266
);
5367

68+
const onDragEnd = useCallback(
69+
(e: UserTouchEvent | UserMouseEvent) => {
70+
e.stopPropagation();
71+
interactionStart('drop', e);
72+
},
73+
[interactionStart]
74+
);
75+
5476
const setDragHandles = useCallback(
5577
(dragHandles: Array<HTMLElement | null>) => {
5678
setDragHandleCount(dragHandles.length);
5779
dragHandleRefs.current = dragHandles;
5880

5981
for (const handle of dragHandles) {
6082
if (handle === null) return;
61-
handle.addEventListener('mousedown', onMouseDown, { passive: true });
83+
handle.addEventListener('mousedown', onDragStart, { passive: true });
84+
handle.addEventListener('touchstart', onDragStart, { passive: false });
85+
handle.addEventListener('touchend', onDragEnd, { passive: true });
6286
}
6387

6488
removeEventListenersRef.current = () => {
6589
for (const handle of dragHandles) {
6690
if (handle === null) return;
67-
handle.removeEventListener('mousedown', onMouseDown);
91+
handle.removeEventListener('mousedown', onDragStart);
92+
handle.removeEventListener('touchstart', onDragStart);
93+
handle.removeEventListener('touchend', onDragEnd);
6894
}
6995
};
7096
},
71-
[onMouseDown]
97+
[onDragStart, onDragEnd]
7298
);
7399

74100
useEffect(() => {
@@ -125,12 +151,10 @@ export const DragHandle = React.forwardRef<
125151
display: none;
126152
}
127153
`}
128-
onMouseDown={(e) => {
129-
interactionStart('drag', e);
130-
}}
131-
onMouseUp={(e) => {
132-
interactionStart('drop', e);
133-
}}
154+
onMouseDown={onDragStart}
155+
onMouseUp={onDragEnd}
156+
onTouchStart={onDragStart}
157+
onTouchEnd={onDragEnd}
134158
>
135159
<EuiIcon type="grabOmnidirectional" />
136160
</button>

packages/kbn-grid-layout/grid/grid_panel/grid_panel.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { combineLatest, skip } from 'rxjs';
1313
import { css } from '@emotion/react';
1414
import { euiThemeVars } from '@kbn/ui-theme';
1515

16-
import { GridLayoutStateManager, PanelInteractionEvent } from '../types';
16+
import { GridLayoutStateManager, UserInteractionEvent, PanelInteractionEvent } from '../types';
1717
import { getKeysInOrder } from '../utils/resolve_grid_row';
1818
import { DragHandle, DragHandleApi } from './drag_handle';
1919
import { ResizeHandle } from './resize_handle';
@@ -25,10 +25,7 @@ export interface GridPanelProps {
2525
panelId: string,
2626
setDragHandles?: (refs: Array<HTMLElement | null>) => void
2727
) => React.ReactNode;
28-
interactionStart: (
29-
type: PanelInteractionEvent['type'] | 'drop',
30-
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
31-
) => void;
28+
interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
3229
gridLayoutStateManager: GridLayoutStateManager;
3330
}
3431

packages/kbn-grid-layout/grid/grid_panel/resize_handle.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,12 @@ import { css } from '@emotion/react';
1212
import { i18n } from '@kbn/i18n';
1313
import { euiThemeVars } from '@kbn/ui-theme';
1414
import React from 'react';
15-
import { PanelInteractionEvent } from '../types';
15+
import { UserInteractionEvent, PanelInteractionEvent } from '../types';
1616

1717
export const ResizeHandle = ({
1818
interactionStart,
1919
}: {
20-
interactionStart: (
21-
type: PanelInteractionEvent['type'] | 'drop',
22-
e: MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
23-
) => void;
20+
interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
2421
}) => {
2522
return (
2623
<button
@@ -31,6 +28,12 @@ export const ResizeHandle = ({
3128
onMouseUp={(e) => {
3229
interactionStart('drop', e);
3330
}}
31+
onTouchStart={(e) => {
32+
interactionStart('resize', e);
33+
}}
34+
onTouchEnd={(e) => {
35+
interactionStart('drop', e);
36+
}}
3437
aria-label={i18n.translate('kbnGridLayout.resizeHandle.ariaLabel', {
3538
defaultMessage: 'Resize panel',
3639
})}

packages/kbn-grid-layout/grid/grid_row/grid_row.tsx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ import { euiThemeVars } from '@kbn/ui-theme';
1717
import { cloneDeep } from 'lodash';
1818
import { DragPreview } from '../drag_preview';
1919
import { GridPanel } from '../grid_panel';
20-
import { GridLayoutStateManager, GridRowData, PanelInteractionEvent } from '../types';
20+
import {
21+
GridLayoutStateManager,
22+
GridRowData,
23+
UserInteractionEvent,
24+
PanelInteractionEvent,
25+
} from '../types';
2126
import { getKeysInOrder } from '../utils/resolve_grid_row';
2227
import { GridRowHeader } from './grid_row_header';
28+
import { isTouchEvent, isMouseEvent } from '../utils/sensors';
2329

2430
export interface GridRowProps {
2531
rowIndex: number;
@@ -213,7 +219,6 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
213219
const panelRef = gridLayoutStateManager.panelRefs.current[rowIndex][panelId];
214220
if (!panelRef) return;
215221

216-
const panelRect = panelRef.getBoundingClientRect();
217222
if (type === 'drop') {
218223
setInteractionEvent(undefined);
219224
/**
@@ -225,17 +230,15 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
225230
getKeysInOrder(gridLayoutStateManager.gridLayout$.getValue()[rowIndex].panels)
226231
);
227232
} else {
233+
const panelRect = panelRef.getBoundingClientRect();
234+
const pointerOffsets = getPointerOffsets(e, panelRect);
235+
228236
setInteractionEvent({
229237
type,
230238
id: panelId,
231239
panelDiv: panelRef,
232240
targetRowIndex: rowIndex,
233-
mouseOffsets: {
234-
top: e.clientY - panelRect.top,
235-
left: e.clientX - panelRect.left,
236-
right: e.clientX - panelRect.right,
237-
bottom: e.clientY - panelRect.bottom,
238-
},
241+
pointerOffsets,
239242
});
240243
}
241244
}}
@@ -284,3 +287,32 @@ export const GridRow = forwardRef<HTMLDivElement, GridRowProps>(
284287
);
285288
}
286289
);
290+
291+
const defaultPointerOffsets = {
292+
top: 0,
293+
left: 0,
294+
right: 0,
295+
bottom: 0,
296+
};
297+
298+
function getPointerOffsets(e: UserInteractionEvent, panelRect: DOMRect) {
299+
if (isTouchEvent(e)) {
300+
if (e.touches.length > 1) return defaultPointerOffsets;
301+
const touch = e.touches[0];
302+
return {
303+
top: touch.clientY - panelRect.top,
304+
left: touch.clientX - panelRect.left,
305+
right: touch.clientX - panelRect.right,
306+
bottom: touch.clientY - panelRect.bottom,
307+
};
308+
}
309+
if (isMouseEvent(e)) {
310+
return {
311+
top: e.clientY - panelRect.top,
312+
left: e.clientX - panelRect.left,
313+
right: e.clientX - panelRect.right,
314+
bottom: e.clientY - panelRect.bottom,
315+
};
316+
}
317+
throw new Error('Invalid event type');
318+
}

packages/kbn-grid-layout/grid/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export interface PanelInteractionEvent {
9999
* The pixel offsets from where the mouse was at drag start to the
100100
* edges of the panel
101101
*/
102-
mouseOffsets: {
102+
pointerOffsets: {
103103
top: number;
104104
left: number;
105105
right: number;
@@ -122,3 +122,9 @@ export interface PanelPlacementSettings {
122122
}
123123

124124
export type GridAccessMode = 'VIEW' | 'EDIT';
125+
126+
export type UserMouseEvent = MouseEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>;
127+
128+
export type UserTouchEvent = TouchEvent | React.TouchEvent<HTMLButtonElement>;
129+
130+
export type UserInteractionEvent = React.UIEvent<HTMLElement> | Event;

0 commit comments

Comments
 (0)