diff --git a/src-docs/src/views/resizable_container/resizable_container_callbacks.tsx b/src-docs/src/views/resizable_container/resizable_container_callbacks.tsx new file mode 100644 index 00000000000..2877e146b4c --- /dev/null +++ b/src-docs/src/views/resizable_container/resizable_container_callbacks.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { + EuiText, + EuiResizableContainer, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiPanel, +} from '../../../../src/components'; +// @ts-ignore - faker does not have type declarations +import { fake } from 'faker'; +import { useGeneratedHtmlId } from '../../../../src/services'; + +const text = ( + <> +

{fake('{{lorem.paragraphs}}')}

+

{fake('{{lorem.paragraphs}}')}

+

{fake('{{lorem.paragraphs}}')}

+ +); + +export default () => { + const firstPanelId = useGeneratedHtmlId({ prefix: 'firstPanel' }); + const secondPanelId = useGeneratedHtmlId({ prefix: 'secondPanel' }); + const [resizeTrigger, setResizeTrigger] = useState<'pointer' | 'key'>(); + const [sizes, setSizes] = useState({ + [firstPanelId]: 50, + [secondPanelId]: 50, + }); + + return ( + + + + + + + + + + + + + + + + + + + + + + { + setSizes((prevSizes) => ({ ...prevSizes, ...newSizes })); + }} + onResizeStart={(trigger) => setResizeTrigger(trigger)} + onResizeEnd={() => setResizeTrigger(undefined)} + > + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + +
{text}
+ Hello world +
+
+ + + + + {text} + + + )} +
+
+
+ ); +}; diff --git a/src-docs/src/views/resizable_container/resizable_container_example.js b/src-docs/src/views/resizable_container/resizable_container_example.js index 51646080d83..8f3e2d6f753 100644 --- a/src-docs/src/views/resizable_container/resizable_container_example.js +++ b/src-docs/src/views/resizable_container/resizable_container_example.js @@ -23,6 +23,7 @@ import { PanelModeType } from '!!prop-loader!../../../../src/components/resizabl import ResizableContainerBasic from './resizable_container_basic'; import ResizableContainerVertical from './resizable_container_vertical'; import ResizableContainerResetValues from './resizable_container_reset_values'; +import ResizableContainerCallbacks from './resizable_container_callbacks'; import ResizablePanels from './resizable_panels'; import ResizablePanelCollapsible from './resizable_panel_collapsible'; import ResizablePanelCollapsibleResponsive from './resizable_panel_collapsible_responsive'; @@ -32,6 +33,7 @@ import ResizablePanelCollapsibleExt from './resizable_panel_collapsible_external const ResizableContainerSource = require('!!raw-loader!./resizable_container_basic'); const ResizableContainerVerticalSource = require('!!raw-loader!./resizable_container_vertical'); const ResizableContainerResetValuesSource = require('!!raw-loader!./resizable_container_reset_values'); +const ResizableContainerCallbacksSource = require('!!raw-loader!./resizable_container_callbacks'); const ResizablePanelsSource = require('!!raw-loader!./resizable_panels'); const ResizablePanelCollapsibleSource = require('!!raw-loader!./resizable_panel_collapsible'); const ResizablePanelCollapsibleResponsiveSource = require('!!raw-loader!./resizable_panel_collapsible_responsive'); @@ -80,6 +82,29 @@ const verticalSnippet = ` )} `; +const callbacksSnippet = ` console.log('onResizeStart', trigger)} + onResizeEnd={() => console.log('onResizeEnd')} +> + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + +

{text}

+
+
+ + + + + +

{text}

+
+
+ + )} +
`; + const collapsibleSnippet = ` {(EuiResizablePanel, EuiResizableButton) => ( <> @@ -313,6 +338,30 @@ export const ResizableContainerExample = { demo: , snippet: verticalSnippet, }, + { + source: [ + { + type: GuideSectionTypes.TSX, + code: ResizableContainerCallbacksSource, + }, + ], + title: 'Resizable container callbacks', + text: ( + <> +

+ EuiResizableContainer supports{' '} + onResizeStart and onResizeEnd{' '} + callback props to listen for when resizing starts and ends. The{' '} + onResizeStart callback is passed a{' '} + {"trigger: 'pointer' | 'key'"} parameter to + determine which user action triggered the resize. +

+ + ), + props: { EuiResizableContainer, EuiResizablePanel, EuiResizableButton }, + demo: , + snippet: callbacksSnippet, + }, { source: [ { diff --git a/src/components/resizable_container/resizable_button.tsx b/src/components/resizable_container/resizable_button.tsx index 80a9676d3fc..4d1fa085fe2 100644 --- a/src/components/resizable_container/resizable_button.tsx +++ b/src/components/resizable_container/resizable_button.tsx @@ -23,11 +23,12 @@ import { useEuiResizableContainerContext } from './context'; import { EuiResizableButtonController, EuiResizableButtonMouseEvent, - EuiResizableButtonKeyDownEvent, + EuiResizableButtonKeyEvent, } from './types'; interface EuiResizableButtonControls { - onKeyDown: (eve: EuiResizableButtonKeyDownEvent) => void; + onKeyDown: (eve: EuiResizableButtonKeyEvent) => void; + onKeyUp: (eve: EuiResizableButtonKeyEvent) => void; onMouseDown: (eve: EuiResizableButtonMouseEvent) => void; onTouchStart: (eve: EuiResizableButtonMouseEvent) => void; onFocus: (id: string) => void; diff --git a/src/components/resizable_container/resizable_container.test.tsx b/src/components/resizable_container/resizable_container.test.tsx index 9dc8971e4da..7f7fe34f0db 100644 --- a/src/components/resizable_container/resizable_container.test.tsx +++ b/src/components/resizable_container/resizable_container.test.tsx @@ -7,10 +7,11 @@ */ import React from 'react'; -import { render } from 'enzyme'; -import { requiredProps } from '../../test'; +import { mount, render } from 'enzyme'; +import { findTestSubject, requiredProps } from '../../test'; import { EuiResizableContainer } from './resizable_container'; +import { keys } from '../../services'; describe('EuiResizableContainer', () => { test('is rendered', () => { @@ -170,4 +171,168 @@ describe('EuiResizableContainer', () => { expect(component).toMatchSnapshot(); }); + + describe('on resize callbacks', () => { + const mountWithCallbacks = ({ + direction, + }: { + direction?: 'vertical' | 'horizontal'; + } = {}) => { + const onResizeStart = jest.fn(); + const onResizeEnd = jest.fn(); + const component = mount( + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + Testing + + 123 + + )} + + ); + const container = findTestSubject(component, 'euiResizableContainer'); + const button = findTestSubject(component, 'euiResizableButton'); + return { container, button, onResizeStart, onResizeEnd }; + }; + + test('onResizeStart and onResizeEnd are called for pointer events', () => { + const { + container, + button, + onResizeStart, + onResizeEnd, + } = mountWithCallbacks(); + button.simulate('mousedown', { + pageX: 0, + pageY: 0, + clientX: 0, + clientY: 0, + }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('pointer'); + container.simulate('mouseup'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + button.simulate('mousedown', { + pageX: 0, + pageY: 0, + clientX: 0, + clientY: 0, + }); + expect(onResizeStart).toHaveBeenCalledTimes(2); + expect(onResizeStart).toHaveBeenLastCalledWith('pointer'); + container.simulate('mouseleave'); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + button.simulate('touchstart', { + touches: [ + { + clientX: 0, + clientY: 0, + }, + ], + }); + expect(onResizeStart).toHaveBeenCalledTimes(3); + expect(onResizeStart).toHaveBeenLastCalledWith('pointer'); + container.simulate('touchend'); + expect(onResizeEnd).toHaveBeenCalledTimes(3); + }); + + test('onResizeStart and onResizeEnd are called for left/right keyboard events', () => { + const { button, onResizeStart, onResizeEnd } = mountWithCallbacks(); + button.simulate('keydown', { key: keys.ARROW_RIGHT }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keyup', { key: keys.ARROW_RIGHT }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + button.simulate('keydown', { key: keys.ARROW_LEFT }); + expect(onResizeStart).toHaveBeenCalledTimes(2); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keyup', { key: keys.ARROW_LEFT }); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + }); + + test('onResizeStart and onResizeEnd are called for up/down keyboard events', () => { + const { button, onResizeStart, onResizeEnd } = mountWithCallbacks({ + direction: 'vertical', + }); + button.simulate('keydown', { key: keys.ARROW_UP }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keyup', { key: keys.ARROW_UP }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + button.simulate('keydown', { key: keys.ARROW_DOWN }); + expect(onResizeStart).toHaveBeenCalledTimes(2); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keyup', { key: keys.ARROW_DOWN }); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + }); + + test('onResizeStart and onResizeEnd are called only for the correct keyboard events', () => { + const { button, onResizeStart, onResizeEnd } = mountWithCallbacks(); + button.simulate('keydown', { key: keys.ARROW_DOWN }); + expect(onResizeStart).toHaveBeenCalledTimes(0); + button.simulate('keydown', { key: keys.ARROW_RIGHT }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keyup', { key: keys.ARROW_DOWN }); + expect(onResizeEnd).toHaveBeenCalledTimes(0); + button.simulate('keyup', { key: keys.ARROW_RIGHT }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); + + test('onResizeStart and onResizeEnd are called correctly when switching resize direction with the keyboard', () => { + const { button, onResizeStart, onResizeEnd } = mountWithCallbacks(); + button.simulate('keydown', { key: keys.ARROW_RIGHT }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keydown', { key: keys.ARROW_LEFT }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenCalledTimes(2); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('keyup', { key: keys.ARROW_RIGHT }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + button.simulate('keyup', { key: keys.ARROW_LEFT }); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + }); + + 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(); + button.simulate('mousedown', { + pageX: 0, + pageY: 0, + clientX: 0, + clientY: 0, + }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('pointer'); + button.simulate('keydown', { key: keys.ARROW_RIGHT }); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenCalledTimes(2); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + container.simulate('mouseup'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + button.simulate('keyup', { key: keys.ARROW_RIGHT }); + expect(onResizeEnd).toHaveBeenCalledTimes(2); + }); + + test('onResizeEnd is called for keyboard resizes when the button is blurred', () => { + const { button, onResizeStart, onResizeEnd } = mountWithCallbacks(); + button.simulate('keydown', { key: keys.ARROW_RIGHT }); + expect(onResizeStart).toHaveBeenCalledTimes(1); + expect(onResizeStart).toHaveBeenLastCalledWith('key'); + button.simulate('blur'); + expect(onResizeEnd).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/components/resizable_container/resizable_container.tsx b/src/components/resizable_container/resizable_container.tsx index 28b1a399351..5ed7e66381e 100644 --- a/src/components/resizable_container/resizable_container.tsx +++ b/src/components/resizable_container/resizable_container.tsx @@ -36,9 +36,11 @@ import { import { useContainerCallbacks, getPosition } from './helpers'; import { EuiResizableButtonMouseEvent, - EuiResizableButtonKeyDownEvent, + EuiResizableButtonKeyEvent, EuiResizableContainerState, EuiResizableContainerActions, + KeyMoveDirection, + ResizeTrigger, } from './types'; const containerDirections = { @@ -66,8 +68,16 @@ export interface EuiResizableContainerProps * Pure function which accepts an object where keys are IDs of panels, which sizes were changed, * and values are actual sizes in percents */ - onPanelWidthChange?: ({}: { [key: string]: number }) => any; + onPanelWidthChange?: ({}: { [key: string]: number }) => void; onToggleCollapsed?: ToggleCollapseCallback; + /** + * Called when resizing starts + */ + onResizeStart?: (trigger: ResizeTrigger) => void; + /** + * Called when resizing ends + */ + onResizeEnd?: () => void; style?: CSSProperties; } @@ -87,6 +97,8 @@ export const EuiResizableContainer: FunctionComponent { const containerRef = useRef(null); @@ -122,6 +134,31 @@ export const EuiResizableContainer: FunctionComponent({}); + + const resizeEnd = useCallback(() => { + onResizeEnd?.(); + resizeContext.current = {}; + }, [onResizeEnd]); + + const resizeStart = useCallback( + (trigger: ResizeTrigger, keyMoveDirection?: KeyMoveDirection) => { + // If another resize starts while the previous one is still in progress + // (e.g. user presses opposite arrow to change direction while the first + // is still held down, or user presses an arrow while dragging with the + // mouse), we want to signal the end of the previous resize first. + if (resizeContext.current.trigger) { + resizeEnd(); + } + onResizeStart?.(trigger); + resizeContext.current = { trigger, keyMoveDirection }; + }, + [onResizeStart, resizeEnd] + ); + const onMouseDown = useCallback( (event: EuiResizableButtonMouseEvent) => { const currentTarget = event.currentTarget; @@ -131,9 +168,10 @@ export const EuiResizableContainer: FunctionComponent { + let direction: KeyMoveDirection | null = null; + if ( + (isHorizontal && key === keys.ARROW_LEFT) || + (!isHorizontal && key === keys.ARROW_UP) + ) { + direction = 'backward'; + } else if ( + (isHorizontal && key === keys.ARROW_RIGHT) || + (!isHorizontal && key === keys.ARROW_DOWN) + ) { + direction = 'forward'; + } + return direction; + }, + [isHorizontal] + ); + const onKeyDown = useCallback( - (event: EuiResizableButtonKeyDownEvent) => { + (event: EuiResizableButtonKeyEvent) => { const { key, currentTarget } = event; - const shouldResizeHorizontalPanel = - isHorizontal && (key === keys.ARROW_LEFT || key === keys.ARROW_RIGHT); - const shouldResizeVerticalPanel = - !isHorizontal && (key === keys.ARROW_UP || key === keys.ARROW_DOWN); + const direction = getKeyMoveDirection(key); const prevPanelId = currentTarget.previousElementSibling!.id; const nextPanelId = currentTarget.nextElementSibling!.id; - let direction; - if (key === keys.ARROW_DOWN || key === keys.ARROW_RIGHT) { - direction = 'forward'; - } - if (key === keys.ARROW_UP || key === keys.ARROW_LEFT) { - direction = 'backward'; + + if (direction && prevPanelId && nextPanelId) { + if (!event.repeat) { + resizeStart('key', direction); + } + event.preventDefault(); + actions.keyMove({ direction, prevPanelId, nextPanelId }); } + }, + [actions, getKeyMoveDirection, resizeStart] + ); + const onKeyUp = useCallback( + ({ key }: EuiResizableButtonKeyEvent) => { + // We only want to signal the end of a resize if the key that was released + // is the same as the one that started the resize. This prevents the end + // of a resize if the user presses one arrow key, then presses the opposite + // arrow key to change direction, then releases the first arrow key. if ( - direction === 'forward' || - (direction === 'backward' && - (shouldResizeHorizontalPanel || shouldResizeVerticalPanel) && - prevPanelId && - nextPanelId) + resizeContext.current.trigger === 'key' && + resizeContext.current.keyMoveDirection === getKeyMoveDirection(key) ) { - event.preventDefault(); - actions.keyMove({ direction, prevPanelId, nextPanelId }); + resizeEnd(); } }, - [actions, isHorizontal] + [getKeyMoveDirection, resizeEnd] ); const onMouseUp = useCallback(() => { + if (resizeContext.current.trigger === 'pointer') { + resizeEnd(); + } actions.reset(); - }, [actions]); + }, [actions, resizeEnd]); + + const onBlur = useCallback(() => { + if (resizeContext.current.trigger === 'key') { + resizeEnd(); + } + actions.resizerBlur(); + }, [actions, resizeEnd]); // eslint-disable-next-line react-hooks/exhaustive-deps const EuiResizableButton = useCallback( euiResizableButtonWithControls({ onKeyDown, + onKeyUp, onMouseDown, onTouchStart: onMouseDown, onFocus: actions.resizerFocus, - onBlur: actions.resizerBlur, + onBlur, isHorizontal, registration: { register: actions.registerResizer, diff --git a/src/components/resizable_container/types.ts b/src/components/resizable_container/types.ts index bcfd3932d15..a02fd128816 100644 --- a/src/components/resizable_container/types.ts +++ b/src/components/resizable_container/types.ts @@ -14,6 +14,10 @@ export type PanelPosition = 'first' | 'middle' | 'last'; export type PanelDirection = 'left' | 'right'; +export type KeyMoveDirection = 'forward' | 'backward'; + +export type ResizeTrigger = 'pointer' | 'key'; + export interface EuiResizablePanelController { id: string; size: number; @@ -41,7 +45,7 @@ export type EuiResizableButtonMouseEvent = | MouseEvent | TouchEvent; -export type EuiResizableButtonKeyDownEvent = KeyboardEvent; +export type EuiResizableButtonKeyEvent = KeyboardEvent; export interface EuiResizableContainerState { isDragging: boolean; @@ -82,7 +86,7 @@ export interface ActionKeyMove { payload: { prevPanelId: string; nextPanelId: string; - direction: 'forward' | 'backward'; + direction: KeyMoveDirection; }; } diff --git a/upcoming_changelogs/6236.md b/upcoming_changelogs/6236.md new file mode 100644 index 00000000000..6f980a65427 --- /dev/null +++ b/upcoming_changelogs/6236.md @@ -0,0 +1 @@ +- Added support for `onResizeStart` and `onResizeEnd` callbacks to `EuiResizableContainer` \ No newline at end of file