diff --git a/packages/eui/changelogs/upcoming/8851.md b/packages/eui/changelogs/upcoming/8851.md new file mode 100644 index 00000000000..c0e818b0cc1 --- /dev/null +++ b/packages/eui/changelogs/upcoming/8851.md @@ -0,0 +1 @@ +Adds a new `EuiFlyoutMenu` component that provides a standardized top menu bar for flyouts. diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx new file mode 100644 index 00000000000..5cf86e26132 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -0,0 +1,478 @@ +/* + * 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. + */ +/* eslint-disable local/i18n */ +import React, { + useEffect, + useRef, + useMemo, + useCallback, + useState, + forwardRef, + ComponentPropsWithRef, + CSSProperties, + ElementType, + FunctionComponent, + MutableRefObject, + ReactNode, + JSX, +} from 'react'; +import classnames from 'classnames'; + +import { + keys, + EuiWindowEvent, + useCombinedRefs, + EuiBreakpointSize, + useIsWithinMinBreakpoint, + useEuiMemoizedStyles, + useGeneratedHtmlId, +} from '../../services'; +import { logicalStyle } from '../../global_styling'; + +import { CommonProps, PropsOfElement } from '../common'; +import { EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap'; +import { EuiOverlayMask, EuiOverlayMaskProps } from '../overlay_mask'; +import type { EuiButtonIconPropsForButton } from '../button'; +import { EuiI18n } from '../i18n'; +import { useResizeObserver } from '../observer/resize_observer'; +import { EuiPortal } from '../portal'; +import { EuiScreenReaderOnly } from '../accessibility'; + +import { EuiFlyoutCloseButton } from './_flyout_close_button'; +import { euiFlyoutStyles } from './flyout.styles'; +import { usePropsWithComponentDefaults } from '../provider/component_defaults'; + +export const TYPES = ['push', 'overlay'] as const; +type _EuiFlyoutType = (typeof TYPES)[number]; + +export const SIDES = ['left', 'right'] as const; +export type _EuiFlyoutSide = (typeof SIDES)[number]; + +export const SIZES = ['s', 'm', 'l'] as const; +export type EuiFlyoutSize = (typeof SIZES)[number]; + +/** + * Custom type checker for named flyout sizes since the prop + * `size` can also be CSSProperties['width'] (string | number) + */ +function isEuiFlyoutSizeNamed(value: any): value is EuiFlyoutSize { + return SIZES.includes(value as any); +} + +export const PADDING_SIZES = ['none', 's', 'm', 'l'] as const; +export type _EuiFlyoutPaddingSize = (typeof PADDING_SIZES)[number]; + +export interface _EuiFlyoutComponentProps { + onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; + /** + * Defines the width of the panel. + * Pass a predefined size of `s | m | l`, or pass any number/string compatible with the CSS `width` attribute + * @default m + */ + size?: EuiFlyoutSize | CSSProperties['width']; + /** + * Sets the max-width of the panel, + * set to `true` to use the default size, + * set to `false` to not restrict the width, + * set to a number for a custom width in px, + * set to a string for a custom width in custom measurement. + * @default false + */ + maxWidth?: boolean | number | string; + /** + * Customize the padding around the content of the flyout header, body and footer + * @default l + */ + paddingSize?: _EuiFlyoutPaddingSize; + /** + * Adds an EuiOverlayMask and wraps in an EuiPortal + * @default true + */ + ownFocus?: boolean; + /** + * Hides the default close button. You must provide another close button somewhere within the flyout. + * @default false + */ + hideCloseButton?: boolean; + /** + * Extends EuiButtonIconProps onto the close button + */ + closeButtonProps?: Partial; + /** + * Position of close button. + * `inside`: Floating to just inside the flyout, always top right; + * `outside`: Floating just outside the flyout near the top (side dependent on `side`). Helpful when the close button may cover other interactable content. + * @default inside + */ + closeButtonPosition?: 'inside' | 'outside'; + /** + * Adjustments to the EuiOverlayMask that is added when `ownFocus = true` + */ + maskProps?: EuiOverlayMaskProps; + /** + * How to display the the flyout in relation to the body content; + * `push` keeps it visible, pushing the `` content via padding + * @default overlay + */ + type?: _EuiFlyoutType; + /** + * Forces this interaction on the mask overlay or body content. + * Defaults depend on `ownFocus` and `type` values + */ + outsideClickCloses?: boolean; + /** + * Which side of the window to attach to. + * The `left` option should only be used for navigation. + * @default right + */ + side?: _EuiFlyoutSide; + /** + * Named breakpoint (`xs` through `xl`) for customizing the minimum window width to enable the `push` type + * @default l + */ + pushMinBreakpoint?: EuiBreakpointSize; + /** + * Enables a slide in animation on push flyouts + * @default false + */ + pushAnimation?: boolean; + style?: CSSProperties; + /** + * Object of props passed to EuiFocusTrap. + * `shards` specifies an array of elements that will be considered part of the flyout, preventing the flyout from being closed when clicked. + * `closeOnMouseup` will delay the close callback, allowing time for external toggle buttons to handle close behavior. + * `returnFocus` defines the return focus behavior and provides the possibility to check the available target element or opt out of the behavior in favor of manually returning focus + */ + focusTrapProps?: Pick< + EuiFocusTrapProps, + 'closeOnMouseup' | 'shards' | 'returnFocus' + >; + /** + * By default, EuiFlyout will consider any fixed `EuiHeader`s that sit alongside or above the EuiFlyout + * as part of the flyout's focus trap. This prevents focus fighting with interactive elements + * within fixed headers. + * + * Set this to `false` if you need to disable this behavior for a specific reason. + */ + includeFixedHeadersInFocusTrap?: boolean; + + /** + * Specify additional css selectors to include in the focus trap. + */ + includeSelectorInFocusTrap?: string[] | string; +} + +const defaultElement = 'div'; + +type Props = CommonProps & { + /** + * Sets the HTML element for `EuiFlyout` + */ + as?: T; +} & _EuiFlyoutComponentProps & + Omit, keyof _EuiFlyoutComponentProps>; + +export type EuiFlyoutComponentProps< + T extends ElementType = typeof defaultElement +> = Props & Omit, keyof Props>; + +export const EuiFlyoutComponent = forwardRef( + ( + props: EuiFlyoutComponentProps, + ref: + | ((instance: ComponentPropsWithRef | null) => void) + | MutableRefObject | null> + | null + ) => { + const { + className, + children, + as, + hideCloseButton = false, + closeButtonProps, + closeButtonPosition = 'inside', + onClose, + ownFocus = true, + side = 'right', + size = 'm', + paddingSize = 'l', + maxWidth = false, + style, + maskProps, + type = 'overlay', + outsideClickCloses, + pushMinBreakpoint = 'l', + pushAnimation = false, + focusTrapProps: _focusTrapProps, + includeFixedHeadersInFocusTrap = true, + includeSelectorInFocusTrap, + 'aria-describedby': _ariaDescribedBy, + ...rest + } = usePropsWithComponentDefaults('EuiFlyout', props); + + const Element = as || defaultElement; + const maskRef = useRef(null); + + const windowIsLargeEnoughToPush = + useIsWithinMinBreakpoint(pushMinBreakpoint); + const isPushed = type === 'push' && windowIsLargeEnoughToPush; + + /** + * 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 + */ + const [resizeRef, setResizeRef] = useState | null>( + null + ); + const setRef = useCombinedRefs([setResizeRef, ref]); + const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); + + useEffect(() => { + /** + * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element + */ + if (isPushed) { + const paddingSide = + side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd'; + + document.body.style[paddingSide] = `${width}px`; + return () => { + document.body.style[paddingSide] = ''; + }; + } + }, [isPushed, side, width]); + + /** + * This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC) + */ + useEffect(() => { + document.body.classList.add('euiBody--hasFlyout'); + return () => { + // Remove the hasFlyout class when the flyout is unmounted + document.body.classList.remove('euiBody--hasFlyout'); + }; + }, []); + + /** + * ESC key closes flyout (always?) + */ + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isPushed && event.key === keys.ESCAPE) { + event.preventDefault(); + onClose(event); + } + }, + [onClose, isPushed] + ); + + /** + * Set inline styles + */ + const inlineStyles = useMemo(() => { + const widthStyle = + !isEuiFlyoutSizeNamed(size) && logicalStyle('width', size); + const maxWidthStyle = + typeof maxWidth !== 'boolean' && logicalStyle('max-width', maxWidth); + + return { + ...style, + ...widthStyle, + ...maxWidthStyle, + }; + }, [style, maxWidth, size]); + + const styles = useEuiMemoizedStyles(euiFlyoutStyles); + const cssStyles = [ + styles.euiFlyout, + styles.paddingSizes[paddingSize], + isEuiFlyoutSizeNamed(size) && styles[size], + maxWidth === false && styles.noMaxWidth, + isPushed ? styles.push.push : styles.overlay.overlay, + isPushed ? styles.push[side] : styles.overlay[side], + isPushed && !pushAnimation && styles.push.noAnimation, + styles[side], + ]; + + const classes = classnames('euiFlyout', className); + + /* + * If not disabled, automatically add fixed EuiHeaders as shards + * to EuiFlyout focus traps, to prevent focus fighting + */ + const flyoutToggle = useRef(document.activeElement); + const [fixedHeaders, setFixedHeaders] = useState([]); + + useEffect(() => { + if (includeFixedHeadersInFocusTrap) { + const fixedHeaderEls = document.querySelectorAll( + '.euiHeader[data-fixed-header]' + ); + setFixedHeaders(Array.from(fixedHeaderEls)); + + // Flyouts that are toggled from fixed headers do not have working + // focus trap autoFocus, so we need to focus the flyout wrapper ourselves + fixedHeaderEls.forEach((header) => { + if (header.contains(flyoutToggle.current)) { + resizeRef?.focus(); + } + }); + } else { + // Clear existing headers if necessary, e.g. switching to `false` + setFixedHeaders((headers) => (headers.length ? [] : headers)); + } + }, [includeFixedHeadersInFocusTrap, resizeRef]); + + const focusTrapProps: EuiFlyoutComponentProps['focusTrapProps'] = useMemo( + () => ({ + ..._focusTrapProps, + shards: [...fixedHeaders, ...(_focusTrapProps?.shards || [])], + }), + [fixedHeaders, _focusTrapProps] + ); + + /* + * Provide meaningful screen reader instructions/details + */ + const hasOverlayMask = ownFocus && !isPushed; + const descriptionId = useGeneratedHtmlId(); + const ariaDescribedBy = classnames(descriptionId, _ariaDescribedBy); + + const screenReaderDescription = useMemo( + () => ( + +

+ {hasOverlayMask ? ( + + ) : ( + + )}{' '} + {fixedHeaders.length > 0 && ( + + )} +

+
+ ), + [hasOverlayMask, descriptionId, fixedHeaders.length] + ); + + /* + * Trap focus even when `ownFocus={false}`, otherwise closing + * the flyout won't return focus to the originating button. + * + * Set `clickOutsideDisables={true}` when `ownFocus={false}` + * to allow non-keyboard users the ability to interact with + * elements outside the flyout. + * + * Set `onClickOutside={onClose}` when `ownFocus` and `type` are the defaults, + * or if `outsideClickCloses={true}` to close on clicks that target + * (both mousedown and mouseup) the overlay mask. + */ + const onClickOutside = useCallback( + (event: MouseEvent | TouchEvent) => { + // Do not close the flyout for any external click + if (outsideClickCloses === false) return undefined; + if (hasOverlayMask) { + // The overlay mask is present, so only clicks on the mask should close the flyout, regardless of outsideClickCloses + if (event.target === maskRef.current) return onClose(event); + } else { + // No overlay mask is present, so any outside clicks should close the flyout + if (outsideClickCloses === true) return onClose(event); + } + // Otherwise if ownFocus is false and outsideClickCloses is undefined, outside clicks should not close the flyout + return undefined; + }, + [onClose, hasOverlayMask, outsideClickCloses] + ); + + return ( + + + + )} + role={!isPushed ? 'dialog' : rest.role} + aria-modal={!isPushed || undefined} + tabIndex={!isPushed ? 0 : rest.tabIndex} + aria-describedby={!isPushed ? ariaDescribedBy : _ariaDescribedBy} + data-autofocus={!isPushed || undefined} + > + {!isPushed && screenReaderDescription} + {!hideCloseButton && onClose && ( + + )} + {children} + + + + ); + } + // React.forwardRef interferes with the inferred element type + // Casting to ensure correct element prop type checking for `as` + // e.g., `href` is not on a `div` +) as ( + props: EuiFlyoutComponentProps +) => JSX.Element; +// Recast to allow `displayName` +(EuiFlyoutComponent as FunctionComponent).displayName = 'EuiFlyoutComponent'; + +/** + * Light wrapper for conditionally rendering portals or overlay masks: + * - If ownFocus is set, wrap with an overlay and allow the user to click it to close it. + * - Otherwise still wrap within an EuiPortal so it appends to the bottom of the window. + * Push flyouts have no overlay OR portal behavior. + */ +const EuiFlyoutComponentWrapper: FunctionComponent<{ + children: ReactNode; + hasOverlayMask: boolean; + maskProps: EuiFlyoutComponentProps['maskProps']; + isPortalled: boolean; +}> = ({ children, hasOverlayMask, maskProps, isPortalled }) => { + if (hasOverlayMask) { + return ( + + {children} + + ); + } else if (isPortalled) { + return {children}; + } else { + return <>{children}; + } +}; diff --git a/packages/eui/src/components/flyout/flyout.stories.tsx b/packages/eui/src/components/flyout/flyout.stories.tsx index f56ca63d4ee..53b577ef33f 100644 --- a/packages/eui/src/components/flyout/flyout.stories.tsx +++ b/packages/eui/src/components/flyout/flyout.stories.tsx @@ -10,7 +10,14 @@ import React, { useRef, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { EuiButton, EuiCallOut, EuiSpacer, EuiText, EuiTitle } from '../index'; +import { + EuiButton, + EuiCallOut, + // EuiComponentDefaultsProvider, + EuiSpacer, + EuiText, + EuiTitle, +} from '../index'; import { EuiFlyout, @@ -68,6 +75,9 @@ const StatefulFlyout = ( }; return ( + // <> handleToggle(!_isOpen)}> Toggle flyout @@ -82,6 +92,7 @@ const StatefulFlyout = ( /> )} + // ); }; diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index b8336a8d434..2f7065e23fa 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -6,583 +6,34 @@ * Side Public License, v 1. */ -import React, { - ComponentProps, - useEffect, - useRef, - useMemo, - useCallback, - useState, - forwardRef, - ComponentPropsWithRef, - CSSProperties, - ElementType, - FunctionComponent, - MutableRefObject, - ReactNode, - JSX, -} from 'react'; -import classnames from 'classnames'; - +import React from 'react'; import { - keys, - EuiWindowEvent, - useCombinedRefs, - EuiBreakpointSize, - useIsWithinMinBreakpoint, - useEuiMemoizedStyles, - useGeneratedHtmlId, - useEuiThemeCSSVariables, -} from '../../services'; -import { logicalStyle } from '../../global_styling'; - -import { CommonProps, PropsOfElement } from '../common'; -import { EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap'; -import { EuiOverlayMask, EuiOverlayMaskProps } from '../overlay_mask'; -import type { EuiButtonIconPropsForButton } from '../button'; -import { EuiI18n } from '../i18n'; -import { useResizeObserver } from '../observer/resize_observer'; -import { EuiPortal } from '../portal'; -import { EuiScreenReaderOnly } from '../accessibility'; - -import { EuiFlyoutCloseButton } from './_flyout_close_button'; -import { euiFlyoutStyles } from './flyout.styles'; -import { EuiFlyoutChild } from './flyout_child'; -import { EuiFlyoutChildProvider } from './flyout_child_manager'; -import { usePropsWithComponentDefaults } from '../provider/component_defaults'; - -export const TYPES = ['push', 'overlay'] as const; -type _EuiFlyoutType = (typeof TYPES)[number]; - -export const SIDES = ['left', 'right'] as const; -export type _EuiFlyoutSide = (typeof SIDES)[number]; - -export const SIZES = ['s', 'm', 'l'] as const; -export type EuiFlyoutSize = (typeof SIZES)[number]; + EuiFlyoutComponent, + type EuiFlyoutComponentProps, +} from './flyout.component'; +import { EuiFlyoutMain } from './managed'; -/** - * Custom type checker for named flyout sizes since the prop - * `size` can also be CSSProperties['width'] (string | number) - */ -function isEuiFlyoutSizeNamed(value: any): value is EuiFlyoutSize { - return SIZES.includes(value as any); -} - -export const PADDING_SIZES = ['none', 's', 'm', 'l'] as const; -export type _EuiFlyoutPaddingSize = (typeof PADDING_SIZES)[number]; +export type { EuiFlyoutSize } from './flyout.component'; +export { SIDES, PADDING_SIZES, SIZES, TYPES } from './flyout.component'; -interface _EuiFlyoutProps { - onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; - /** - * Defines the width of the panel. - * Pass a predefined size of `s | m | l`, or pass any number/string compatible with the CSS `width` attribute - * @default m - */ - size?: EuiFlyoutSize | CSSProperties['width']; - /** - * Sets the max-width of the panel, - * set to `true` to use the default size, - * set to `false` to not restrict the width, - * set to a number for a custom width in px, - * set to a string for a custom width in custom measurement. - * @default false - */ - maxWidth?: boolean | number | string; - /** - * Customize the padding around the content of the flyout header, body and footer - * @default l - */ - paddingSize?: _EuiFlyoutPaddingSize; - /** - * Adds an EuiOverlayMask and wraps in an EuiPortal - * @default true - */ - ownFocus?: boolean; - /** - * Hides the default close button. You must provide another close button somewhere within the flyout. - * @default false - */ - hideCloseButton?: boolean; - /** - * Extends EuiButtonIconProps onto the close button - */ - closeButtonProps?: Partial; - /** - * Position of close button. - * `inside`: Floating to just inside the flyout, always top right; - * `outside`: Floating just outside the flyout near the top (side dependent on `side`). Helpful when the close button may cover other interactable content. - * @default inside - */ - closeButtonPosition?: 'inside' | 'outside'; - /** - * Adjustments to the EuiOverlayMask that is added when `ownFocus = true` - */ - maskProps?: EuiOverlayMaskProps; - /** - * How to display the the flyout in relation to the body content; - * `push` keeps it visible, pushing the `` content via padding - * @default overlay - */ - type?: _EuiFlyoutType; - /** - * Forces this interaction on the mask overlay or body content. - * Defaults depend on `ownFocus` and `type` values - */ - outsideClickCloses?: boolean; - /** - * Which side of the window to attach to. - * The `left` option should only be used for navigation. - * @default right - */ - side?: _EuiFlyoutSide; - /** - * Named breakpoint (`xs` through `xl`) for customizing the minimum window width to enable the `push` type - * @default l - */ - pushMinBreakpoint?: EuiBreakpointSize; - /** - * Enables a slide in animation on push flyouts - * @default false - */ - pushAnimation?: boolean; - style?: CSSProperties; - /** - * Object of props passed to EuiFocusTrap. - * `shards` specifies an array of elements that will be considered part of the flyout, preventing the flyout from being closed when clicked. - * `closeOnMouseup` will delay the close callback, allowing time for external toggle buttons to handle close behavior. - * `returnFocus` defines the return focus behavior and provides the possibility to check the available target element or opt out of the behavior in favor of manually returning focus - */ - focusTrapProps?: Pick< - EuiFocusTrapProps, - 'closeOnMouseup' | 'shards' | 'returnFocus' - >; - /** - * By default, EuiFlyout will consider any fixed `EuiHeader`s that sit alongside or above the EuiFlyout - * as part of the flyout's focus trap. This prevents focus fighting with interactive elements - * within fixed headers. - * - * Set this to `false` if you need to disable this behavior for a specific reason. - */ - includeFixedHeadersInFocusTrap?: boolean; - - /** - * Specify additional css selectors to include in the focus trap. - */ - includeSelectorInFocusTrap?: string[] | string; +export interface EuiFlyoutProps extends EuiFlyoutComponentProps { + session?: boolean; } -const defaultElement = 'div'; - -type Props = CommonProps & { - /** - * Sets the HTML element for `EuiFlyout` - */ - as?: T; -} & _EuiFlyoutProps & - Omit, keyof _EuiFlyoutProps>; - -export type EuiFlyoutProps = - Props & Omit, keyof Props>; - -export const EuiFlyout = forwardRef( - ( - props: EuiFlyoutProps, - ref: - | ((instance: ComponentPropsWithRef | null) => void) - | MutableRefObject | null> - | null - ) => { - const { - className, - children, - as, - hideCloseButton = false, - closeButtonProps, - closeButtonPosition: _closeButtonPosition = 'inside', - onClose, - ownFocus = true, - side = 'right', - size = 'm', - paddingSize = 'l', - maxWidth = false, - style, - maskProps, - type = 'overlay', - outsideClickCloses, - pushMinBreakpoint = 'l', - pushAnimation = false, - focusTrapProps: _focusTrapProps, - includeFixedHeadersInFocusTrap = true, - includeSelectorInFocusTrap, - 'aria-describedby': _ariaDescribedBy, - ...rest - } = usePropsWithComponentDefaults('EuiFlyout', props); - - const { setGlobalCSSVariables } = useEuiThemeCSSVariables(); - - const Element = as || defaultElement; - const maskRef = useRef(null); - - // Ref for the main flyout element to pass to context - const internalParentFlyoutRef = useRef(null); - - const [isChildFlyoutOpen, setIsChildFlyoutOpen] = useState(false); - const [childLayoutMode, setChildLayoutMode] = useState< - 'side-by-side' | 'stacked' - >('side-by-side'); - - // Check for child flyout - const childFlyoutElement = React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && - (child.type === EuiFlyoutChild || - (child.type as any).displayName === 'EuiFlyoutChild') - ) as React.ReactElement> | undefined; - - const hasChildFlyout = !!childFlyoutElement; - - // Validate props, determine close button position and set child flyout classes - let closeButtonPosition: 'inside' | 'outside'; - let childFlyoutClasses: string[] = []; - if (hasChildFlyout) { - if (side !== 'right') { - throw new Error( - 'EuiFlyout: When an EuiFlyoutChild is present, the `side` prop of EuiFlyout must be "right".' - ); - } - if (!isEuiFlyoutSizeNamed(size) || !['s', 'm'].includes(size)) { - throw new Error( - `EuiFlyout: When an EuiFlyoutChild is present, the \`size\` prop of EuiFlyout must be "s" or "m". Received "${size}".` - ); - } - if (_closeButtonPosition !== 'inside') { - throw new Error( - 'EuiFlyout: When an EuiFlyoutChild is present, the `closeButtonPosition` prop of EuiFlyout must be "inside".' - ); - } - - closeButtonPosition = 'inside'; - childFlyoutClasses = [ - 'euiFlyout--hasChild', - `euiFlyout--hasChild--${childLayoutMode}`, - `euiFlyout--hasChild--${childFlyoutElement.props.size || 's'}`, - ]; - } else { - closeButtonPosition = _closeButtonPosition; - } - - const windowIsLargeEnoughToPush = - useIsWithinMinBreakpoint(pushMinBreakpoint); - const isPushed = type === 'push' && windowIsLargeEnoughToPush; - - /** - * 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 - */ - const [resizeRef, setResizeRef] = useState | null>( - null - ); - const setRef = useCombinedRefs([ - setResizeRef, - ref, - internalParentFlyoutRef, - ]); - const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); - - useEffect(() => { - /** - * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element - */ - if (isPushed) { - const paddingSide = - side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd'; - const cssVarName = `--euiPushFlyoutOffset${ - side === 'left' ? 'InlineStart' : 'InlineEnd' - }`; +export const EuiFlyout = ({ session, ...props }: EuiFlyoutProps) => { + // const hasActiveSession = useHasActiveSession(); - document.body.style[paddingSide] = `${width}px`; - - // EUI doesn't use this css variable, but it is useful for consumers - setGlobalCSSVariables({ - [cssVarName]: `${width}px`, - }); - return () => { - document.body.style[paddingSide] = ''; - setGlobalCSSVariables({ - [cssVarName]: null, - }); - }; - } - }, [isPushed, setGlobalCSSVariables, side, width]); - - /** - * This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC) - */ - useEffect(() => { - document.body.classList.add('euiBody--hasFlyout'); - return () => { - // Remove the hasFlyout class when the flyout is unmounted - document.body.classList.remove('euiBody--hasFlyout'); - }; - }, []); - - /** - * ESC key closes flyout (always?) - */ - const onKeyDown = useCallback( - (event: KeyboardEvent) => { - if (!isPushed && event.key === keys.ESCAPE && !isChildFlyoutOpen) { - event.preventDefault(); - onClose(event); - } - }, - [onClose, isPushed, isChildFlyoutOpen] - ); - - /** - * Set inline styles - */ - const inlineStyles = useMemo(() => { - const widthStyle = - !isEuiFlyoutSizeNamed(size) && logicalStyle('width', size); - const maxWidthStyle = - typeof maxWidth !== 'boolean' && logicalStyle('max-width', maxWidth); - - return { - ...style, - ...widthStyle, - ...maxWidthStyle, - }; - }, [style, maxWidth, size]); - - const styles = useEuiMemoizedStyles(euiFlyoutStyles); - const cssStyles = [ - styles.euiFlyout, - styles.paddingSizes[paddingSize], - isEuiFlyoutSizeNamed(size) && styles[size], - maxWidth === false && styles.noMaxWidth, - isPushed ? styles.push.push : styles.overlay.overlay, - isPushed ? styles.push[side] : styles.overlay[side], - isPushed && !pushAnimation && styles.push.noAnimation, - styles[side], - ]; - - const classes = classnames('euiFlyout', ...childFlyoutClasses, className); - - /* - * Trap focus even when `ownFocus={false}`, otherwise closing - * the flyout won't return focus to the originating button. - * - * Set `clickOutsideDisables={true}` when `ownFocus={false}` - * to allow non-keyboard users the ability to interact with - * elements outside the flyout. - * - * Set `onClickOutside={onClose}` when `ownFocus` and `type` are the defaults, - * or if `outsideClickCloses={true}` to close on clicks that target - * (both mousedown and mouseup) the overlay mask. - */ - const flyoutToggle = useRef(document.activeElement); - const [focusTrapShards, setFocusTrapShards] = useState([]); - - const focusTrapSelectors = useMemo(() => { - let selectors: string[] = []; - - if (includeSelectorInFocusTrap) { - selectors = Array.isArray(includeSelectorInFocusTrap) - ? includeSelectorInFocusTrap - : [includeSelectorInFocusTrap]; - } - - if (includeFixedHeadersInFocusTrap) { - selectors.push('.euiHeader[data-fixed-header]'); - } - - return selectors; - }, [includeSelectorInFocusTrap, includeFixedHeadersInFocusTrap]); - - useEffect(() => { - if (focusTrapSelectors.length > 0) { - const shardsEls = focusTrapSelectors.flatMap((selector) => - Array.from(document.querySelectorAll(selector)) - ); - - setFocusTrapShards(Array.from(shardsEls)); - - // Flyouts that are toggled from shards do not have working - // focus trap autoFocus, so we need to focus the flyout wrapper ourselves - shardsEls.forEach((shard) => { - if (shard.contains(flyoutToggle.current)) { - resizeRef?.focus(); - } - }); - } else { - // Clear existing shards if necessary, e.g. switching to `false` - setFocusTrapShards((shards) => (shards.length ? [] : shards)); - } - }, [focusTrapSelectors, resizeRef]); - - const focusTrapProps: EuiFlyoutProps['focusTrapProps'] = useMemo( - () => ({ - ..._focusTrapProps, - shards: [...focusTrapShards, ...(_focusTrapProps?.shards || [])], - }), - [_focusTrapProps, focusTrapShards] - ); - - /* - * Provide meaningful screen reader instructions/details - */ - const hasOverlayMask = ownFocus && !isPushed; - const descriptionId = useGeneratedHtmlId(); - const ariaDescribedBy = classnames(descriptionId, _ariaDescribedBy); - - const screenReaderDescription = useMemo( - () => ( - -

- {hasOverlayMask ? ( - - ) : ( - - )}{' '} - {focusTrapShards.length > 0 && ( - - )} -

-
- ), - [hasOverlayMask, descriptionId, focusTrapShards.length] - ); - - /* - * Trap focus even when `ownFocus={false}`, otherwise closing - * the flyout won't return focus to the originating button. - * - * Set `clickOutsideDisables={true}` when `ownFocus={false}` - * to allow non-keyboard users the ability to interact with - * elements outside the flyout. - * - * Set `onClickOutside={onClose}` when `ownFocus` and `type` are the defaults, - * or if `outsideClickCloses={true}` to close on clicks that target - * (both mousedown and mouseup) the overlay mask. - */ - const onClickOutside = useCallback( - (event: MouseEvent | TouchEvent) => { - // Do not close the flyout for any external click - if (outsideClickCloses === false) return undefined; - if (hasOverlayMask) { - // The overlay mask is present, so only clicks on the mask should close the flyout, regardless of outsideClickCloses - if (event.target === maskRef.current) return onClose(event); - } else { - // No overlay mask is present, so any outside clicks should close the flyout - if (outsideClickCloses === true) return onClose(event); - } - // Otherwise if ownFocus is false and outsideClickCloses is undefined, outside clicks should not close the flyout - return undefined; - }, - [onClose, hasOverlayMask, outsideClickCloses] - ); - - const closeButton = !hideCloseButton && ( - - ); + // If session={true}, render EuiMainFlyout. + if (session === true) { + return ; + } - // render content within EuiFlyoutChildProvider if childFlyoutElement is present - let contentToRender: React.ReactElement = children; - if (hasChildFlyout && childFlyoutElement) { - contentToRender = ( - - ); - } + // Else if this flyout is a child of a session, render EuiChildFlyout. + // if (hasActiveSession) { + // return ; + // } - return ( - - - - )} - role={!isPushed ? 'dialog' : rest.role} - aria-modal={!isPushed || undefined} - tabIndex={!isPushed ? 0 : rest.tabIndex} - aria-describedby={!isPushed ? ariaDescribedBy : _ariaDescribedBy} - data-autofocus={!isPushed || undefined} - > - {!isPushed && screenReaderDescription} - {closeButton} - {contentToRender} - - - - ); - } - // React.forwardRef interferes with the inferred element type - // Casting to ensure correct element prop type checking for `as` - // e.g., `href` is not on a `div` -) as ( - props: EuiFlyoutProps -) => JSX.Element; -// Recast to allow `displayName` -(EuiFlyout as FunctionComponent).displayName = 'EuiFlyout'; + // TODO: if resizeable={true}, render EuiResizableFlyout. -/** - * Light wrapper for conditionally rendering portals or overlay masks: - * - If ownFocus is set, wrap with an overlay and allow the user to click it to close it. - * - Otherwise still wrap within an EuiPortal so it appends to the bottom of the window. - * Push flyouts have no overlay OR portal behavior. - */ -const EuiFlyoutWrapper: FunctionComponent<{ - children: ReactNode; - hasOverlayMask: boolean; - maskProps: EuiFlyoutProps['maskProps']; - isPortalled: boolean; -}> = ({ children, hasOverlayMask, maskProps, isPortalled }) => { - if (hasOverlayMask) { - return ( - - {children} - - ); - } else if (isPortalled) { - return {children}; - } else { - return <>{children}; - } + return ; }; diff --git a/packages/eui/src/components/flyout/flyout_child.tsx b/packages/eui/src/components/flyout/flyout_child.tsx index 4ee71b73282..d90d84acd38 100644 --- a/packages/eui/src/components/flyout/flyout_child.tsx +++ b/packages/eui/src/components/flyout/flyout_child.tsx @@ -24,6 +24,8 @@ import { euiFlyoutChildStyles } from './flyout_child.styles'; import { EuiFlyoutCloseButton } from './_flyout_close_button'; import { EuiFlyoutContext } from './flyout_context'; import { EuiFlyoutBody } from './flyout_body'; +import { EuiFlyoutMenu } from './flyout_menu'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; import { EuiFocusTrap } from '../focus_trap'; /** @@ -118,8 +120,16 @@ export const EuiFlyoutChild: FunctionComponent = ({ let flyoutTitleText: string | undefined; let hasDescribedByBody = false; + let hasFlyoutMenu = false; Children.forEach(children, (child) => { if (React.isValidElement(child)) { + if ( + child.type === EuiFlyoutMenu || + (child.type as any).displayName === 'EuiFlyoutMenu' + ) { + hasFlyoutMenu = true; + } + if ((child.type as any)?.displayName === 'EuiFlyoutHeader') { // Attempt to extract string content from header for ARIA const headerChildren = child.props.children; @@ -257,7 +267,7 @@ export const EuiFlyoutChild: FunctionComponent = ({ {flyoutTitleText} )} - {!hideCloseButton && ( + {!hideCloseButton && !hasFlyoutMenu && ( = ({ className="euiFlyoutChild__overflowContent" css={styles.overflow.wrapper} > - {processedChildren} + + {processedChildren} + diff --git a/packages/eui/src/components/flyout/flyout_menu.stories.tsx b/packages/eui/src/components/flyout/flyout_menu.stories.tsx new file mode 100644 index 00000000000..a034e07365a --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu.stories.tsx @@ -0,0 +1,68 @@ +/* + * 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 { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React, { useState } from 'react'; +import { EuiButton, EuiButtonIcon } from '../button'; +import { EuiText } from '../text'; +import { EuiFlyout } from './flyout'; +import { EuiFlyoutBody } from './flyout_body'; +import { EuiFlyoutChild } from './flyout_child'; +import { EuiFlyoutMenu } from './flyout_menu'; + +const meta: Meta = { + title: 'Layout/EuiFlyout/EuiFlyoutMenu', + component: EuiFlyoutMenu, +}; + +export default meta; + +const MenuBarFlyout = () => { + const [isOpen, setIsOpen] = useState(true); + + const openFlyout = () => setIsOpen(true); + const closeFlyout = () => setIsOpen(false); + + const handleCustomActionClick = () => { + action('custom action clicked')(); + }; + + return ( + <> + Open flyout + {isOpen && ( + + + + Main flyout content. + + + + + + + Child with custom action in the menu bar. + + + + )} + + ); +}; + +export const MenuBarExample: StoryObj = { + name: 'Menu bar example', + render: () => , +}; diff --git a/packages/eui/src/components/flyout/flyout_menu.styles.ts b/packages/eui/src/components/flyout/flyout_menu.styles.ts new file mode 100644 index 00000000000..49733bc6f33 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu.styles.ts @@ -0,0 +1,32 @@ +/* + * 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 { css } from '@emotion/react'; +import { UseEuiTheme } from '../../services'; + +export const euiFlyoutMenuStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + return { + euiFlyoutMenu__container: css` + block-size: calc(${euiTheme.size.m} * 3.5); + flex-shrink: 0; + padding-block: ${euiTheme.size.s}; + padding-inline: ${euiTheme.size.s}; + border-block-end: ${euiTheme.border.width.thin} solid + ${euiTheme.border.color}; + padding-block-start: calc(${euiTheme.size.m} * 0.8); + + .euiTitle { + padding-inline: ${euiTheme.size.s}; + } + `, + euiFlyoutMenu__spacer: css` + padding-inline: ${euiTheme.size.m}; + `, + }; +}; diff --git a/packages/eui/src/components/flyout/flyout_menu.tsx b/packages/eui/src/components/flyout/flyout_menu.tsx new file mode 100644 index 00000000000..7fefaae1a4f --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu.tsx @@ -0,0 +1,84 @@ +/* + * 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 classNames from 'classnames'; +import React, { FunctionComponent, HTMLAttributes, useContext } from 'react'; +import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../services'; +import { CommonProps } from '../common'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiTitle } from '../title'; +import { EuiFlyoutCloseButton } from './_flyout_close_button'; +import { euiFlyoutMenuStyles } from './flyout_menu.styles'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; + +export type EuiFlyoutMenuProps = CommonProps & + HTMLAttributes & { + backButton?: React.ReactNode; + popover?: React.ReactNode; + title?: React.ReactNode; + hideCloseButton?: boolean; + }; + +export const EuiFlyoutMenu: FunctionComponent = ({ + children, + className, + backButton, + popover, + title, + hideCloseButton, + ...rest +}) => { + const { onClose } = useContext(EuiFlyoutMenuContext); + + const styles = useEuiMemoizedStyles(euiFlyoutMenuStyles); + const classes = classNames('euiFlyoutMenu', className); + const titleId = useGeneratedHtmlId(); + + let titleNode; + if (title) { + titleNode = ( + +

{title}

+
+ ); + } + + const handleClose = (event: MouseEvent | TouchEvent | KeyboardEvent) => { + onClose?.(event); + }; + + let closeButton; + if (!hideCloseButton) { + closeButton = ( + + ); + } + + return ( +
+ + {backButton && {backButton}} + {popover && {popover}} + {titleNode && {titleNode}} + + {children && {children}} + + + {closeButton} +
+ ); +}; diff --git a/packages/eui/src/components/flyout/flyout_menu_context.ts b/packages/eui/src/components/flyout/flyout_menu_context.ts new file mode 100644 index 00000000000..fc0eb673b76 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu_context.ts @@ -0,0 +1,18 @@ +/* + * 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 { createContext } from 'react'; +import { EuiFlyoutProps } from './flyout'; + +interface EuiFlyoutMenuContextProps { + onClose?: EuiFlyoutProps['onClose']; +} + +export const EuiFlyoutMenuContext = createContext( + {} +); diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index 6c34bdb77a9..bbe7bc29077 100644 --- a/packages/eui/src/components/flyout/index.ts +++ b/packages/eui/src/components/flyout/index.ts @@ -6,8 +6,12 @@ * Side Public License, v 1. */ -export type { EuiFlyoutProps, EuiFlyoutSize } from './flyout'; export { EuiFlyout } from './flyout'; +export type { EuiFlyoutProps } from './flyout'; + +// When props can be better aligned, we can switch to `managed`. +// export { EuiFlyout } from './managed'; +// export type { EuiFlyoutProps } from './managed'; export type { EuiFlyoutBodyProps } from './flyout_body'; export { EuiFlyoutBody } from './flyout_body'; @@ -26,12 +30,16 @@ export { EuiFlyoutResizable } from './flyout_resizable'; export { EuiFlyoutChild } from './flyout_child'; export type { EuiFlyoutChildProps } from './flyout_child'; +export type { EuiFlyoutMenuProps } from './flyout_menu'; +export { EuiFlyoutMenu } from './flyout_menu'; + export type { EuiFlyoutSessionApi, EuiFlyoutSessionConfig, EuiFlyoutSessionOpenChildOptions, - EuiFlyoutSessionOpenMainOptions, EuiFlyoutSessionOpenGroupOptions, + EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionOpenManagedOptions, EuiFlyoutSessionProviderComponentProps, EuiFlyoutSessionRenderContext, } from './sessions'; diff --git a/packages/eui/src/components/flyout/managed/flyout_child.tsx b/packages/eui/src/components/flyout/managed/flyout_child.tsx new file mode 100644 index 00000000000..a08c202d0f2 --- /dev/null +++ b/packages/eui/src/components/flyout/managed/flyout_child.tsx @@ -0,0 +1,17 @@ +/* + * 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 { EuiFlyoutComponentProps } from '../flyout.component'; +import { EuiManagedFlyout } from './flyout_managed'; + +export interface EuiFlyoutChildProps extends EuiFlyoutComponentProps {} + +export function EuiFlyoutChild(props: EuiFlyoutChildProps) { + return ; +} diff --git a/packages/eui/src/components/flyout/managed/flyout_main.tsx b/packages/eui/src/components/flyout/managed/flyout_main.tsx new file mode 100644 index 00000000000..e709694aec5 --- /dev/null +++ b/packages/eui/src/components/flyout/managed/flyout_main.tsx @@ -0,0 +1,17 @@ +/* + * 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 { EuiFlyoutComponentProps } from '../flyout.component'; +import { EuiManagedFlyout } from './flyout_managed'; + +export interface EuiFlyoutMainProps extends EuiFlyoutComponentProps {} + +export function EuiFlyoutMain(props: EuiFlyoutMainProps) { + return ; +} diff --git a/packages/eui/src/components/flyout/managed/flyout_managed.tsx b/packages/eui/src/components/flyout/managed/flyout_managed.tsx new file mode 100644 index 00000000000..dcc5c6238d1 --- /dev/null +++ b/packages/eui/src/components/flyout/managed/flyout_managed.tsx @@ -0,0 +1,31 @@ +/* + * 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, { useId, useRef } from 'react'; +import { EuiFlyoutComponentProps } from '../flyout.component'; + +export interface EuiManagedFlyoutProps extends EuiFlyoutComponentProps {} + +// The persistent component that renders in the provider +export const EuiManagedFlyout = ({ + id, + onClose, + ...props +}: EuiFlyoutComponentProps) => { + const defaultId = useId(); + const componentIdRef = useRef(id || `persistent-${defaultId}`); + + /* + TODO: use the id to register or render the flyout, EuiFlyoutComponent. The point here is to render + the flyout in the provider, not in the parent, but still respond to the props provided to it by the + parent. Past attempts have caused infinite re-renders, or no re-renders at all. + */ + + // This component renders nothing in its parent - it only renders in the provider + return <>; +}; diff --git a/packages/eui/src/components/flyout/managed/flyout_manager.stories.tsx b/packages/eui/src/components/flyout/managed/flyout_manager.stories.tsx new file mode 100644 index 00000000000..19cb3f7fcf8 --- /dev/null +++ b/packages/eui/src/components/flyout/managed/flyout_manager.stories.tsx @@ -0,0 +1,263 @@ +/* + * 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 { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; + +import { + EuiButton, + EuiFlexGroup, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSpacer, + EuiText, + EuiTitle, +} from '../../index'; +import { EuiFlyout, EuiFlyoutProps } from '../flyout'; + +const meta: Meta = { + title: 'Layout/EuiFlyout/Flyout Manager', + component: EuiFlyout, +}; + +export default meta; + +interface ECommerceContentProps { + itemQuantity: number; +} + +interface ShoppingCartProps + extends ECommerceContentProps, + Pick { + onQuantityChange: (delta: number) => void; +} + +const ShoppingCartFlyout = ({ + itemQuantity, + onQuantityChange, + onClose: onCloseProp, +}: ShoppingCartProps) => { + console.log('RENDERING SHOPPING CART FLYOUT'); + const [isItemDetailsOpen, setIsItemDetailsOpen] = useState(false); + const [isReviewCartOpen, setIsReviewCartOpen] = useState(false); + + const onClose: typeof onCloseProp = (event) => { + onCloseProp(event); + }; + + return ( + + + +

Shopping cart

+
+
+ + +

Item: Flux Capacitor

+
+ setIsItemDetailsOpen(!isItemDetailsOpen)}> + {isItemDetailsOpen ? 'Close item details' : 'View item details'} + + + Quantity: {itemQuantity} + onQuantityChange(-1)} + iconType="minusInCircle" + aria-label="Decrease quantity" + isDisabled={itemQuantity <= 0} + > + -1 + {' '} + onQuantityChange(1)} + iconType="plusInCircle" + aria-label="Increase quantity" + > + +1 + + + setIsReviewCartOpen(true)} + isDisabled={itemQuantity <= 0} + fill + > + {isReviewCartOpen ? 'Close review' : 'Proceed to review'} + + {isItemDetailsOpen && ( + + )} + {isReviewCartOpen && ( + <> + + + )} +
+ + ) => + onClose(e.nativeEvent) + } + color="danger" + > + Close + + +
+ ); +}; + +interface ReviewOrderProps + extends ECommerceContentProps, + Pick {} + +const ReviewOrderFlyout = ({ itemQuantity, ...props }: ReviewOrderProps) => { + console.log('RENDERING REVIEW ORDER FLYOUT'); + const [orderConfirmed, setOrderConfirmed] = useState(false); + + return ( + + + +

Review order

+
+
+ + +

Review your order

+

Item: Flux Capacitor

+

Quantity: {itemQuantity}

+
+ + {orderConfirmed ? ( + +

Order confirmed!

+
+ ) : ( + setOrderConfirmed(true)} + fill + color="accent" + > + Confirm purchase + + )} +
+ + {!orderConfirmed && ( + { + console.log('Go back button clicked'); + // goBack(); + }} + color="danger" + > + Go back + + )}{' '} + ) => + props.onClose(e.nativeEvent) + } + color="danger" + > + Close + + +
+ ); +}; + +interface ItemDetailsProps + extends ECommerceContentProps, + Pick {} + +const ItemDetailsFlyout = ({ onClose, itemQuantity }: ItemDetailsProps) => { + return ( + + + +

Item details

+
+
+ + +

+ Item: Flux Capacitor +

+

+ Selected quantity: {itemQuantity} +

+

+ This amazing device makes time travel possible! Handle with care. +

+
+
+ + ) => + onClose(e.nativeEvent) + } + color="danger" + > + Close details + + +
+ ); +}; + +const BasicExampleComponent = () => { + const [isShoppingCartOpen, setIsShoppingCartOpen] = useState(false); + const [isReviewCartOpen, setIsReviewCartOpen] = useState(false); + const [isItemDetailsOpen, setIsItemDetailsOpen] = useState(false); + const [itemQuantity, setItemQuantity] = useState(1); + + return ( + <> + + setIsShoppingCartOpen(true)}> + Shopping cart + + setIsReviewCartOpen(true)}> + Review order + + setIsItemDetailsOpen(true)}> + Item details + + + + {isShoppingCartOpen && ( + setIsShoppingCartOpen(false)} + onQuantityChange={(delta: number) => + setItemQuantity(itemQuantity + delta) + } + itemQuantity={itemQuantity} + /> + )} + {isReviewCartOpen && ( + setIsReviewCartOpen(false)} + itemQuantity={itemQuantity} + /> + )} + {isItemDetailsOpen && ( + setIsItemDetailsOpen(false)} + itemQuantity={itemQuantity} + /> + )} + + ); +}; + +export const BasicExample: StoryObj = { + render: () => , +}; diff --git a/packages/eui/src/components/flyout/managed/index.ts b/packages/eui/src/components/flyout/managed/index.ts new file mode 100644 index 00000000000..1013067bf5c --- /dev/null +++ b/packages/eui/src/components/flyout/managed/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { EuiFlyoutMain, type EuiFlyoutMainProps } from './flyout_main'; +export { EuiFlyoutChild, type EuiFlyoutChildProps } from './flyout_child'; diff --git a/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx b/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx index 29ca5cb696f..ef30c5cbc37 100644 --- a/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx +++ b/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx @@ -8,7 +8,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { EuiButton, @@ -32,11 +32,12 @@ import type { EuiFlyoutSessionOpenChildOptions, EuiFlyoutSessionOpenGroupOptions, EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionOpenManagedOptions, EuiFlyoutSessionRenderContext, + EuiFlyoutSessionGroup, } from './types'; import { useEuiFlyoutSession } from './use_eui_flyout'; -// Create a single action logger instance to use throughout the file const loggerAction = action('flyout-session-log'); const meta: Meta = { @@ -74,8 +75,9 @@ interface ECommerceContentProps { interface ShoppingCartContentProps extends ECommerceContentProps { onQuantityChange: (delta: number) => void; } -interface ReviewOrderContentProps extends ECommerceContentProps {} interface ItemDetailsContentProps extends ECommerceContentProps {} +interface ReviewOrderContentProps extends ECommerceContentProps {} +interface OrderConfirmedContentProps extends ECommerceContentProps {} /** * @@ -84,7 +86,7 @@ interface ItemDetailsContentProps extends ECommerceContentProps {} * function as a conditional to determine which component to render in the main flyout. */ interface ECommerceAppMeta { - ecommerceMainFlyoutKey?: 'shoppingCart' | 'reviewOrder'; + ecommerceMainFlyoutKey?: 'shoppingCart' | 'reviewOrder' | 'orderConfirmed'; } const ShoppingCartContent: React.FC = ({ @@ -93,7 +95,7 @@ const ShoppingCartContent: React.FC = ({ }) => { const { openChildFlyout, - openFlyout, + openManagedFlyout, isChildFlyoutOpen, closeChildFlyout, closeSession, @@ -101,6 +103,7 @@ const ShoppingCartContent: React.FC = ({ const handleOpenItemDetails = () => { const options: EuiFlyoutSessionOpenChildOptions = { + title: 'Item details', size: 's', flyoutProps: { className: 'itemDetailsFlyoutChild', @@ -115,7 +118,9 @@ const ShoppingCartContent: React.FC = ({ }; const handleProceedToReview = () => { - const options: EuiFlyoutSessionOpenMainOptions = { + const options: EuiFlyoutSessionOpenManagedOptions = { + title: 'Review order', + hideTitle: true, // title will only show in the history popover size: 'm', meta: { ecommerceMainFlyoutKey: 'reviewOrder' }, flyoutProps: { @@ -128,12 +133,12 @@ const ShoppingCartContent: React.FC = ({ }, }, }; - openFlyout(options); + openManagedFlyout(options); }; return ( <> - +

Shopping cart

@@ -185,12 +190,11 @@ const ShoppingCartContent: React.FC = ({ const ReviewOrderContent: React.FC = ({ itemQuantity, }) => { - const { goBack, closeSession } = useEuiFlyoutSession(); - const [orderConfirmed, setOrderConfirmed] = useState(false); + const { goBack, openManagedFlyout, closeSession } = useEuiFlyoutSession(); return ( <> - +

Review order

@@ -202,36 +206,39 @@ const ReviewOrderContent: React.FC = ({

Quantity: {itemQuantity}

- {orderConfirmed ? ( - -

Order confirmed!

-
- ) : ( - setOrderConfirmed(true)} - fill - color="accent" - > - Confirm purchase - - )} + + openManagedFlyout({ + title: 'Order confirmed', + size: 'm', + flyoutProps: { + type: 'push', + className: 'orderConfirmedFlyout', + 'aria-label': 'Order confirmed', + onClose: () => { + loggerAction('Order confirmed onClose triggered'); + closeSession(); // If we add an onClose handler to the main flyout, we have to call closeSession within it for the flyout to actually close + }, + }, + meta: { ecommerceMainFlyoutKey: 'orderConfirmed' }, + }) + } + fill + color="accent" + > + Confirm purchase + - {!orderConfirmed && ( - { - loggerAction('Go back button clicked'); - goBack(); - // Add a setTimeout to check the state a little after the action is dispatched - setTimeout(() => { - loggerAction('After goBack timeout check'); - }, 100); - }} - color="danger" - > - Go back - - )}{' '} + { + loggerAction('Go back button clicked'); + goBack(); + }} + color="danger" + > + Go back + {' '} Close @@ -246,11 +253,6 @@ const ItemDetailsContent: React.FC = ({ const { closeChildFlyout } = useEuiFlyoutSession(); return ( <> - - -

Item details

-
-

@@ -273,10 +275,34 @@ const ItemDetailsContent: React.FC = ({ ); }; +const OrderConfirmedContent: React.FC = ({ + itemQuantity, +}) => { + const { closeSession } = useEuiFlyoutSession(); + return ( + <> + + +

Order confirmed

+

Item: Flux Capacitor

+

Quantity: {itemQuantity}

+ +

Your order has been confirmed. Check your email for details.

+
+
+ + + Close + + + + ); +}; + // Component for the main control buttons and state display const ECommerceAppControls: React.FC = () => { const { - openFlyout, + openManagedFlyout, goBack, isFlyoutOpen, canGoBack, @@ -294,7 +320,9 @@ const ECommerceAppControls: React.FC = () => { } }; const handleOpenShoppingCart = () => { - const options: EuiFlyoutSessionOpenMainOptions = { + const options: EuiFlyoutSessionOpenManagedOptions = { + title: 'Shopping cart', + hideTitle: true, // title will only show in the history popover size: 'm', meta: { ecommerceMainFlyoutKey: 'shoppingCart' }, flyoutProps: { @@ -308,7 +336,7 @@ const ECommerceAppControls: React.FC = () => { }, }, }; - openFlyout(options); + openManagedFlyout(options); }; return ( @@ -356,19 +384,24 @@ const ECommerceApp: React.FC = () => { const { meta } = context; const { ecommerceMainFlyoutKey } = meta || {}; - if (ecommerceMainFlyoutKey === 'shoppingCart') { - return ( - - ); - } - if (ecommerceMainFlyoutKey === 'reviewOrder') { - return ; + switch (ecommerceMainFlyoutKey) { + case 'orderConfirmed': + return ; + case 'reviewOrder': + return ; + case 'shoppingCart': + return ( + + ); } - loggerAction('renderMainFlyoutContent: Unknown flyout key', meta); + loggerAction( + 'renderMainFlyoutContent: Unknown flyout key', + meta?.ecommerceMainFlyoutKey + ); return null; }; @@ -377,10 +410,30 @@ const ECommerceApp: React.FC = () => { return ; }; + const ecommerceHistoryFilter = useCallback( + ( + history: EuiFlyoutSessionHistoryState['history'], + activeFlyoutGroup?: EuiFlyoutSessionGroup | null + ) => { + const isOrderConfirmationActive = + activeFlyoutGroup?.meta?.ecommerceMainFlyoutKey === 'orderConfirmed'; + + // If on order confirmation page, clear history to remove "Back" button + if (isOrderConfirmationActive) { + loggerAction('Clearing history'); + return []; + } + + return history; + }, + [] + ); + return ( { loggerAction('All flyouts have been unmounted'); }} @@ -402,6 +455,150 @@ export const ECommerceWithHistory: StoryObj = { }, }; +/** + * -------------------------------------- + * Deep History Example (advanced use case) + * -------------------------------------- + */ + +interface DeepHistoryAppMeta { + page: 'page01' | 'page02' | 'page03' | 'page04' | 'page05' | ''; +} + +const getHistoryManagedFlyoutOptions = ( + page: DeepHistoryAppMeta['page'] +): EuiFlyoutSessionOpenManagedOptions => { + return { + title: page, + size: 'm', + meta: { page }, + flyoutProps: { + type: 'push', + pushMinBreakpoint: 'xs', + 'aria-label': page, + }, + }; +}; + +const DeepHistoryPage: React.FC = ({ page }) => { + const { openManagedFlyout, closeSession } = useEuiFlyoutSession(); + const [nextPage, setNextPage] = useState(''); + + useEffect(() => { + switch (page) { + case 'page01': + setNextPage('page02'); + break; + case 'page02': + setNextPage('page03'); + break; + case 'page03': + setNextPage('page04'); + break; + case 'page04': + setNextPage('page05'); + break; + case 'page05': + setNextPage(''); + break; + } + }, [page]); + + const handleOpenNextFlyout = () => { + const options = getHistoryManagedFlyoutOptions(nextPage); + openManagedFlyout(options); + }; + + return ( + <> + + +

Page {page}

+
+
+ + {nextPage === '' ? ( + <> + +

+ This is the content for {page}.
+ You have reached the end of the history. +

+
+ + ) : ( + <> + +

This is the content for {page}.

+
+ + + Navigate to {nextPage} + + + )} +
+ + + Close + + + + ); +}; + +// Component for the main control buttons and state display +const DeepHistoryAppControls: React.FC = () => { + const { openManagedFlyout, isFlyoutOpen } = useEuiFlyoutSession(); + const { state } = useEuiFlyoutSessionContext(); // Use internal hook for displaying raw state + + const handleOpenManagedFlyout = () => { + const options = getHistoryManagedFlyoutOptions('page01'); + openManagedFlyout(options); + }; + + return ( + <> + + Begin flyout navigation + + + + + ); +}; + +const DeepHistoryApp: React.FC = () => { + // Render function for MAIN flyout content + const renderMainFlyoutContent = ( + context: EuiFlyoutSessionRenderContext + ) => { + const { meta } = context; + const { page } = meta || { page: 'page01' }; + return ; + }; + + return ( + loggerAction('All flyouts have been unmounted')} + > + + + ); +}; + +export const DeepHistory: StoryObj = { + name: 'Deep History Navigation', + render: () => { + return ; + }, +}; + /** * -------------------------------------- * Group opener example (simple use case) @@ -429,6 +626,7 @@ const GroupOpenerControls: React.FC<{ } const options: EuiFlyoutSessionOpenGroupOptions = { main: { + title: 'Group opener, main flyout', size: mainFlyoutSize, flyoutProps: { type: mainFlyoutType, @@ -444,6 +642,7 @@ const GroupOpenerControls: React.FC<{ }, }, child: { + title: 'Group opener, child flyout', size: childFlyoutSize, flyoutProps: { className: 'groupOpenerChildFlyout', @@ -514,11 +713,6 @@ const GroupOpenerApp: React.FC = () => { const { closeSession } = useEuiFlyoutSession(); return ( <> - - -

Main Flyout

-
-

@@ -540,11 +734,6 @@ const GroupOpenerApp: React.FC = () => { const { closeChildFlyout } = useEuiFlyoutSession(); return ( <> - - -

Child Flyout

- -

diff --git a/packages/eui/src/components/flyout/sessions/flyout_provider.tsx b/packages/eui/src/components/flyout/sessions/flyout_provider.tsx index b1ba6391400..c2a06224d7f 100644 --- a/packages/eui/src/components/flyout/sessions/flyout_provider.tsx +++ b/packages/eui/src/components/flyout/sessions/flyout_provider.tsx @@ -6,11 +6,17 @@ * Side Public License, v 1. */ -import React, { createContext, useContext, useReducer } from 'react'; +import React, { + createContext, + useContext, + useReducer, + useCallback, +} from 'react'; +import { EuiFlyoutMenu } from '../flyout_menu'; import { EuiFlyout, EuiFlyoutChild } from '../index'; - import { flyoutReducer, initialFlyoutState } from './flyout_reducer'; +import { ManagedFlyoutMenu } from './managed_flyout_menu'; import { EuiFlyoutSessionAction, EuiFlyoutSessionHistoryState, @@ -22,6 +28,7 @@ interface FlyoutSessionContextProps { state: EuiFlyoutSessionHistoryState; dispatch: React.Dispatch; onUnmount?: EuiFlyoutSessionProviderComponentProps['onUnmount']; + historyFilter: EuiFlyoutSessionProviderComponentProps['historyFilter']; } const EuiFlyoutSessionContext = createContext( @@ -58,9 +65,32 @@ export const EuiFlyoutSessionProvider: React.FC< children, renderMainFlyoutContent, renderChildFlyoutContent, + historyFilter, onUnmount, }) => { - const [state, dispatch] = useReducer(flyoutReducer, initialFlyoutState); + const wrappedReducer = useCallback( + ( + state: EuiFlyoutSessionHistoryState, + action: EuiFlyoutSessionAction + ) => { + const nextState = flyoutReducer(state, action); + + if (!historyFilter) return nextState; + + const filteredHistory = historyFilter( + nextState.history || [], + nextState.activeFlyoutGroup + ); + + return { + ...nextState, + history: filteredHistory, + }; + }, + [historyFilter] + ); + + const [state, dispatch] = useReducer(wrappedReducer, initialFlyoutState); const { activeFlyoutGroup } = state; const handleClose = () => { @@ -71,6 +101,14 @@ export const EuiFlyoutSessionProvider: React.FC< dispatch({ type: 'CLOSE_CHILD_FLYOUT' }); }; + const handleGoBack = () => { + dispatch({ type: 'GO_BACK' }); + }; + + const handleGoToHistoryItem = (index: number) => { + dispatch({ type: 'GO_TO_HISTORY_ITEM', index }); + }; + let mainFlyoutContentNode: React.ReactNode = null; let childFlyoutContentNode: React.ReactNode = null; @@ -95,7 +133,9 @@ export const EuiFlyoutSessionProvider: React.FC< const flyoutPropsChild = config?.childFlyoutProps || {}; return ( - + {children} {activeFlyoutGroup?.isMainOpen && ( + {config?.isManaged && ( + + )} {mainFlyoutContentNode} {activeFlyoutGroup.isChildOpen && childFlyoutContentNode && ( + {childFlyoutContentNode} )} diff --git a/packages/eui/src/components/flyout/sessions/flyout_reducer.ts b/packages/eui/src/components/flyout/sessions/flyout_reducer.ts index 2e7ae9f21a1..ee89b36a4a7 100644 --- a/packages/eui/src/components/flyout/sessions/flyout_reducer.ts +++ b/packages/eui/src/components/flyout/sessions/flyout_reducer.ts @@ -54,6 +54,19 @@ const applySizeConstraints = ( }; }; +/** + * Helper to merge meta objects from current state and incoming action + * @internal + */ +const mergeMeta = ( + currentMeta: FlyoutMeta | undefined, + newMeta: FlyoutMeta | undefined +): FlyoutMeta | undefined => { + if (newMeta === undefined) return currentMeta; + if (currentMeta === undefined) return newMeta; + return { ...currentMeta, ...newMeta }; +}; + /** * Flyout reducer * Controls state changes for flyout groups @@ -68,7 +81,7 @@ export function flyoutReducer( const newHistory = [...state.history]; if (state.activeFlyoutGroup) { - newHistory.push(state.activeFlyoutGroup); + newHistory.unshift(state.activeFlyoutGroup); } const newActiveGroup: EuiFlyoutSessionGroup = { @@ -78,7 +91,34 @@ export function flyoutReducer( mainSize: size, mainFlyoutProps: flyoutProps, }, - meta, + meta: mergeMeta(state.activeFlyoutGroup?.meta, meta), + }; + + return { + activeFlyoutGroup: applySizeConstraints(newActiveGroup), + history: newHistory, + }; + } + + case 'OPEN_MANAGED_FLYOUT': { + const { size, title, hideTitle, flyoutProps, meta } = action.payload; // EuiFlyoutSessionOpenManagedOptions + const newHistory = [...state.history]; + + if (state.activeFlyoutGroup) { + newHistory.unshift(state.activeFlyoutGroup); + } + + const newActiveGroup: EuiFlyoutSessionGroup = { + isMainOpen: true, + isChildOpen: false, + config: { + isManaged: true, + mainSize: size, + mainTitle: title, + hideMainTitle: hideTitle, + mainFlyoutProps: flyoutProps, + }, + meta: mergeMeta(state.activeFlyoutGroup?.meta, meta), }; return { @@ -95,16 +135,17 @@ export function flyoutReducer( return state; } - const { size, flyoutProps, meta } = action.payload; + const { size, flyoutProps, title, meta } = action.payload; const updatedActiveGroup: EuiFlyoutSessionGroup = { ...state.activeFlyoutGroup, isChildOpen: true, config: { - ...state.activeFlyoutGroup.config, + ...state.activeFlyoutGroup.config, // retain main flyout config + childTitle: title, childSize: size, childFlyoutProps: flyoutProps, }, - meta, + meta: mergeMeta(state.activeFlyoutGroup?.meta, meta), }; return { @@ -118,7 +159,7 @@ export function flyoutReducer( const newHistory = [...state.history]; if (state.activeFlyoutGroup) { - newHistory.push(state.activeFlyoutGroup); + newHistory.unshift(state.activeFlyoutGroup); } // Create the new active group with both main and child flyouts open @@ -126,12 +167,16 @@ export function flyoutReducer( isMainOpen: true, isChildOpen: true, config: { + isManaged: true, mainSize: main.size, + mainTitle: main.title, + hideMainTitle: main.hideTitle, + childTitle: child.title, childSize: child.size, mainFlyoutProps: main.flyoutProps, childFlyoutProps: child.flyoutProps, }, - meta, + meta: mergeMeta(state.activeFlyoutGroup?.meta, meta), }; return { @@ -163,6 +208,19 @@ export function flyoutReducer( }; } + case 'GO_TO_HISTORY_ITEM': { + const { index } = action; + const targetGroup = state.history[index]; + const newHistory = state.history.slice(index + 1); + + return { + activeFlyoutGroup: targetGroup + ? applySizeConstraints(targetGroup) + : state.activeFlyoutGroup, + history: newHistory, + }; + } + case 'GO_BACK': { if (!state.activeFlyoutGroup) return initialFlyoutState as EuiFlyoutSessionHistoryState; @@ -170,7 +228,7 @@ export function flyoutReducer( // Restore from history or return to initial state if (state.history.length > 0) { const newHistory = [...state.history]; - const previousGroup = newHistory.pop(); + const previousGroup = newHistory.shift(); return { activeFlyoutGroup: previousGroup ? applySizeConstraints(previousGroup) diff --git a/packages/eui/src/components/flyout/sessions/index.ts b/packages/eui/src/components/flyout/sessions/index.ts index 899c423ea4f..444a3e4f37a 100644 --- a/packages/eui/src/components/flyout/sessions/index.ts +++ b/packages/eui/src/components/flyout/sessions/index.ts @@ -17,6 +17,7 @@ export type { EuiFlyoutSessionOpenChildOptions, EuiFlyoutSessionOpenGroupOptions, EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionOpenManagedOptions, EuiFlyoutSessionProviderComponentProps, EuiFlyoutSessionRenderContext, } from './types'; diff --git a/packages/eui/src/components/flyout/sessions/managed_flyout_menu.test.tsx b/packages/eui/src/components/flyout/sessions/managed_flyout_menu.test.tsx new file mode 100644 index 00000000000..ba3d51e9234 --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/managed_flyout_menu.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 { fireEvent } from '@testing-library/react'; +import { render } from '../../../test/rtl'; +import { ManagedFlyoutMenu } from './managed_flyout_menu'; +import { EuiFlyoutSessionGroup } from './types'; + +describe('FlyoutSystemMenu', () => { + const mockHistoryItems: Array> = [ + { + isMainOpen: true, + isChildOpen: false, + config: { mainSize: 's', mainTitle: 'History Item 1' }, + }, + { + isMainOpen: true, + isChildOpen: false, + config: { mainSize: 'm', mainTitle: 'History Item 2' }, + }, + ]; + + it('renders with a title', () => { + const { getByText } = render( + {}} + handleGoToHistoryItem={() => {}} + /> + ); + expect(getByText('Test Title')).toBeInTheDocument(); + }); + + it('renders without a title', () => { + const { queryByText } = render( + {}} + handleGoToHistoryItem={() => {}} + /> + ); + expect(queryByText('Test Title')).not.toBeInTheDocument(); + }); + + it('renders with back button and history popover when history items are present', () => { + const { getByText, getByLabelText } = render( + {}} + handleGoToHistoryItem={() => {}} + /> + ); + expect(getByText('Back')).toBeInTheDocument(); + expect(getByLabelText('History')).toBeInTheDocument(); + }); + + it('calls handleGoBack when back button is clicked', () => { + const handleGoBack = jest.fn(); + const { getByText } = render( + {}} + /> + ); + fireEvent.click(getByText('Back')); + expect(handleGoBack).toHaveBeenCalledTimes(1); + }); + + it('calls handleGoToHistoryItem when a history item is clicked', () => { + const handleGoToHistoryItem = jest.fn(); + const { getByLabelText, getByText } = render( + {}} + handleGoToHistoryItem={handleGoToHistoryItem} + /> + ); + + fireEvent.click(getByLabelText('History')); + fireEvent.click(getByText('History Item 1')); + + expect(handleGoToHistoryItem).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/eui/src/components/flyout/sessions/managed_flyout_menu.tsx b/packages/eui/src/components/flyout/sessions/managed_flyout_menu.tsx new file mode 100644 index 00000000000..ebeea27647f --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/managed_flyout_menu.tsx @@ -0,0 +1,89 @@ +/* + * 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, { useState } from 'react'; + +import { EuiButtonEmpty, EuiButtonIcon } from '../../button'; +import { EuiIcon } from '../../icon'; +import { EuiListGroup } from '../../list_group'; +import { EuiListGroupItem } from '../../list_group/list_group_item'; +import { EuiPopover } from '../../popover'; +import { EuiFlyoutMenu, EuiFlyoutMenuProps } from '../flyout_menu'; +import { EuiFlyoutSessionGroup } from './types'; + +/** + * Top flyout menu bar + * This automatically appears for "managed flyouts" (those that were opened with `openManagedFlyout`), + * @internal + */ +export const ManagedFlyoutMenu = ( + props: Pick & { + handleGoBack: () => void; + handleGoToHistoryItem: (index: number) => void; + historyItems: Array>; + } +) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { title, historyItems, handleGoBack, handleGoToHistoryItem } = props; + + let backButton: React.ReactNode | undefined; + let historyPopover: React.ReactNode | undefined; + + if (!!historyItems.length) { + const handlePopoverButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + backButton = ( + + Back + + ); + + historyPopover = ( + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="xs" + anchorPosition="downLeft" + > + + {historyItems.map((item, index) => ( + { + handleGoToHistoryItem(index); + setIsPopoverOpen(false); + }} + > + {item.config.mainTitle} + + ))} + + + ); + } + + return ( + + ); +}; diff --git a/packages/eui/src/components/flyout/sessions/types.ts b/packages/eui/src/components/flyout/sessions/types.ts index 97c611ee9ac..6da9ab62c4a 100644 --- a/packages/eui/src/components/flyout/sessions/types.ts +++ b/packages/eui/src/components/flyout/sessions/types.ts @@ -6,17 +6,24 @@ * Side Public License, v 1. */ -import { EuiFlyoutProps, EuiFlyoutSize } from '../flyout'; -import { EuiFlyoutChildProps } from '../flyout_child'; +import type { EuiFlyoutProps, EuiFlyoutSize } from '../flyout'; +import type { EuiFlyoutChildProps } from '../flyout_child'; /** * Configuration used for setting display options for main and child flyouts in a session. */ export interface EuiFlyoutSessionConfig { mainSize: EuiFlyoutSize; + mainTitle?: string; + hideMainTitle?: boolean; childSize?: 's' | 'm'; + childTitle?: string; mainFlyoutProps?: Partial>; childFlyoutProps?: Partial>; + /** + * Indicates if the flyout was opened with openManagedFlyout or openFlyout + */ + isManaged?: boolean; } /** @@ -31,12 +38,31 @@ export interface EuiFlyoutSessionOpenMainOptions { meta?: Meta; } +export interface EuiFlyoutSessionOpenManagedOptions { + size: EuiFlyoutSize; + flyoutProps?: EuiFlyoutSessionConfig['mainFlyoutProps']; + /** + * Title to display in top menu bar and in the options of the history popover + */ + title: string; + /** + * Allows title to be hidden from top menu bar. If this is true, + * the title will only be used for the history popover + */ + hideTitle?: boolean; + /** + * Caller-defined data + */ + meta?: Meta; +} + /** * Options that control a child flyout in a session */ export interface EuiFlyoutSessionOpenChildOptions { size: 's' | 'm'; flyoutProps?: EuiFlyoutSessionConfig['childFlyoutProps']; + title: string; /** * Caller-defined data */ @@ -47,7 +73,7 @@ export interface EuiFlyoutSessionOpenChildOptions { * Options for opening both a main flyout and child flyout simultaneously */ export interface EuiFlyoutSessionOpenGroupOptions { - main: EuiFlyoutSessionOpenMainOptions; + main: EuiFlyoutSessionOpenManagedOptions; child: EuiFlyoutSessionOpenChildOptions; /** * Caller-defined data @@ -89,6 +115,10 @@ export type EuiFlyoutSessionAction = type: 'OPEN_MAIN_FLYOUT'; payload: EuiFlyoutSessionOpenMainOptions; } + | { + type: 'OPEN_MANAGED_FLYOUT'; + payload: EuiFlyoutSessionOpenManagedOptions; + } | { type: 'OPEN_CHILD_FLYOUT'; payload: EuiFlyoutSessionOpenChildOptions; @@ -98,6 +128,7 @@ export type EuiFlyoutSessionAction = payload: EuiFlyoutSessionOpenGroupOptions; } | { type: 'GO_BACK' } + | { type: 'GO_TO_HISTORY_ITEM'; index: number } | { type: 'CLOSE_CHILD_FLYOUT' } | { type: 'CLOSE_SESSION' }; @@ -117,16 +148,21 @@ export interface EuiFlyoutSessionRenderContext { */ export interface EuiFlyoutSessionProviderComponentProps { children: React.ReactNode; - onUnmount?: () => void; renderMainFlyoutContent: ( context: EuiFlyoutSessionRenderContext ) => React.ReactNode; renderChildFlyoutContent?: ( context: EuiFlyoutSessionRenderContext ) => React.ReactNode; + historyFilter?: ( + history: EuiFlyoutSessionHistoryState['history'], + activeFlyoutGroup?: EuiFlyoutSessionGroup | null + ) => EuiFlyoutSessionHistoryState['history']; + onUnmount?: () => void; } export interface EuiFlyoutSessionApi { + openManagedFlyout: (options: EuiFlyoutSessionOpenManagedOptions) => void; openFlyout: (options: EuiFlyoutSessionOpenMainOptions) => void; openChildFlyout: (options: EuiFlyoutSessionOpenChildOptions) => void; openFlyoutGroup: (options: EuiFlyoutSessionOpenGroupOptions) => void; diff --git a/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx b/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx index e7ef635a8b0..f2b6c145050 100644 --- a/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx +++ b/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx @@ -6,14 +6,14 @@ * Side Public License, v 1. */ +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; import { EuiFlyoutSessionProvider } from './flyout_provider'; import type { - EuiFlyoutSessionOpenMainOptions, EuiFlyoutSessionOpenChildOptions, EuiFlyoutSessionOpenGroupOptions, + EuiFlyoutSessionOpenMainOptions, } from './types'; import { useEuiFlyoutSession } from './use_eui_flyout'; @@ -79,6 +79,7 @@ const TestComponent: React.FC = ({ data-testid="openChildFlyoutButton" onClick={() => { const options: EuiFlyoutSessionOpenChildOptions = { + title: 'Child flyout', size: 's', meta: { type: 'testChild' }, }; @@ -95,10 +96,12 @@ const TestComponent: React.FC = ({ onClick={() => { const options: EuiFlyoutSessionOpenGroupOptions = { main: { + title: 'Main flyout', size: 'm', flyoutProps: { className: 'main-flyout' }, }, child: { + title: 'Child flyout', size: 's', flyoutProps: { className: 'child-flyout' }, }, diff --git a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts index b2192b5049a..67a15a531f0 100644 --- a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts +++ b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts @@ -13,6 +13,7 @@ import type { EuiFlyoutSessionOpenChildOptions, EuiFlyoutSessionOpenGroupOptions, EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionOpenManagedOptions, } from './types'; /** @@ -33,6 +34,9 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { } }, [state.activeFlyoutGroup, onUnmount]); + /** + * Open a "plain" main flyout without an automatic top menu bar + */ const openFlyout = (options: EuiFlyoutSessionOpenMainOptions) => { dispatch({ type: 'OPEN_MAIN_FLYOUT', @@ -40,6 +44,19 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { }); }; + /** + * Open a "managed" main flyout, with an automatic top menu bar + */ + const openManagedFlyout = (options: EuiFlyoutSessionOpenManagedOptions) => { + dispatch({ + type: 'OPEN_MANAGED_FLYOUT', + payload: options, + }); + }; + + /** + * Open a "managed" child flyout, with an automatic top menu bar + */ const openChildFlyout = (options: EuiFlyoutSessionOpenChildOptions) => { if (!state.activeFlyoutGroup || !state.activeFlyoutGroup.isMainOpen) { console.warn( @@ -53,6 +70,9 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { }); }; + /** + * Open a pair of managed main and child flyouts + */ const openFlyoutGroup = (options: EuiFlyoutSessionOpenGroupOptions) => { dispatch({ type: 'OPEN_FLYOUT_GROUP', @@ -80,6 +100,7 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { return { openFlyout, + openManagedFlyout, openChildFlyout, openFlyoutGroup, closeChildFlyout, diff --git a/packages/eui/src/components/provider/component_defaults/component_defaults.tsx b/packages/eui/src/components/provider/component_defaults/component_defaults.tsx index 85e7a60e1c5..727babf5860 100644 --- a/packages/eui/src/components/provider/component_defaults/component_defaults.tsx +++ b/packages/eui/src/components/provider/component_defaults/component_defaults.tsx @@ -17,7 +17,7 @@ import React, { import type { EuiPortalProps } from '../../portal'; import type { EuiFocusTrapProps } from '../../focus_trap'; import type { EuiTablePaginationProps, EuiTableProps } from '../../table'; -import type { EuiFlyoutProps } from '../../flyout'; +import { EuiFlyoutProps } from '../../flyout'; export type EuiComponentDefaults = { /** @@ -51,7 +51,8 @@ export type EuiComponentDefaults = { */ EuiFlyout?: Pick< EuiFlyoutProps, - 'includeSelectorInFocusTrap' | 'includeFixedHeadersInFocusTrap' + | 'includeSelectorInFocusTrap' + | 'includeFixedHeadersInFocusTrap' /*| 'managed'*/ >; }; diff --git a/packages/eui/src/components/provider/provider.tsx b/packages/eui/src/components/provider/provider.tsx index 0224674f4b0..2504e6c69ce 100644 --- a/packages/eui/src/components/provider/provider.tsx +++ b/packages/eui/src/components/provider/provider.tsx @@ -33,6 +33,7 @@ import { EuiComponentDefaults, EuiComponentDefaultsProvider, } from './component_defaults'; +import { ManagedFlyoutProvider } from '../flyout/managed'; const isEmotionCacheObject = ( obj: EmotionCache | Object @@ -164,7 +165,7 @@ export const EuiProvider = ({ )} - {children} + {children} diff --git a/packages/website/docs/components/containers/flyout/index.mdx b/packages/website/docs/components/containers/flyout/index.mdx index 07ada387f79..2d9f89ac4b7 100644 --- a/packages/website/docs/components/containers/flyout/index.mdx +++ b/packages/website/docs/components/containers/flyout/index.mdx @@ -1001,6 +1001,25 @@ The `EuiFlyoutChild` must include an `EuiFlyoutBody` child and can only be used Both parent and child flyouts use `role="dialog"` and `aria-modal="true"` for accessibility. Focus is managed automatically between them, with the child flyout taking focus when open and returning focus to the parent when closed. +### Flyout menu (Beta) + +:::info Note +This component is still in beta and may change in the future. +::: + +Use `EuiFlyoutChild` to create a nested flyout that aligns to the left edge of a parent `EuiFlyout`. On smaller screens, the child flyout stacks above the parent. + +```tsx + + + Hi mom + + Parent header + Parent body + Parent footer + +``` + ## Props import docgen from '@elastic/eui-docgen/dist/components/flyout';