diff --git a/packages/eui/changelogs/upcoming/8771.md b/packages/eui/changelogs/upcoming/8771.md new file mode 100644 index 00000000000..ddb9b62053b --- /dev/null +++ b/packages/eui/changelogs/upcoming/8771.md @@ -0,0 +1 @@ +- Added `EuiFlyoutChild` and `EuiFlyoutSessionProvider` diff --git a/packages/eui/src/components/flyout/flyout.styles.ts b/packages/eui/src/components/flyout/flyout.styles.ts index b5aef57c55a..962e9ad10b3 100644 --- a/packages/eui/src/components/flyout/flyout.styles.ts +++ b/packages/eui/src/components/flyout/flyout.styles.ts @@ -64,19 +64,24 @@ export const euiFlyoutStyles = (euiThemeContext: UseEuiTheme) => { outline: none; } - ${euiMaxBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { - /* 1. Leave only a small sliver exposed on small screens so users understand that this is not a new page - 2. If a custom maxWidth is set, we need to override it. */ - ${logicalCSS('max-width', '90vw !important')} - } + ${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 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 { + ${composeFlyoutSizing(euiThemeContext, 's')} + } `, l: css` ${composeFlyoutSizing(euiThemeContext, 'l')} @@ -94,6 +99,10 @@ export const euiFlyoutStyles = (euiThemeContext: UseEuiTheme) => { animation: ${euiFlyoutSlideInRight} ${euiTheme.animation.normal} ${euiTheme.animation.resistance}; } + + &.euiFlyout--hasChild { + clip-path: none; + } `, // Left-side flyouts should only be used for navigation left: css` @@ -166,7 +175,13 @@ export const euiFlyoutStyles = (euiThemeContext: UseEuiTheme) => { }; }; -const composeFlyoutSizing = ( +export const maxedFlyoutWidth = (euiThemeContext: UseEuiTheme) => ` + ${euiMaxBreakpoint(euiThemeContext, FLYOUT_BREAKPOINT)} { + ${logicalCSS('max-width', '90vw !important')} + } +`; + +export const composeFlyoutSizing = ( euiThemeContext: UseEuiTheme, size: EuiFlyoutSize ) => { diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index 87e68cff594..c541c4a3757 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -7,6 +7,7 @@ */ import React, { + ComponentProps, useEffect, useRef, useMemo, @@ -45,6 +46,8 @@ 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'; export const TYPES = ['push', 'overlay'] as const; type _EuiFlyoutType = (typeof TYPES)[number]; @@ -182,7 +185,7 @@ export const EuiFlyout = forwardRef( as, hideCloseButton = false, closeButtonProps, - closeButtonPosition = 'inside', + closeButtonPosition: _closeButtonPosition = 'inside', onClose, ownFocus = true, side = 'right', @@ -208,6 +211,54 @@ export const EuiFlyout = forwardRef( 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; @@ -219,7 +270,11 @@ export const EuiFlyout = forwardRef( const [resizeRef, setResizeRef] = useState | null>( null ); - const setRef = useCombinedRefs([setResizeRef, ref]); + const setRef = useCombinedRefs([ + setResizeRef, + ref, + internalParentFlyoutRef, + ]); const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); useEffect(() => { @@ -289,11 +344,19 @@ export const EuiFlyout = forwardRef( styles[side], ]; - const classes = classnames('euiFlyout', className); + const classes = classnames('euiFlyout', ...childFlyoutClasses, className); /* - * If not disabled, automatically add fixed EuiHeaders as shards - * to EuiFlyout focus traps, to prevent focus fighting + * 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 [fixedHeaders, setFixedHeaders] = useState([]); @@ -323,7 +386,7 @@ export const EuiFlyout = forwardRef( ..._focusTrapProps, shards: [...fixedHeaders, ...(_focusTrapProps?.shards || [])], }), - [fixedHeaders, _focusTrapProps] + [_focusTrapProps, fixedHeaders] ); /* @@ -389,6 +452,30 @@ export const EuiFlyout = forwardRef( [onClose, hasOverlayMask, outsideClickCloses] ); + const closeButton = !hideCloseButton && ( + + ); + + // render content within EuiFlyoutChildProvider if childFlyoutElement is present + let contentToRender: React.ReactElement = children; + if (hasChildFlyout && childFlyoutElement) { + contentToRender = ( + + ); + } + return ( { + if (!isChildFlyoutOpen && flyoutToggle.current) { + (flyoutToggle.current as HTMLElement).focus(); + return false; // We've handled focus + } + return true; + }} {...focusTrapProps} > {!isPushed && screenReaderDescription} - {!hideCloseButton && onClose && ( - - )} - {children} + {closeButton} + {contentToRender} diff --git a/packages/eui/src/components/flyout/flyout_child.stories.tsx b/packages/eui/src/components/flyout/flyout_child.stories.tsx new file mode 100644 index 00000000000..c9c29344709 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_child.stories.tsx @@ -0,0 +1,261 @@ +/* + * 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, ComponentProps } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { EuiButton } from '../button'; +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'; + +const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; + +type EuiFlyoutChildActualProps = ComponentProps; + +type FlyoutChildStoryArgs = EuiFlyoutChildActualProps & { + pushMinBreakpoint: EuiBreakpointSize; +}; + +const meta: Meta = { + title: 'Layout/EuiFlyout/EuiFlyoutChild', + component: EuiFlyoutChild, + argTypes: { + size: { + options: ['s', 'm'], + control: { type: 'radio' }, + }, + 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.', + }, + }, + args: { + scrollableTabIndex: 0, + hideCloseButton: false, + size: 's', + pushMinBreakpoint: 'xs', + }, + parameters: { + docs: { + description: { + component: ` +## EuiFlyoutChild +A child panel component that can be nested within an EuiFlyout. + +### Responsive behavior +- On larger screens (>= medium breakpoint), the child panel appears side-by-side with the main flyout +- On smaller screens (< medium breakpoint), the child panel stacks on top of the main flyout + +### Restrictions +- 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' + - Custom pixel sizes are not allowed when using a child flyout + `, + }, + }, + loki: { + chromeSelector: LOKI_SELECTORS.portal, + }, + }, +}; + +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 [isMainOpen, setIsMainOpen] = useState(true); + const [isChildOpen, setIsChildOpen] = useState(false); + const [flyoutType, setFlyoutType] = useState('overlay'); + + const openMain = () => setIsMainOpen(true); + const closeMain = () => { + setIsMainOpen(false); + setIsChildOpen(false); + }; + const openChild = () => setIsChildOpen(true); + const closeChild = () => setIsChildOpen(false); + + const typeRadios: EuiRadioGroupOption[] = [ + { id: 'overlay', label: 'Overlay' }, + { id: 'push', label: 'Push' }, + ]; + + return ( + <> + +

+ This is the main page content. Watch how it behaves when the flyout + type changes. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. +

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

Main Flyout ({mainSize})

+
+
+ )} + + +

This is the main flyout content.

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolorum + neque sequi illo, cum rerum quia ab animi velit sit incidunt + inventore temporibus eaque nam veritatis amet maxime maiores + optio quam? +

+
+ + + {!isChildOpen ? ( + Open child panel + ) : ( + Close child panel + )} +
+ {showFooter && ( + + +

Main flyout footer

+
+
+ )} + + {isChildOpen && ( + + {showHeader && ( + + +

Child Flyout ({childSize})

+
+
+ )} + + +

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'
  • +
+ +

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. + Dolorum neque sequi illo, cum rerum quia ab animi velit sit + incidunt inventore temporibus eaque nam veritatis amet + maxime maiores optio quam? +

+
+
+ {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 WithSmallMainLargeChlld: Story = { + name: 'Main Size: s, Child Size: m', + render: (args) => ( + + ), +}; diff --git a/packages/eui/src/components/flyout/flyout_child.styles.ts b/packages/eui/src/components/flyout/flyout_child.styles.ts new file mode 100644 index 00000000000..2f7c5972e92 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_child.styles.ts @@ -0,0 +1,86 @@ +/* + * 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'; +import { + logicalCSS, + logicalCSSWithFallback, + highContrastModeStyles, + euiYScroll, +} from '../../global_styling'; +import { composeFlyoutSizing, maxedFlyoutWidth } from './flyout.styles'; + +export const euiFlyoutChildStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + return { + // Base styles for the child flyout + euiFlyoutChild: css` + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + block-size: 100%; + background: ${euiTheme.colors.backgroundBaseSubdued}; + display: flex; + flex-direction: column; + ${logicalCSSWithFallback('overflow-y', 'hidden')} + ${logicalCSS('height', '100%')} + z-index: ${Number(euiTheme.levels.flyout) + 1}; + border-inline-start: ${euiTheme.border.thin}; + + ${maxedFlyoutWidth(euiThemeContext)} + `, + + // 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}; + `, + + s: css` + ${composeFlyoutSizing(euiThemeContext, 's')} + `, + + m: css` + ${composeFlyoutSizing(euiThemeContext, 'm')} + `, + + closeButton: css` + position: absolute; + inset-block-start: ${euiTheme.size.s}; + inset-inline-end: ${euiTheme.size.s}; + z-index: 1; + `, + + overflow: { + overflow: css` + flex-grow: 1; + display: flex; + flex-direction: column; + ${euiYScroll(euiThemeContext)} + `, + wrapper: css` + display: flex; + flex-direction: column; + flex-grow: 1; + ${logicalCSS('overflow-x', 'auto')} + `, + }, + banner: css` + ${logicalCSSWithFallback('overflow-x', 'hidden')} + ${highContrastModeStyles(euiThemeContext, { + preferred: logicalCSS('border-bottom', euiTheme.border.thin), + })} + `, + }; +}; diff --git a/packages/eui/src/components/flyout/flyout_child.test.tsx b/packages/eui/src/components/flyout/flyout_child.test.tsx new file mode 100644 index 00000000000..580ccc17b9b --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_child.test.tsx @@ -0,0 +1,229 @@ +/* + * 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 { render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { screen } from '../../test/rtl'; + +import { EuiButton } from '../button'; +import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiFlyoutChild } from '.'; + +const mockGeneratedId = jest.fn((prefix) => `${prefix || 'generated'}TestId`); +jest.mock('../../services/accessibility/html_id_generator', () => ({ + ...jest.requireActual('../../services/accessibility/html_id_generator'), + useGeneratedHtmlId: ({ prefix }: { prefix?: string } = {}) => + mockGeneratedId(prefix), +})); + +const TestFlyoutWithChild: React.FC<{ + initialMainOpen?: boolean; + initialChildOpen?: boolean; +}> = ({ initialMainOpen = false, initialChildOpen = false }) => { + const [isMainOpen, setIsMainOpen] = useState(initialMainOpen); + const [isChildOpen, setIsChildOpen] = useState(initialChildOpen); + + const mainFlyoutTitleId = 'main-flyout-title'; + const childFlyoutTitleId = 'child-flyout-title'; + + return ( + <> + {!isMainOpen && ( + setIsMainOpen(true)}> + Open Main Flyout + + )} + {isMainOpen && ( + { + setIsMainOpen(false); + setIsChildOpen(false); + }} + aria-labelledby={mainFlyoutTitleId} + data-test-subj="main-flyout" + closeButtonProps={{ + 'data-test-subj': 'main-flyout-close-button', + 'aria-label': 'Close main flyout', + }} + > + +

Main Flyout

+
+ +

Main content

+ {}}> + Main Button 1 + + {!isChildOpen && ( + setIsChildOpen(true)} + > + Open Child Flyout + + )} + {}}> + Main Button 2 + +
+ + {isChildOpen && ( + setIsChildOpen(false)} + aria-labelledby={childFlyoutTitleId} + data-test-subj="child-flyout" + > + +

Child Flyout

+
+ +

Child content

+ {}}> + Child Button 1 + + {}}> + Child Button 2 + +
+
+ )} +
+ )} + + ); +}; + +describe('EuiFlyoutChild', () => { + test('renders correctly with required children and proper ARIA attributes', () => { + render( + + ); + + const mainFlyout = screen.getByTestSubject('main-flyout'); + const childFlyout = screen.getByTestSubject('child-flyout'); + + expect(mainFlyout).toBeInTheDocument(); + expect(childFlyout).toBeInTheDocument(); + + expect(childFlyout).toHaveAttribute('role', 'dialog'); + expect(childFlyout).toHaveAttribute('aria-modal', 'true'); + + const childCloseButton = screen.getByTestSubject( + 'euiFlyoutChildCloseButton' + ); + expect(childCloseButton).toBeInTheDocument(); + + expect(screen.getByText('Child Flyout')).toBeInTheDocument(); + expect(screen.getByText('Child content')).toBeInTheDocument(); + + expect(screen.getByTestSubject('child-button-1')).toBeInTheDocument(); + expect(screen.getByTestSubject('child-button-2')).toBeInTheDocument(); + }); + + test('throws error when used outside of EuiFlyout', () => { + const originalConsoleError = console.error; + console.error = jest.fn(); + + expect(() => { + render( + {}} + data-test-subj="standalone-child-flyout" + > + Required body content + + ); + }).toThrow('EuiFlyoutChild must be used as a child of EuiFlyout'); + + console.error = originalConsoleError; + }); + + test('focus is trapped correctly and returns as expected', async () => { + render(); + + // 1. Open the main flyout and check the parent's focus trapping + act(() => { + userEvent.click(screen.getByText('Open Main Flyout')); + }); + const mainFlyoutPanel = screen.getByTestSubject('main-flyout'); + const mainFlyoutCloseButton = screen.getByTestSubject( + 'main-flyout-close-button' + ); + const openChildButton = screen.getByTestSubject('open-child-flyout-button'); + + expect( + document.activeElement === mainFlyoutPanel || + document.activeElement === mainFlyoutCloseButton + ).toBe(true); + + // Hit tab a few times to ensure focus stays in parent. Land focus on the open child button + act(() => { + if (document.activeElement === mainFlyoutPanel) userEvent.tab(); // to main close button + if (document.activeElement === mainFlyoutCloseButton) userEvent.tab(); // to main outer overflow + userEvent.tab(); // from main outer overflow to main inner overflow + userEvent.tab(); // from main body inner overflow to open child button + }); + expect(document.activeElement).toBe(openChildButton); + expect(mainFlyoutPanel.contains(document.activeElement)).toBe(true); + + // 2. Open the child flyout and check the child's focus trapping + act(() => { + userEvent.click(openChildButton); + }); + const childFlyoutPanel = screen.getByTestSubject('child-flyout'); + const childFlyoutCloseButton = screen.getByTestSubject( + 'euiFlyoutChildCloseButton' + ); + + expect( + document.activeElement === childFlyoutPanel || + document.activeElement === childFlyoutCloseButton + ).toBe(true); + expect(childFlyoutPanel.contains(document.activeElement)).toBe(true); + + // Hit tab a few times to ensure focus stays in child. Land focus on childButton1 + act(() => { + if (document.activeElement === childFlyoutPanel) userEvent.tab(); // to child close + if (document.activeElement === childFlyoutCloseButton) userEvent.tab(); // to child outer overflow + userEvent.tab(); // from child outer overflow to child inner overflow + userEvent.tab(); // from child body inner overflow to childButton1 + }); + const childButton1 = screen.getByTestSubject('child-button-1'); + expect(document.activeElement).toBe(childButton1); + expect(childFlyoutPanel.contains(document.activeElement)).toBe(true); + + // Ensure focus is within the child flyout + expect(childFlyoutPanel.contains(document.activeElement)).toBe(true); + // Verify all interactive elements in the parent flyout are not focused + const mainFlyoutButtons = screen.getAllByTestSubject( + /main-button-\d+|open-child-flyout-button|main-flyout-close-button/ + ); + mainFlyoutButtons.forEach((button) => { + expect(document.activeElement).not.toBe(button); + }); + + // 3. Close the child flyout and check the parent's focus + act(() => { + userEvent.click(childFlyoutCloseButton); + }); + + expect(document.activeElement).toBe(mainFlyoutPanel); + expect(mainFlyoutPanel.contains(document.activeElement)).toBe(true); + + // Tab a few times to ensure focus is re-trapped in parent + act(() => { + if (document.activeElement === mainFlyoutPanel) userEvent.tab(); // to main close + if (document.activeElement === mainFlyoutCloseButton) userEvent.tab(); // to main outer overflow + userEvent.tab(); // from main outer overflow to main inner overflow + }); + + const mainButton1AfterChildClose = screen.getByTestSubject('main-button-1'); + expect(document.activeElement).toBe(mainButton1AfterChildClose); + expect(mainFlyoutPanel.contains(document.activeElement)).toBe(true); + }); +}); diff --git a/packages/eui/src/components/flyout/flyout_child.tsx b/packages/eui/src/components/flyout/flyout_child.tsx new file mode 100644 index 00000000000..a072de20162 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_child.tsx @@ -0,0 +1,274 @@ +/* + * 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, { + FunctionComponent, + HTMLAttributes, + ReactNode, + useContext, + Children, + useEffect, + useMemo, + useRef, +} from 'react'; +import classNames from 'classnames'; +import { CommonProps } from '../common'; +import { 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 { EuiFocusTrap } from '../focus_trap'; + +/** + * Props used to render and configure the child flyout panel + */ +export interface EuiFlyoutChildProps + extends HTMLAttributes, + CommonProps { + /** + * Called when the child panel's close button is clicked + */ + onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; + /** + * Use to display a banner at the top of the child. It is suggested to use `EuiCallOut` for it. + */ + banner?: ReactNode; + /** + * Hides the default close button. You must provide another close button somewhere within the child flyout. + * @default false + */ + hideCloseButton?: boolean; + /** + * [Scrollable regions (or their children) should be focusable](https://dequeuniversity.com/rules/axe/4.0/scrollable-region-focusable) + * to allow keyboard users to scroll the region via arrow keys. + * + * By default, EuiFlyoutChild's scroll overflow wrapper sets a `tabIndex` of `0`. + * If you know your flyout child content already contains focusable children + * that satisfy keyboard accessibility requirements, you can use this prop + * to override this default. + */ + scrollableTabIndex?: number; + /** + * Size of the child flyout panel. + * When the parent flyout is 'm', child is limited to 's'. + * @default 's' + */ + size?: 's' | 'm'; + /** + * Children are implicitly part of FunctionComponent, but good to have if props type is standalone. + */ + children?: ReactNode; +} + +/** + * The child flyout is a panel that appears to the left of the parent flyout. + * It is only visible when the parent flyout is open. + */ +export const EuiFlyoutChild: FunctionComponent = ({ + children, + className, + banner, + hideCloseButton = false, + onClose, + scrollableTabIndex = 0, + size = 's', + ...rest +}) => { + const flyoutContext = useContext(EuiFlyoutContext); + + if (!flyoutContext) { + throw new Error('EuiFlyoutChild must be used as a child of EuiFlyout.'); + } + + const { setIsChildFlyoutOpen, parentSize } = flyoutContext; + + useEffect(() => { + setIsChildFlyoutOpen?.(true); + return () => { + setIsChildFlyoutOpen?.(false); + }; + }, [setIsChildFlyoutOpen]); + + if (React.Children.count(children) === 0) { + console.warn('EuiFlyoutChild was rendered with no children!'); + } + + 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.' + ); + } + + const handleClose = (event: MouseEvent | TouchEvent | KeyboardEvent) => { + setIsChildFlyoutOpen?.(false); + onClose(event); + }; + + let flyoutTitleText: string | undefined; + let hasDescribedByBody = false; + Children.forEach(children, (child) => { + if (React.isValidElement(child)) { + if ((child.type as any)?.displayName === 'EuiFlyoutHeader') { + // Attempt to extract string content from header for ARIA + const headerChildren = child.props.children; + if (typeof headerChildren === 'string') { + flyoutTitleText = headerChildren; + } else if ( + React.isValidElement(headerChildren) && + // Check if props exist and children is a string + typeof (headerChildren.props as { children?: ReactNode }).children === + 'string' + ) { + flyoutTitleText = (headerChildren.props as { children: string }) + .children; + } else if (Array.isArray(headerChildren)) { + // Find the first string child if headerChildren is an array + flyoutTitleText = headerChildren.find( + (cNode) => typeof cNode === 'string' + ) as string | undefined; + } + } + if (child.type === EuiFlyoutBody) { + hasDescribedByBody = true; + } + } + }); + + const titleIdGenerated = useGeneratedHtmlId({ + prefix: 'euiFlyoutChildTitle', + }); + const bodyIdGenerated = useGeneratedHtmlId({ prefix: 'euiFlyoutChildBody' }); + const ariaLabelledBy = flyoutTitleText ? titleIdGenerated : undefined; + const ariaDescribedBy = hasDescribedByBody ? bodyIdGenerated : undefined; + // Use existing aria-label if provided, otherwise fallback if no labelledby can be derived + const ariaLabel = + rest['aria-label'] || + (!ariaLabelledBy && !flyoutTitleText ? 'Flyout panel' : undefined); + + const processedChildren = useMemo(() => { + return Children.map(children, (child) => { + if (React.isValidElement(child)) { + if ( + (child.type === EuiFlyoutBody || + (child.type as any)?.displayName === 'EuiFlyoutBody') && + hasDescribedByBody + ) { + return React.cloneElement(child as React.ReactElement, { + id: bodyIdGenerated, + }); + } + // If EuiFlyoutHeader is found and we derived flyoutTitleText, set its ID + if ( + (child.type as any)?.displayName === 'EuiFlyoutHeader' && + flyoutTitleText && + ariaLabelledBy + ) { + return React.cloneElement(child as React.ReactElement, { + id: titleIdGenerated, + }); + } + } + return child; + }); + }, [ + children, + bodyIdGenerated, + titleIdGenerated, + hasDescribedByBody, + flyoutTitleText, + ariaLabelledBy, + ]); + + const flyoutWrapperRef = useRef(null); + + const classes = classNames('euiFlyoutChild', className); + + const styles = useEuiMemoizedStyles(euiFlyoutChildStyles); + + const { childLayoutMode, parentFlyoutRef } = flyoutContext; + + const flyoutChildCss = [ + styles.euiFlyoutChild, + size === 's' ? styles.s : styles.m, + childLayoutMode === 'side-by-side' + ? styles.sidePosition + : styles.stackedPosition, + ]; + + return ( + { + if (parentFlyoutRef?.current) { + parentFlyoutRef.current.focus(); + return false; // We've handled focus + } + return true; + }} + shards={[]} + disabled={false} + > +
+ {/* Fallback title for screen readers if a title was derived but not used for aria-labelledby + (e.g. if the EuiFlyoutHeader itself wasn't given the ID via processedChildren) + This ensures a title is announced if one was found. + */} + {flyoutTitleText && !ariaLabelledBy && ( +

+ {flyoutTitleText} +

+ )} + {!hideCloseButton && ( + + )} +
+ {banner && ( +
+ {banner} +
+ )} +
+ {processedChildren} +
+
+
+
+ ); +}; + +EuiFlyoutChild.displayName = 'EuiFlyoutChild'; diff --git a/packages/eui/src/components/flyout/flyout_child_manager.tsx b/packages/eui/src/components/flyout/flyout_child_manager.tsx new file mode 100644 index 00000000000..9b9ee8819ce --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_child_manager.tsx @@ -0,0 +1,129 @@ +/* + * 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, { + ComponentProps, + FunctionComponent, + ReactNode, + useEffect, + useMemo, + useState, +} from 'react'; + +import { useEuiTheme } from '../../services'; +import { EuiFlyoutContext, EuiFlyoutContextValue } from './flyout_context'; +import { EuiFlyoutChild } from './flyout_child'; + +interface EuiFlyoutChildProviderProps { + parentSize: 's' | 'm'; + parentFlyoutRef: React.RefObject; + childElement: React.ReactElement>; + childrenToRender: ReactNode; + reportIsChildOpen: (isOpen: boolean) => void; + reportChildLayoutMode: (mode: 'side-by-side' | 'stacked') => void; +} + +/** + * An intermediate component between EuiFlyout and EuiFlyoutChild. + * It is responsible for managing the state of the child flyout, and passing it to EuiFlyoutContext. + * It removes the responsibility of managing child flyout state from EuiFlyout, which is especially important there might not be a child flyout. + */ +export const EuiFlyoutChildProvider: FunctionComponent< + EuiFlyoutChildProviderProps +> = ({ + parentSize, + parentFlyoutRef, + childElement, + childrenToRender, + reportIsChildOpen, + reportChildLayoutMode, +}) => { + const { euiTheme } = useEuiTheme(); + + const [isChildFlyoutOpen, setIsChildFlyoutOpen] = useState(false); + const [windowWidth, setWindowWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : Infinity + ); + const [childLayoutMode, setChildLayoutMode] = useState< + 'side-by-side' | 'stacked' + >('side-by-side'); + + // update windowWidth on resize + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleResize = () => { + setWindowWidth(window.innerWidth); + }; + + window.addEventListener('resize', handleResize); + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + // Calculate stacking breakpoint value for child flyout. + // Stacking breakpoint value is a sum of parent breakpoint value and child breakpoint value. + const stackingBreakpointValue = useMemo(() => { + const parentSizeName = parentSize; + const childSizeName = childElement.props.size || 's'; + + let parentNumericValue = 0; + if (parentSizeName === 's') parentNumericValue = euiTheme.breakpoint.s; + else if (parentSizeName === 'm') parentNumericValue = euiTheme.breakpoint.m; + // Parent 'l' size is not allowed when child is present, so no need to check here + + let childNumericValue = 0; + if (childSizeName === 's') childNumericValue = euiTheme.breakpoint.s; + else if (childSizeName === 'm') childNumericValue = euiTheme.breakpoint.m; + + return parentNumericValue + childNumericValue; + }, [parentSize, childElement.props.size, euiTheme.breakpoint]); + + // update childLayoutMode based on windowWidth and the calculated stackingBreakpoint + useEffect(() => { + if (windowWidth >= stackingBreakpointValue) { + setChildLayoutMode('side-by-side'); + } else { + setChildLayoutMode('stacked'); + } + }, [windowWidth, stackingBreakpointValue]); + + // report isChildFlyoutOpen changes to the parent EuiFlyout + useEffect(() => { + reportIsChildOpen(isChildFlyoutOpen); + }, [isChildFlyoutOpen, reportIsChildOpen]); + + // report childLayoutMode changes to the parent EuiFlyout + useEffect(() => { + reportChildLayoutMode(childLayoutMode); + }, [childLayoutMode, reportChildLayoutMode]); + + const contextValue = useMemo( + () => ({ + parentSize, + parentFlyoutRef, + isChildFlyoutOpen, + setIsChildFlyoutOpen, + childLayoutMode, + }), + [ + parentSize, + parentFlyoutRef, + isChildFlyoutOpen, + setIsChildFlyoutOpen, + childLayoutMode, + ] + ); + + return ( + + {childrenToRender} + + ); +}; diff --git a/packages/eui/src/components/flyout/flyout_context.ts b/packages/eui/src/components/flyout/flyout_context.ts new file mode 100644 index 00000000000..351528d022a --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_context.ts @@ -0,0 +1,26 @@ +/* + * 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, RefObject } from 'react'; +import { EuiFlyoutSize } from './flyout'; + +/** + * Context shared between the main and child flyouts + * @internal + */ +export interface EuiFlyoutContextValue { + parentSize?: EuiFlyoutSize | string | number; + parentFlyoutRef?: RefObject; + isChildFlyoutOpen?: boolean; + setIsChildFlyoutOpen?: (isOpen: boolean) => void; + childLayoutMode?: 'side-by-side' | 'stacked'; +} + +export const EuiFlyoutContext = createContext( + null +); diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index aa80a23d85a..a4084092d4c 100644 --- a/packages/eui/src/components/flyout/index.ts +++ b/packages/eui/src/components/flyout/index.ts @@ -22,3 +22,15 @@ export { euiFlyoutSlideInRight, euiFlyoutSlideInLeft } from './flyout.styles'; export type { EuiFlyoutResizableProps } from './flyout_resizable'; export { EuiFlyoutResizable } from './flyout_resizable'; + +export { EuiFlyoutChild } from './flyout_child'; +export type { EuiFlyoutChildProps } from './flyout_child'; + +export type { + EuiFlyoutSessionConfig, + EuiFlyoutSessionOpenChildOptions, + EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionProviderComponentProps, + EuiFlyoutSessionRenderContext, +} from './sessions'; +export { EuiFlyoutSessionProvider, useEuiFlyoutSession } from './sessions'; diff --git a/packages/eui/src/components/flyout/sessions/README.md b/packages/eui/src/components/flyout/sessions/README.md new file mode 100644 index 00000000000..dc8cc0692c7 --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/README.md @@ -0,0 +1,98 @@ +# EUI Flyout State Management (Flyout Sessions) + +EUI has a state management system for programmatically controlling flyouts. It provides a flexible way to open, close, and manage the content of flyouts from anywhere within a React component tree. + +This system is ideal for scenarios flows involving sequence of flyouts (e.g., a main flyout that opens a child flyout, a main flyout opens a "next" flyout), otherwise known as a "flyout session". + +## `EuiFlyoutSessionProvider` API + +The `EuiFlyoutSessionProvider` is the core stateful component. You must wrap it around any components that need to use the `useEuiFlyoutSession` hook. + +### Props + +* **`renderMainFlyoutContent`**: `(flyoutContext: EuiFlyoutSessionRenderContext) => ReactNode;` +* **`renderChildFlyoutContent`**: `(flyoutContext: EuiFlyoutSessionRenderContext) => ReactNode;` (optional) + +### The `flyoutContext` Object + +The `flyoutContext` object passed to your render prop functions is of type `EuiFlyoutSessionRenderContext` and contains the following useful properties: + +* **`meta`**: `MetaType` - The arbitrary data object you passed into the `meta` property when calling `openFlyout` or `openChildFlyout`. This is a generic, allowing you to have type safety for your custom data. +* **`onCloseFlyout`**: `() => void` - A callback function that closes the current main flyout. +* **`onCloseChildFlyout`**: `() => void` - A callback function that closes the current child flyout. +* **`flyoutSize`**: The size of the currently active flyout. +* **`flyoutType`**: `'main' | 'child'` - Indicates which flyout the render prop is being called for. + +## `useEuiFlyoutSession` Hook API + +The `useEuiFlyoutSession` hook is generic and can be typed to match the `meta` object you are working with (e.g., `useEuiFlyoutSession()`). + +### Methods + +* `openFlyout(options)`: Opens a new main flyout. If a flyout is already open, it adds the new one to a history stack. +* `openChildFlyout(options)`: Opens a new child flyout to the left of the main flyout. +* `openFlyoutGroup(options)`: Opens a group containing a main flyout and a child flyout. +* `closeFlyout()`: Closes the currently open main flyout. If there's a previous flyout in the history stack, it will be shown. +* `closeChildFlyout()`: Closes the currently open child flyout. +* `clearHistory()`: Closes all flyouts by clearing the history stack of all flyouts in the session. + +### State Values + +The hook also returns boolean flags representing the current state: + +* `isFlyoutOpen`: `true` if a main flyout is currently open. +* `isChildFlyoutOpen`: `true` if a child flyout is currently open. +* `canGoBack`: `true` if there is a previous flyout in the history stack. + +## Basic Usage Example + +Here is a simplified example of how to set up and use the flyout state management hook. + +```tsx +import React from 'react'; + +import { + EuiButton, + EuiFlyoutBody, + EuiText, + EuiFlyoutSessionProvider, + useEuiFlyoutSession, +} from '@elastic/eui'; + +const FlyoutAppControls: React.FC = () => { + const { openFlyout, isFlyoutOpen } = useEuiFlyoutSession(); + + const handleOpenFlyout = () => { + // Calling `openFlyout` again within the same `EuiFlyoutSessionProvider` instance + // will add the new flyout to the history stack. + openFlyout({ + size: 'm', + }); + }; + + return ( + + Open simple flyout + + ); +}; + +const FlyoutApp: React.FC = () => { + // The EuiFlyoutSessionRenderContext is passed to your render prop functions. + // This can contain a custom `meta` object (set in the `openFlyout` function call) + // which allows you to customize the content shown in the flyout. + const renderMainFlyoutContent = (context: EuiFlyoutSessionRenderContext) => ( + + +

Simple flyout content

+
+
+ ); + + return ( + + + + ); +}; +``` diff --git a/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx b/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx new file mode 100644 index 00000000000..33f86be2924 --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx @@ -0,0 +1,519 @@ +/* + * 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, + EuiCodeBlock, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSpacer, + EuiText, + EuiTitle, +} from '../../index'; + +import { + EuiFlyoutSessionProvider, + useEuiFlyoutSessionContext, +} from './flyout_provider'; +import { EuiFlyoutSessionRenderContext } from './types'; +import { + type EuiFlyoutSessionOpenChildOptions, + type EuiFlyoutSessionOpenGroupOptions, + type EuiFlyoutSessionOpenMainOptions, + useEuiFlyoutSession, +} from './use_eui_flyout'; + +const meta: Meta = { + title: 'Layout/EuiFlyout/EuiFlyoutChild', + component: EuiFlyoutSessionProvider, +}; + +export default meta; + +/** + * --------------------------------------------------- + * Ecommerce Shopping Cart Example (advanced use case) + * --------------------------------------------------- + */ + +interface ECommerceContentProps { + itemQuantity: number; +} +interface ShoppingCartContentProps extends ECommerceContentProps { + onQuantityChange: (delta: number) => void; +} +interface ReviewOrderContentProps extends ECommerceContentProps {} +interface ItemDetailsContentProps extends ECommerceContentProps {} + +/** + * + * The flyout system allows custom meta data to be provided by the consumer, in the "EuiFlyoutSessionOpen*Options" + * interfaces. In the advanced use case, (WithHistoryApp), we're using metadata within the renderMainFlyoutContent + * function as a conditional to determine which component to render in the main flyout. + */ +interface WithHistoryAppMeta { + ecommerceMainFlyoutKey?: 'shoppingCart' | 'reviewOrder'; +} + +const ShoppingCartContent: React.FC = ({ + itemQuantity, + onQuantityChange, +}) => { + const { openChildFlyout, openFlyout, closeChildFlyout, clearHistory } = + useEuiFlyoutSession(); + const { state } = useEuiFlyoutSessionContext(); + const { config, isChildOpen } = state.activeFlyoutGroup || {}; + + const handleOpenChildDetails = () => { + const options: EuiFlyoutSessionOpenChildOptions = { + size: 's', + flyoutProps: { + className: 'demoFlyoutChild', + 'aria-label': 'Item details', + onClose: () => { + console.log('Item details onClose triggered'); + closeChildFlyout(); // If we add an onClose handler to the child flyout, we have to call closeChildFlyout within it for the flyout to actually close + }, + }, + onUnmount: () => console.log('Unmounted item details child flyout'), + }; + openChildFlyout(options); + }; + + const handleProceedToReview = () => { + const reviewFlyoutSize = config?.mainSize || 'm'; + const options: EuiFlyoutSessionOpenMainOptions = { + size: reviewFlyoutSize, + meta: { ecommerceMainFlyoutKey: 'reviewOrder' }, + flyoutProps: { + ...config?.mainFlyoutProps, + 'aria-label': 'Review order', + }, + onUnmount: () => + console.log(`Unmounted review order flyout (${reviewFlyoutSize})`), + }; + openFlyout(options); + }; + + return ( + <> + + +

Shopping cart

+
+
+ + +

Item: Flux Capacitor

+
+ + {isChildOpen ? '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 + + + + Proceed to review + +
+ + + Close + + + + ); +}; + +const ReviewOrderContent: React.FC = ({ + itemQuantity, +}) => { + const { goBack, clearHistory } = useEuiFlyoutSession(); + 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(); + // Add a setTimeout to check the state a little after the action is dispatched + setTimeout(() => { + console.log('After goBack timeout check'); + }, 100); + }} + color="danger" + > + Go back + + )}{' '} + + Close + + + + ); +}; + +const ItemDetailsContent: React.FC = ({ + itemQuantity, +}) => { + const { closeChildFlyout } = useEuiFlyoutSession(); + return ( + <> + + +

Item details

+
+
+ + +

+ Item: Flux Capacitor +

+

+ Selected quantity: {itemQuantity} +

+

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

+
+
+ + + Close details + + + + ); +}; + +// Component for the main control buttons and state display +const WithHistoryAppControls: React.FC = () => { + const { + openFlyout, + goBack, + isFlyoutOpen, + canGoBack, + isChildFlyoutOpen, + closeChildFlyout, + clearHistory, + } = useEuiFlyoutSession(); + + const handleOpenShoppingCart = () => { + const options: EuiFlyoutSessionOpenMainOptions = { + size: 'm', + meta: { ecommerceMainFlyoutKey: 'shoppingCart' }, + flyoutProps: { + type: 'push', + pushMinBreakpoint: 'xs', + className: 'shoppingCartFlyoutMain', + 'aria-label': 'Shopping cart', + onClose: (event) => { + console.log('Shopping cart onClose triggered', event); + clearHistory(); // If we add an onClose handler to the main flyout, we have to call clearHistory within it for the flyout to actually close + }, + }, + onUnmount: () => console.log('Unmounted shopping cart flyout'), + }; + openFlyout(options); + }; + + const { state } = useEuiFlyoutSessionContext(); // For displaying raw state and history length + + return ( + <> + + Open shopping cart + + + + Close child flyout + + + + Close/Go back + + + + +

Current state

+
+ + {JSON.stringify(state, null, 2)} + + + ); +}; + +const WithHistoryApp: React.FC = () => { + const [itemQuantity, setItemQuantity] = useState(1); + + const handleQuantityChange = (delta: number) => { + setItemQuantity((prev) => Math.max(0, prev + delta)); + }; + + // Render function for MAIN flyout content + const renderMainFlyoutContent = ( + context: EuiFlyoutSessionRenderContext + ) => { + const { meta } = context; + const { ecommerceMainFlyoutKey } = meta || {}; + + if (ecommerceMainFlyoutKey === 'shoppingCart') { + return ( + + ); + } + if (ecommerceMainFlyoutKey === 'reviewOrder') { + return ; + } + + console.warn('renderMainFlyoutContent: Unknown flyout key', meta); + return null; + }; + + // Render function for CHILD flyout content + const renderChildFlyoutContent = () => { + return ; + }; + + return ( + + + + ); +}; + +export const WithHistory = { + name: 'FlyoutProvider with History', + render: () => { + return ; + }, +}; + +/** + * -------------------------------------- + * Group opener example (simple use case) + * -------------------------------------- + */ + +const GroupOpenerControls: React.FC = () => { + const { openFlyoutGroup, isFlyoutOpen, isChildFlyoutOpen, clearHistory } = + useEuiFlyoutSession(); + + const { state } = useEuiFlyoutSessionContext(); + + const handleOpenGroup = () => { + const options: EuiFlyoutSessionOpenGroupOptions = { + main: { + size: 'm', + flyoutProps: { + type: 'push', + pushMinBreakpoint: 'xs', + className: 'groupOpenerMainFlyout', + 'aria-label': 'Main flyout', + }, + onUnmount: () => console.log('Unmounted main flyout'), + }, + child: { + size: 's', + flyoutProps: { + className: 'groupOpenerChildFlyout', + 'aria-label': 'Child flyout', + }, + onUnmount: () => console.log('Unmounted child flyout'), + }, + }; + openFlyoutGroup(options); + }; + + return ( +
+ +

EuiFlyoutSession Group Opener

+
+ + +

+ This demo shows how to use the openFlyoutGroup function + to simultaneously open both main and child flyouts. +

+
+ + + Open flyouts + + {(isFlyoutOpen || isChildFlyoutOpen) && ( + <> + + + Close All Flyouts + + + )} + + + {JSON.stringify(state, null, 2)} + +
+ ); +}; + +const WithGroupOpenerApp: React.FC = () => { + const MainFlyoutContent = () => { + const { clearHistory } = useEuiFlyoutSession(); + return ( + <> + + +

Main Flyout

+
+
+ + +

+ This is the main flyout content. It was opened simultaneously with + the child flyout using the openFlyoutGroup function. +

+
+
+ + + Close All Flyouts + + + + ); + }; + + const ChildFlyoutContent = () => { + const { closeChildFlyout } = useEuiFlyoutSession(); + return ( + <> + + +

Child Flyout

+
+
+ + +

+ This is the child flyout content. It was opened simultaneously + with the main flyout using the openFlyoutGroup + function. +

+
+
+ + + Close Child Only + + + + ); + }; + + const renderMainFlyoutContent = () => { + return ; + }; + + const renderChildFlyoutContent = () => { + return ; + }; + + return ( + + + + ); +}; + +export const WithGroupOpener: StoryObj = { + name: 'FlyoutProvider with Group Opener', + render: () => { + return ; + }, +}; diff --git a/packages/eui/src/components/flyout/sessions/flyout_provider.tsx b/packages/eui/src/components/flyout/sessions/flyout_provider.tsx new file mode 100644 index 00000000000..380b5af380c --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/flyout_provider.tsx @@ -0,0 +1,131 @@ +/* + * 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, { createContext, useContext, useReducer } from 'react'; + +import { EuiFlyout, EuiFlyoutChild } from '../index'; + +import { initialFlyoutState, flyoutReducer } from './flyout_reducer'; +import { + EuiFlyoutSessionAction, + EuiFlyoutSessionHistoryState, + EuiFlyoutSessionRenderContext, + EuiFlyoutSessionProviderComponentProps, +} from './types'; + +interface FlyoutSessionContextProps { + state: EuiFlyoutSessionHistoryState; + dispatch: React.Dispatch; +} + +const EuiFlyoutSessionContext = createContext( + null +); + +/** + * Accesses the state data and dispatch function from the context of EuiFlyoutSessionProvider + * Use this if you need to debug the state or need direct access to the dispatch function, otherwise use useEuiFlyoutSession hook. + */ +export const useEuiFlyoutSessionContext = () => { + const context = useContext(EuiFlyoutSessionContext); + if (!context) { + throw new Error( + 'useEuiFlyoutSessionContext must be used within a EuiFlyoutSessionProvider' + ); + } + return context; +}; + +/** + * FlyoutProvider is a component that provides a context for Flyout components. + * It is used to manage the state of the Flyout and its child. + * It also renders the Flyout and FlyoutChild components. + * + * @param children - The children of the FlyoutProvider component. + * @param renderMainFlyoutContent - A function that renders the content of the main Flyout. + * @param renderChildFlyoutContent - A function that renders the content of the child Flyout. + * @returns The FlyoutProvider component. + */ +export const EuiFlyoutSessionProvider: React.FC< + EuiFlyoutSessionProviderComponentProps +> = ({ children, renderMainFlyoutContent, renderChildFlyoutContent }) => { + const [state, dispatch] = useReducer(flyoutReducer, initialFlyoutState); + const { activeFlyoutGroup } = state; + + const handleClose = () => { + dispatch({ type: 'CLEAR_HISTORY' }); + }; + + const handleCloseChild = () => { + dispatch({ type: 'CLOSE_CHILD_FLYOUT' }); + }; + + let mainFlyoutContentNode: React.ReactNode = null; + let childFlyoutContentNode: React.ReactNode = null; + + if (activeFlyoutGroup) { + const mainRenderContext: EuiFlyoutSessionRenderContext = { + flyoutProps: activeFlyoutGroup.config.mainFlyoutProps || {}, + flyoutSize: activeFlyoutGroup.config.mainSize, + flyoutType: 'main', + dispatch, + activeFlyoutGroup, + onCloseFlyout: handleClose, + onCloseChildFlyout: handleCloseChild, + meta: activeFlyoutGroup.meta, + }; + mainFlyoutContentNode = renderMainFlyoutContent(mainRenderContext); + + if (activeFlyoutGroup.isChildOpen && renderChildFlyoutContent) { + const childRenderContext: EuiFlyoutSessionRenderContext = { + flyoutProps: activeFlyoutGroup.config.childFlyoutProps || {}, + flyoutSize: activeFlyoutGroup.config.childSize, + flyoutType: 'child', + dispatch, + activeFlyoutGroup, + onCloseFlyout: handleClose, + onCloseChildFlyout: handleCloseChild, + meta: activeFlyoutGroup.meta, + }; + childFlyoutContentNode = renderChildFlyoutContent(childRenderContext); + } else if (activeFlyoutGroup.isChildOpen && !renderChildFlyoutContent) { + console.warn( + 'EuiFlyoutSessionProvider: A child flyout is open, but renderChildFlyoutContent was not provided.' + ); + } + } + + const config = activeFlyoutGroup?.config; + const flyoutPropsMain = config?.mainFlyoutProps || {}; + const flyoutPropsChild = config?.childFlyoutProps || {}; + + return ( + + {children} + {activeFlyoutGroup?.isMainOpen && ( + + {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 new file mode 100644 index 00000000000..6d55f10abeb --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/flyout_reducer.ts @@ -0,0 +1,254 @@ +/* + * 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 { + EuiFlyoutSessionAction, + EuiFlyoutSessionHistoryState, + EuiFlyoutSessionGroup, +} from './types'; + +/** + * Initial state for the flyout session + * @internal + */ +export const initialFlyoutState: EuiFlyoutSessionHistoryState = { + activeFlyoutGroup: null, + history: [], +}; + +// Helper to apply size constraints for flyout groups +const applySizeConstraints = ( + group: EuiFlyoutSessionGroup +): EuiFlyoutSessionGroup => { + const originalMainSize = group.config.mainSize; + const originalChildSize = group.config.childSize; + let newMainSize = originalMainSize; + let newChildSize = originalChildSize; + + if (group.isChildOpen) { + if (originalMainSize === 'l') { + newMainSize = 'm'; // If main is 'l' with child, it must be converted to 'm' + newChildSize = 's'; // And child must be 's' + } else if (originalMainSize === 'm' && originalChildSize !== 's') { + newChildSize = 's'; // If main is 'm' with child, child must be 's' + } + } + + // If sizes haven't changed, return the original group to preserve references + if (newMainSize === originalMainSize && newChildSize === originalChildSize) { + return group; + } + + return { + ...group, + config: { + ...group.config, + mainSize: newMainSize, + childSize: newChildSize, + }, + }; +}; + +/** + * Flyout reducer + * Controls state changes for flyout groups + */ +export function flyoutReducer( + state: EuiFlyoutSessionHistoryState, + action: EuiFlyoutSessionAction +): EuiFlyoutSessionHistoryState { + switch (action.type) { + case 'OPEN_MAIN_FLYOUT': { + const { size, flyoutProps, onUnmount } = action.payload; + const newHistory = [...state.history]; + + if (state.activeFlyoutGroup) { + newHistory.push(state.activeFlyoutGroup); + } + + const newActiveGroup: EuiFlyoutSessionGroup = { + isMainOpen: true, + isChildOpen: false, + config: { + mainSize: size, + childSize: 's', + mainFlyoutProps: flyoutProps, + childFlyoutProps: {}, + }, + mainOnUnmount: onUnmount, + childOnUnmount: undefined, + meta: + action.payload.meta !== undefined + ? state.activeFlyoutGroup?.meta !== undefined + ? { ...state.activeFlyoutGroup.meta, ...action.payload.meta } + : action.payload.meta + : state.activeFlyoutGroup?.meta, + }; + + return { + activeFlyoutGroup: applySizeConstraints(newActiveGroup), + history: newHistory, + }; + } + + case 'OPEN_CHILD_FLYOUT': { + if (!state.activeFlyoutGroup || !state.activeFlyoutGroup.isMainOpen) { + console.warn( + 'Cannot open child flyout: main flyout is not open or no active group.' + ); + return state; + } + + const { size, flyoutProps, onUnmount } = action.payload; + const updatedActiveGroup: EuiFlyoutSessionGroup = { + ...state.activeFlyoutGroup, + isChildOpen: true, + config: { + ...state.activeFlyoutGroup.config, + childSize: size, + childFlyoutProps: flyoutProps, + }, + childOnUnmount: onUnmount, + meta: + action.payload.meta !== undefined + ? state.activeFlyoutGroup.meta !== undefined + ? { ...state.activeFlyoutGroup.meta, ...action.payload.meta } + : action.payload.meta + : state.activeFlyoutGroup.meta, + }; + + return { + history: state.history, + activeFlyoutGroup: applySizeConstraints(updatedActiveGroup), + }; + } + + case 'OPEN_FLYOUT_GROUP': { + const { main, child, meta } = action.payload; + const newHistory = [...state.history]; + + if (state.activeFlyoutGroup) { + newHistory.push(state.activeFlyoutGroup); + } + + // Create the new active group with both main and child flyouts open + const newActiveGroup: EuiFlyoutSessionGroup = { + isMainOpen: true, + isChildOpen: true, + config: { + mainSize: main.size, + childSize: child.size, + mainFlyoutProps: main.flyoutProps, + childFlyoutProps: child.flyoutProps, + }, + mainOnUnmount: main.onUnmount, + childOnUnmount: child.onUnmount, + meta, + }; + + return { + activeFlyoutGroup: applySizeConstraints(newActiveGroup), + history: newHistory, + }; + } + + case 'CLOSE_CHILD_FLYOUT': { + if (!state.activeFlyoutGroup || !state.activeFlyoutGroup.isChildOpen) { + console.warn( + 'Cannot close child flyout: no child is open or no active group.' + ); + return state; + } + + state.activeFlyoutGroup.childOnUnmount?.(); + + const updatedActiveGroup: EuiFlyoutSessionGroup = { + ...state.activeFlyoutGroup, + isChildOpen: false, + config: { + ...state.activeFlyoutGroup.config, + childFlyoutProps: {}, + }, + childOnUnmount: undefined, + }; + + return { + history: state.history, + activeFlyoutGroup: applySizeConstraints(updatedActiveGroup), + }; + } + + case 'GO_BACK': { + if (!state.activeFlyoutGroup) + return initialFlyoutState as EuiFlyoutSessionHistoryState; + + if (state.activeFlyoutGroup.isChildOpen) { + state.activeFlyoutGroup.childOnUnmount?.(); + } + + state.activeFlyoutGroup.mainOnUnmount?.(); + + // Restore from history or return to initial state + if (state.history.length > 0) { + const newHistory = [...state.history]; + const previousGroup = newHistory.pop(); + return { + activeFlyoutGroup: previousGroup + ? applySizeConstraints(previousGroup) + : null, + history: newHistory, + }; + } else { + return initialFlyoutState as EuiFlyoutSessionHistoryState; + } + } + + case 'UPDATE_ACTIVE_FLYOUT_CONFIG': { + if (!state.activeFlyoutGroup) { + console.warn('Cannot update config: no active flyout group.'); + return state; + } + + const { configChanges, newMainOnUnmount, newChildOnUnmount } = + action.payload; + + const updatedActiveGroup: EuiFlyoutSessionGroup = { + ...state.activeFlyoutGroup, + config: { + ...state.activeFlyoutGroup.config, + ...configChanges, + }, + mainOnUnmount: + newMainOnUnmount !== undefined + ? newMainOnUnmount + : state.activeFlyoutGroup.mainOnUnmount, + childOnUnmount: + newChildOnUnmount !== undefined + ? newChildOnUnmount + : state.activeFlyoutGroup.childOnUnmount, + }; + + const finalUpdatedActiveGroup = applySizeConstraints(updatedActiveGroup); + + return { + ...state, + activeFlyoutGroup: finalUpdatedActiveGroup, + }; + } + + case 'CLEAR_HISTORY': + // Clear the history and remove the active group + return { + activeFlyoutGroup: null, + history: [], + }; + + default: + return state; + } +} diff --git a/packages/eui/src/components/flyout/sessions/index.ts b/packages/eui/src/components/flyout/sessions/index.ts new file mode 100644 index 00000000000..e21963c5e15 --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { + EuiFlyoutSessionProvider, + useEuiFlyoutSessionContext, +} from './flyout_provider'; + +export type { + EuiFlyoutSessionConfig, + EuiFlyoutSessionProviderComponentProps, + EuiFlyoutSessionRenderContext, +} from './types'; + +export { + useEuiFlyoutSession, + type EuiFlyoutSessionOpenChildOptions, + type EuiFlyoutSessionOpenMainOptions, +} from './use_eui_flyout'; diff --git a/packages/eui/src/components/flyout/sessions/types.ts b/packages/eui/src/components/flyout/sessions/types.ts new file mode 100644 index 00000000000..9eaabeabdea --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/types.ts @@ -0,0 +1,116 @@ +/* + * 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 { EuiFlyoutProps, EuiFlyoutSize } from '../flyout'; +import { 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'; + mainFlyoutProps?: Partial>; + childFlyoutProps?: Partial>; +} + +/** + * A configuration user state for past and current main and child flyouts in a session + * @internal + */ +export interface EuiFlyoutSessionGroup { + isMainOpen: boolean; + isChildOpen: boolean; + config: EuiFlyoutSessionConfig; + mainOnUnmount?: () => void; + childOnUnmount?: () => void; + meta?: FlyoutMeta; +} + +/** + * State used for tracking various EuiFlyoutSessionGroups + * @internal + */ +export interface EuiFlyoutSessionHistoryState { + activeFlyoutGroup: EuiFlyoutSessionGroup | null; + history: Array>; +} + +export type EuiFlyoutSessionAction = + | { + type: 'UPDATE_ACTIVE_FLYOUT_CONFIG'; + payload: { + configChanges: Partial; + newMainOnUnmount?: () => void; + newChildOnUnmount?: () => void; + }; + } + | { + type: 'OPEN_MAIN_FLYOUT'; + payload: { + size: EuiFlyoutSize; + flyoutProps?: Partial>; + onUnmount?: () => void; + meta?: FlyoutMeta; + }; + } + | { + type: 'OPEN_CHILD_FLYOUT'; + payload: { + size: 's' | 'm'; + flyoutProps?: Partial>; + onUnmount?: () => void; + meta?: FlyoutMeta; + }; + } + | { + type: 'OPEN_FLYOUT_GROUP'; + payload: { + main: { + size: EuiFlyoutSize; + flyoutProps?: Partial>; + onUnmount?: () => void; + }; + child: { + size: 's' | 'm'; + flyoutProps?: Partial>; + onUnmount?: () => void; + }; + meta?: FlyoutMeta; + }; + } + | { type: 'GO_BACK' } + | { type: 'CLOSE_CHILD_FLYOUT' } + | { type: 'CLEAR_HISTORY' }; + +/** + * Flyout session context managed by `EuiFlyoutSessionProvider`, and passed to the `renderMainFlyoutContent` and `renderChildFlyoutContent` functions. + */ +export interface EuiFlyoutSessionRenderContext { + flyoutProps: Partial; + flyoutSize: EuiFlyoutProps['size'] | EuiFlyoutChildProps['size']; + flyoutType: 'main' | 'child'; + dispatch: React.Dispatch>; + activeFlyoutGroup: EuiFlyoutSessionGroup | null; + onCloseFlyout: () => void; + onCloseChildFlyout: () => void; + meta?: FlyoutMeta; +} + +/** + * Props that can be passed to `EuiFlyoutSessionProvider` to render the main and child flyouts in a session. + */ +export interface EuiFlyoutSessionProviderComponentProps { + children: React.ReactNode; + renderMainFlyoutContent: ( + context: EuiFlyoutSessionRenderContext + ) => React.ReactNode; + renderChildFlyoutContent?: ( + context: EuiFlyoutSessionRenderContext + ) => React.ReactNode; +} 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 new file mode 100644 index 00000000000..ef39cddca84 --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx @@ -0,0 +1,496 @@ +/* + * 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 { render, fireEvent, screen } from '@testing-library/react'; + +import { EuiFlyoutSessionProvider } from './flyout_provider'; +import { + useEuiFlyoutSession, + EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionOpenChildOptions, + EuiFlyoutSessionOpenGroupOptions, +} from './use_eui_flyout'; + +// Mock the flyout components for testing +jest.mock('../flyout', () => ({ + EuiFlyout: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + EuiFlyoutSize: { s: 's', m: 'm', l: 'l' }, +})); + +jest.mock('../flyout_child', () => ({ + EuiFlyoutChild: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +// Create a test component to exercise the hook +interface TestComponentProps { + onOpenFlyout?: () => void; + onOpenChildFlyout?: () => void; + onOpenFlyoutGroup?: () => void; + onCloseChildFlyout?: () => void; + onGoBack?: () => void; + onClearHistory?: () => void; +} + +const TestComponent: React.FC = ({ + onOpenFlyout, + onOpenChildFlyout, + onOpenFlyoutGroup, + onCloseChildFlyout, + onGoBack, + onClearHistory, +}) => { + const { + openFlyout, + openChildFlyout, + openFlyoutGroup, + closeChildFlyout, + goBack, + isFlyoutOpen, + isChildFlyoutOpen, + canGoBack, + clearHistory, + } = useEuiFlyoutSession(); + + return ( +
+ + + + + + + + + + + + +
+ {isFlyoutOpen ? 'Flyout is open' : 'Flyout is closed'} +
+ +
+ {isChildFlyoutOpen ? 'Child flyout is open' : 'Child flyout is closed'} +
+ +
+ {canGoBack ? 'Can go back' : 'Cannot go back'} +
+
+ ); +}; + +// Create a wrapper component that provides the context +const TestWrapper: React.FC< + React.PropsWithChildren & { children?: React.ReactNode } +> = ({ children }) => { + const renderMainFlyoutContent = (context: any) => ( +
+ Main flyout content: {JSON.stringify(context.meta)} +
+ ); + + const renderChildFlyoutContent = (context: any) => ( +
+ Child flyout content: {JSON.stringify(context.meta)} +
+ ); + + return ( + + {children} + + ); +}; + +describe('useEuiFlyoutSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('openFlyout dispatches the correct action', () => { + const onOpenFlyout = jest.fn(); + render( + + + + ); + + fireEvent.click(screen.getByTestId('openFlyoutButton')); + + expect(onOpenFlyout).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is closed' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Can go back' + ); + }); + + test('openChildFlyout dispatches the correct action when main flyout is open', () => { + const onOpenChildFlyout = jest.fn(); + render( + + + + ); + + // First open the main flyout + fireEvent.click(screen.getByTestId('openFlyoutButton')); + // Then open the child flyout + fireEvent.click(screen.getByTestId('openChildFlyoutButton')); + + expect(onOpenChildFlyout).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is open' + ); + }); + + test('openFlyoutGroup opens both main and child flyouts in a single action', () => { + const onOpenFlyoutGroup = jest.fn(); + render( + + + + ); + + fireEvent.click(screen.getByTestId('openFlyoutGroupButton')); + + expect(onOpenFlyoutGroup).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is open' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Can go back' + ); + }); + + test('closeChildFlyout closes only the child flyout', () => { + const onCloseChildFlyout = jest.fn(); + render( + + + + ); + + // Open both flyouts + fireEvent.click(screen.getByTestId('openFlyoutGroupButton')); + + // Then close the child flyout + fireEvent.click(screen.getByTestId('closeChildFlyoutButton')); + + expect(onCloseChildFlyout).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is closed' + ); + }); + + test('goBack navigates through the history stack', () => { + const onGoBack = jest.fn(); + render( + + + + ); + + // First open one flyout + fireEvent.click(screen.getByTestId('openFlyoutButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + + // Then open another flyout to create history + fireEvent.click(screen.getByTestId('openFlyoutButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Can go back' + ); + + // Go back, should restore previous flyout (not tested here) or close if no history + fireEvent.click(screen.getByTestId('goBackButton')); + + expect(onGoBack).toHaveBeenCalledTimes(1); + // Verify we can still go back as there's history + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Can go back' + ); + + // Go back again should close the main flyout + fireEvent.click(screen.getByTestId('goBackButton')); + + expect(onGoBack).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is closed' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Cannot go back' + ); + }); + + test('goBack navigates through the history stack and restores previous flyouts', () => { + render( + + + + ); + + // Open first flyout (A) + fireEvent.click(screen.getByTestId('openFlyoutButton')); + // Verify the content is rendered by checking the element exists and has content + expect(screen.getByTestId('mainFlyoutContent')).toBeTruthy(); + expect(screen.getByTestId('mainFlyoutContent').textContent).toContain( + '"type":"test"' + ); + + // Open second flyout (B), which should push A to history + fireEvent.click(screen.getByTestId('openFlyoutButton')); + // Verify the main flyout is still open + expect(screen.getByTestId('mainFlyoutContent')).toBeTruthy(); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Can go back' + ); + + // Go back to first flyout (A) + fireEvent.click(screen.getByTestId('goBackButton')); + + // Should still have a flyout open (first one) + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Can go back' + ); + + // Go back again, which should close everything as there's no more history + fireEvent.click(screen.getByTestId('goBackButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is closed' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Cannot go back' + ); + }); + + // We can skip this test since our implementation no longer depends on onClose handlers + // and we've fixed the issue in the reducer already + test('goBack works correctly regardless of onClose handlers', () => { + // Create a test component with onClose handlers + const CustomTestComponent = () => { + const { openFlyout, goBack, clearHistory, isFlyoutOpen, canGoBack } = + useEuiFlyoutSession(); + + return ( +
+ + + + + + +
+ {isFlyoutOpen ? 'Flyout is open' : 'Flyout is closed'} +
+ +
+ {canGoBack ? 'Can go back' : 'Cannot go back'} +
+
+ ); + }; + + render( + + + + ); + + // Open flyout + fireEvent.click(screen.getByTestId('openFlyoutButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + + // Go back should close the flyout + fireEvent.click(screen.getByTestId('goBackButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is closed' + ); + }); + + test('clearHistory closes all flyouts', () => { + const onClearHistory = jest.fn(); + render( + + + + ); + + // Open both flyouts + fireEvent.click(screen.getByTestId('openFlyoutGroupButton')); + + // Clear history should close everything + fireEvent.click(screen.getByTestId('clearHistoryButton')); + + expect(onClearHistory).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is closed' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is closed' + ); + expect(screen.getByTestId('canGoBackStatus').textContent).toBe( + 'Cannot go back' + ); + }); + + test('isFlyoutOpen and isChildFlyoutOpen correctly reflect state', () => { + render( + + + + ); + + // Initially both are closed + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is closed' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is closed' + ); + + // Open main flyout + fireEvent.click(screen.getByTestId('openFlyoutButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is closed' + ); + + // Open child flyout + fireEvent.click(screen.getByTestId('openChildFlyoutButton')); + expect(screen.getByTestId('flyoutStatus').textContent).toBe( + 'Flyout is open' + ); + expect(screen.getByTestId('childFlyoutStatus').textContent).toBe( + 'Child flyout is open' + ); + }); +}); diff --git a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts new file mode 100644 index 00000000000..59476dc7119 --- /dev/null +++ b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts @@ -0,0 +1,124 @@ +/* + * 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 { EuiFlyoutSize } from '../flyout'; +import { useEuiFlyoutSessionContext } from './flyout_provider'; +import { EuiFlyoutSessionConfig } from './types'; + +/** + * Options that control a main flyout in a session + */ +export interface EuiFlyoutSessionOpenMainOptions { + size: EuiFlyoutSize; + flyoutProps?: EuiFlyoutSessionConfig['mainFlyoutProps']; + onUnmount?: () => void; + meta?: Meta; +} + +/** + * Options that control a child flyout in a session + */ +export interface EuiFlyoutSessionOpenChildOptions { + size: 's' | 'm'; + flyoutProps?: EuiFlyoutSessionConfig['childFlyoutProps']; + onUnmount?: () => void; + meta?: Meta; +} + +/** + * Options for opening both a main flyout and child flyout simultaneously + */ +export interface EuiFlyoutSessionOpenGroupOptions { + main: { + size: EuiFlyoutSize; + flyoutProps?: EuiFlyoutSessionConfig['mainFlyoutProps']; + onUnmount?: () => void; + }; + child: { + size: 's' | 'm'; + flyoutProps?: EuiFlyoutSessionConfig['childFlyoutProps']; + onUnmount?: () => void; + }; + meta?: Meta; // Shared meta for both flyouts +} + +/** + * Hook for accessing the flyout API + */ +export function useEuiFlyoutSession() { + const { state, dispatch } = useEuiFlyoutSessionContext(); + + const openFlyout = (options: EuiFlyoutSessionOpenMainOptions) => { + dispatch({ + type: 'OPEN_MAIN_FLYOUT', + payload: options, + }); + }; + + const openChildFlyout = (options: EuiFlyoutSessionOpenChildOptions) => { + if (!state.activeFlyoutGroup || !state.activeFlyoutGroup.isMainOpen) { + console.warn( + 'useEuiFlyoutApi: Cannot open child flyout when main flyout is not open.' + ); + return; + } + dispatch({ + type: 'OPEN_CHILD_FLYOUT', + payload: options, + }); + }; + + const openFlyoutGroup = (options: EuiFlyoutSessionOpenGroupOptions) => { + dispatch({ + type: 'OPEN_FLYOUT_GROUP', + payload: { + main: { + size: options.main.size, + flyoutProps: options.main.flyoutProps, + onUnmount: options.main.onUnmount, + }, + child: { + size: options.child.size, + flyoutProps: options.child.flyoutProps, + onUnmount: options.child.onUnmount, + }, + meta: options.meta, + }, + }); + }; + + const closeChildFlyout = () => { + dispatch({ type: 'CLOSE_CHILD_FLYOUT' }); + }; + + const goBack = () => { + dispatch({ type: 'GO_BACK' }); + }; + + const canGoBack = !!state.activeFlyoutGroup; + + const isFlyoutOpen = !!state.activeFlyoutGroup?.isMainOpen; + + const isChildFlyoutOpen = !!state.activeFlyoutGroup?.isChildOpen; + + const clearHistory = () => { + dispatch({ type: 'CLEAR_HISTORY' }); + }; + + return { + openFlyout, + openChildFlyout, + openFlyoutGroup, + closeChildFlyout, + goBack, + canGoBack, + isFlyoutOpen, + isChildFlyoutOpen, + clearHistory, + }; +}