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_close_button.styles.ts b/packages/eui/src/components/flyout/_flyout_close_button.styles.ts index d2c573014ab..1cafa9f1a6d 100644 --- a/packages/eui/src/components/flyout/_flyout_close_button.styles.ts +++ b/packages/eui/src/components/flyout/_flyout_close_button.styles.ts @@ -16,7 +16,7 @@ import { } from '../../global_styling'; import { UseEuiTheme } from '../../services'; -import { FLYOUT_BREAKPOINT } from './flyout.styles'; +import { FLYOUT_BREAKPOINT } from './flyout_shared.styles'; export const euiFlyoutCloseButtonStyles = (euiThemeContext: UseEuiTheme) => { const euiTheme = euiThemeContext.euiTheme; diff --git a/packages/eui/src/components/flyout/flyout.styles.ts b/packages/eui/src/components/flyout/flyout.styles.ts index 962e9ad10b3..c7d8ea3a800 100644 --- a/packages/eui/src/components/flyout/flyout.styles.ts +++ b/packages/eui/src/components/flyout/flyout.styles.ts @@ -9,18 +9,10 @@ import { css, keyframes } from '@emotion/react'; import { euiShadowXLarge } from '@elastic/eui-theme-common'; -import { _EuiFlyoutPaddingSize, EuiFlyoutSize } from './flyout'; -import { - euiCanAnimate, - euiMaxBreakpoint, - euiMinBreakpoint, - logicalCSS, - mathWithUnits, -} from '../../global_styling'; +import { _EuiFlyoutPaddingSize } from './flyout'; +import { euiCanAnimate, logicalCSS, mathWithUnits } from '../../global_styling'; import { UseEuiTheme } from '../../services'; -import { euiFormMaxWidth } from '../form/form.styles'; - -export const FLYOUT_BREAKPOINT = 'm' as const; +import { composeFlyoutSizing, maxedFlyoutWidth } from './flyout_shared.styles'; export const euiFlyoutSlideInRight = keyframes` 0% { @@ -67,19 +59,20 @@ export const euiFlyoutStyles = (euiThemeContext: UseEuiTheme) => { ${maxedFlyoutWidth(euiThemeContext)} `, - // Flyout sizes - // When a child flyout is stacked on top of the parent, the parent flyout size will match the child flyout size + /** + * Flyout sizes + * + * When a child flyout is stacked on top of the parent, the parent flyout size updates to fill the space required by the child. + * FIXME: make sure that effect does not cause a child flyout to "push" the page content. Child flyouts should always overlay. + */ s: css` ${composeFlyoutSizing(euiThemeContext, 's')} - - &.euiFlyout--hasChild--stacked.euiFlyout--hasChild--m { - ${composeFlyoutSizing(euiThemeContext, 'm')} - } `, m: css` ${composeFlyoutSizing(euiThemeContext, 'm')} &.euiFlyout--hasChild--stacked.euiFlyout--hasChild--s { + /* when a size "s" child flyout is stacked on top of a size "m" parent, the parent flyout size is lowered to match the child. */ ${composeFlyoutSizing(euiThemeContext, 's')} } `, @@ -175,55 +168,6 @@ export const euiFlyoutStyles = (euiThemeContext: UseEuiTheme) => { }; }; -export const maxedFlyoutWidth = (euiThemeContext: UseEuiTheme) => ` - ${euiMaxBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { - ${logicalCSS('max-width', '90vw !important')} - } -`; - -export const composeFlyoutSizing = ( - euiThemeContext: UseEuiTheme, - size: EuiFlyoutSize -) => { - const euiTheme = euiThemeContext.euiTheme; - const formMaxWidth = euiFormMaxWidth(euiThemeContext); - - // 1. Calculating the minimum width based on the screen takeover breakpoint - const flyoutSizes = { - s: { - min: `${Math.round(euiTheme.breakpoint.m * 0.5)}px`, // 1. - width: '25vw', - max: `${Math.round(euiTheme.breakpoint.s * 0.7)}px`, - }, - - m: { - // Calculated for forms plus padding - min: `${mathWithUnits(formMaxWidth, (x) => x + 24)}`, - width: '50vw', - max: `${euiTheme.breakpoint.m}px`, - }, - - l: { - min: `${Math.round(euiTheme.breakpoint.m * 0.9)}px`, // 1. - width: '75vw', - max: `${euiTheme.breakpoint.l}px`, - }, - }; - - return ` - ${logicalCSS('max-width', flyoutSizes[size].max)} - - ${euiMaxBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { - ${logicalCSS('min-width', 0)} - ${logicalCSS('width', flyoutSizes[size].min)} - } - ${euiMinBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { - ${logicalCSS('min-width', flyoutSizes[size].min)} - ${logicalCSS('width', flyoutSizes[size].width)} - } - `; -}; - const composeFlyoutPadding = ( euiThemeContext: UseEuiTheme, paddingSize: _EuiFlyoutPaddingSize diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index b8336a8d434..7ec73eaa402 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -48,6 +48,8 @@ import { EuiScreenReaderOnly } from '../accessibility'; import { EuiFlyoutCloseButton } from './_flyout_close_button'; import { euiFlyoutStyles } from './flyout.styles'; import { EuiFlyoutChild } from './flyout_child'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; +import { EuiFlyoutMenu } from './flyout_menu'; import { EuiFlyoutChildProvider } from './flyout_child_manager'; import { usePropsWithComponentDefaults } from '../provider/component_defaults'; @@ -242,6 +244,13 @@ export const EuiFlyout = forwardRef( const hasChildFlyout = !!childFlyoutElement; // Validate props, determine close button position and set child flyout classes + const hasFlyoutMenu = React.Children.toArray(children).some( + (child) => + React.isValidElement(child) && + (child.type === EuiFlyoutMenu || + (child.type as any).displayName === 'EuiFlyoutMenu') + ); + let closeButtonPosition: 'inside' | 'outside'; let childFlyoutClasses: string[] = []; if (hasChildFlyout) { @@ -492,7 +501,7 @@ export const EuiFlyout = forwardRef( [onClose, hasOverlayMask, outsideClickCloses] ); - const closeButton = !hideCloseButton && ( + const closeButton = !hideCloseButton && !hasFlyoutMenu && ( {!isPushed && screenReaderDescription} {closeButton} - {contentToRender} + + {contentToRender} + diff --git a/packages/eui/src/components/flyout/flyout_child.stories.tsx b/packages/eui/src/components/flyout/flyout_child.stories.tsx index e81876dbe3f..fd5678b9b8f 100644 --- a/packages/eui/src/components/flyout/flyout_child.stories.tsx +++ b/packages/eui/src/components/flyout/flyout_child.stories.tsx @@ -6,49 +6,119 @@ * Side Public License, v 1. */ -import React, { useState, ComponentProps } from 'react'; +import { actions } from '@storybook/addon-actions'; import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState, ComponentProps } from 'react'; +import { LOKI_SELECTORS } from '../../../.storybook/loki'; +import { EuiBreakpointSize } from '../../services'; import { EuiButton } from '../button'; +import { EuiSpacer } from '../spacer'; +import { EuiText } from '../text'; import { EuiFlyout, TYPES } from './flyout'; import { EuiFlyoutBody } from './flyout_body'; import { EuiFlyoutChild } from './flyout_child'; -import { EuiFlyoutHeader } from './flyout_header'; import { EuiFlyoutFooter } from './flyout_footer'; -import { EuiText } from '../text'; -import { EuiSpacer } from '../spacer'; -import { EuiRadioGroup, EuiRadioGroupOption } from '../form'; -import { LOKI_SELECTORS } from '../../../.storybook/loki'; -import { EuiBreakpointSize } from '../../services'; +import { EuiFlyoutHeader } from './flyout_header'; +import { EuiFlyoutMenu } from './flyout_menu'; -const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; +type EuiFlyoutChildActualProps = Pick< + ComponentProps, + | 'onClose' + | 'size' + | 'backgroundStyle' + | 'maxWidth' + | 'hideCloseButton' + | 'scrollableTabIndex' + | 'banner' + | 'children' +>; -type EuiFlyoutChildActualProps = ComponentProps; +type EuiFlyoutType = (typeof TYPES)[number]; -type FlyoutChildStoryArgs = EuiFlyoutChildActualProps & { +interface FlyoutChildStoryArgs extends EuiFlyoutChildActualProps { + mainSize?: 's' | 'm'; + childSize?: 's' | 'm' | 'fill'; + childBackgroundStyle?: 'default' | 'shaded'; + mainFlyoutType: EuiFlyoutType; pushMinBreakpoint: EuiBreakpointSize; -}; + mainMaxWidth?: number; + childMaxWidth?: number; + showTopMenu?: boolean; +} + +const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; + +const playgroundActions = actions('log'); const meta: Meta = { title: 'Layout/EuiFlyout/EuiFlyoutChild', component: EuiFlyoutChild, argTypes: { - size: { + mainSize: { options: ['s', 'm'], control: { type: 'radio' }, + description: + 'The size of the main (parent) flyout. If `m`, the child must be `s` or `fill`. If `s`, the child can be `s`, `m`, or `fill`.', + }, + childSize: { + options: ['s', 'm', 'fill'], + control: { type: 'radio' }, + description: + 'The size of the child flyout. If the main is `s`, the child can be `s`, `m`, or `fill`. If the main is `m`, the child can only be `s` or `fill`.', + }, + mainMaxWidth: { + control: { type: 'number' }, + description: 'The maximum width of the main flyout.', + }, + childMaxWidth: { + control: { type: 'number' }, + description: 'The maximum width of the child flyout.', + }, + mainFlyoutType: { + options: TYPES, + control: { type: 'radio' }, + description: 'The type of the main flyout..', + }, + childBackgroundStyle: { + options: ['default', 'shaded'], + control: { type: 'radio' }, + description: 'The background style of the child flyout.', }, pushMinBreakpoint: { options: breakpointSizes, control: { type: 'select' }, description: - 'Breakpoint at which the parent EuiFlyout (if type=`push`) will start pushing content. `xs` makes it always push.', + 'Breakpoint at which the main flyout (if `type="push"`) will convert to an overlay flyout. Defaults to `xs`.', + }, + showTopMenu: { + control: { type: 'boolean' }, + description: + 'Whether to show the top flyout menu bar. If `false`, an `EuiFlyoutHeader` will not be rendered instead.', }, + + // use "childBackgroundStyle" instead + backgroundStyle: { table: { disable: true } }, + // use "mainSize" and "childSize" instead + size: { table: { disable: true } }, + // use "mainMaxWidth" and "childMaxWidth" instead + maxWidth: { table: { disable: true } }, + // props below this line are not configurable in the playground + onClose: { table: { disable: true } }, + banner: { table: { disable: true } }, + hideCloseButton: { table: { disable: true } }, + scrollableTabIndex: { table: { disable: true } }, + children: { table: { disable: true } }, }, args: { - scrollableTabIndex: 0, - hideCloseButton: false, - size: 's', + mainSize: 'm', + childSize: 's', + childBackgroundStyle: 'default', + mainFlyoutType: 'push', pushMinBreakpoint: 'xs', + showTopMenu: true, + scrollableTabIndex: 0, + hideCloseButton: false, // FIXME: not implemented in EuiFlyoutChild or EuiFlyoutMenu }, parameters: { docs: { @@ -65,8 +135,13 @@ A child panel component that can be nested within an EuiFlyout. - EuiFlyoutChild can only be used as a direct child of EuiFlyout - EuiFlyoutChild must include an EuiFlyoutBody child component - When a flyout includes a child panel: - - The main flyout size is limited to 's' or 'm' (not 'l') - - If the main flyout is 'm', then the child flyout is limited to 's' + - The main flyout size is limited to 's', 'm', or 'fill' + - If the main flyout is 's', then the child flyout can be 's', 'm', or 'fill' + - If the main flyout is 'm', then the child flyout can only be 's' or 'fill' + - The child flyout size is limited to 's', 'm', or 'fill' + - If the child flyout is 's', then the main flyout can be 's', 'm', or 'fill' + - If the child flyout is 'm', then the main flyout can only be 's' or 'fill' + - If the child flyout is 'fill', then the main flyout can be 's' or 'm' - Custom pixel sizes are not allowed when using a child flyout `, }, @@ -80,43 +155,35 @@ A child panel component that can be nested within an EuiFlyout. export default meta; type Story = StoryObj; -interface StatefulFlyoutProps { - mainSize?: 's' | 'm'; - childSize?: 's' | 'm'; - showHeader?: boolean; - showFooter?: boolean; - pushMinBreakpoint?: EuiBreakpointSize; -} - -type EuiFlyoutType = (typeof TYPES)[number]; - /** * A shared helper component used to demo management of internal state. It keeps internal state of * the selected flyout type (overlay/push) and the open/closed state of child flyout. */ -const StatefulFlyout: React.FC = ({ - mainSize = 'm', - childSize = 's', - showHeader = true, - showFooter = true, - pushMinBreakpoint = 'xs', +const StatefulFlyout: React.FC = ({ + mainSize, + childSize, + childBackgroundStyle, + mainFlyoutType, + pushMinBreakpoint, + mainMaxWidth, + childMaxWidth, + showTopMenu, + ...args }) => { const [isMainOpen, setIsMainOpen] = useState(true); const [isChildOpen, setIsChildOpen] = useState(false); - const [flyoutType, setFlyoutType] = useState('overlay'); const openMain = () => setIsMainOpen(true); const closeMain = () => { setIsMainOpen(false); setIsChildOpen(false); + playgroundActions.log('Parent flyout closed'); }; const openChild = () => setIsChildOpen(true); - const closeChild = () => setIsChildOpen(false); - - const typeRadios: EuiRadioGroupOption[] = [ - { id: 'overlay', label: 'Overlay' }, - { id: 'push', label: 'Push' }, - ]; + const closeChild = () => { + setIsChildOpen(false); + playgroundActions.log('Child flyout closed'); + }; return ( <> @@ -132,27 +199,22 @@ const StatefulFlyout: React.FC = ({ aliquip ex ea commodo consequat.

- - setFlyoutType(id as EuiFlyoutType)} - legend={{ children: 'Main flyout type' }} - name="statefulFlyoutTypeToggle" - /> - - + Open Main Flyout {isMainOpen && ( - {showHeader && ( - + {showTopMenu ? ( + + ) : ( +

Main Flyout ({mainSize})

@@ -176,18 +238,25 @@ const StatefulFlyout: React.FC = ({ Close child panel )} - {showFooter && ( - - -

Main flyout footer

-
-
- )} + + +

Main flyout footer

+
+
{isChildOpen && ( - - {showHeader && ( - + + {showTopMenu ? ( + + ) : ( +

Child Flyout ({childSize})

@@ -198,8 +267,12 @@ const StatefulFlyout: React.FC = ({

This is the child flyout content.

Size restrictions apply:

    -
  • When main panel is 's', child can be 's' or 'm'
  • -
  • When main panel is 'm', child is limited to 's'
  • +
  • + When main panel is 's', child can be 's', 'm', or 'fill' +
  • +
  • + When main panel is 'm', child is limited to 's' or 'fill' +

@@ -210,131 +283,11 @@ const StatefulFlyout: React.FC = ({

- {showFooter && ( - - -

Child flyout footer

-
-
- )} -
- )} -
- )} - - ); -}; - -export const WithMediumMainSize: Story = { - name: 'Main Size: m, Child Size: s', - render: (args) => ( - - ), -}; - -export const WithSmallMainSize: Story = { - name: 'Main Size: s, Child Size: s', - render: (args) => ( - - ), -}; - -export const WithSmallMainLargeChild: Story = { - name: 'Main Size: s, Child Size: m', - render: (args) => ( - - ), -}; - -const ChildBackgroundStylesFlyout = () => { - const [isMainOpen, setIsMainOpen] = useState(true); - const [isDefaultChildOpen, setIsDefaultChildOpen] = useState(true); - const [isShadedChildOpen, setIsShadedChildOpen] = useState(false); - - const openDefaultChild = () => { - setIsDefaultChildOpen(true); - setIsShadedChildOpen(false); - }; - const openShadedChild = () => { - setIsDefaultChildOpen(false); - setIsShadedChildOpen(true); - }; - - const closeMain = () => { - setIsMainOpen(false); - setIsDefaultChildOpen(false); - setIsShadedChildOpen(false); - }; - const closeChild = () => { - setIsDefaultChildOpen(false); - setIsShadedChildOpen(false); - }; - - const ChildFlyoutContent = () => ( - - -

- This is the child flyout content, with a{' '} - {isShadedChildOpen ? 'shaded' : 'default'} background. -

-
-
- ); - - return ( - <> - - - Open flyout with default child background - - - - Open flyout with shaded child background - - - - {isMainOpen && ( - - - -

This is the main flyout content.

-
- -
- - {isDefaultChildOpen && ( - - - - )} - {isShadedChildOpen && ( - - + + +

Child flyout footer

+
+
)}
@@ -343,7 +296,7 @@ const ChildBackgroundStylesFlyout = () => { ); }; -export const WithChildBackgroundStyles: Story = { - name: 'Child Background Styles', - render: () => , +export const FlyoutChildDemo: Story = { + name: 'Playground', + render: (args) => , }; diff --git a/packages/eui/src/components/flyout/flyout_child.styles.ts b/packages/eui/src/components/flyout/flyout_child.styles.ts index 91a3068b147..112ed0ad822 100644 --- a/packages/eui/src/components/flyout/flyout_child.styles.ts +++ b/packages/eui/src/components/flyout/flyout_child.styles.ts @@ -7,17 +7,20 @@ */ import { css } from '@emotion/react'; -import { UseEuiTheme } from '../../services'; import { + euiYScroll, + highContrastModeStyles, logicalCSS, logicalCSSWithFallback, - highContrastModeStyles, - euiYScroll, + mathWithUnits, } from '../../global_styling'; -import { composeFlyoutSizing, maxedFlyoutWidth } from './flyout.styles'; +import { UseEuiTheme } from '../../services'; +import { euiFormMaxWidth } from '../form/form.styles'; +import { composeFlyoutSizing, maxedFlyoutWidth } from './flyout_shared.styles'; export const euiFlyoutChildStyles = (euiThemeContext: UseEuiTheme) => { const { euiTheme } = euiThemeContext; + const formMaxWidth = euiFormMaxWidth(euiThemeContext); return { // Base styles for the child flyout euiFlyoutChild: css` @@ -31,10 +34,15 @@ export const euiFlyoutChildStyles = (euiThemeContext: UseEuiTheme) => { ${logicalCSS('height', '100%')} z-index: ${Number(euiTheme.levels.flyout) + 1}; border-inline-start: ${euiTheme.border.thin}; + border-inline-end: ${euiTheme.border.thin}; ${maxedFlyoutWidth(euiThemeContext)} `, + noMaxWidth: css` + ${logicalCSS('max-width', 'none')} + `, + backgroundDefault: css` background: ${euiTheme.colors.backgroundBasePlain}; `, @@ -45,13 +53,23 @@ export const euiFlyoutChildStyles = (euiThemeContext: UseEuiTheme) => { // Position variants based on screen size sidePosition: css` transform: translateX(-100%); - border-inline-end: ${euiTheme.border.thin}; `, stackedPosition: css` - inset-inline-end: 0; - inline-size: 100%; - border-block-end: ${euiTheme.border.thin}; + /* FIXME not sure why this is needed for stacked layout */ + transform: translateX(-100%); `, + // FIXME: no hardcoding + // FIXME: do these break if parent has maxWidth prop? + stackedPositionWithParent: { + s: css` + /* FIXME not sure why this works */ + margin-inline-start: 383px; + `, + m: css` + /* FIXME totally broken */ + margin-inline-start: 729px; + `, + }, s: css` ${composeFlyoutSizing(euiThemeContext, 's')} @@ -61,8 +79,34 @@ export const euiFlyoutChildStyles = (euiThemeContext: UseEuiTheme) => { ${composeFlyoutSizing(euiThemeContext, 'm')} `, + fill: css` + ${logicalCSS('min-width', '90vw')}; + ${logicalCSS('width', '90vw')}; + ${logicalCSS('max-width', '90vw')}; + `, + + fillWithParent: { + s: css` + max-inline-size: 90vw; + ${logicalCSSWithFallback( + 'width', + `calc(90vw - max(25vw, ${Math.round(euiTheme.breakpoint.m * 0.5)}px))` + )}; + `, + m: css` + max-inline-size: 90vw; + ${logicalCSSWithFallback( + 'width', + `calc(90vw - max(50vw, ${mathWithUnits( + formMaxWidth, + (x) => x + 24 + )}))` + )}; + `, + }, + overflow: { - overflow: css` + base: css` flex-grow: 1; display: flex; flex-direction: column; diff --git a/packages/eui/src/components/flyout/flyout_child.tsx b/packages/eui/src/components/flyout/flyout_child.tsx index 4ee71b73282..1e731c2d0e1 100644 --- a/packages/eui/src/components/flyout/flyout_child.tsx +++ b/packages/eui/src/components/flyout/flyout_child.tsx @@ -18,12 +18,15 @@ import React, { useCallback, } from 'react'; import classNames from 'classnames'; +import { logicalStyle } from '../../global_styling'; import { CommonProps } from '../common'; import { keys, useEuiMemoizedStyles, useGeneratedHtmlId } from '../../services'; 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'; /** @@ -32,6 +35,12 @@ import { EuiFocusTrap } from '../focus_trap'; export interface EuiFlyoutChildProps extends HTMLAttributes, CommonProps { + /** + * Sets the max-width of the child flyout panel. + * See `maxWidth` from EuiFlyoutProps for more details. + */ + maxWidth?: boolean | number | string; + /** * Called when the child panel's close button is clicked */ @@ -58,9 +67,10 @@ export interface EuiFlyoutChildProps /** * Size of the child flyout panel. * When the parent flyout is 'm', child is limited to 's'. + * 'fill' will take up the remaining space up to the max (90vw - parent size). * @default 's' */ - size?: 's' | 'm'; + size?: 's' | 'm' | 'fill'; /* * The background of the child flyout can be optionally shaded. Use `shaded` to add the shading. */ @@ -84,6 +94,7 @@ export const EuiFlyoutChild: FunctionComponent = ({ onClose, scrollableTabIndex = 0, size = 's', + maxWidth = false, ...rest }) => { const flyoutContext = useContext(EuiFlyoutContext); @@ -92,7 +103,13 @@ export const EuiFlyoutChild: FunctionComponent = ({ throw new Error('EuiFlyoutChild must be used as a child of EuiFlyout.'); } - const { isChildFlyoutOpen, setIsChildFlyoutOpen, parentSize } = flyoutContext; + const { + isChildFlyoutOpen, + setIsChildFlyoutOpen, + parentSize, + childLayoutMode, + parentFlyoutRef, + } = flyoutContext; useEffect(() => { setIsChildFlyoutOpen?.(true); @@ -107,7 +124,7 @@ export const EuiFlyoutChild: FunctionComponent = ({ if (parentSize === 'm' && size === 'm') { throw new Error( - 'When the parent EuiFlyout size is "m", the EuiFlyoutChild size cannot be "m". Please use size "s" for the EuiFlyoutChild.' + 'When the parent EuiFlyout size is "m", the EuiFlyoutChild size cannot be "m". Please use size "s" or "fill" for the EuiFlyoutChild.' ); } @@ -118,8 +135,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; @@ -197,17 +222,34 @@ export const EuiFlyoutChild: FunctionComponent = ({ const styles = useEuiMemoizedStyles(euiFlyoutChildStyles); - const { childLayoutMode, parentFlyoutRef } = flyoutContext; + /** + * Set inline styles + */ + const inlineStyles = useMemo(() => { + const maxWidthStyle = + typeof maxWidth !== 'boolean' && logicalStyle('max-width', maxWidth); + + return { + ...maxWidthStyle, + }; + }, [maxWidth]); - const flyoutChildCss = [ + const cssStyles = [ styles.euiFlyoutChild, backgroundStyle === 'shaded' ? styles.backgroundShaded : styles.backgroundDefault, - size === 's' ? styles.s : styles.m, + maxWidth === false && styles.noMaxWidth, + (size === 's' || size === 'm') && styles[size], childLayoutMode === 'side-by-side' ? styles.sidePosition : styles.stackedPosition, + childLayoutMode === 'stacked' && + styles.stackedPositionWithParent[parentSize as 's' | 'm'], + size === 'fill' && + childLayoutMode === 'side-by-side' && + styles.fillWithParent[parentSize as 's' | 'm'], // fill mode when parent is side-by-side + size === 'fill' && childLayoutMode === 'stacked' && styles.fill, // fill mode when parent is stacked under ]; const onKeyDown = useCallback( @@ -237,7 +279,8 @@ export const EuiFlyoutChild: FunctionComponent = ({
= ({ {flyoutTitleText} )} - {!hideCloseButton && ( + {!hideCloseButton && !hasFlyoutMenu && ( = ({
{banner && (
= ({ className="euiFlyoutChild__overflowContent" css={styles.overflow.wrapper} > - {processedChildren} + + {processedChildren} +
diff --git a/packages/eui/src/components/flyout/flyout_child_manager.tsx b/packages/eui/src/components/flyout/flyout_child_manager.tsx index 92fd089fa38..792198461b6 100644 --- a/packages/eui/src/components/flyout/flyout_child_manager.tsx +++ b/packages/eui/src/components/flyout/flyout_child_manager.tsx @@ -20,7 +20,7 @@ import { EuiFlyoutContext, EuiFlyoutContextValue } from './flyout_context'; import { EuiFlyoutChild } from './flyout_child'; interface EuiFlyoutChildProviderProps { - parentSize: 's' | 'm'; + parentSize: 's' | 'm' | 'fill'; parentFlyoutRef: React.RefObject; childElement: React.ReactElement>; childrenToRender: ReactNode; @@ -84,7 +84,8 @@ export const EuiFlyoutChildProvider: FunctionComponent< let childNumericValue = 0; if (childSizeName === 's') childNumericValue = euiTheme.breakpoint.s; - else if (childSizeName === 'm') childNumericValue = euiTheme.breakpoint.m; + else if (childSizeName === 'm' || childSizeName === 'fill') + childNumericValue = euiTheme.breakpoint.m; return parentNumericValue + childNumericValue; }, [parentSize, childElement.props.size, euiTheme.breakpoint]); 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/flyout_shared.styles.ts b/packages/eui/src/components/flyout/flyout_shared.styles.ts new file mode 100644 index 00000000000..70f65ca15d7 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_shared.styles.ts @@ -0,0 +1,74 @@ +/* + * 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 { + euiMaxBreakpoint, + euiMinBreakpoint, + logicalCSS, + mathWithUnits, +} from '../../global_styling'; +import { UseEuiTheme } from '../../services'; +import { euiFormMaxWidth } from '../form/form.styles'; +import { EuiFlyoutSize } from './flyout'; + +export const FLYOUT_BREAKPOINT = 'm' as const; + +export const composeFlyoutSizing = ( + euiThemeContext: UseEuiTheme, + size: EuiFlyoutSize | 'fill' +) => { + const euiTheme = euiThemeContext.euiTheme; + const formMaxWidth = euiFormMaxWidth(euiThemeContext); + + // 1. Calculating the minimum width based on the screen takeover breakpoint + const flyoutSizes = { + s: { + min: `${Math.round(euiTheme.breakpoint.m * 0.5)}px`, // 1. + width: '25vw', + max: `${Math.round(euiTheme.breakpoint.s * 0.7)}px`, + }, + + m: { + // Calculated for forms plus padding + min: `${mathWithUnits(formMaxWidth, (x) => x + 24)}`, + width: '50vw', + max: `${euiTheme.breakpoint.m}px`, + }, + + l: { + min: `${Math.round(euiTheme.breakpoint.m * 0.9)}px`, // 1. + width: '75vw', + max: `${euiTheme.breakpoint.l}px`, + }, + + fill: { + min: '90vw', + width: '90vw', + max: '90vw', + }, + }; + + return ` + ${logicalCSS('max-width', flyoutSizes[size].max)} + + ${euiMaxBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { + ${logicalCSS('min-width', 0)} + ${logicalCSS('width', flyoutSizes[size].min)} + } + ${euiMinBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { + ${logicalCSS('min-width', flyoutSizes[size].min)} + ${logicalCSS('width', flyoutSizes[size].width)} + } + `; +}; + +export const maxedFlyoutWidth = (euiThemeContext: UseEuiTheme) => ` + ${euiMaxBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { + ${logicalCSS('max-width', '90vw !important')} + } +`; diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index 6c34bdb77a9..7745b60a95b 100644 --- a/packages/eui/src/components/flyout/index.ts +++ b/packages/eui/src/components/flyout/index.ts @@ -26,12 +26,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/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..a351396e232 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; - childSize?: 's' | 'm'; + mainTitle?: string; + hideMainTitle?: boolean; + childSize?: 's' | 'm' | 'fill'; + 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'; + size: 's' | 'm' | 'fill'; 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/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';