diff --git a/packages/eui/changelogs/upcoming/8999.md b/packages/eui/changelogs/upcoming/8999.md new file mode 100644 index 00000000000..ad7fd45868e --- /dev/null +++ b/packages/eui/changelogs/upcoming/8999.md @@ -0,0 +1 @@ +- Added a new optional `resizable` (boolean) prop to `EuiFlyout`. Resizability can now be controlled dynamically without the need to use `EuiFlyoutResizable`. diff --git a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx index 600a765fe31..e9c516e7844 100644 --- a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx +++ b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx @@ -35,7 +35,7 @@ import { EuiCollapsibleNavButton } from './collapsible_nav_button'; import { euiCollapsibleNavBetaStyles } from './collapsible_nav_beta.styles'; export type EuiCollapsibleNavBetaProps = CommonProps & - HTMLAttributes & + Omit, 'onResize'> & Pick< EuiFlyoutProps, // Extend only specific flyout props - EuiCollapsibleNav is much less customizable than EuiFlyout 'side' | 'focusTrapProps' | 'includeFixedHeadersInFocusTrap' diff --git a/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap b/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap index 07f31c2b389..f3806c8ba65 100644 --- a/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap +++ b/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap @@ -1051,52 +1051,58 @@ exports[`EuiFlyout props size accepts custom number 1`] = ` `; exports[`EuiFlyout props size fill is rendered 1`] = ` -[ +
-
-
+
+
-
-
, -] +
+ `; exports[`EuiFlyout props size l is rendered 1`] = ` diff --git a/packages/eui/src/components/flyout/flyout_resizable.styles.ts b/packages/eui/src/components/flyout/_flyout_resize_button.styles.ts similarity index 88% rename from packages/eui/src/components/flyout/flyout_resizable.styles.ts rename to packages/eui/src/components/flyout/_flyout_resize_button.styles.ts index aea884d6144..921ece731ed 100644 --- a/packages/eui/src/components/flyout/flyout_resizable.styles.ts +++ b/packages/eui/src/components/flyout/_flyout_resize_button.styles.ts @@ -7,12 +7,11 @@ */ import { css } from '@emotion/react'; - -import { UseEuiTheme } from '../../services'; import { logicalCSS } from '../../global_styling'; +import { UseEuiTheme } from '../../services'; -export const euiFlyoutResizableButtonStyles = ({ euiTheme }: UseEuiTheme) => ({ - euiFlyoutResizableButton: css` +export const euiFlyoutResizeButtonStyles = ({ euiTheme }: UseEuiTheme) => ({ + root: css` position: absolute; `, overlay: { @@ -32,7 +31,7 @@ export const euiFlyoutResizableButtonStyles = ({ euiTheme }: UseEuiTheme) => ({ `, }, noOverlay: { - noOverlay: css` + root: css` margin-inline: 0; `, left: css` diff --git a/packages/eui/src/components/flyout/_flyout_resize_button.tsx b/packages/eui/src/components/flyout/_flyout_resize_button.tsx new file mode 100644 index 00000000000..818421e449f --- /dev/null +++ b/packages/eui/src/components/flyout/_flyout_resize_button.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useEuiMemoizedStyles } from '../../services'; +import { + EuiResizableButton, + EuiResizableButtonProps, +} from '../resizable_container'; +import type { _EuiFlyoutType, _EuiFlyoutSide } from './const'; +import type { EuiFlyoutComponentProps } from './flyout.component'; +import { euiFlyoutResizeButtonStyles } from './_flyout_resize_button.styles'; + +type EuiFlyoutResizeButtonProps = Pick< + EuiResizableButtonProps, + 'onMouseDown' | 'onKeyDown' | 'onTouchStart' +> & { + type: _EuiFlyoutType; + side: _EuiFlyoutSide; + ownFocus: EuiFlyoutComponentProps['ownFocus']; + isPushed: boolean; +}; + +export const EuiFlyoutResizeButton = ({ + type, + side, + ownFocus, + isPushed, + ...resizableButtonProps +}: EuiFlyoutResizeButtonProps) => { + const hasOverlay = ownFocus && type === 'overlay'; + const styles = useEuiMemoizedStyles(euiFlyoutResizeButtonStyles); + + const cssStyles = [ + styles.root, + styles[type][side], + !hasOverlay && styles.noOverlay.root, + !hasOverlay && styles.noOverlay[side], + ]; + + return ( + + ); +}; diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index dbe1318fd61..906f7b9e080 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -67,6 +67,8 @@ import { isEuiFlyoutSizeNamed, } from './const'; import { useIsPushed } from './hooks'; +import { EuiFlyoutResizeButton } from './_flyout_resize_button'; +import { useEuiFlyoutResizable } from './use_flyout_resizable'; interface _EuiFlyoutComponentProps { onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; @@ -76,6 +78,11 @@ interface _EuiFlyoutComponentProps { * @default m */ size?: EuiFlyoutSize | CSSProperties['width']; + /** + * Sets the minimum width of the panel. + * Especially useful when set with `resizable = true`. + */ + minWidth?: number; /** * Sets the max-width of the panel, * set to `true` to use the default size, @@ -166,6 +173,17 @@ interface _EuiFlyoutComponentProps { * Specify additional css selectors to include in the focus trap. */ includeSelectorInFocusTrap?: string[] | string; + + /** + * Whether the flyout should be resizable. + * @default false + */ + resizable?: boolean; + + /** + * Optional callback that fires when the flyout is resized. + */ + onResize?: (width: number) => void; } const defaultElement = 'div'; @@ -199,7 +217,7 @@ export const EuiFlyoutComponent = forwardRef( onClose, ownFocus = true, side = DEFAULT_SIDE, - size = DEFAULT_SIZE, + size: _size = DEFAULT_SIZE, paddingSize = DEFAULT_PADDING_SIZE, maxWidth = false, style, @@ -213,6 +231,9 @@ export const EuiFlyoutComponent = forwardRef( includeSelectorInFocusTrap, 'aria-describedby': _ariaDescribedBy, id, + resizable = false, + minWidth, + onResize, ...rest } = usePropsWithComponentDefaults('EuiFlyout', props); @@ -225,6 +246,20 @@ export const EuiFlyoutComponent = forwardRef( const internalParentFlyoutRef = useRef(null); const isPushed = useIsPushed({ type, pushMinBreakpoint }); + const { + onMouseDown: onMouseDownResizableButton, + onKeyDown: onKeyDownResizableButton, + size, + setFlyoutRef, + } = useEuiFlyoutResizable({ + enabled: resizable, + minWidth, + maxWidth: typeof maxWidth === 'number' ? maxWidth : 0, + onResize, + side, + size: _size, + }); + /** * Setting up the refs on the actual flyout element in order to * accommodate for the `isPushed` state by adding padding to the body equal to the width of the element @@ -236,6 +271,7 @@ export const EuiFlyoutComponent = forwardRef( setResizeRef, ref, internalParentFlyoutRef, + setFlyoutRef, ]); const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); @@ -534,6 +570,17 @@ export const EuiFlyoutComponent = forwardRef( side={side} /> )} + {resizable && ( + + )} {children} diff --git a/packages/eui/src/components/flyout/flyout_resizable.tsx b/packages/eui/src/components/flyout/flyout_resizable.tsx index 84dc5136ddb..f3ba68ddeb3 100644 --- a/packages/eui/src/components/flyout/flyout_resizable.tsx +++ b/packages/eui/src/components/flyout/flyout_resizable.tsx @@ -6,192 +6,18 @@ * Side Public License, v 1. */ -import React, { - forwardRef, - useState, - useEffect, - useRef, - useMemo, - useCallback, -} from 'react'; - -import { keys, useCombinedRefs, useEuiMemoizedStyles } from '../../services'; -import { EuiResizableButton } from '../resizable_container'; -import { getPosition } from '../resizable_container/helpers'; +import React, { forwardRef } from 'react'; import { EuiFlyout, EuiFlyoutProps } from './flyout'; -import { euiFlyoutResizableButtonStyles } from './flyout_resizable.styles'; -import { DEFAULT_SIDE, DEFAULT_TYPE } from './const'; export type EuiFlyoutResizableProps = { maxWidth?: number; - minWidth?: number; - /** - * Optional callback that fires on user resize with the new flyout width - */ - onResize?: (width: number) => void; -} & Omit; // If not omitted, the correct props don't show up in the docs prop table - -export const EuiFlyoutResizable = forwardRef( - ( - { - size, - maxWidth, - minWidth = 200, - onResize, - side = DEFAULT_SIDE, - type = DEFAULT_TYPE, - ownFocus = true, - children, - ...rest - }: EuiFlyoutResizableProps, - ref - ) => { - const hasOverlay = type === 'overlay' && ownFocus; - - const styles = useEuiMemoizedStyles(euiFlyoutResizableButtonStyles); - const cssStyles = [ - styles.euiFlyoutResizableButton, - styles[type][side], - !hasOverlay && styles.noOverlay.noOverlay, - !hasOverlay && styles.noOverlay[side], - ]; - - const getFlyoutMinMaxWidth = useCallback( - (width: number) => { - return Math.min( - Math.max(width, minWidth), - maxWidth || Infinity, - window.innerWidth - 20 // Leave some offset - ); - }, - [minWidth, maxWidth] - ); - - const [flyoutWidth, setFlyoutWidth] = useState(0); - const [callOnResize, setCallOnResize] = useState(false); - - // Must use state for the flyout ref in order for the useEffect to be correctly called after render - const [flyoutRef, setFlyoutRef] = useState(null); - const setRefs = useCombinedRefs([setFlyoutRef, ref]); - - useEffect(() => { - if (!flyoutWidth && flyoutRef) { - setCallOnResize(false); // Don't call `onResize` for non-user width changes - setFlyoutWidth(getFlyoutMinMaxWidth(flyoutRef.offsetWidth)); - } - }, [flyoutWidth, flyoutRef, getFlyoutMinMaxWidth]); - - // Update flyout width when consumers pass in a new `size` - useEffect(() => { - setCallOnResize(false); - // For string `size`s, resetting flyoutWidth to 0 will trigger the above useEffect's recalculation - setFlyoutWidth(typeof size === 'number' ? getFlyoutMinMaxWidth(size) : 0); - }, [size, getFlyoutMinMaxWidth]); - - // Initial numbers to calculate from, on resize drag start - const initialWidth = useRef(0); - const initialMouseX = useRef(0); - - // Account for flyout side and logical property direction - const direction = useMemo(() => { - let modifier = side === 'right' ? -1 : 1; - if (flyoutRef) { - const languageDirection = window.getComputedStyle(flyoutRef).direction; - if (languageDirection === 'rtl') modifier *= -1; - } - return modifier; - }, [side, flyoutRef]); - - const onMouseMove = useCallback( - (e: MouseEvent | TouchEvent) => { - const mouseOffset = getPosition(e, true) - initialMouseX.current; - const changedFlyoutWidth = - initialWidth.current + mouseOffset * direction; - - setFlyoutWidth(getFlyoutMinMaxWidth(changedFlyoutWidth)); - }, - [getFlyoutMinMaxWidth, direction] - ); - - const onMouseUp = useCallback(() => { - setCallOnResize(true); - initialMouseX.current = 0; - - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - window.removeEventListener('touchmove', onMouseMove); - window.removeEventListener('touchend', onMouseUp); - }, [onMouseMove]); - - const onMouseDown = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { - setCallOnResize(false); - initialMouseX.current = getPosition(e, true); - initialWidth.current = flyoutRef?.offsetWidth ?? 0; - - // Window event listeners instead of React events are used - // in case the user's mouse leaves the component - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - window.addEventListener('touchmove', onMouseMove); - window.addEventListener('touchend', onMouseUp); - }, - [flyoutRef, onMouseMove, onMouseUp] - ); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - setCallOnResize(true); - const KEYBOARD_OFFSET = 10; - - switch (e.key) { - case keys.ARROW_RIGHT: - e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise - setFlyoutWidth((flyoutWidth) => - getFlyoutMinMaxWidth(flyoutWidth + KEYBOARD_OFFSET * direction) - ); - break; - case keys.ARROW_LEFT: - e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise - setFlyoutWidth((flyoutWidth) => - getFlyoutMinMaxWidth(flyoutWidth - KEYBOARD_OFFSET * direction) - ); - } - }, - [getFlyoutMinMaxWidth, direction] - ); - - // To reduce unnecessary calls, only fire onResize callback: - // 1. After initial mount / on user width change events only - // 2. If not currently mouse dragging - useEffect(() => { - if (callOnResize) { - onResize?.(flyoutWidth); - } - }, [onResize, callOnResize, flyoutWidth]); - - return ( - - - {children} - - ); - } -); +} & Omit; // If not omitted, the correct props don't show up in the docs prop table + +export const EuiFlyoutResizable = forwardRef< + HTMLDivElement | HTMLElement, + EuiFlyoutResizableProps +>((props, ref) => { + return ; +}); EuiFlyoutResizable.displayName = 'EuiFlyoutResizable'; diff --git a/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx index d9195ad2cae..28c2ae874e7 100644 --- a/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx @@ -54,6 +54,8 @@ interface FlyoutChildStoryArgs extends EuiFlyoutChildActualProps { paddingSize?: 'none' | 's' | 'm' | 'l'; pushMinBreakpoint: EuiBreakpointSize; showFooter?: boolean; + mainFlyoutResizable?: boolean; + childFlyoutResizable?: boolean; } const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; @@ -105,6 +107,14 @@ const meta: Meta = { description: 'Whether to show the flyout footer. If `false`, an `EuiFlyoutFooter` will not be rendered.', }, + mainFlyoutResizable: { + control: { type: 'boolean' }, + description: 'Whether the main flyout should be resizable.', + }, + childFlyoutResizable: { + control: { type: 'boolean' }, + description: 'Whether the child flyout should be resizable.', + }, // use "childBackgroundStyle" instead backgroundStyle: { table: { disable: true } }, @@ -135,6 +145,8 @@ const meta: Meta = { pushAnimation: true, pushMinBreakpoint: 'xs', showFooter: true, + mainFlyoutResizable: false, + childFlyoutResizable: false, }, parameters: { loki: { @@ -159,6 +171,8 @@ const StatefulFlyout: React.FC = ({ mainMaxWidth, childMaxWidth, showFooter, + mainFlyoutResizable, + childFlyoutResizable, ...args }) => { const [isMainOpen, setIsMainOpen] = useState(true); @@ -221,6 +235,7 @@ const StatefulFlyout: React.FC = ({ pushMinBreakpoint={pushMinBreakpoint} maxWidth={mainMaxWidth} ownFocus={false} + resizable={mainFlyoutResizable} {...args} onClose={closeMain} > @@ -249,6 +264,7 @@ const StatefulFlyout: React.FC = ({ backgroundStyle={childBackgroundStyle} maxWidth={childMaxWidth} ownFocus={false} + resizable={childFlyoutResizable} {...args} onClose={closeChild} > diff --git a/packages/eui/src/components/flyout/use_flyout_resizable.ts b/packages/eui/src/components/flyout/use_flyout_resizable.ts new file mode 100644 index 00000000000..d931788ecb5 --- /dev/null +++ b/packages/eui/src/components/flyout/use_flyout_resizable.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { keys } from '../../services'; +import { getPosition } from '../resizable_container/helpers'; +import type { EuiFlyoutResizableProps } from './flyout_resizable'; + +type UseEuiFlyoutResizable = Pick< + EuiFlyoutResizableProps, + 'onResize' | 'side' +> & { + enabled: boolean; + minWidth?: number; + maxWidth: number | undefined; + size: string | number; +}; + +/** + * @internal + */ +export const useEuiFlyoutResizable = ({ + enabled, + minWidth = 0, + maxWidth, + onResize, + side, + size: _size, +}: UseEuiFlyoutResizable) => { + const getFlyoutMinMaxWidth = useCallback( + (width: number) => { + return Math.min( + Math.max(width, minWidth), + maxWidth || Infinity, + window.innerWidth - 20 // Leave some offset + ); + }, + [minWidth, maxWidth] + ); + + const [flyoutWidth, setFlyoutWidth] = useState(0); + const [callOnResize, setCallOnResize] = useState(false); + + // Must use state for the flyout ref in order for the useEffect to be correctly called after render + const [flyoutRef, setFlyoutRef] = useState(null); + + useEffect(() => { + if (!flyoutWidth && flyoutRef) { + setCallOnResize(false); // Don't call `onResize` for non-user width changes + setFlyoutWidth(getFlyoutMinMaxWidth(flyoutRef.offsetWidth)); + } + }, [flyoutWidth, flyoutRef, getFlyoutMinMaxWidth]); + + // Update flyout width when consumers pass in a new `size` + useEffect(() => { + setCallOnResize(false); + // For string `size`s, resetting flyoutWidth to 0 will trigger the above useEffect's recalculation + setFlyoutWidth(typeof _size === 'number' ? getFlyoutMinMaxWidth(_size) : 0); + }, [_size, getFlyoutMinMaxWidth]); + + // Initial numbers to calculate from, on resize drag start + const initialWidth = useRef(0); + const initialMouseX = useRef(0); + + // Account for flyout side and logical property direction + const direction = useMemo(() => { + let modifier = side === 'right' ? -1 : 1; + if (flyoutRef) { + const languageDirection = window.getComputedStyle(flyoutRef).direction; + if (languageDirection === 'rtl') modifier *= -1; + } + return modifier; + }, [side, flyoutRef]); + + const onMouseMove = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!enabled) { + return; + } + + const mouseOffset = getPosition(e, true) - initialMouseX.current; + const changedFlyoutWidth = initialWidth.current + mouseOffset * direction; + + setFlyoutWidth(getFlyoutMinMaxWidth(changedFlyoutWidth)); + }, + [getFlyoutMinMaxWidth, direction, enabled] + ); + + const onMouseUp = useCallback(() => { + setCallOnResize(true); + + if (!enabled) { + return; + } + + initialMouseX.current = 0; + + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchmove', onMouseMove); + window.removeEventListener('touchend', onMouseUp); + }, [onMouseMove, enabled]); + + const onMouseDown = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + setCallOnResize(false); + + if (!enabled) { + return; + } + + initialMouseX.current = getPosition(e, true); + initialWidth.current = flyoutRef?.offsetWidth ?? 0; + + // Window event listeners instead of React events are used + // in case the user's mouse leaves the component + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchmove', onMouseMove); + window.addEventListener('touchend', onMouseUp); + }, + [flyoutRef, onMouseMove, onMouseUp, enabled] + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + setCallOnResize(true); + + if (!enabled) { + return; + } + + const KEYBOARD_OFFSET = 10; + + switch (e.key) { + case keys.ARROW_RIGHT: + e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise + setFlyoutWidth((flyoutWidth) => + getFlyoutMinMaxWidth(flyoutWidth + KEYBOARD_OFFSET * direction) + ); + break; + case keys.ARROW_LEFT: + e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise + setFlyoutWidth((flyoutWidth) => + getFlyoutMinMaxWidth(flyoutWidth - KEYBOARD_OFFSET * direction) + ); + } + }, + [getFlyoutMinMaxWidth, direction, enabled] + ); + + // To reduce unnecessary calls, only fire onResize callback: + // 1. After initial mount / on user width change events only + // 2. If not currently mouse dragging + useEffect(() => { + if (callOnResize && enabled) { + onResize?.(flyoutWidth); + } + }, [onResize, callOnResize, flyoutWidth, enabled]); + + const size = useMemo(() => flyoutWidth || _size, [flyoutWidth, _size]); + + return { + onKeyDown, + onMouseDown, + setFlyoutRef, + size, + }; +};