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, }} > -
+
{render()}