Skip to content

Commit

Permalink
[8.x] [Dashboard][Collapsable Panels] Respond to touch events (#204225)…
Browse files Browse the repository at this point in the history
… (#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]>
  • Loading branch information
kibanamachine and mbondyra authored Jan 9, 2025
1 parent 1e6f754 commit c678d61
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 @@ -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<HTMLElement | null>) => void;
Expand All @@ -25,7 +32,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 @@ -36,13 +43,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 @@ -51,24 +65,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 @@ -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}
>
<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 { 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';
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 { 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<HTMLButtonElement, MouseEvent>
) => void;
interactionStart: (type: PanelInteractionEvent['type'] | 'drop', e: UserInteractionEvent) => void;
}) => {
return (
<button
Expand All @@ -31,6 +28,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 @@ -17,9 +17,15 @@ import { euiThemeVars } from '@kbn/ui-theme';
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 @@ -213,7 +219,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 @@ -225,17 +230,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 @@ -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');
}
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 c678d61

Please sign in to comment.