From 154014e603d0bafdcbb937000db18055804fe1b1 Mon Sep 17 00:00:00 2001
From: Cee Chen <549407+cee-chen@users.noreply.github.com>
Date: Thu, 11 Jan 2024 14:17:08 -0800
Subject: [PATCH] [EuiResizableContainer] Mouse drags outside of the container
no longer lose dragging state (#7456)
---
changelogs/upcoming/7456.md | 1 +
.../flyout/flyout_resizable.spec.tsx | 42 +++++++-----
src/components/flyout/flyout_resizable.tsx | 15 +----
src/components/resizable_container/helpers.ts | 24 +++----
.../resizable_container.test.tsx | 24 ++++---
.../resizable_container.tsx | 67 +++++++------------
6 files changed, 79 insertions(+), 94 deletions(-)
create mode 100644 changelogs/upcoming/7456.md
diff --git a/changelogs/upcoming/7456.md b/changelogs/upcoming/7456.md
new file mode 100644
index 00000000000..dcee055cdea
--- /dev/null
+++ b/changelogs/upcoming/7456.md
@@ -0,0 +1 @@
+- Enhanced `EuiResizableContainer` to preserve the drag/resize event when the user's mouse leaves the parent container and re-enters
diff --git a/src/components/flyout/flyout_resizable.spec.tsx b/src/components/flyout/flyout_resizable.spec.tsx
index f86c6cf080f..d789c8cdafc 100644
--- a/src/components/flyout/flyout_resizable.spec.tsx
+++ b/src/components/flyout/flyout_resizable.spec.tsx
@@ -62,27 +62,33 @@ describe('EuiFlyoutResizable', () => {
it('mouse drag', () => {
cy.mount();
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 400 })
- .trigger('mousemove', { pageX: 600 });
+ .trigger('mousedown', { clientX: 400 })
+ .trigger('mousemove', { clientX: 600 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '600px');
cy.get('[data-test-subj="euiResizableButton"]').trigger('mousemove', {
- pageX: 200,
+ clientX: 200,
});
cy.get('.euiFlyout').should('have.css', 'inline-size', '1000px');
// Should not change the flyout width if not dragging
cy.get('[data-test-subj="euiResizableButton"]')
.trigger('mouseup')
- .trigger('mousemove', { pageX: 1000 });
+ .trigger('mousemove', { clientX: 1000 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '1000px');
});
it('mobile touch drag', () => {
cy.mount();
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('touchstart', { targetTouches: [{ pageX: 400 }], touches: [] })
- .trigger('touchmove', { targetTouches: [{ pageX: 800 }], touches: [] })
+ .trigger('touchstart', {
+ targetTouches: [{ clientX: 400 }],
+ touches: [],
+ })
+ .trigger('touchmove', {
+ targetTouches: [{ clientX: 800 }],
+ touches: [],
+ })
.trigger('touchend', { touches: [] });
cy.get('.euiFlyout').should('have.css', 'inline-size', '400px');
});
@@ -101,8 +107,8 @@ describe('EuiFlyoutResizable', () => {
it('does not allow the flyout to be resized past the window width', () => {
cy.mount();
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 400 })
- .trigger('mousemove', { pageX: -100 });
+ .trigger('mousedown', { clientX: 400 })
+ .trigger('mousemove', { clientX: -100 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '1180px');
});
@@ -111,8 +117,8 @@ describe('EuiFlyoutResizable', () => {
);
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 400 })
- .trigger('mousemove', { pageX: 100 });
+ .trigger('mousedown', { clientX: 400 })
+ .trigger('mousemove', { clientX: 100 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '1000px');
});
@@ -121,8 +127,8 @@ describe('EuiFlyoutResizable', () => {
);
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 400 })
- .trigger('mousemove', { pageX: 2000 });
+ .trigger('mousedown', { clientX: 400 })
+ .trigger('mousemove', { clientX: 2000 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '100px');
});
@@ -155,8 +161,8 @@ describe('EuiFlyoutResizable', () => {
cy.get('.euiFlyout').should('have.css', 'inline-size', '850px');
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 850, ...options })
- .trigger('mousemove', { pageX: 400, ...options });
+ .trigger('mousedown', { clientX: 850, ...options })
+ .trigger('mousemove', { clientX: 400, ...options });
cy.get('.euiFlyout').should('have.css', 'inline-size', '400px');
};
});
@@ -168,8 +174,8 @@ describe('EuiFlyoutResizable', () => {
cy.get('body').should('have.css', 'padding-inline-end', '800px');
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 400 })
- .trigger('mousemove', { pageX: 1000 });
+ .trigger('mousedown', { clientX: 400 })
+ .trigger('mousemove', { clientX: 1000 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '200px');
cy.get('body').should('have.css', 'padding-inline-end', '200px');
@@ -187,8 +193,8 @@ describe('EuiFlyoutResizable', () => {
cy.get('body').should('have.css', 'padding-inline-start', '800px');
cy.get('[data-test-subj="euiResizableButton"]')
- .trigger('mousedown', { pageX: 800 })
- .trigger('mousemove', { pageX: 200 });
+ .trigger('mousedown', { clientX: 800 })
+ .trigger('mousemove', { clientX: 200 });
cy.get('.euiFlyout').should('have.css', 'inline-size', '200px');
cy.get('body').should('have.css', 'padding-inline-start', '200px');
diff --git a/src/components/flyout/flyout_resizable.tsx b/src/components/flyout/flyout_resizable.tsx
index 0c97713edb7..632d3af67c3 100644
--- a/src/components/flyout/flyout_resizable.tsx
+++ b/src/components/flyout/flyout_resizable.tsx
@@ -17,6 +17,7 @@ import React, {
import { keys, useCombinedRefs, useEuiTheme } from '../../services';
import { EuiResizableButton } from '../resizable_container';
+import { getPosition } from '../resizable_container/helpers';
import { EuiFlyout, EuiFlyoutProps } from './flyout';
import { euiFlyoutResizableButtonStyles } from './flyout_resizable.styles';
@@ -80,7 +81,7 @@ export const EuiFlyoutResizable = forwardRef(
const onMouseMove = useCallback(
(e: MouseEvent | TouchEvent) => {
- const mouseOffset = getMouseOrTouchX(e) - initialMouseX.current;
+ const mouseOffset = getPosition(e, true) - initialMouseX.current;
const changedFlyoutWidth =
initialWidth.current + mouseOffset * direction;
@@ -100,7 +101,7 @@ export const EuiFlyoutResizable = forwardRef(
const onMouseDown = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
- initialMouseX.current = getMouseOrTouchX(e);
+ initialMouseX.current = getPosition(e, true);
initialWidth.current = flyoutRef?.offsetWidth ?? 0;
// Window event listeners instead of React events are used
@@ -155,13 +156,3 @@ export const EuiFlyoutResizable = forwardRef(
}
);
EuiFlyoutResizable.displayName = 'EuiFlyoutResizable';
-
-const getMouseOrTouchX = (
- e: TouchEvent | MouseEvent | React.MouseEvent | React.TouchEvent
-): number => {
- // Some Typescript fooling is needed here
- const x = (e as TouchEvent).targetTouches
- ? (e as TouchEvent).targetTouches[0].pageX
- : (e as MouseEvent).pageX;
- return x;
-};
diff --git a/src/components/resizable_container/helpers.ts b/src/components/resizable_container/helpers.ts
index e70ba268c63..a02997e9d0f 100644
--- a/src/components/resizable_container/helpers.ts
+++ b/src/components/resizable_container/helpers.ts
@@ -34,11 +34,10 @@ interface Params {
onPanelWidthChange?: ({}: { [key: string]: number }) => any;
}
-function isMouseEvent(
- event: ReactMouseEvent | ReactTouchEvent
-): event is ReactMouseEvent {
- return typeof event === 'object' && 'pageX' in event && 'pageY' in event;
-}
+export const isTouchEvent = (
+ event: MouseEvent | ReactMouseEvent | TouchEvent | ReactTouchEvent
+): event is TouchEvent | ReactTouchEvent =>
+ typeof event === 'object' && 'targetTouches' in event;
export const pxToPercent = (proportion: number, whole: number) => {
if (whole < 1 || proportion < 0) return 0;
@@ -81,16 +80,13 @@ export const getPanelMinSize = (
};
export const getPosition = (
- event: ReactMouseEvent | ReactTouchEvent,
+ event: ReactMouseEvent | MouseEvent | ReactTouchEvent | TouchEvent,
isHorizontal: boolean
-) => {
- const clientX = isMouseEvent(event)
- ? event.clientX
- : event.touches[0].clientX;
- const clientY = isMouseEvent(event)
- ? event.clientY
- : event.touches[0].clientY;
- return isHorizontal ? clientX : clientY;
+): number => {
+ const direction = isHorizontal ? 'clientX' : 'clientY';
+ return isTouchEvent(event)
+ ? event.targetTouches[0][direction]
+ : event[direction];
};
const getSiblingPanel = (
diff --git a/src/components/resizable_container/resizable_container.test.tsx b/src/components/resizable_container/resizable_container.test.tsx
index 5518eb2fc72..c8d6edaf831 100644
--- a/src/components/resizable_container/resizable_container.test.tsx
+++ b/src/components/resizable_container/resizable_container.test.tsx
@@ -7,7 +7,9 @@
*/
import React from 'react';
+import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
+
import { findTestSubject, requiredProps } from '../../test';
import { shouldRenderCustomStyles } from '../../test/internal';
import { render } from '../../test/rtl';
@@ -217,8 +219,7 @@ describe('EuiResizableContainer', () => {
};
test('onResizeStart and onResizeEnd are called for pointer events', () => {
- const { container, button, onResizeStart, onResizeEnd } =
- mountWithCallbacks();
+ const { button, onResizeStart, onResizeEnd } = mountWithCallbacks();
button.simulate('mousedown', {
pageX: 0,
pageY: 0,
@@ -227,7 +228,9 @@ describe('EuiResizableContainer', () => {
});
expect(onResizeStart).toHaveBeenCalledTimes(1);
expect(onResizeStart).toHaveBeenLastCalledWith('pointer');
- container.simulate('mouseup');
+ act(() => {
+ window.dispatchEvent(new Event('mouseup'));
+ });
expect(onResizeEnd).toHaveBeenCalledTimes(1);
button.simulate('mousedown', {
pageX: 0,
@@ -237,7 +240,9 @@ describe('EuiResizableContainer', () => {
});
expect(onResizeStart).toHaveBeenCalledTimes(2);
expect(onResizeStart).toHaveBeenLastCalledWith('pointer');
- container.simulate('mouseleave');
+ act(() => {
+ window.dispatchEvent(new Event('mouseup'));
+ });
expect(onResizeEnd).toHaveBeenCalledTimes(2);
button.simulate('touchstart', {
touches: [
@@ -249,7 +254,9 @@ describe('EuiResizableContainer', () => {
});
expect(onResizeStart).toHaveBeenCalledTimes(3);
expect(onResizeStart).toHaveBeenLastCalledWith('pointer');
- container.simulate('touchend');
+ act(() => {
+ window.dispatchEvent(new Event('touchend'));
+ });
expect(onResizeEnd).toHaveBeenCalledTimes(3);
});
@@ -312,8 +319,7 @@ describe('EuiResizableContainer', () => {
});
test('onResizeEnd is called before starting a new resize if a keyboard resize is triggered while a pointer resize is in progress', () => {
- const { container, button, onResizeStart, onResizeEnd } =
- mountWithCallbacks();
+ const { button, onResizeStart, onResizeEnd } = mountWithCallbacks();
button.simulate('mousedown', {
pageX: 0,
pageY: 0,
@@ -326,7 +332,9 @@ describe('EuiResizableContainer', () => {
expect(onResizeEnd).toHaveBeenCalledTimes(1);
expect(onResizeStart).toHaveBeenCalledTimes(2);
expect(onResizeStart).toHaveBeenLastCalledWith('key');
- container.simulate('mouseup');
+ act(() => {
+ window.dispatchEvent(new Event('mouseup'));
+ });
expect(onResizeEnd).toHaveBeenCalledTimes(1);
button.simulate('keyup', { key: keys.ARROW_RIGHT });
expect(onResizeEnd).toHaveBeenCalledTimes(2);
diff --git a/src/components/resizable_container/resizable_container.tsx b/src/components/resizable_container/resizable_container.tsx
index b5fc1c017f6..c6e035f8d1a 100644
--- a/src/components/resizable_container/resizable_container.tsx
+++ b/src/components/resizable_container/resizable_container.tsx
@@ -165,32 +165,32 @@ export const EuiResizableContainer: FunctionComponent<
const position = getPosition(event, isHorizontal);
resizeStart('pointer');
actions.dragStart({ position, prevPanelId, nextPanelId });
- },
- [actions, isHorizontal, resizeStart]
- );
- const onMouseMove = useCallback(
- (event: React.MouseEvent | React.TouchEvent) => {
- if (
- !reducerState.prevPanelId ||
- !reducerState.nextPanelId ||
- !reducerState.isDragging
- )
- return;
- const position = getPosition(event, isHorizontal);
- actions.dragMove({
- position,
- prevPanelId: reducerState.prevPanelId,
- nextPanelId: reducerState.nextPanelId,
- });
+ // Window event listeners instead of React events are used to continue
+ // detecting movement even if the user's mouse leaves the container
+
+ const onMouseMove = (event: MouseEvent | TouchEvent) => {
+ const position = getPosition(event, isHorizontal);
+ actions.dragMove({ position, prevPanelId, nextPanelId });
+ };
+
+ const onMouseUp = () => {
+ if (resizeContext.current.trigger === 'pointer') {
+ resizeEnd();
+ }
+ actions.reset();
+
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ window.removeEventListener('touchmove', onMouseMove);
+ window.removeEventListener('touchend', onMouseUp);
+ };
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ window.addEventListener('touchmove', onMouseMove);
+ window.addEventListener('touchend', onMouseUp);
},
- [
- actions,
- isHorizontal,
- reducerState.prevPanelId,
- reducerState.nextPanelId,
- reducerState.isDragging,
- ]
+ [actions, isHorizontal, resizeStart, resizeEnd]
);
const getKeyMoveDirection = useCallback(
@@ -246,13 +246,6 @@ export const EuiResizableContainer: FunctionComponent<
[getKeyMoveDirection, resizeEnd]
);
- const onMouseUp = useCallback(() => {
- if (resizeContext.current.trigger === 'pointer') {
- resizeEnd();
- }
- actions.reset();
- }, [actions, resizeEnd]);
-
const onBlur = useCallback(() => {
if (resizeContext.current.trigger === 'key') {
resizeEnd();
@@ -322,17 +315,7 @@ export const EuiResizableContainer: FunctionComponent<
resizers: reducerState.resizers,
}}
>
-