From 4c698c405e47630e55097828d7ffbf97fea5abde Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Fri, 25 Jul 2025 12:56:28 -0700 Subject: [PATCH 01/30] Add FlyoutSystemMenu component (#8851) --- packages/eui/changelogs/upcoming/8851.md | 1 + packages/eui/src/components/flyout/flyout.tsx | 15 +- .../src/components/flyout/flyout_child.tsx | 16 +- .../components/flyout/flyout_menu.stories.tsx | 68 ++++ .../components/flyout/flyout_menu.styles.ts | 32 ++ .../eui/src/components/flyout/flyout_menu.tsx | 84 +++++ .../components/flyout/flyout_menu_context.ts | 18 + packages/eui/src/components/flyout/index.ts | 6 +- .../sessions/flyout_provider.stories.tsx | 325 ++++++++++++++---- .../flyout/sessions/flyout_provider.tsx | 59 +++- .../flyout/sessions/flyout_reducer.ts | 74 +++- .../src/components/flyout/sessions/index.ts | 1 + .../sessions/managed_flyout_menu.test.tsx | 92 +++++ .../flyout/sessions/managed_flyout_menu.tsx | 89 +++++ .../src/components/flyout/sessions/types.ts | 44 ++- .../flyout/sessions/use_eui_flyout.test.tsx | 7 +- .../flyout/sessions/use_eui_flyout.ts | 21 ++ .../components/containers/flyout/index.mdx | 19 + 18 files changed, 880 insertions(+), 91 deletions(-) create mode 100644 packages/eui/changelogs/upcoming/8851.md create mode 100644 packages/eui/src/components/flyout/flyout_menu.stories.tsx create mode 100644 packages/eui/src/components/flyout/flyout_menu.styles.ts create mode 100644 packages/eui/src/components/flyout/flyout_menu.tsx create mode 100644 packages/eui/src/components/flyout/flyout_menu_context.ts create mode 100644 packages/eui/src/components/flyout/sessions/managed_flyout_menu.test.tsx create mode 100644 packages/eui/src/components/flyout/sessions/managed_flyout_menu.tsx 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.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.tsx b/packages/eui/src/components/flyout/flyout_child.tsx index 4ee71b73282..d90d84acd38 100644 --- a/packages/eui/src/components/flyout/flyout_child.tsx +++ b/packages/eui/src/components/flyout/flyout_child.tsx @@ -24,6 +24,8 @@ import { euiFlyoutChildStyles } from './flyout_child.styles'; import { EuiFlyoutCloseButton } from './_flyout_close_button'; import { EuiFlyoutContext } from './flyout_context'; import { EuiFlyoutBody } from './flyout_body'; +import { EuiFlyoutMenu } from './flyout_menu'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; import { EuiFocusTrap } from '../focus_trap'; /** @@ -118,8 +120,16 @@ export const EuiFlyoutChild: FunctionComponent = ({ let flyoutTitleText: string | undefined; let hasDescribedByBody = false; + let hasFlyoutMenu = false; Children.forEach(children, (child) => { if (React.isValidElement(child)) { + if ( + child.type === EuiFlyoutMenu || + (child.type as any).displayName === 'EuiFlyoutMenu' + ) { + hasFlyoutMenu = true; + } + if ((child.type as any)?.displayName === 'EuiFlyoutHeader') { // Attempt to extract string content from header for ARIA const headerChildren = child.props.children; @@ -257,7 +267,7 @@ export const EuiFlyoutChild: FunctionComponent = ({ {flyoutTitleText} )} - {!hideCloseButton && ( + {!hideCloseButton && !hasFlyoutMenu && ( = ({ className="euiFlyoutChild__overflowContent" css={styles.overflow.wrapper} > - {processedChildren} + + {processedChildren} + diff --git a/packages/eui/src/components/flyout/flyout_menu.stories.tsx b/packages/eui/src/components/flyout/flyout_menu.stories.tsx new file mode 100644 index 00000000000..a034e07365a --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu.stories.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React, { useState } from 'react'; +import { EuiButton, EuiButtonIcon } from '../button'; +import { EuiText } from '../text'; +import { EuiFlyout } from './flyout'; +import { EuiFlyoutBody } from './flyout_body'; +import { EuiFlyoutChild } from './flyout_child'; +import { EuiFlyoutMenu } from './flyout_menu'; + +const meta: Meta = { + title: 'Layout/EuiFlyout/EuiFlyoutMenu', + component: EuiFlyoutMenu, +}; + +export default meta; + +const MenuBarFlyout = () => { + const [isOpen, setIsOpen] = useState(true); + + const openFlyout = () => setIsOpen(true); + const closeFlyout = () => setIsOpen(false); + + const handleCustomActionClick = () => { + action('custom action clicked')(); + }; + + return ( + <> + Open flyout + {isOpen && ( + + + + Main flyout content. + + + + + + + Child with custom action in the menu bar. + + + + )} + + ); +}; + +export const MenuBarExample: StoryObj = { + name: 'Menu bar example', + render: () => , +}; diff --git a/packages/eui/src/components/flyout/flyout_menu.styles.ts b/packages/eui/src/components/flyout/flyout_menu.styles.ts new file mode 100644 index 00000000000..49733bc6f33 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu.styles.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { css } from '@emotion/react'; +import { UseEuiTheme } from '../../services'; + +export const euiFlyoutMenuStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + return { + euiFlyoutMenu__container: css` + block-size: calc(${euiTheme.size.m} * 3.5); + flex-shrink: 0; + padding-block: ${euiTheme.size.s}; + padding-inline: ${euiTheme.size.s}; + border-block-end: ${euiTheme.border.width.thin} solid + ${euiTheme.border.color}; + padding-block-start: calc(${euiTheme.size.m} * 0.8); + + .euiTitle { + padding-inline: ${euiTheme.size.s}; + } + `, + euiFlyoutMenu__spacer: css` + padding-inline: ${euiTheme.size.m}; + `, + }; +}; diff --git a/packages/eui/src/components/flyout/flyout_menu.tsx b/packages/eui/src/components/flyout/flyout_menu.tsx new file mode 100644 index 00000000000..7fefaae1a4f --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import classNames from 'classnames'; +import React, { FunctionComponent, HTMLAttributes, useContext } from 'react'; +import { useEuiMemoizedStyles, useGeneratedHtmlId } from '../../services'; +import { CommonProps } from '../common'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiTitle } from '../title'; +import { EuiFlyoutCloseButton } from './_flyout_close_button'; +import { euiFlyoutMenuStyles } from './flyout_menu.styles'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; + +export type EuiFlyoutMenuProps = CommonProps & + HTMLAttributes & { + backButton?: React.ReactNode; + popover?: React.ReactNode; + title?: React.ReactNode; + hideCloseButton?: boolean; + }; + +export const EuiFlyoutMenu: FunctionComponent = ({ + children, + className, + backButton, + popover, + title, + hideCloseButton, + ...rest +}) => { + const { onClose } = useContext(EuiFlyoutMenuContext); + + const styles = useEuiMemoizedStyles(euiFlyoutMenuStyles); + const classes = classNames('euiFlyoutMenu', className); + const titleId = useGeneratedHtmlId(); + + let titleNode; + if (title) { + titleNode = ( + +

{title}

+
+ ); + } + + const handleClose = (event: MouseEvent | TouchEvent | KeyboardEvent) => { + onClose?.(event); + }; + + let closeButton; + if (!hideCloseButton) { + closeButton = ( + + ); + } + + return ( +
+ + {backButton && {backButton}} + {popover && {popover}} + {titleNode && {titleNode}} + + {children && {children}} + + + {closeButton} +
+ ); +}; diff --git a/packages/eui/src/components/flyout/flyout_menu_context.ts b/packages/eui/src/components/flyout/flyout_menu_context.ts new file mode 100644 index 00000000000..fc0eb673b76 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout_menu_context.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createContext } from 'react'; +import { EuiFlyoutProps } from './flyout'; + +interface EuiFlyoutMenuContextProps { + onClose?: EuiFlyoutProps['onClose']; +} + +export const EuiFlyoutMenuContext = createContext( + {} +); diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index 6c34bdb77a9..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..6da9ab62c4a 100644 --- a/packages/eui/src/components/flyout/sessions/types.ts +++ b/packages/eui/src/components/flyout/sessions/types.ts @@ -6,17 +6,24 @@ * Side Public License, v 1. */ -import { EuiFlyoutProps, EuiFlyoutSize } from '../flyout'; -import { EuiFlyoutChildProps } from '../flyout_child'; +import type { EuiFlyoutProps, EuiFlyoutSize } from '../flyout'; +import type { EuiFlyoutChildProps } from '../flyout_child'; /** * Configuration used for setting display options for main and child flyouts in a session. */ export interface EuiFlyoutSessionConfig { mainSize: EuiFlyoutSize; + mainTitle?: string; + hideMainTitle?: boolean; childSize?: 's' | 'm'; + childTitle?: string; mainFlyoutProps?: Partial>; childFlyoutProps?: Partial>; + /** + * Indicates if the flyout was opened with openManagedFlyout or openFlyout + */ + isManaged?: boolean; } /** @@ -31,12 +38,31 @@ export interface EuiFlyoutSessionOpenMainOptions { meta?: Meta; } +export interface EuiFlyoutSessionOpenManagedOptions { + size: EuiFlyoutSize; + flyoutProps?: EuiFlyoutSessionConfig['mainFlyoutProps']; + /** + * Title to display in top menu bar and in the options of the history popover + */ + title: string; + /** + * Allows title to be hidden from top menu bar. If this is true, + * the title will only be used for the history popover + */ + hideTitle?: boolean; + /** + * Caller-defined data + */ + meta?: Meta; +} + /** * Options that control a child flyout in a session */ export interface EuiFlyoutSessionOpenChildOptions { size: 's' | 'm'; flyoutProps?: EuiFlyoutSessionConfig['childFlyoutProps']; + title: string; /** * Caller-defined data */ @@ -47,7 +73,7 @@ export interface EuiFlyoutSessionOpenChildOptions { * Options for opening both a main flyout and child flyout simultaneously */ export interface EuiFlyoutSessionOpenGroupOptions { - main: EuiFlyoutSessionOpenMainOptions; + main: EuiFlyoutSessionOpenManagedOptions; child: EuiFlyoutSessionOpenChildOptions; /** * Caller-defined data @@ -89,6 +115,10 @@ export type EuiFlyoutSessionAction = type: 'OPEN_MAIN_FLYOUT'; payload: EuiFlyoutSessionOpenMainOptions; } + | { + type: 'OPEN_MANAGED_FLYOUT'; + payload: EuiFlyoutSessionOpenManagedOptions; + } | { type: 'OPEN_CHILD_FLYOUT'; payload: EuiFlyoutSessionOpenChildOptions; @@ -98,6 +128,7 @@ export type EuiFlyoutSessionAction = payload: EuiFlyoutSessionOpenGroupOptions; } | { type: 'GO_BACK' } + | { type: 'GO_TO_HISTORY_ITEM'; index: number } | { type: 'CLOSE_CHILD_FLYOUT' } | { type: 'CLOSE_SESSION' }; @@ -117,16 +148,21 @@ export interface EuiFlyoutSessionRenderContext { */ export interface EuiFlyoutSessionProviderComponentProps { children: React.ReactNode; - onUnmount?: () => void; renderMainFlyoutContent: ( context: EuiFlyoutSessionRenderContext ) => React.ReactNode; renderChildFlyoutContent?: ( context: EuiFlyoutSessionRenderContext ) => React.ReactNode; + historyFilter?: ( + history: EuiFlyoutSessionHistoryState['history'], + activeFlyoutGroup?: EuiFlyoutSessionGroup | null + ) => EuiFlyoutSessionHistoryState['history']; + onUnmount?: () => void; } export interface EuiFlyoutSessionApi { + openManagedFlyout: (options: EuiFlyoutSessionOpenManagedOptions) => void; openFlyout: (options: EuiFlyoutSessionOpenMainOptions) => void; openChildFlyout: (options: EuiFlyoutSessionOpenChildOptions) => void; openFlyoutGroup: (options: EuiFlyoutSessionOpenGroupOptions) => void; diff --git a/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx b/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx index e7ef635a8b0..f2b6c145050 100644 --- a/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx +++ b/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx @@ -6,14 +6,14 @@ * Side Public License, v 1. */ +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; import { EuiFlyoutSessionProvider } from './flyout_provider'; import type { - EuiFlyoutSessionOpenMainOptions, EuiFlyoutSessionOpenChildOptions, EuiFlyoutSessionOpenGroupOptions, + EuiFlyoutSessionOpenMainOptions, } from './types'; import { useEuiFlyoutSession } from './use_eui_flyout'; @@ -79,6 +79,7 @@ const TestComponent: React.FC = ({ data-testid="openChildFlyoutButton" onClick={() => { const options: EuiFlyoutSessionOpenChildOptions = { + title: 'Child flyout', size: 's', meta: { type: 'testChild' }, }; @@ -95,10 +96,12 @@ const TestComponent: React.FC = ({ onClick={() => { const options: EuiFlyoutSessionOpenGroupOptions = { main: { + title: 'Main flyout', size: 'm', flyoutProps: { className: 'main-flyout' }, }, child: { + title: 'Child flyout', size: 's', flyoutProps: { className: 'child-flyout' }, }, diff --git a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts index b2192b5049a..67a15a531f0 100644 --- a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts +++ b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts @@ -13,6 +13,7 @@ import type { EuiFlyoutSessionOpenChildOptions, EuiFlyoutSessionOpenGroupOptions, EuiFlyoutSessionOpenMainOptions, + EuiFlyoutSessionOpenManagedOptions, } from './types'; /** @@ -33,6 +34,9 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { } }, [state.activeFlyoutGroup, onUnmount]); + /** + * Open a "plain" main flyout without an automatic top menu bar + */ const openFlyout = (options: EuiFlyoutSessionOpenMainOptions) => { dispatch({ type: 'OPEN_MAIN_FLYOUT', @@ -40,6 +44,19 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { }); }; + /** + * Open a "managed" main flyout, with an automatic top menu bar + */ + const openManagedFlyout = (options: EuiFlyoutSessionOpenManagedOptions) => { + dispatch({ + type: 'OPEN_MANAGED_FLYOUT', + payload: options, + }); + }; + + /** + * Open a "managed" child flyout, with an automatic top menu bar + */ const openChildFlyout = (options: EuiFlyoutSessionOpenChildOptions) => { if (!state.activeFlyoutGroup || !state.activeFlyoutGroup.isMainOpen) { console.warn( @@ -53,6 +70,9 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { }); }; + /** + * Open a pair of managed main and child flyouts + */ const openFlyoutGroup = (options: EuiFlyoutSessionOpenGroupOptions) => { dispatch({ type: 'OPEN_FLYOUT_GROUP', @@ -80,6 +100,7 @@ export function useEuiFlyoutSession(): EuiFlyoutSessionApi { return { openFlyout, + openManagedFlyout, openChildFlyout, openFlyoutGroup, closeChildFlyout, diff --git a/packages/website/docs/components/containers/flyout/index.mdx b/packages/website/docs/components/containers/flyout/index.mdx index 0729fe0094f..ac8674d7089 100644 --- a/packages/website/docs/components/containers/flyout/index.mdx +++ b/packages/website/docs/components/containers/flyout/index.mdx @@ -1039,6 +1039,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'; From 8f1370b5457e32f3eec29ff50c46ca4d27de3d5a Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 2 Sep 2025 07:31:16 -0500 Subject: [PATCH 02/30] [flyouts] Developer API + sessions (#8939) Co-authored-by: Tim Sullivan Co-authored-by: Timothy Sullivan Co-authored-by: Weronika Olejniczak <32842468+weronikaolejniczak@users.noreply.github.com> --- .gitignore | 2 + .../collapsible_nav_beta/context.ts | 2 +- packages/eui/src/components/flyout/README.md | 174 ++++ packages/eui/src/components/flyout/const.ts | 48 + .../components/flyout/flyout.component.tsx | 536 ++++++++++ .../src/components/flyout/flyout.stories.tsx | 4 +- .../src/components/flyout/flyout.styles.ts | 25 +- .../eui/src/components/flyout/flyout.test.tsx | 13 +- packages/eui/src/components/flyout/flyout.tsx | 649 ++---------- .../flyout/flyout_child.stories.tsx | 349 ------- .../components/flyout/flyout_child.styles.ts | 85 -- .../components/flyout/flyout_child.test.tsx | 229 ----- .../src/components/flyout/flyout_child.tsx | 307 ------ .../flyout/flyout_child_manager.tsx | 133 --- .../src/components/flyout/flyout_context.ts | 26 - .../components/flyout/flyout_menu.stories.tsx | 159 ++- .../components/flyout/flyout_resizable.tsx | 5 +- packages/eui/src/components/flyout/hooks.ts | 28 + packages/eui/src/components/flyout/index.ts | 16 +- .../__snapshots__/context.test.tsx.snap | 3 + .../__snapshots__/provider.test.tsx.snap | 3 + .../components/flyout/manager/actions.test.ts | 295 ++++++ .../src/components/flyout/manager/actions.ts | 143 +++ .../flyout/manager/activity_stage.test.tsx | 600 +++++++++++ .../flyout/manager/activity_stage.ts | 121 +++ .../src/components/flyout/manager/const.ts | 50 + .../flyout/manager/context.test.tsx | 81 ++ .../src/components/flyout/manager/context.tsx | 36 + .../flyout/manager/flyout_child.stories.tsx | 300 ++++++ .../flyout/manager/flyout_child.styles.ts | 37 + .../flyout/manager/flyout_child.test.tsx | 158 +++ .../flyout/manager/flyout_child.tsx | 96 ++ .../flyout/manager/flyout_main.styles.ts | 38 + .../flyout/manager/flyout_main.test.tsx | 67 ++ .../components/flyout/manager/flyout_main.tsx | 60 ++ .../flyout/manager/flyout_managed.styles.ts | 142 +++ .../flyout/manager/flyout_managed.test.tsx | 123 +++ .../flyout/manager/flyout_managed.tsx | 162 +++ .../flyout/manager/flyout_manager.stories.tsx | 438 ++++++++ .../components/flyout/manager/hooks.test.tsx | 367 +++++++ .../src/components/flyout/manager/hooks.ts | 122 +++ .../src/components/flyout/manager/index.ts | 48 + .../flyout/manager/layout_mode.test.tsx | 612 ++++++++++++ .../components/flyout/manager/layout_mode.ts | 188 ++++ .../flyout/manager/provider.test.tsx | 75 ++ .../components/flyout/manager/provider.tsx | 44 + .../components/flyout/manager/reducer.test.ts | 403 ++++++++ .../src/components/flyout/manager/reducer.ts | 196 ++++ .../flyout/manager/selectors.test.tsx | 541 ++++++++++ .../components/flyout/manager/selectors.ts | 88 ++ .../src/components/flyout/manager/types.ts | 72 ++ .../flyout/manager/validation.test.ts | 142 +++ .../components/flyout/manager/validation.ts | 128 +++ .../src/components/flyout/sessions/README.md | 105 -- .../sessions/flyout_provider.stories.tsx | 938 ------------------ .../flyout/sessions/flyout_provider.tsx | 172 ---- .../flyout/sessions/flyout_reducer.ts | 277 ------ .../src/components/flyout/sessions/index.ts | 25 - .../sessions/managed_flyout_menu.test.tsx | 92 -- .../flyout/sessions/managed_flyout_menu.tsx | 89 -- .../src/components/flyout/sessions/types.ts | 175 ---- .../flyout/sessions/use_eui_flyout.test.tsx | 446 --------- .../flyout/sessions/use_eui_flyout.ts | 113 --- .../overlay_mask/overlay_mask.styles.ts | 6 +- .../overlay_mask/overlay_mask.test.tsx | 4 +- .../components/overlay_mask/overlay_mask.tsx | 12 +- .../eui/src/components/provider/provider.tsx | 3 +- .../eui/src/services/theme/provider.test.tsx | 4 +- .../components/containers/flyout/index.mdx | 4 +- 69 files changed, 7019 insertions(+), 4215 deletions(-) create mode 100644 packages/eui/src/components/flyout/README.md create mode 100644 packages/eui/src/components/flyout/const.ts create mode 100644 packages/eui/src/components/flyout/flyout.component.tsx delete mode 100644 packages/eui/src/components/flyout/flyout_child.stories.tsx delete mode 100644 packages/eui/src/components/flyout/flyout_child.styles.ts delete mode 100644 packages/eui/src/components/flyout/flyout_child.test.tsx delete mode 100644 packages/eui/src/components/flyout/flyout_child.tsx delete mode 100644 packages/eui/src/components/flyout/flyout_child_manager.tsx delete mode 100644 packages/eui/src/components/flyout/flyout_context.ts create mode 100644 packages/eui/src/components/flyout/hooks.ts create mode 100644 packages/eui/src/components/flyout/manager/__snapshots__/context.test.tsx.snap create mode 100644 packages/eui/src/components/flyout/manager/__snapshots__/provider.test.tsx.snap create mode 100644 packages/eui/src/components/flyout/manager/actions.test.ts create mode 100644 packages/eui/src/components/flyout/manager/actions.ts create mode 100644 packages/eui/src/components/flyout/manager/activity_stage.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/activity_stage.ts create mode 100644 packages/eui/src/components/flyout/manager/const.ts create mode 100644 packages/eui/src/components/flyout/manager/context.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/context.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_child.stories.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_child.styles.ts create mode 100644 packages/eui/src/components/flyout/manager/flyout_child.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_child.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_main.styles.ts create mode 100644 packages/eui/src/components/flyout/manager/flyout_main.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_main.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_managed.styles.ts create mode 100644 packages/eui/src/components/flyout/manager/flyout_managed.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_managed.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx create mode 100644 packages/eui/src/components/flyout/manager/hooks.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/hooks.ts create mode 100644 packages/eui/src/components/flyout/manager/index.ts create mode 100644 packages/eui/src/components/flyout/manager/layout_mode.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/layout_mode.ts create mode 100644 packages/eui/src/components/flyout/manager/provider.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/provider.tsx create mode 100644 packages/eui/src/components/flyout/manager/reducer.test.ts create mode 100644 packages/eui/src/components/flyout/manager/reducer.ts create mode 100644 packages/eui/src/components/flyout/manager/selectors.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/selectors.ts create mode 100644 packages/eui/src/components/flyout/manager/types.ts create mode 100644 packages/eui/src/components/flyout/manager/validation.test.ts create mode 100644 packages/eui/src/components/flyout/manager/validation.ts delete mode 100644 packages/eui/src/components/flyout/sessions/README.md delete mode 100644 packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx delete mode 100644 packages/eui/src/components/flyout/sessions/flyout_provider.tsx delete mode 100644 packages/eui/src/components/flyout/sessions/flyout_reducer.ts delete mode 100644 packages/eui/src/components/flyout/sessions/index.ts delete mode 100644 packages/eui/src/components/flyout/sessions/managed_flyout_menu.test.tsx delete mode 100644 packages/eui/src/components/flyout/sessions/managed_flyout_menu.tsx delete mode 100644 packages/eui/src/components/flyout/sessions/types.ts delete mode 100644 packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx delete mode 100644 packages/eui/src/components/flyout/sessions/use_eui_flyout.ts diff --git a/.gitignore b/.gitignore index 5c1d3b074dd..a125f4c291a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ yarn-error.log* !.yarn/sdks !.yarn/versions yarn-error.log +.cursorrules +WARP.md diff --git a/packages/eui/src/components/collapsible_nav_beta/context.ts b/packages/eui/src/components/collapsible_nav_beta/context.ts index 7ae67a730a9..f2c21265a9a 100644 --- a/packages/eui/src/components/collapsible_nav_beta/context.ts +++ b/packages/eui/src/components/collapsible_nav_beta/context.ts @@ -8,7 +8,7 @@ import { createContext, MouseEventHandler } from 'react'; -import { _EuiFlyoutSide } from '../flyout/flyout'; +import { _EuiFlyoutSide } from '../flyout/const'; type _EuiCollapsibleNavContext = { isCollapsed: boolean; diff --git a/packages/eui/src/components/flyout/README.md b/packages/eui/src/components/flyout/README.md new file mode 100644 index 00000000000..9b01dc13558 --- /dev/null +++ b/packages/eui/src/components/flyout/README.md @@ -0,0 +1,174 @@ +# EUI Flyout System + +## Core Flyout Components + +### `src/components/flyout/flyout.tsx` +The main flyout component that serves as the entry point for all flyout functionality. It intelligently renders different flyout types based on context: +- **Session flyouts**: When `session={true}` or within an active session, renders `EuiFlyoutMain` +- **Child flyouts**: When within a managed flyout context, renders `EuiFlyoutChild` +- **Standard flyouts**: Default behavior renders `EuiFlyoutComponent` +- **Resizable flyouts**: `EuiFlyoutResizable` component exists but is not integrated into main routing logic + +### `src/components/flyout/flyout.component.tsx` +The core flyout implementation with comprehensive functionality: +- **Props**: Extensive configuration options including size, padding, positioning, focus management +- **Types**: Support for `push` and `overlay` types, left/right sides, various sizes (s/m/l) +- **Accessibility**: Built-in screen reader support, focus trapping, keyboard navigation with sophisticated ESC key handling +- **Styling**: Dynamic width handling, responsive behavior, theme integration +- **Portal/Overlay**: Conditional portal rendering and overlay mask management +- **Session Logic**: Complex routing logic that determines flyout type based on session state and managed context +- **Responsive Behavior**: Adaptive layout switching for managed flyouts based on viewport width and flyout size combinations + +### `src/components/flyout/flyout.styles.ts` +Contains the emotion-based styling for the flyout component, including: +- Base flyout styles +- Size-specific styles (s/m/l) +- Padding size variations +- Push vs overlay type styles +- Side-specific positioning (left/right) +- Animation and transition styles + +## Flyout Management System + +### `src/components/flyout/manager/flyout_manager.tsx` +The central state management system for flyout sessions: +- **Context Provider**: `EuiFlyoutManager` provides global flyout state +- **Session Management**: Tracks main and child flyout relationships with complex state transitions +- **State Reducer**: Handles flyout lifecycle (add, close, set active, set width) +- **Hooks**: Provides utilities like `useHasActiveSession`, `useCurrentSession`, `useFlyoutWidth` +- **Actions**: `addFlyout`, `closeFlyout`, `setActiveFlyout`, `setFlyoutWidth` +- **Responsive Layout**: `useFlyoutLayoutMode` hook manages responsive behavior for managed flyouts with 90% viewport width rule for switching between `side-by-side` and `stacked` layouts + +### `src/components/flyout/manager/flyout_main.tsx` +Renders the primary flyout in a session. Currently a simple wrapper around `EuiManagedFlyout` with `session={true}`. TODO items include handling child flyout presence and adjusting focus/shadow behavior. + +### `src/components/flyout/manager/flyout_child.tsx` +Renders child flyouts within a session: +- **Positioning**: Automatically positions relative to main flyout width +- **Styling**: Supports `backgroundStyle` prop for default/shaded backgrounds +- **Constraints**: Forces `type="overlay"` and `ownFocus={false}` +- **Width Integration**: Uses main flyout width for positioning + +### `src/components/flyout/manager/flyout_managed.tsx` +The managed flyout wrapper that integrates with the flyout manager system, handling registration and lifecycle management. Includes size validation for managed flyouts according to business rules. + +### `src/components/flyout/manager/flyout_validation.ts` +Validation utilities for flyout size business rules: +- **Named Size Validation**: Managed flyouts must use named sizes (s, m, l) +- **Size Combination Rules**: Parent and child can't both be 'm', parent can't be 'l' with child +- **Error Handling**: Comprehensive error messages for invalid configurations + +### `src/components/flyout/manager/index.ts` +Exports all manager-related components and utilities for easy importing. + +## Specialized Flyout Components + +### `src/components/flyout/flyout_resizable.tsx` +A resizable flyout variant that adds drag-to-resize functionality: +- **Drag Resize**: Mouse/touch drag to resize flyout width +- **Keyboard Resize**: Arrow key navigation for accessibility +- **Constraints**: Configurable min/max width with window bounds checking +- **Callbacks**: `onResize` callback for width change notifications +- **Visual Indicator**: Resize handle with border indicator +- **Note**: Not yet integrated into main flyout routing logic + +### `src/components/flyout/flyout_menu.tsx` +A specialized flyout component for menu-style content: +- **Layout**: Flex-based header with back button, popover, title, and close button +- **Context Integration**: Uses `EuiFlyoutMenuContext` for close handling +- **Accessibility**: Proper ARIA labels and screen reader support +- **Styling**: Custom menu-specific styling via `flyout_menu.styles.ts` + +### `src/components/flyout/flyout_menu_context.ts` +React context for flyout menu components, providing `onClose` callback to child components. + +## Styling and Theming + +### `src/components/flyout/flyout.styles.ts` +Core flyout styling with emotion CSS-in-JS: +- Responsive design patterns +- Theme variable integration +- Animation and transition styles +- Size and positioning utilities + +### `src/components/flyout/flyout_menu.styles.ts` +Menu-specific styling for the flyout menu component. + +### `src/components/flyout/manager/flyout.styles.ts` +Managed flyout styling, including background styles for child flyouts. + +## Testing and Documentation + +### `src/components/flyout/flyout.spec.tsx` +Unit tests for the main flyout component functionality. + +### `src/components/flyout/flyout.test.tsx` +Additional test coverage for flyout behavior and edge cases. + +### `src/components/flyout/flyout_menu.stories.tsx` +Storybook stories demonstrating flyout menu usage and variations. + +### `src/components/flyout/manager/flyout_manager.stories.tsx` +Storybook stories for the flyout manager system and session management. + +### `src/components/flyout/manager/flyout_child.stories.tsx` +Storybook stories showcasing child flyout behavior and positioning. + +## Integration + +### `src/components/flyout/index.ts` +Main export file that exposes all public flyout APIs: +- Core components: `EuiFlyout`, `EuiFlyoutComponent` +- Body/Header/Footer components +- Resizable and menu variants +- Animation utilities + +### `src/components/provider/provider.tsx` +The EUI provider that includes `EuiFlyoutManager` in its component tree, ensuring flyout management is available throughout the application. + +## Key Features + +- **Session Management**: Multi-level flyout sessions with main/child relationships +- **Accessibility**: Full keyboard navigation, screen reader support, focus management with sophisticated ESC key handling +- **Responsive Design**: Adaptive behavior based on screen size and breakpoints with intelligent layout switching for managed flyouts (side-by-side vs stacked) when combined flyout widths exceed 90% of viewport +- **Theme Integration**: Seamless integration with EUI's theming system +- **Type Safety**: Comprehensive TypeScript support with proper prop typing and validation +- **Performance**: Optimized rendering with proper cleanup and memory management +- **Size Validation**: Business rule enforcement for flyout size combinations and managed flyout constraints + +## TODOs + +### Performance Issues + +- **Excessive Re-renders**: The flyout manager reducer creates new arrays on every action, causing unnecessary re-renders for all flyout components +- **Unmemoized Style Calculations**: The `cssStyles` array in `flyout.component.tsx` is recalculated on every render without memoization +- **Memory Leaks**: `document.activeElement` is stored in a ref but never cleaned up, potentially causing memory leaks +- **Inefficient DOM Queries**: Focus trap selectors query the DOM on every render without caching + +### Accessibility Issues + +- **Focus Trap Edge Cases**: The focus trap logic with shards could fail if DOM elements are removed or changed during flyout lifecycle +- **Missing Error Recovery**: No fallback behavior when focus management fails +- **Inconsistent Keyboard Navigation**: Different flyout types may have different keyboard behavior patterns + +### Architectural Concerns + +- **Tight Coupling**: The flyout system is tightly coupled to the provider system, making it difficult to use standalone +- **State Management Complexity**: The session management system has complex state transitions that could lead to inconsistent UI states +- **Missing Error Boundaries**: No error handling for flyout rendering failures or state corruption +- **Unclear Session Logic**: The complex session routing logic in `flyout.tsx` (lines 40-50) is difficult to understand and maintain +- **Incomplete Integration**: Resizable flyout functionality exists but is not integrated into main routing logic +- **Missing Cleanup**: Focus references and event listeners are not properly cleaned up + +### Recommended Improvements + +1. **Memoize Style Calculations**: Use `useMemo` for the `cssStyles` array to prevent unnecessary recalculations +2. **Add Error Boundaries**: Wrap flyout components in error boundaries to handle rendering failures gracefully +3. **Improve Type Safety**: Replace `any` types with proper type guards and add comprehensive prop validation +4. **Optimize State Updates**: Use immutable update patterns that minimize re-renders in the manager +5. **Add Cleanup Logic**: Properly clean up focus references and event listeners in useEffect cleanup functions +6. **Simplify Session Logic**: Break down the complex session routing logic into smaller, testable functions +7. **Integrate Resizable Flyouts**: Complete the integration of resizable flyout functionality into the main routing logic +8. **Add Comprehensive Testing**: Add unit tests for complex state transitions and edge cases +9. **Improve Documentation**: Add inline documentation for complex logic and state management patterns +10. **Performance Monitoring**: Add performance monitoring for flyout rendering and state updates diff --git a/packages/eui/src/components/flyout/const.ts b/packages/eui/src/components/flyout/const.ts new file mode 100644 index 00000000000..9abe9754f1e --- /dev/null +++ b/packages/eui/src/components/flyout/const.ts @@ -0,0 +1,48 @@ +/* + * 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 { EuiBreakpointSize } from '../../services'; + +/** Allowed flyout render types. */ +export const FLYOUT_TYPES = ['push', 'overlay'] as const; +/** Type representing a supported flyout render type. */ +export type _EuiFlyoutType = (typeof FLYOUT_TYPES)[number]; + +/** Allowed flyout attachment sides. */ +export const FLYOUT_SIDES = ['left', 'right'] as const; +/** Type representing a supported flyout side. */ +export type _EuiFlyoutSide = (typeof FLYOUT_SIDES)[number]; + +/** Allowed named flyout sizes used by the manager. */ +export const FLYOUT_SIZES = ['s', 'm', 'l'] as const; +/** Type representing a supported named flyout size. */ +export type EuiFlyoutSize = (typeof FLYOUT_SIZES)[number]; + +/** Allowed padding sizes for flyout content. */ +export const FLYOUT_PADDING_SIZES = ['none', 's', 'm', 'l'] as const; +/** Type representing a supported flyout padding size. */ +export type _EuiFlyoutPaddingSize = (typeof FLYOUT_PADDING_SIZES)[number]; + +/** Default minimum breakpoint at which push-type flyouts begin to push content. */ +export const DEFAULT_PUSH_MIN_BREAKPOINT: EuiBreakpointSize = 'l'; +/** Default flyout type when none is provided. */ +export const DEFAULT_TYPE: _EuiFlyoutType = 'overlay'; +/** Default side where flyouts anchor when none is provided. */ +export const DEFAULT_SIDE: _EuiFlyoutSide = 'right'; +/** Default named flyout size. */ +export const DEFAULT_SIZE: EuiFlyoutSize = 'm'; +/** Default padding size inside flyouts. */ +export const DEFAULT_PADDING_SIZE: _EuiFlyoutPaddingSize = 'l'; + +/** + * Custom type checker for named flyout sizes since the prop + * `size` can also be CSSProperties['width'] (string | number) + */ +export function isEuiFlyoutSizeNamed(value: unknown): value is EuiFlyoutSize { + return FLYOUT_SIZES.includes(value as EuiFlyoutSize); +} diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx new file mode 100644 index 00000000000..b4f59ec08e8 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -0,0 +1,536 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable local/i18n */ + +import React, { + useEffect, + useRef, + useMemo, + useCallback, + useState, + forwardRef, + ComponentPropsWithRef, + CSSProperties, + ElementType, + FunctionComponent, + MutableRefObject, + ReactNode, + JSX, +} from 'react'; +import classnames from 'classnames'; + +import { + keys, + EuiWindowEvent, + useCombinedRefs, + EuiBreakpointSize, + useEuiMemoizedStyles, + useGeneratedHtmlId, + useEuiThemeCSSVariables, +} from '../../services'; +import { useCurrentSession, useIsInManagedFlyout } from './manager'; +import { logicalStyle } from '../../global_styling'; + +import { CommonProps, PropsOfElement } from '../common'; +import { EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap'; +import { EuiOverlayMask, EuiOverlayMaskProps } from '../overlay_mask'; +import type { EuiButtonIconPropsForButton } from '../button'; +import { EuiI18n } from '../i18n'; +import { useResizeObserver } from '../observer/resize_observer'; +import { EuiPortal } from '../portal'; +import { EuiScreenReaderOnly } from '../accessibility'; + +import { EuiFlyoutCloseButton } from './_flyout_close_button'; +import { euiFlyoutStyles } from './flyout.styles'; +import { usePropsWithComponentDefaults } from '../provider/component_defaults'; +import { + _EuiFlyoutPaddingSize, + _EuiFlyoutSide, + _EuiFlyoutType, + DEFAULT_PADDING_SIZE, + DEFAULT_PUSH_MIN_BREAKPOINT, + DEFAULT_SIDE, + DEFAULT_SIZE, + DEFAULT_TYPE, + EuiFlyoutSize, + isEuiFlyoutSizeNamed, +} from './const'; +import { useIsPushed } from './hooks'; + +interface _EuiFlyoutComponentProps { + onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; + /** + * Defines the width of the panel. + * Pass a predefined size of `s | m | l`, or pass any number/string compatible with the CSS `width` attribute + * @default m + */ + size?: EuiFlyoutSize | CSSProperties['width']; + /** + * Sets the max-width of the panel, + * set to `true` to use the default size, + * set to `false` to not restrict the width, + * set to a number for a custom width in px, + * set to a string for a custom width in custom measurement. + * @default false + */ + maxWidth?: boolean | number | string; + /** + * Customize the padding around the content of the flyout header, body and footer + * @default l + */ + paddingSize?: _EuiFlyoutPaddingSize; + /** + * Adds an EuiOverlayMask and wraps in an EuiPortal + * @default true + */ + ownFocus?: boolean; + /** + * Hides the default close button. You must provide another close button somewhere within the flyout. + * @default false + */ + hideCloseButton?: boolean; + /** + * Extends EuiButtonIconProps onto the close button + */ + closeButtonProps?: Partial; + /** + * Position of close button. + * `inside`: Floating to just inside the flyout, always top right; + * `outside`: Floating just outside the flyout near the top (side dependent on `side`). Helpful when the close button may cover other interactable content. + * @default inside + */ + closeButtonPosition?: 'inside' | 'outside'; + /** + * Adjustments to the EuiOverlayMask that is added when `ownFocus = true` + */ + maskProps?: EuiOverlayMaskProps; + /** + * How to display the the flyout in relation to the body content; + * `push` keeps it visible, pushing the `` content via padding + * @default overlay + */ + type?: _EuiFlyoutType; + /** + * Forces this interaction on the mask overlay or body content. + * Defaults depend on `ownFocus` and `type` values + */ + outsideClickCloses?: boolean; + /** + * Which side of the window to attach to. + * The `left` option should only be used for navigation. + * @default right + */ + side?: _EuiFlyoutSide; + /** + * Named breakpoint (`xs` through `xl`) for customizing the minimum window width to enable the `push` type + * @default l + */ + pushMinBreakpoint?: EuiBreakpointSize; + /** + * Enables a slide in animation on push flyouts + * @default false + */ + pushAnimation?: boolean; + style?: CSSProperties; + /** + * Object of props passed to EuiFocusTrap. + * `shards` specifies an array of elements that will be considered part of the flyout, preventing the flyout from being closed when clicked. + * `closeOnMouseup` will delay the close callback, allowing time for external toggle buttons to handle close behavior. + * `returnFocus` defines the return focus behavior and provides the possibility to check the available target element or opt out of the behavior in favor of manually returning focus + */ + focusTrapProps?: Pick< + EuiFocusTrapProps, + 'closeOnMouseup' | 'shards' | 'returnFocus' + >; + /** + * By default, EuiFlyout will consider any fixed `EuiHeader`s that sit alongside or above the EuiFlyout + * as part of the flyout's focus trap. This prevents focus fighting with interactive elements + * within fixed headers. + * + * Set this to `false` if you need to disable this behavior for a specific reason. + */ + includeFixedHeadersInFocusTrap?: boolean; + + /** + * Specify additional css selectors to include in the focus trap. + */ + includeSelectorInFocusTrap?: string[] | string; +} + +const defaultElement = 'div'; + +type Props = CommonProps & { + /** + * Sets the HTML element for `EuiFlyout` + */ + as?: T; +} & _EuiFlyoutComponentProps & + Omit, keyof _EuiFlyoutComponentProps>; + +export type EuiFlyoutComponentProps = + Props & Omit, keyof Props>; + +export const EuiFlyoutComponent = forwardRef( + ( + props: EuiFlyoutComponentProps, + ref: + | ((instance: ComponentPropsWithRef | null) => void) + | MutableRefObject | null> + | null + ) => { + const { + className, + children, + as, + hideCloseButton = false, + closeButtonProps, + closeButtonPosition = 'inside', + onClose, + ownFocus = true, + side = DEFAULT_SIDE, + size = DEFAULT_SIZE, + paddingSize = DEFAULT_PADDING_SIZE, + maxWidth = false, + style, + maskProps, + type = DEFAULT_TYPE, + outsideClickCloses, + pushMinBreakpoint = DEFAULT_PUSH_MIN_BREAKPOINT, + pushAnimation = false, + focusTrapProps: _focusTrapProps, + includeFixedHeadersInFocusTrap = true, + includeSelectorInFocusTrap, + 'aria-describedby': _ariaDescribedBy, + id, + ...rest + } = usePropsWithComponentDefaults('EuiFlyout', props); + + const { setGlobalCSSVariables } = useEuiThemeCSSVariables(); + + const Element = as || defaultElement; + const maskRef = useRef(null); + + // Ref for the main flyout element to pass to context + const internalParentFlyoutRef = useRef(null); + const isPushed = useIsPushed({ type, pushMinBreakpoint }); + + /** + * Setting up the refs on the actual flyout element in order to + * accommodate for the `isPushed` state by adding padding to the body equal to the width of the element + */ + const [resizeRef, setResizeRef] = useState | null>( + null + ); + const setRef = useCombinedRefs([ + setResizeRef, + ref, + internalParentFlyoutRef, + ]); + const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); + + useEffect(() => { + /** + * Accomodate for the `isPushed` state by adding padding to the body equal to the width of the element + */ + if (isPushed) { + const paddingSide = + side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd'; + const cssVarName = `--euiPushFlyoutOffset${ + side === 'left' ? 'InlineStart' : 'InlineEnd' + }`; + + document.body.style[paddingSide] = `${width}px`; + + // EUI doesn't use this css variable, but it is useful for consumers + setGlobalCSSVariables({ + [cssVarName]: `${width}px`, + }); + return () => { + document.body.style[paddingSide] = ''; + setGlobalCSSVariables({ + [cssVarName]: null, + }); + }; + } + }, [isPushed, setGlobalCSSVariables, side, width]); + + /** + * This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC) + */ + useEffect(() => { + document.body.classList.add('euiBody--hasFlyout'); + return () => { + // Remove the hasFlyout class when the flyout is unmounted + document.body.classList.remove('euiBody--hasFlyout'); + }; + }, []); + + const currentSession = useCurrentSession(); + const isInManagedContext = useIsInManagedFlyout(); + const hasChildFlyout = currentSession?.child != null; + const isChildFlyout = + isInManagedContext && hasChildFlyout && currentSession?.child === id; + + const shouldCloseOnEscape = useMemo(() => { + // Regular flyout - always close on ESC + if (!isInManagedContext) { + return true; + } + + // Managed flyout with no child - close on ESC + if (!hasChildFlyout) { + return true; + } + + // Child flyout - close on ESC + if (isChildFlyout) { + return true; + } + + // Main flyout with child flyout - don't close on ESC + return false; + }, [isInManagedContext, hasChildFlyout, isChildFlyout]); + + /** + * ESC key closes flyout based on flyout hierarchy rules + */ + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isPushed && event.key === keys.ESCAPE && shouldCloseOnEscape) { + event.preventDefault(); + onClose(event); + } + }, + [onClose, isPushed, shouldCloseOnEscape] + ); + + /** + * Set inline styles + */ + const inlineStyles = useMemo(() => { + const widthStyle = + !isEuiFlyoutSizeNamed(size) && logicalStyle('width', size); + const maxWidthStyle = + typeof maxWidth !== 'boolean' && logicalStyle('max-width', maxWidth); + + return { + ...style, + ...widthStyle, + ...maxWidthStyle, + }; + }, [style, maxWidth, size]); + + const styles = useEuiMemoizedStyles(euiFlyoutStyles); + const cssStyles = [ + styles.euiFlyout, + styles.paddingSizes[paddingSize], + isEuiFlyoutSizeNamed(size) && styles[size], + maxWidth === false && styles.noMaxWidth, + isPushed ? styles.push.push : styles.overlay.overlay, + isPushed ? styles.push[side] : styles.overlay[side], + isPushed && !pushAnimation && styles.push.noAnimation, + styles[side], + ]; + + const classes = classnames('euiFlyout', className); + + const flyoutToggle = useRef(document.activeElement); + const [focusTrapShards, setFocusTrapShards] = useState([]); + + const focusTrapSelectors = useMemo(() => { + let selectors: string[] = []; + + if (includeSelectorInFocusTrap) { + selectors = Array.isArray(includeSelectorInFocusTrap) + ? includeSelectorInFocusTrap + : [includeSelectorInFocusTrap]; + } + + if (includeFixedHeadersInFocusTrap) { + selectors.push('.euiHeader[data-fixed-header]'); + } + + return selectors; + }, [includeSelectorInFocusTrap, includeFixedHeadersInFocusTrap]); + + useEffect(() => { + if (focusTrapSelectors.length > 0) { + const shardsEls = focusTrapSelectors.flatMap((selector) => + Array.from(document.querySelectorAll(selector)) + ); + + setFocusTrapShards(Array.from(shardsEls)); + + // Flyouts that are toggled from shards do not have working + // focus trap autoFocus, so we need to focus the flyout wrapper ourselves + shardsEls.forEach((shard) => { + if (shard.contains(flyoutToggle.current)) { + resizeRef?.focus(); + } + }); + } else { + // Clear existing shards if necessary, e.g. switching to `false` + setFocusTrapShards((shards) => (shards.length ? [] : shards)); + } + }, [focusTrapSelectors, resizeRef]); + + const focusTrapProps: EuiFlyoutComponentProps['focusTrapProps'] = useMemo( + () => ({ + ..._focusTrapProps, + shards: [...focusTrapShards, ...(_focusTrapProps?.shards || [])], + }), + [_focusTrapProps, focusTrapShards] + ); + + /* + * Provide meaningful screen reader instructions/details + */ + const hasOverlayMask = ownFocus && !isPushed; + const descriptionId = useGeneratedHtmlId(); + const ariaDescribedBy = classnames(descriptionId, _ariaDescribedBy); + + const screenReaderDescription = useMemo( + () => ( + +

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

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

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

-
- ), - [hasOverlayMask, descriptionId, focusTrapShards.length] - ); - - /* - * Trap focus even when `ownFocus={false}`, otherwise closing - * the flyout won't return focus to the originating button. - * - * Set `clickOutsideDisables={true}` when `ownFocus={false}` - * to allow non-keyboard users the ability to interact with - * elements outside the flyout. - * - * Set `onClickOutside={onClose}` when `ownFocus` and `type` are the defaults, - * or if `outsideClickCloses={true}` to close on clicks that target - * (both mousedown and mouseup) the overlay mask. - */ - const onClickOutside = useCallback( - (event: MouseEvent | TouchEvent) => { - // Do not close the flyout for any external click - if (outsideClickCloses === false) return undefined; - if (hasOverlayMask) { - // The overlay mask is present, so only clicks on the mask should close the flyout, regardless of outsideClickCloses - if (event.target === maskRef.current) return onClose(event); - } else { - // No overlay mask is present, so any outside clicks should close the flyout - if (outsideClickCloses === true) return onClose(event); - } - // Otherwise if ownFocus is false and outsideClickCloses is undefined, outside clicks should not close the flyout - return undefined; - }, - [onClose, hasOverlayMask, outsideClickCloses] - ); - - const closeButton = !hideCloseButton && !hasFlyoutMenu && ( - - ); +}; - // render content within EuiFlyoutChildProvider if childFlyoutElement is present - let contentToRender: React.ReactElement = children; - if (hasChildFlyout && childFlyoutElement) { - contentToRender = ( - - ); +export const EuiFlyout = forwardRef< + HTMLDivElement | HTMLElement, + EuiFlyoutProps<'div' | 'nav'> +>((props, ref) => { + const { session, as, onClose, ...rest } = usePropsWithComponentDefaults( + 'EuiFlyout', + props + ); + const hasActiveSession = useHasActiveSession(); + const isUnmanagedFlyout = useRef(false); + const isInManagedFlyout = useIsInManagedFlyout(); + + /* + * Flyout routing logic + * 1. Main Flyout: When session={true} OR when there's an active session and this flyout + * is rendered outside of a managed flyout context. + * 2. Child Flyout: When there's an active session AND this flyout IS rendered within a + * managed flyout context. + * 3. Standard Flyout: Default fallback when neither condition is met. + */ + if (session === true || (hasActiveSession && !isInManagedFlyout)) { + if (isUnmanagedFlyout.current) { + // TODO: @tkajtoch - We need to find a better way to handle the missing event. + onClose?.({} as any); + return null; } - - return ( - - - - )} - role={!isPushed ? 'dialog' : rest.role} - aria-modal={!isPushed || undefined} - tabIndex={!isPushed ? 0 : rest.tabIndex} - aria-describedby={!isPushed ? ariaDescribedBy : _ariaDescribedBy} - data-autofocus={!isPushed || undefined} - > - {!isPushed && screenReaderDescription} - {closeButton} - - {contentToRender} - - - - - ); + return ; } - // React.forwardRef interferes with the inferred element type - // Casting to ensure correct element prop type checking for `as` - // e.g., `href` is not on a `div` -) as ( - props: EuiFlyoutProps -) => JSX.Element; -// Recast to allow `displayName` -(EuiFlyout as FunctionComponent).displayName = 'EuiFlyout'; -/** - * Light wrapper for conditionally rendering portals or overlay masks: - * - If ownFocus is set, wrap with an overlay and allow the user to click it to close it. - * - Otherwise still wrap within an EuiPortal so it appends to the bottom of the window. - * Push flyouts have no overlay OR portal behavior. - */ -const EuiFlyoutWrapper: FunctionComponent<{ - children: ReactNode; - hasOverlayMask: boolean; - maskProps: EuiFlyoutProps['maskProps']; - isPortalled: boolean; -}> = ({ children, hasOverlayMask, maskProps, isPortalled }) => { - if (hasOverlayMask) { - return ( - - {children} - - ); - } else if (isPortalled) { - return {children}; - } else { - return <>{children}; + // Else if this flyout is a child of a session AND within a managed flyout context, render EuiChildFlyout. + if (hasActiveSession && isInManagedFlyout) { + return ; } -}; + + // TODO: if resizeable={true}, render EuiResizableFlyout. + + isUnmanagedFlyout.current = true; + return ; +}); +EuiFlyout.displayName = 'EuiFlyout'; diff --git a/packages/eui/src/components/flyout/flyout_child.stories.tsx b/packages/eui/src/components/flyout/flyout_child.stories.tsx deleted file mode 100644 index e81876dbe3f..00000000000 --- a/packages/eui/src/components/flyout/flyout_child.stories.tsx +++ /dev/null @@ -1,349 +0,0 @@ -/* - * 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" - /> - - - 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 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 && ( - - - - )} -
- )} - - ); -}; - -export const WithChildBackgroundStyles: Story = { - name: 'Child Background Styles', - render: () => , -}; diff --git a/packages/eui/src/components/flyout/flyout_child.styles.ts b/packages/eui/src/components/flyout/flyout_child.styles.ts deleted file mode 100644 index 91a3068b147..00000000000 --- a/packages/eui/src/components/flyout/flyout_child.styles.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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%; - 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)} - `, - - backgroundDefault: css` - background: ${euiTheme.colors.backgroundBasePlain}; - `, - backgroundShaded: css` - background: ${euiTheme.colors.backgroundBaseSubdued}; - `, - - // 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')} - `, - - 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 deleted file mode 100644 index 580ccc17b9b..00000000000 --- a/packages/eui/src/components/flyout/flyout_child.test.tsx +++ /dev/null @@ -1,229 +0,0 @@ -/* - * 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 deleted file mode 100644 index d90d84acd38..00000000000 --- a/packages/eui/src/components/flyout/flyout_child.tsx +++ /dev/null @@ -1,307 +0,0 @@ -/* - * 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, - useCallback, -} from 'react'; -import classNames from 'classnames'; -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'; - -/** - * 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'; - /* - * The background of the child flyout can be optionally shaded. Use `shaded` to add the shading. - */ - backgroundStyle?: 'shaded' | 'default'; - /** - * 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, - backgroundStyle = 'default', - 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 { isChildFlyoutOpen, 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; - 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; - 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, - backgroundStyle === 'shaded' - ? styles.backgroundShaded - : styles.backgroundDefault, - size === 's' ? styles.s : styles.m, - childLayoutMode === 'side-by-side' - ? styles.sidePosition - : styles.stackedPosition, - ]; - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (isChildFlyoutOpen && event.key === keys.ESCAPE) { - event.preventDefault(); - setIsChildFlyoutOpen?.(false); - onClose(event.nativeEvent); - } - }, - [isChildFlyoutOpen, onClose, setIsChildFlyoutOpen] - ); - - return ( - { - if (parentFlyoutRef?.current) { - parentFlyoutRef.current.focus(); - return false; // We've handled focus - } - return true; - }} - shards={[]} - disabled={false} - > - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} -
- {/* 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 && !hasFlyoutMenu && ( - - )} -
- {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 deleted file mode 100644 index 92fd089fa38..00000000000 --- a/packages/eui/src/components/flyout/flyout_child_manager.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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); - reportIsChildOpen?.(false); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // 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 deleted file mode 100644 index 351528d022a..00000000000 --- a/packages/eui/src/components/flyout/flyout_context.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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/flyout_menu.stories.tsx b/packages/eui/src/components/flyout/flyout_menu.stories.tsx index a034e07365a..95c31077789 100644 --- a/packages/eui/src/components/flyout/flyout_menu.stories.tsx +++ b/packages/eui/src/components/flyout/flyout_menu.stories.tsx @@ -6,63 +6,158 @@ * 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 { action } from '@storybook/addon-actions'; +import { Meta, StoryObj } from '@storybook/react'; +import { EuiButton, EuiButtonEmpty, EuiButtonIcon } from '../button'; +import { EuiSpacer } from '../spacer'; import { EuiText } from '../text'; import { EuiFlyout } from './flyout'; import { EuiFlyoutBody } from './flyout_body'; -import { EuiFlyoutChild } from './flyout_child'; -import { EuiFlyoutMenu } from './flyout_menu'; +import { EuiFlyoutMenu, EuiFlyoutMenuProps } from './flyout_menu'; +import { EuiIcon } from '../icon'; +import { EuiPopover } from '../popover'; +import { EuiListGroup, EuiListGroupItem } from '../list_group'; +import { EuiFlyoutMenuContext } from './flyout_menu_context'; + +interface Args extends EuiFlyoutMenuProps { + showBackButton: boolean; + showCustomAction: boolean; + showPopover: boolean; +} -const meta: Meta = { +const meta: Meta = { title: 'Layout/EuiFlyout/EuiFlyoutMenu', component: EuiFlyoutMenu, + argTypes: { + showBackButton: { control: 'boolean' }, + showCustomAction: { control: 'boolean' }, + showPopover: { control: 'boolean' }, + backButton: { table: { disable: true } }, + }, + args: { + showBackButton: true, + showCustomAction: true, + showPopover: true, + }, }; export default meta; -const MenuBarFlyout = () => { - const [isOpen, setIsOpen] = useState(true); +const MenuBarFlyout = (args: Args) => { + const { showCustomAction, showBackButton, showPopover } = args; + + const [isFlyoutOpen, setIsFlyoutOpen] = useState(true); + const openFlyout = () => setIsFlyoutOpen(true); + const closeFlyout = () => { + setIsFlyoutOpen(false); + }; + + /* Back button */ + + const backButton = ( + + Back + + ); - const openFlyout = () => setIsOpen(true); - const closeFlyout = () => setIsOpen(false); + /* History popover */ + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const handlePopoverButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const historyItems = [ + { config: { mainTitle: 'First item' } }, + { config: { mainTitle: 'Second item' } }, + { config: { mainTitle: 'Third item' } }, + ]; + + const historyPopover = ( + + } + isOpen={isPopoverOpen} + onClick={handlePopoverButtonClick} + closePopover={() => setIsPopoverOpen(false)} + panelPaddingSize="xs" + anchorPosition="downLeft" + > + + {historyItems.map((item, index) => ( + { + action(`Clicked ${item.config.mainTitle}`)(); + setIsPopoverOpen(false); + }} + > + {item.config.mainTitle} + + ))} + + + ); + + /* Custom action */ const handleCustomActionClick = () => { action('custom action clicked')(); }; + /* Render */ + return ( <> - Open flyout - {isOpen && ( - - + + Open flyout + + + {isFlyoutOpen && ( + + + + {showCustomAction && ( + + )} + + - Main flyout content. + +

Simple flyout content.

+ +
- - - - - - Child with custom action in the menu bar. - -
)} ); }; -export const MenuBarExample: StoryObj = { - name: 'Menu bar example', - render: () => , +export const MenuBarExample: StoryObj = { + name: 'Playground', + render: (args) => , }; diff --git a/packages/eui/src/components/flyout/flyout_resizable.tsx b/packages/eui/src/components/flyout/flyout_resizable.tsx index 0aebb4bb855..84dc5136ddb 100644 --- a/packages/eui/src/components/flyout/flyout_resizable.tsx +++ b/packages/eui/src/components/flyout/flyout_resizable.tsx @@ -21,6 +21,7 @@ import { getPosition } from '../resizable_container/helpers'; import { EuiFlyout, EuiFlyoutProps } from './flyout'; import { euiFlyoutResizableButtonStyles } from './flyout_resizable.styles'; +import { DEFAULT_SIDE, DEFAULT_TYPE } from './const'; export type EuiFlyoutResizableProps = { maxWidth?: number; @@ -38,8 +39,8 @@ export const EuiFlyoutResizable = forwardRef( maxWidth, minWidth = 200, onResize, - side = 'right', - type = 'overlay', + side = DEFAULT_SIDE, + type = DEFAULT_TYPE, ownFocus = true, children, ...rest diff --git a/packages/eui/src/components/flyout/hooks.ts b/packages/eui/src/components/flyout/hooks.ts new file mode 100644 index 00000000000..036b9cc3583 --- /dev/null +++ b/packages/eui/src/components/flyout/hooks.ts @@ -0,0 +1,28 @@ +/* + * 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 { useIsWithinMinBreakpoint } from '../../services'; +import { EuiFlyoutProps } from './flyout'; +import { usePropsWithComponentDefaults } from '../provider/component_defaults'; +import { DEFAULT_PUSH_MIN_BREAKPOINT, DEFAULT_TYPE } from './const'; + +/** + * Determines if a flyout should be rendered in a "pushed" state based on its + * configuration and the current window size. + */ +export const useIsPushed = ( + props: Pick +) => { + const { + type = DEFAULT_TYPE, + pushMinBreakpoint = DEFAULT_PUSH_MIN_BREAKPOINT, + } = usePropsWithComponentDefaults('EuiFlyout', props); + + const windowIsLargeEnoughToPush = useIsWithinMinBreakpoint(pushMinBreakpoint); + return type === 'push' && windowIsLargeEnoughToPush; +}; diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index 7745b60a95b..e375dd60164 100644 --- a/packages/eui/src/components/flyout/index.ts +++ b/packages/eui/src/components/flyout/index.ts @@ -23,20 +23,8 @@ 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 { EuiFlyoutMenuProps } from './flyout_menu'; export { EuiFlyoutMenu } from './flyout_menu'; -export type { - EuiFlyoutSessionApi, - EuiFlyoutSessionConfig, - EuiFlyoutSessionOpenChildOptions, - EuiFlyoutSessionOpenGroupOptions, - EuiFlyoutSessionOpenMainOptions, - EuiFlyoutSessionOpenManagedOptions, - EuiFlyoutSessionProviderComponentProps, - EuiFlyoutSessionRenderContext, -} from './sessions'; -export { EuiFlyoutSessionProvider, useEuiFlyoutSession } from './sessions'; +// Hooks for using Manager-based flyouts +export { useIsInManagedFlyout, useHasActiveSession } from './manager'; diff --git a/packages/eui/src/components/flyout/manager/__snapshots__/context.test.tsx.snap b/packages/eui/src/components/flyout/manager/__snapshots__/context.test.tsx.snap new file mode 100644 index 00000000000..f937e237eb6 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/__snapshots__/context.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiFlyoutIsManagedProvider renders 1`] = `
`; diff --git a/packages/eui/src/components/flyout/manager/__snapshots__/provider.test.tsx.snap b/packages/eui/src/components/flyout/manager/__snapshots__/provider.test.tsx.snap new file mode 100644 index 00000000000..219143aefcd --- /dev/null +++ b/packages/eui/src/components/flyout/manager/__snapshots__/provider.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiFlyoutManager renders 1`] = `
`; diff --git a/packages/eui/src/components/flyout/manager/actions.test.ts b/packages/eui/src/components/flyout/manager/actions.test.ts new file mode 100644 index 00000000000..fe3fd442d6c --- /dev/null +++ b/packages/eui/src/components/flyout/manager/actions.test.ts @@ -0,0 +1,295 @@ +/* + * 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. + */ + +/* + * 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 the election, the Elastic License 2.0 or the Server Side Public License, v 1. + */ + +import { + addFlyout, + closeFlyout, + setActiveFlyout, + setFlyoutWidth, + setLayoutMode, + setActivityStage, + ACTION_ADD, + ACTION_CLOSE, + ACTION_SET_ACTIVE, + ACTION_SET_WIDTH, + ACTION_SET_LAYOUT_MODE, + ACTION_SET_ACTIVITY_STAGE, +} from './actions'; +import { + LEVEL_MAIN, + LEVEL_CHILD, + LAYOUT_MODE_SIDE_BY_SIDE, + LAYOUT_MODE_STACKED, + STAGE_OPENING, + STAGE_ACTIVE, + STAGE_CLOSING, +} from './const'; + +describe('flyout manager actions', () => { + describe('action constants', () => { + it('should have correct action type prefixes', () => { + expect(ACTION_ADD).toBe('eui/flyoutManager/add'); + expect(ACTION_CLOSE).toBe('eui/flyoutManager/close'); + expect(ACTION_SET_ACTIVE).toBe('eui/flyoutManager/setActive'); + expect(ACTION_SET_WIDTH).toBe('eui/flyoutManager/setWidth'); + expect(ACTION_SET_LAYOUT_MODE).toBe('eui/flyoutManager/setLayoutMode'); + expect(ACTION_SET_ACTIVITY_STAGE).toBe( + 'eui/flyoutManager/setActivityStage' + ); + }); + }); + + describe('addFlyout', () => { + it('should create add flyout action with required parameters', () => { + const action = addFlyout('flyout-1', LEVEL_MAIN); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'flyout-1', + level: LEVEL_MAIN, + }); + }); + + it('should create add flyout action with all parameters', () => { + const action = addFlyout('flyout-1', LEVEL_MAIN, 'l'); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'flyout-1', + level: LEVEL_MAIN, + size: 'l', + }); + }); + + it('should default to LEVEL_MAIN when level is not provided', () => { + const action = addFlyout('flyout-1'); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'flyout-1', + level: LEVEL_MAIN, + }); + }); + + it('should handle child level flyouts', () => { + const action = addFlyout('child-1', LEVEL_CHILD, 'm'); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'child-1', + level: LEVEL_CHILD, + size: 'm', + }); + }); + + it('should handle undefined size parameter', () => { + const action = addFlyout('flyout-1', LEVEL_MAIN, undefined); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'flyout-1', + level: LEVEL_MAIN, + size: undefined, + }); + }); + }); + + describe('closeFlyout', () => { + it('should create close flyout action', () => { + const action = closeFlyout('flyout-1'); + + expect(action).toEqual({ + type: ACTION_CLOSE, + flyoutId: 'flyout-1', + }); + }); + + it('should handle different flyout IDs', () => { + const action1 = closeFlyout('main-1'); + const action2 = closeFlyout('child-1'); + + expect(action1.flyoutId).toBe('main-1'); + expect(action2.flyoutId).toBe('child-1'); + }); + }); + + describe('setActiveFlyout', () => { + it('should create set active flyout action with flyout ID', () => { + const action = setActiveFlyout('child-1'); + + expect(action).toEqual({ + type: ACTION_SET_ACTIVE, + flyoutId: 'child-1', + }); + }); + + it('should create set active flyout action with null', () => { + const action = setActiveFlyout(null); + + expect(action).toEqual({ + type: ACTION_SET_ACTIVE, + flyoutId: null, + }); + }); + + it('should handle empty string flyout ID', () => { + const action = setActiveFlyout(''); + + expect(action).toEqual({ + type: ACTION_SET_ACTIVE, + flyoutId: '', + }); + }); + }); + + describe('setFlyoutWidth', () => { + it('should create set width action', () => { + const action = setFlyoutWidth('flyout-1', 400); + + expect(action).toEqual({ + type: ACTION_SET_WIDTH, + flyoutId: 'flyout-1', + width: 400, + }); + }); + + it('should handle different width values', () => { + const action1 = setFlyoutWidth('flyout-1', 0); + const action2 = setFlyoutWidth('flyout-2', 1000); + const action3 = setFlyoutWidth('flyout-3', 500.5); + + expect(action1.width).toBe(0); + expect(action2.width).toBe(1000); + expect(action3.width).toBe(500.5); + }); + + it('should handle different flyout IDs', () => { + const action1 = setFlyoutWidth('main-1', 400); + const action2 = setFlyoutWidth('child-1', 300); + + expect(action1.flyoutId).toBe('main-1'); + expect(action2.flyoutId).toBe('child-1'); + }); + }); + + describe('setLayoutMode', () => { + it('should create set layout mode action for side-by-side', () => { + const action = setLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE); + + expect(action).toEqual({ + type: ACTION_SET_LAYOUT_MODE, + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }); + }); + + it('should create set layout mode action for stacked', () => { + const action = setLayoutMode(LAYOUT_MODE_STACKED); + + expect(action).toEqual({ + type: ACTION_SET_LAYOUT_MODE, + layoutMode: LAYOUT_MODE_STACKED, + }); + }); + + it('should handle layout mode constants correctly', () => { + expect(LAYOUT_MODE_SIDE_BY_SIDE).toBe('side-by-side'); + expect(LAYOUT_MODE_STACKED).toBe('stacked'); + }); + }); + + describe('setActivityStage', () => { + it('should create set activity stage action', () => { + const action = setActivityStage('flyout-1', STAGE_ACTIVE); + + expect(action).toEqual({ + type: ACTION_SET_ACTIVITY_STAGE, + flyoutId: 'flyout-1', + activityStage: STAGE_ACTIVE, + }); + }); + + it('should handle different activity stages', () => { + const stages = [STAGE_OPENING, STAGE_ACTIVE, STAGE_CLOSING]; + + stages.forEach((stage) => { + const action = setActivityStage('flyout-1', stage as any); + expect(action.activityStage).toBe(stage); + }); + }); + + it('should handle different flyout IDs', () => { + const action1 = setActivityStage('main-1', STAGE_ACTIVE); + const action2 = setActivityStage('child-1', STAGE_OPENING); + + expect(action1.flyoutId).toBe('main-1'); + expect(action2.flyoutId).toBe('child-1'); + }); + }); + + describe('action type safety', () => { + it('should have correct action type structure', () => { + const addAction = addFlyout('test'); + const closeAction = closeFlyout('test'); + const setActiveAction = setActiveFlyout('test'); + const setWidthAction = setFlyoutWidth('test', 100); + const setLayoutAction = setLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE); + const setStageAction = setActivityStage('test', STAGE_ACTIVE); + + expect(addAction.type).toMatch(/^eui\/flyoutManager\//); + expect(closeAction.type).toMatch(/^eui\/flyoutManager\//); + expect(setActiveAction.type).toMatch(/^eui\/flyoutManager\//); + expect(setWidthAction.type).toMatch(/^eui\/flyoutManager\//); + expect(setLayoutAction.type).toMatch(/^eui\/flyoutManager\//); + expect(setStageAction.type).toMatch(/^eui\/flyoutManager\//); + }); + + it('should maintain action immutability', () => { + const action = addFlyout('test', LEVEL_MAIN, 'm'); + const originalAction = { ...action }; + + // Modify the action object + (action as any).flyoutId = 'modified'; + + // Original action should remain unchanged + expect(originalAction.flyoutId).toBe('test'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string flyout IDs', () => { + const action = addFlyout('', LEVEL_MAIN); + expect(action.flyoutId).toBe(''); + }); + + it('should handle special characters in flyout IDs', () => { + const specialIds = ['flyout-1', 'flyout_2', 'flyout.3', 'flyout-4']; + + specialIds.forEach((id) => { + const action = addFlyout(id, LEVEL_MAIN); + expect(action.flyoutId).toBe(id); + }); + }); + + it('should handle very large width values', () => { + const action = setFlyoutWidth('flyout-1', Number.MAX_SAFE_INTEGER); + expect(action.width).toBe(Number.MAX_SAFE_INTEGER); + }); + + it('should handle zero width values', () => { + const action = setFlyoutWidth('flyout-1', 0); + expect(action.width).toBe(0); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/actions.ts b/packages/eui/src/components/flyout/manager/actions.ts new file mode 100644 index 00000000000..dcbfa6a8e22 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/actions.ts @@ -0,0 +1,143 @@ +/* + * 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 { LEVEL_MAIN } from './const'; +import { + EuiFlyoutActivityStage, + EuiFlyoutLevel, + EuiFlyoutLayoutMode, +} from './types'; + +const PREFIX = 'eui/flyoutManager' as const; + +interface BaseAction { + type: `${typeof PREFIX}/${string}`; +} + +/** Dispatched when a flyout registers itself with the manager. */ +export const ACTION_ADD = `${PREFIX}/add` as const; +/** Dispatched to remove a flyout from the manager (usually on close/unmount). */ +export const ACTION_CLOSE = `${PREFIX}/close` as const; +/** Dispatched to set which flyout is currently active within the session. */ +export const ACTION_SET_ACTIVE = `${PREFIX}/setActive` as const; +/** Dispatched when an active flyout's pixel width changes (for responsive layout). */ +export const ACTION_SET_WIDTH = `${PREFIX}/setWidth` as const; +/** Dispatched to switch layout mode between `side-by-side` and `stacked`. */ +export const ACTION_SET_LAYOUT_MODE = `${PREFIX}/setLayoutMode` as const; +/** Dispatched to update a flyout's activity stage (e.g., opening -> active). */ +export const ACTION_SET_ACTIVITY_STAGE = `${PREFIX}/setActivityStage` as const; + +/** + * Add a flyout to manager state. The manager will create or update + * the current session depending on the `level` provided. + */ +export interface AddFlyoutAction extends BaseAction { + type: typeof ACTION_ADD; + flyoutId: string; + level: EuiFlyoutLevel; + size?: string; +} + +/** Remove a flyout from manager state. Also updates the active session. */ +export interface CloseFlyoutAction extends BaseAction { + type: typeof ACTION_CLOSE; + flyoutId: string; +} + +/** Set the active flyout within the current session (or clear with `null`). */ +export interface SetActiveFlyoutAction extends BaseAction { + type: typeof ACTION_SET_ACTIVE; + flyoutId: string | null; +} + +/** Update a flyout's measured width in pixels. */ +export interface SetWidthAction extends BaseAction { + type: typeof ACTION_SET_WIDTH; + flyoutId: string; + width: number; +} + +/** Change how flyouts are arranged: `side-by-side` or `stacked`. */ +export interface SetLayoutModeAction extends BaseAction { + type: typeof ACTION_SET_LAYOUT_MODE; + layoutMode: EuiFlyoutLayoutMode; +} + +/** Set a specific flyout's activity stage. */ +export interface SetActivityStageAction extends BaseAction { + type: typeof ACTION_SET_ACTIVITY_STAGE; + flyoutId: string; + activityStage: EuiFlyoutActivityStage; +} + +/** Union of all flyout manager actions. */ +export type Action = + | AddFlyoutAction + | CloseFlyoutAction + | SetActiveFlyoutAction + | SetWidthAction + | SetLayoutModeAction + | SetActivityStageAction; + +/** + * Register a flyout with the manager. + * - `level` determines whether the flyout is `main` or `child`. + * - Optional `size` is the named EUI size (e.g. `s`, `m`, `l`). + */ +export const addFlyout = ( + flyoutId: string, + level: EuiFlyoutLevel = LEVEL_MAIN, + size?: string +): AddFlyoutAction => ({ + type: ACTION_ADD, + flyoutId, + level, + size, +}); + +/** Unregister a flyout and update the session accordingly. */ +export const closeFlyout = (flyoutId: string): CloseFlyoutAction => ({ + type: ACTION_CLOSE, + flyoutId, +}); + +/** Set or clear the active flyout for the current session. */ +export const setActiveFlyout = ( + flyoutId: string | null +): SetActiveFlyoutAction => ({ + type: ACTION_SET_ACTIVE, + flyoutId, +}); + +/** Record a flyout's current width in pixels. */ +export const setFlyoutWidth = ( + flyoutId: string, + width: number +): SetWidthAction => ({ + type: ACTION_SET_WIDTH, + flyoutId, + width, +}); + +/** Switch layout mode between `side-by-side` and `stacked`. */ +export const setLayoutMode = ( + layoutMode: EuiFlyoutLayoutMode +): SetLayoutModeAction => ({ + type: ACTION_SET_LAYOUT_MODE, + layoutMode, +}); + +/** Update a flyout's activity stage. */ +export const setActivityStage = ( + flyoutId: string, + activityStage: EuiFlyoutActivityStage +): SetActivityStageAction => ({ + type: ACTION_SET_ACTIVITY_STAGE, + flyoutId, + activityStage, +}); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx new file mode 100644 index 00000000000..e7d2a6ba950 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx @@ -0,0 +1,600 @@ +/* + * 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, screen } from '../../../test/rtl'; +import { useFlyoutActivityStage } from './activity_stage'; +import { useFlyoutManager } from './provider'; +import { + LAYOUT_MODE_SIDE_BY_SIDE, + LAYOUT_MODE_STACKED, + LEVEL_MAIN, + LEVEL_CHILD, + STAGE_ACTIVE, + STAGE_BACKGROUNDED, + STAGE_BACKGROUNDING, + STAGE_CLOSING, + STAGE_INACTIVE, + STAGE_OPENING, + STAGE_RETURNING, +} from './const'; + +// Mock the hooks to control their return values +jest.mock('./hooks', () => ({ + useFlyoutLayoutMode: jest.fn(), + useHasChildFlyout: jest.fn(), + useIsFlyoutActive: jest.fn(), +})); + +jest.mock('./provider', () => ({ + EuiFlyoutManager: ({ children }: { children: React.ReactNode }) => children, + useFlyoutManager: jest.fn(), +})); + +// Mock the actions +jest.mock('./actions', () => ({ + setActivityStage: jest.fn(), +})); + +import { + useFlyoutLayoutMode, + useHasChildFlyout, + useIsFlyoutActive, +} from './hooks'; +import { setActivityStage } from './actions'; + +const mockUseFlyoutLayoutMode = useFlyoutLayoutMode as jest.Mock; +const mockUseHasChildFlyout = useHasChildFlyout as jest.Mock; +const mockUseIsFlyoutActive = useIsFlyoutActive as jest.Mock; +const mockUseFlyoutManager = useFlyoutManager as jest.Mock; +const mockSetActivityStage = setActivityStage as jest.Mock; + +describe('useFlyoutActivityStage', () => { + const mockDispatch = jest.fn(); + const mockState = { + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + { + flyoutId: 'child-1', + level: LEVEL_CHILD, + activityStage: STAGE_ACTIVE, + }, + ], + sessions: [{ main: 'main-1', child: 'child-1' }], + layoutMode: LAYOUT_MODE_STACKED, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseFlyoutManager.mockReturnValue({ + state: mockState, + dispatch: mockDispatch, + }); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_STACKED); + mockUseHasChildFlyout.mockReturnValue(false); + mockUseIsFlyoutActive.mockReturnValue(true); + mockSetActivityStage.mockReturnValue({ type: 'SET_ACTIVITY_STAGE' }); + }); + + const TestComponent = ({ + flyoutId, + level, + }: { + flyoutId: string; + level: 'main' | 'child'; + }) => { + const { activityStage, onAnimationEnd } = useFlyoutActivityStage({ + flyoutId, + level, + }); + + return ( +
+
{activityStage}
+ +
+ ); + }; + + describe('initial state', () => { + it('returns the activity stage from state when available', () => { + render(); + + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_ACTIVE + ); + }); + + it('returns STAGE_OPENING when flyout is active but no stage in state', () => { + const stateWithoutStage = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + // No activityStage + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithoutStage, + dispatch: mockDispatch, + }); + + render(); + + // When no activityStage in state, it should default to STAGE_ACTIVE for active flyouts + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_ACTIVE + ); + }); + + it('returns STAGE_INACTIVE when flyout is not active and no stage in state', () => { + mockUseIsFlyoutActive.mockReturnValue(false); + const stateWithoutStage = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + // No activityStage + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithoutStage, + dispatch: mockDispatch, + }); + + render(); + + // When no activityStage in state and flyout is inactive, it should default to STAGE_INACTIVE + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_INACTIVE + ); + }); + }); + + describe('stage transitions based on activity', () => { + it('transitions from ACTIVE to CLOSING when flyout becomes inactive', () => { + const { rerender } = render( + + ); + + // Initially active + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_ACTIVE + ); + + // Change to inactive + mockUseIsFlyoutActive.mockReturnValue(false); + rerender(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_CLOSING) + ); + }); + + it('transitions from INACTIVE to RETURNING when flyout becomes active', () => { + const stateWithInactive = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_INACTIVE, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithInactive, + dispatch: mockDispatch, + }); + + const { rerender } = render( + + ); + + // Initially inactive + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_INACTIVE + ); + + // Change to active + mockUseIsFlyoutActive.mockReturnValue(true); + rerender(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_RETURNING) + ); + }); + }); + + describe('main flyout backgrounding logic', () => { + it('transitions to BACKGROUNDING when main flyout is active, has child, and layout is stacked', () => { + mockUseHasChildFlyout.mockReturnValue(true); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_STACKED); + + render(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_BACKGROUNDING) + ); + }); + + it('does not transition to BACKGROUNDING when layout is side-by-side', () => { + mockUseHasChildFlyout.mockReturnValue(true); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_SIDE_BY_SIDE); + + render(); + + expect(mockDispatch).not.toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_BACKGROUNDING) + ); + }); + + it('does not transition to BACKGROUNDING when no child flyout', () => { + mockUseHasChildFlyout.mockReturnValue(false); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_STACKED); + + render(); + + expect(mockDispatch).not.toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_BACKGROUNDING) + ); + }); + + it('does not transition to BACKGROUNDING for child flyouts', () => { + mockUseHasChildFlyout.mockReturnValue(true); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_STACKED); + + render(); + + expect(mockDispatch).not.toHaveBeenCalledWith( + mockSetActivityStage('child-1', STAGE_BACKGROUNDING) + ); + }); + }); + + describe('main flyout returning logic', () => { + it('transitions from BACKGROUNDED to RETURNING when child is gone', () => { + const stateWithBackgrounded = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDED, + }, + ], + sessions: [{ main: 'main-1', child: null }], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounded, + dispatch: mockDispatch, + }); + mockUseHasChildFlyout.mockReturnValue(false); + + render(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_RETURNING) + ); + }); + + it('transitions from BACKGROUNDING to RETURNING when child is gone', () => { + const stateWithBackgrounding = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDING, + }, + ], + sessions: [{ main: 'main-1', child: null }], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounding, + dispatch: mockDispatch, + }); + mockUseHasChildFlyout.mockReturnValue(false); + + render(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_RETURNING) + ); + }); + + it('transitions from BACKGROUNDED to RETURNING when layout changes to side-by-side', () => { + const stateWithBackgrounded = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDED, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounded, + dispatch: mockDispatch, + }); + mockUseHasChildFlyout.mockReturnValue(true); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_SIDE_BY_SIDE); + + render(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_RETURNING) + ); + }); + + it('does not transition to RETURNING when still has child and layout is stacked', () => { + const stateWithBackgrounded = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDED, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounded, + dispatch: mockDispatch, + }); + mockUseHasChildFlyout.mockReturnValue(true); + mockUseFlyoutLayoutMode.mockReturnValue(LAYOUT_MODE_STACKED); + + render(); + + expect(mockDispatch).not.toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_RETURNING) + ); + }); + }); + + describe('onAnimationEnd transitions', () => { + it('transitions from OPENING to ACTIVE', () => { + const stateWithOpening = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_OPENING, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithOpening, + dispatch: mockDispatch, + }); + + render(); + + screen.getByTestSubject('animation-end').click(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_ACTIVE) + ); + }); + + it('transitions from RETURNING to ACTIVE', () => { + const stateWithReturning = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_RETURNING, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithReturning, + dispatch: mockDispatch, + }); + + render(); + + screen.getByTestSubject('animation-end').click(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_ACTIVE) + ); + }); + + it('transitions from CLOSING to INACTIVE', () => { + const stateWithClosing = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_CLOSING, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithClosing, + dispatch: mockDispatch, + }); + + render(); + + screen.getByTestSubject('animation-end').click(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_INACTIVE) + ); + }); + + it('transitions from BACKGROUNDING to BACKGROUNDED', () => { + const stateWithBackgrounding = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDING, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounding, + dispatch: mockDispatch, + }); + + render(); + + screen.getByTestSubject('animation-end').click(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_BACKGROUNDED) + ); + }); + + it('does not transition when stage is already final', () => { + const stateWithActive = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithActive, + dispatch: mockDispatch, + }); + + render(); + + screen.getByTestSubject('animation-end').click(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('handles missing flyout in state gracefully', () => { + const stateWithoutFlyout = { + ...mockState, + flyouts: [], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithoutFlyout, + dispatch: mockDispatch, + }); + + render(); + + // When flyout is not in state, it should default to STAGE_ACTIVE for active flyouts + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_ACTIVE + ); + }); + + it('handles null context gracefully', () => { + mockUseFlyoutManager.mockReturnValue(null); + + render(); + + // When context is null, it should default to STAGE_ACTIVE for active flyouts + expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( + STAGE_ACTIVE + ); + }); + + it('handles missing dispatch gracefully', () => { + mockUseFlyoutManager.mockReturnValue({ + state: mockState, + dispatch: undefined, + }); + + render(); + + // Should not crash + expect(screen.getByTestSubject('activity-stage')).toBeInTheDocument(); + }); + + it('prevents unnecessary transitions when stage is already correct', () => { + const stateWithActive = { + ...mockState, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + ], + }; + mockUseFlyoutManager.mockReturnValue({ + state: stateWithActive, + dispatch: mockDispatch, + }); + mockUseIsFlyoutActive.mockReturnValue(true); + + render(); + + // Should not dispatch since stage is already ACTIVE + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('hook integration', () => { + it('calls all required hooks', () => { + render(); + + expect(mockUseIsFlyoutActive).toHaveBeenCalledWith('main-1'); + expect(mockUseHasChildFlyout).toHaveBeenCalledWith('main-1'); + expect(mockUseFlyoutLayoutMode).toHaveBeenCalled(); + expect(mockUseFlyoutManager).toHaveBeenCalled(); + }); + + it('returns correct interface', () => { + const TestHookComponent = () => { + const result = useFlyoutActivityStage({ + flyoutId: 'main-1', + level: LEVEL_MAIN, + }); + + return ( +
+
{result.activityStage}
+
+ {typeof result.onAnimationEnd === 'function' + ? 'function' + : 'not-function'} +
+
+ ); + }; + + render(); + + expect(screen.getByTestSubject('activity-stage')).toBeInTheDocument(); + expect(screen.getByTestSubject('has-on-animation-end')).toHaveTextContent( + 'function' + ); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.ts b/packages/eui/src/components/flyout/manager/activity_stage.ts new file mode 100644 index 00000000000..080ef6b4141 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/activity_stage.ts @@ -0,0 +1,121 @@ +/* + * 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 { useCallback, useEffect, useRef } from 'react'; +import { + LAYOUT_MODE_SIDE_BY_SIDE, + LAYOUT_MODE_STACKED, + LEVEL_MAIN, + STAGE_ACTIVE, + STAGE_BACKGROUNDED, + STAGE_BACKGROUNDING, + STAGE_CLOSING, + STAGE_INACTIVE, + STAGE_OPENING, + STAGE_RETURNING, +} from './const'; +import type { EuiFlyoutActivityStage, EuiFlyoutLevel } from './types'; +import { + useFlyoutLayoutMode, + useHasChildFlyout, + useIsFlyoutActive, +} from './hooks'; +import { setActivityStage } from './actions'; +import { useFlyoutManager } from './provider'; + +export interface UseFlyoutActivityStageParams { + flyoutId: string; + level: EuiFlyoutLevel; +} + +export interface UseFlyoutActivityStageReturn { + activityStage: EuiFlyoutActivityStage; + onAnimationEnd: () => void; +} + +/** + * Encapsulates all activity-stage transitions and animation-driven updates + * for managed flyouts. + */ +export const useFlyoutActivityStage = ({ + flyoutId, + level, +}: UseFlyoutActivityStageParams) => { + const isActive = useIsFlyoutActive(flyoutId); + const hasChild = useHasChildFlyout(flyoutId); + const layoutMode = useFlyoutLayoutMode(); + const ctx = useFlyoutManager(); + + const stage: EuiFlyoutActivityStage = + ctx?.state.flyouts.find((f) => f.flyoutId === flyoutId)?.activityStage || + (isActive ? STAGE_ACTIVE : STAGE_INACTIVE); + + const stageRef = useRef(stage); + if (stageRef.current !== stage) { + stageRef.current = stage; + } + + /** + * 1. ACTIVE -> CLOSING when no longer the active flyout. + * 2. INACTIVE -> RETURNING when it becomes active again (e.g., reopened or brought forward). + * 3. (Main flyout only) ACTIVE + stacked + has child -> BACKGROUNDING (begin background animation). + * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (bring main to foreground). + * + * Any stages that depend on animation end (OPENING, RETURNING, CLOSING, BACKGROUNDING) are finalized in `onAnimationEnd`. + */ + useEffect(() => { + const s = stageRef.current; + let next: EuiFlyoutActivityStage | null = null; + + if (s === STAGE_ACTIVE && !isActive) next = STAGE_CLOSING; + else if (s === STAGE_INACTIVE && isActive) next = STAGE_RETURNING; + else if ( + level === LEVEL_MAIN && + isActive && + s === STAGE_ACTIVE && + hasChild && + layoutMode === LAYOUT_MODE_STACKED + ) + next = STAGE_BACKGROUNDING; + else if ( + level === LEVEL_MAIN && + (s === STAGE_BACKGROUNDED || s === STAGE_BACKGROUNDING) && + (!hasChild || layoutMode === LAYOUT_MODE_SIDE_BY_SIDE) + ) + next = STAGE_RETURNING; + + if (next && next !== s) { + ctx?.dispatch?.(setActivityStage(flyoutId, next)); + stageRef.current = next; + } + }, [isActive, hasChild, layoutMode, level, ctx, flyoutId, stage]); + + /** + * OPENING / RETURNING -> ACTIVE + * CLOSING -> INACTIVE + * BACKGROUNDING -> BACKGROUNDED + */ + const onAnimationEnd = useCallback(() => { + const s = stageRef.current; + const next: EuiFlyoutActivityStage | null = + s === STAGE_OPENING || s === STAGE_RETURNING + ? STAGE_ACTIVE + : s === STAGE_CLOSING + ? STAGE_INACTIVE + : s === STAGE_BACKGROUNDING + ? STAGE_BACKGROUNDED + : null; + + if (next && next !== s) { + ctx?.dispatch?.(setActivityStage(flyoutId, next)); + stageRef.current = next; + } + }, [ctx, flyoutId]); + + return { activityStage: stage, onAnimationEnd }; +}; diff --git a/packages/eui/src/components/flyout/manager/const.ts b/packages/eui/src/components/flyout/manager/const.ts new file mode 100644 index 00000000000..ccfa99e83e2 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/const.ts @@ -0,0 +1,50 @@ +/* + * 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. + */ + +const PREFIX = 'data-managed-flyout'; + +/** + * Data attribute applied to a managed flyout element to help identify it as a managed flyout. + */ +export const PROPERTY_FLYOUT = `${PREFIX}`; + +/** + * Data attribute indicating whether the flyout is the `main` or `child` flyout. + */ +export const PROPERTY_LEVEL = `${PREFIX}-level`; + +/** + * Data attribute representing how multiple flyouts are laid out + * (`side-by-side` or `stacked`). + */ +export const PROPERTY_LAYOUT_MODE = `${PREFIX}-layout-mode`; + +/** Stacked layout mode where child flyouts overlay on top of the main flyout. */ +export const LAYOUT_MODE_STACKED = 'stacked'; +/** Side-by-side layout mode where child flyouts render adjacent to the main flyout. */ +export const LAYOUT_MODE_SIDE_BY_SIDE = 'side-by-side'; + +/** The primary (parent) flyout in a session. */ +export const LEVEL_MAIN = 'main'; +/** The secondary (child) flyout spawned by the main flyout. */ +export const LEVEL_CHILD = 'child'; + +/** Flyout is mounting and playing its opening animation. */ +export const STAGE_OPENING = 'opening'; +/** Flyout is fully visible and interactive. */ +export const STAGE_ACTIVE = 'active'; +/** Flyout is unmounted or not currently visible/interactable. */ +export const STAGE_INACTIVE = 'inactive'; +/** Main flyout is transitioning behind an active session flyout. */ +export const STAGE_BACKGROUNDING = 'backgrounding'; +/** Main flyout is backgrounded behind an active session flyout. */ +export const STAGE_BACKGROUNDED = 'backgrounded'; +/** Flyout is returning to the foreground from a backgrounded state. */ +export const STAGE_RETURNING = 'returning'; +/** Flyout is playing its closing animation. */ +export const STAGE_CLOSING = 'closing'; diff --git a/packages/eui/src/components/flyout/manager/context.test.tsx b/packages/eui/src/components/flyout/manager/context.test.tsx new file mode 100644 index 00000000000..a09d90c7ddc --- /dev/null +++ b/packages/eui/src/components/flyout/manager/context.test.tsx @@ -0,0 +1,81 @@ +/* + * 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 } from '../../../test/rtl'; +import { requiredProps } from '../../../test/required_props'; +import { EuiFlyoutIsManagedProvider, useIsInManagedFlyout } from './context'; + +describe('EuiFlyoutIsManagedProvider', () => { + it('renders', () => { + const { container } = render( + +
+ + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + describe('context value', () => { + it('provides true when isManaged is true', () => { + const TestComponent = () => { + const isManaged = useIsInManagedFlyout(); + return ( +
+ {isManaged ? 'Managed' : 'Not Managed'} +
+ ); + }; + + const { getByTestSubject } = render( + + + + ); + + expect(getByTestSubject('context-value')).toHaveTextContent('Managed'); + }); + + it('provides false when isManaged is false', () => { + const TestComponent = () => { + const isManaged = useIsInManagedFlyout(); + return ( +
+ {isManaged ? 'Managed' : 'Not Managed'} +
+ ); + }; + + const { getByTestSubject } = render( + + + + ); + + expect(getByTestSubject('context-value')).toHaveTextContent( + 'Not Managed' + ); + }); + }); + + describe('useIsInManagedFlyout hook', () => { + it('returns false when used outside of provider', () => { + const TestComponent = () => { + const isManaged = useIsInManagedFlyout(); + return ( +
{isManaged ? 'True' : 'False'}
+ ); + }; + + const { getByTestSubject } = render(); + + expect(getByTestSubject('hook-test')).toHaveTextContent('False'); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/context.tsx b/packages/eui/src/components/flyout/manager/context.tsx new file mode 100644 index 00000000000..ec0dfcaba6a --- /dev/null +++ b/packages/eui/src/components/flyout/manager/context.tsx @@ -0,0 +1,36 @@ +/* + * 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 } from 'react'; + +// Context to track if we're within a managed flyout +const EuiFlyoutIsManagedContext = createContext(false); + +/** + * React provider that marks descendants as being rendered inside + * an EUI managed flyout. Used by hooks/components to alter behavior + * (e.g., focus handling) when inside a managed flyout tree. + */ +export const EuiFlyoutIsManagedProvider = ({ + isManaged, + children, +}: { + isManaged: boolean; + children: React.ReactNode; +}) => { + return ( + + {children} + + ); +}; + +/** + * Hook that returns `true` when called within an EUI managed flyout subtree. + */ +export const useIsInManagedFlyout = () => useContext(EuiFlyoutIsManagedContext); diff --git a/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx new file mode 100644 index 00000000000..d9195ad2cae --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx @@ -0,0 +1,300 @@ +/* + * 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 { actions } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } 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 { FLYOUT_TYPES, EuiFlyout } from '../flyout'; +import { EuiFlyoutBody } from '../flyout_body'; +import { EuiFlyoutFooter } from '../flyout_footer'; +import { EuiFlyoutMenu } from '../flyout_menu'; +import { EuiFlyoutChild, EuiFlyoutChildProps } from './flyout_child'; +import { useFlyoutLayoutMode } from './hooks'; + +type EuiFlyoutChildActualProps = Pick< + EuiFlyoutChildProps, + | 'aria-label' + | 'as' + | 'backgroundStyle' + | 'children' + | 'closeButtonProps' + | 'focusTrapProps' + | 'includeFixedHeadersInFocusTrap' + | 'includeSelectorInFocusTrap' + | 'maskProps' + | 'maxWidth' + | 'onClose' + | 'ownFocus' + | 'pushAnimation' + | 'size' + | 'style' +>; + +type EuiFlyoutType = (typeof FLYOUT_TYPES)[number]; + +interface FlyoutChildStoryArgs extends EuiFlyoutChildActualProps { + mainSize?: 's' | 'm'; + childSize?: 's' | 'm'; + childBackgroundStyle?: 'default' | 'shaded'; + childMaxWidth?: number; + mainFlyoutType: EuiFlyoutType; + mainMaxWidth?: number; + outsideClickCloses?: boolean; + paddingSize?: 'none' | 's' | 'm' | 'l'; + pushMinBreakpoint: EuiBreakpointSize; + showFooter?: boolean; +} + +const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; + +const playgroundActions = actions('log'); + +const meta: Meta = { + title: 'Layout/EuiFlyout/Flyout Manager', + component: EuiFlyoutChild, + argTypes: { + childSize: { + options: ['s', 'm'], + control: { type: 'radio' }, + description: + 'The size of the child flyout. If the main is `s`, the child can be `s`, or `m`. If the main is `m`, the child can only be `s`.', + }, + childBackgroundStyle: { + options: ['default', 'shaded'], + control: { type: 'radio' }, + description: 'The background style of the child flyout.', + }, + childMaxWidth: { + control: { type: 'number' }, + description: 'The maximum width of the child flyout.', + }, + mainSize: { + options: ['s', 'm'], + control: { type: 'radio' }, + description: + 'The size of the main (parent) flyout. If `m`, the child must be `s`. If `s`, the child can be `s`, or `m`.', + }, + mainFlyoutType: { + options: FLYOUT_TYPES, + control: { type: 'radio' }, + description: 'The type of the main flyout..', + }, + mainMaxWidth: { + control: { type: 'number' }, + description: 'The maximum width of the main flyout.', + }, + pushMinBreakpoint: { + options: breakpointSizes, + control: { type: 'select' }, + description: + 'Breakpoint at which the main flyout (if `type="push"`) will convert to an overlay flyout. Defaults to `xs`.', + }, + showFooter: { + control: { type: 'boolean' }, + description: + 'Whether to show the flyout footer. If `false`, an `EuiFlyoutFooter` will not be rendered.', + }, + + // 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 + ['aria-label']: { table: { disable: true } }, + as: { table: { disable: true } }, + children: { table: { disable: true } }, + closeButtonProps: { table: { disable: true } }, + focusTrapProps: { table: { disable: true } }, + includeFixedHeadersInFocusTrap: { table: { disable: true } }, + includeSelectorInFocusTrap: { table: { disable: true } }, + maskProps: { table: { disable: true } }, + onClose: { table: { disable: true } }, + style: { table: { disable: true } }, + }, + args: { + mainSize: 'm', + childSize: 's', + childBackgroundStyle: 'default', + mainFlyoutType: 'push', + outsideClickCloses: false, + ownFocus: true, // Depends on `mainFlyoutType=overlay` + paddingSize: 'm', + pushAnimation: true, + pushMinBreakpoint: 'xs', + showFooter: true, + }, + parameters: { + loki: { + chromeSelector: LOKI_SELECTORS.portal, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * 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, + childSize, + childBackgroundStyle, + mainFlyoutType, + pushMinBreakpoint, + mainMaxWidth, + childMaxWidth, + showFooter, + ...args +}) => { + const [isMainOpen, setIsMainOpen] = useState(true); + + /* TODO: Allow child to be open automatically on initial render. Currently, + * this is not supported due to the child not having a reference to the + * session context */ + const [isChildOpen, setIsChildOpen] = useState(false); + + const openMain = () => { + setIsMainOpen(true); + playgroundActions.log('Parent flyout opened'); + }; + const closeMain = () => { + setIsMainOpen(false); + setIsChildOpen(false); + playgroundActions.log('Parent flyout closed'); + }; + const openChild = () => { + setIsChildOpen(true); + playgroundActions.log('Child flyout opened'); + }; + const closeChild = () => { + setIsChildOpen(false); + playgroundActions.log('Child flyout closed'); + }; + + const layoutMode = useFlyoutLayoutMode(); + + return ( + <> + +

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

+

+ Current layout mode: {layoutMode} +

+

+ 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. +

+
+ + {isMainOpen ? ( + Close Main Flyout + ) : ( + Open Main Flyout + )} + + {isMainOpen && ( + + + + +

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 + )} + {isChildOpen && ( + + + + +

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

+
+
+ )} + {/* Footer is optional */} +
+ )} +
+ {showFooter && ( + + +

Main flyout footer

+
+
+ )} +
+ )} + + ); +}; + +export const FlyoutChildDemo: Story = { + name: 'Playground', + render: (args) => , +}; diff --git a/packages/eui/src/components/flyout/manager/flyout_child.styles.ts b/packages/eui/src/components/flyout/manager/flyout_child.styles.ts new file mode 100644 index 00000000000..71a08c3cd2c --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_child.styles.ts @@ -0,0 +1,37 @@ +/* + * 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'; + +/** + * Emotion styles for child flyouts. + * Provides background variants coordinated with the EUI theme. + * + * Returns an object with the following CSS snippets: + * - `backgroundDefault`: plain background + * - `backgroundShaded`: subdued background used to de-emphasize child content + */ +export const euiChildFlyoutStyles = (euiThemeContext: UseEuiTheme) => { + const { + euiTheme: { + colors: { backgroundBasePlain, backgroundBaseSubdued }, + }, + } = euiThemeContext; + + return { + backgroundDefault: css` + /* Default background for flyouts */ + background: ${backgroundBasePlain}; + `, + backgroundShaded: css` + /* Shaded background for child flyouts */ + background: ${backgroundBaseSubdued}; + `, + }; +}; diff --git a/packages/eui/src/components/flyout/manager/flyout_child.test.tsx b/packages/eui/src/components/flyout/manager/flyout_child.test.tsx new file mode 100644 index 00000000000..57e93ad4f5f --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_child.test.tsx @@ -0,0 +1,158 @@ +/* + * 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, screen } from '../../../test/rtl'; +import { EuiProvider } from '../../provider'; +import { EuiFlyoutChild } from './flyout_child'; +import { EuiFlyout } from '../flyout'; + +// Error boundary to catch validation errors +class ErrorBoundary extends React.Component< + { children: React.ReactNode; onError?: (error: Error) => void }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { + children: React.ReactNode; + onError?: (error: Error) => void; + }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, _errorInfo: React.ErrorInfo) { + if (this.props.onError) { + this.props.onError(error); + } + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong.

+
+ {this.state.error?.toString()} +
+
+ ); + } + + return this.props.children; + } +} + +let consoleErrorSpy: jest.SpyInstance; + +beforeAll(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterAll(() => { + consoleErrorSpy.mockRestore(); +}); + +describe('EuiFlyoutChild', () => { + let originalNodeEnv: string | undefined; + + beforeAll(() => { + originalNodeEnv = process.env.NODE_ENV; + }); + + afterAll(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + describe('Main flyout parent validation', () => { + it('should throw an error in development when rendered without an EuiFlyoutMain parent', () => { + process.env.NODE_ENV = 'development'; + + let caughtError: Error | null = null; + + render( + { + caughtError = error; + }} + > + + {}}> + Orphan child content + + + + ); + + expect(caughtError).toBeTruthy(); + expect(caughtError!.message).toContain( + 'EuiFlyoutChild must be used with an EuiFlyoutMain' + ); + expect(screen.getByTestId('error-boundary')).toBeInTheDocument(); + }); + + it('should log error and return null in production when rendered without an EuiFlyoutMain parent', () => { + process.env.NODE_ENV = 'production'; + + const { container } = render( + + {}}> + Orphan child content + + + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'EuiFlyoutChild validation failed:', + 'EuiFlyoutChild must be used with an EuiFlyoutMain. ' + + 'This usually means the main flyout was not rendered before the child flyout.' + ); + expect(container.firstChild).toBeNull(); + }); + + it('should render normally when used with an EuiFlyoutMain parent', () => { + const TestComponent = () => { + const [isMainOpen] = React.useState(true); + const [isChildOpen, setIsChildOpen] = React.useState(true); + + return ( + + {isMainOpen && ( + {}} + > + Main flyout content + {isChildOpen && ( + setIsChildOpen(false)} + > +
+ Valid child content +
+
+ )} +
+ )} +
+ ); + }; + + render(); + + expect(screen.getByTestId('child-flyout-content')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/flyout_child.tsx b/packages/eui/src/components/flyout/manager/flyout_child.tsx new file mode 100644 index 00000000000..890d32076b5 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_child.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useEuiMemoizedStyles, useEuiTheme } from '../../../services'; +import { euiChildFlyoutStyles } from './flyout_child.styles'; +import { EuiManagedFlyout, type EuiManagedFlyoutProps } from './flyout_managed'; +import { + useCurrentMainFlyout, + useFlyoutLayoutMode, + useFlyoutWidth, +} from './hooks'; +import { + LAYOUT_MODE_SIDE_BY_SIDE, + LAYOUT_MODE_STACKED, + LEVEL_CHILD, +} from './const'; +import { DEFAULT_SIDE } from '../const'; + +/** + * Props for `EuiFlyoutChild`, a managed child flyout that pairs with a main flyout. + * + * Notes: + * - `type`, `side`, and `level` are fixed by the component and thus omitted. + * - `backgroundStyle` toggles between default and shaded backgrounds. + */ +export interface EuiFlyoutChildProps + extends Omit< + EuiManagedFlyoutProps, + 'closeButtonPosition' | 'hideCloseButton' | 'type' | 'level' + > { + backgroundStyle?: 'default' | 'shaded'; +} + +/** + * Managed child flyout that renders alongside or stacked over the main flyout, + * depending on the current layout mode. Handles background styling and required + * managed flyout props. + */ +export function EuiFlyoutChild({ + css: customCss, + backgroundStyle, + side = DEFAULT_SIDE, + ...props +}: EuiFlyoutChildProps) { + const { euiTheme } = useEuiTheme(); + const styles = useEuiMemoizedStyles(euiChildFlyoutStyles); + const mainFlyout = useCurrentMainFlyout(); + const mainWidth = useFlyoutWidth(mainFlyout?.flyoutId); + const layoutMode = useFlyoutLayoutMode(); + + // Runtime validation: prevent orphan child flyouts + if (!mainFlyout) { + const errorMessage = + 'EuiFlyoutChild must be used with an EuiFlyoutMain. ' + + 'This usually means the main flyout was not rendered before the child flyout.'; + + // In development, throw an error to catch the issue early + if (process.env.NODE_ENV === 'development') { + throw new Error(errorMessage); + } + + // In production, log a warning and prevent rendering + console.error('EuiFlyoutChild validation failed:', errorMessage); + return null; + } + + let style: React.CSSProperties = {}; + if (mainWidth && layoutMode === LAYOUT_MODE_SIDE_BY_SIDE) { + style = { [side]: mainWidth }; + } else if (layoutMode === LAYOUT_MODE_STACKED) { + style = { zIndex: Number(euiTheme.levels.flyout) + 2 }; + } + + return ( + + ); +} diff --git a/packages/eui/src/components/flyout/manager/flyout_main.styles.ts b/packages/eui/src/components/flyout/manager/flyout_main.styles.ts new file mode 100644 index 00000000000..0112a23600a --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_main.styles.ts @@ -0,0 +1,38 @@ +/* + * 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'; + +/** + * Emotion styles for the main (parent) managed flyout. + * Adds subtle borders when a child flyout is present, depending on side. + * + * Returns an object with: + * - `hasChildFlyout.left` and `.right`: border styles to separate from child. + */ +export const euiMainFlyoutStyles = (euiThemeContext: UseEuiTheme) => { + const { + euiTheme: { + border: { thin }, + }, + } = euiThemeContext; + + return { + hasChildFlyout: { + left: css` + box-shadow: none; + border-inline-end: ${thin}; + `, + right: css` + box-shadow: none; + border-inline-start: ${thin}; + `, + }, + }; +}; diff --git a/packages/eui/src/components/flyout/manager/flyout_main.test.tsx b/packages/eui/src/components/flyout/manager/flyout_main.test.tsx new file mode 100644 index 00000000000..27b947abf77 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_main.test.tsx @@ -0,0 +1,67 @@ +/* + * 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 } from '../../../test/rtl'; + +import { EuiFlyoutMain } from './flyout_main'; +import { EuiFlyoutManager } from './provider'; +import { LEVEL_MAIN, PROPERTY_LEVEL } from './const'; + +// Mock managed flyout so we can observe props passed through +jest.mock('./flyout_managed', () => ({ + EuiManagedFlyout: ({ children, pushMinBreakpoint, level, ...props }: any) => ( +
+ {children} +
+ ), +})); + +// Keep layout/ID hooks deterministic +jest.mock('./hooks', () => ({ + useFlyoutManagerReducer: () => ({ + state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + }), + useFlyoutManager: () => ({ + state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + }), + useHasChildFlyout: () => false, + useFlyoutId: (id?: string) => id ?? 'generated-id', +})); + +describe('EuiFlyoutMain', () => { + const renderInProvider = (ui: React.ReactElement) => + render({ui}); + + // intentionally skipping shouldRenderCustomStyles for this wrapper + + it('renders and passes level=main to managed flyout', () => { + const { getByTestSubject } = renderInProvider( + {}} /> + ); + expect(getByTestSubject('main-flyout')).toHaveAttribute( + PROPERTY_LEVEL, + LEVEL_MAIN + ); + }); + + // CSS pass-through covered elsewhere; avoid hook re-mocking complexity here +}); diff --git a/packages/eui/src/components/flyout/manager/flyout_main.tsx b/packages/eui/src/components/flyout/manager/flyout_main.tsx new file mode 100644 index 00000000000..dfe5ab286a1 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_main.tsx @@ -0,0 +1,60 @@ +/* + * 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 { EuiManagedFlyout, type EuiManagedFlyoutProps } from './flyout_managed'; +import { useHasChildFlyout, useFlyoutId } from './hooks'; +import { euiMainFlyoutStyles } from './flyout_main.styles'; +import { useEuiMemoizedStyles } from '../../../services'; +import { + DEFAULT_PUSH_MIN_BREAKPOINT, + DEFAULT_SIDE, + DEFAULT_TYPE, +} from '../const'; +import { useIsPushed } from '../hooks'; +import { LEVEL_MAIN } from './const'; + +/** + * Props for `EuiFlyoutMain`, the primary managed flyout component. + * The `level` prop is fixed internally to `main` and is therefore omitted. + */ +export type EuiFlyoutMainProps = Omit; + +/** + * Managed main flyout. Handles ID management, child-flyout styling, + * and push/overlay behavior based on provided props. + */ +export function EuiFlyoutMain({ + id, + pushMinBreakpoint = DEFAULT_PUSH_MIN_BREAKPOINT, + type = DEFAULT_TYPE, + side = DEFAULT_SIDE, + ...props +}: EuiFlyoutMainProps) { + const flyoutId = useFlyoutId(id); + const hasChildFlyout = useHasChildFlyout(flyoutId); + const styles = useEuiMemoizedStyles(euiMainFlyoutStyles); + const isPushed = useIsPushed({ type, pushMinBreakpoint }); + + const cssStyles = [ + hasChildFlyout && !isPushed && styles.hasChildFlyout[side], + ]; + + const style: React.CSSProperties = {}; + + return ( + + ); +} diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts new file mode 100644 index 00000000000..ecacc6de0af --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts @@ -0,0 +1,142 @@ +/* + * 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, keyframes } from '@emotion/react'; +import { euiCanAnimate, logicalCSS } from '../../../global_styling'; +import { UseEuiTheme } from '../../../services'; +import { + STAGE_ACTIVE, + STAGE_BACKGROUNDED, + STAGE_BACKGROUNDING, + STAGE_CLOSING, + STAGE_INACTIVE, + STAGE_OPENING, + STAGE_RETURNING, +} from './const'; +import { euiFlyoutSlideInLeft, euiFlyoutSlideInRight } from '../flyout.styles'; +import { _EuiFlyoutSide, DEFAULT_SIDE } from '../const'; +import { EuiFlyoutActivityStage } from './types'; + +/** + * Emotion styles for managed flyouts. + * Provides base 3D context and animations tied to managed flyout stages + * via data attributes. + */ +export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + + return { + stage: ( + activeStage: EuiFlyoutActivityStage, + side: _EuiFlyoutSide = DEFAULT_SIDE + ) => { + // Animation for moving flyout backwards in 3D space (z-axis) when inactive + const euiFlyoutSlideBack3D = keyframes` + from { + transform: translateZ(0) translateX(0) scale(1); + filter: blur(0px); + opacity: 1; + } + to { + transform: translateZ(-1500px) translateX(${ + side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)' + }) scale(0.5); + filter: blur(3px); + opacity: 0.6; + } + `; + + // Animation for bringing flyout forward from 3D space when transitioning to active + const euiFlyoutSlideForward3D = keyframes` + from { + transform: translateZ(-500px) translateX(${ + side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)' + }) scale(0.85); + filter: blur(3px); + opacity: 0.6; + } + to { + transform: translateZ(0) translateX(0) scale(1); + filter: blur(0px); + opacity: 1; + } + `; + // When flyout is becoming inactive, animate backwards in 3D space + const inactiveTransition = css` + ${euiCanAnimate} { + animation: ${euiFlyoutSlideBack3D} ${euiTheme.animation.extraSlow} + ${euiTheme.animation.resistance} forwards; + pointer-events: none; + } + `; + + // When flyout is becoming active from a backgrounded state, animate forward in 3D space + const returningTransition = css` + ${euiCanAnimate} { + animation: ${euiFlyoutSlideForward3D} ${euiTheme.animation.normal} + ${euiTheme.animation.resistance} forwards; + } + `; + + const openingTransition = css` + ${euiCanAnimate} { + animation: ${side === 'left' + ? euiFlyoutSlideInLeft + : euiFlyoutSlideInRight} + ${euiTheme.animation.normal} ${euiTheme.animation.resistance} + forwards; + } + `; + + const noTransition = css` + ${euiCanAnimate} { + animation: none; + opacity: 1; + } + `; + + const activeFlyout = css` + z-index: ${parseInt(euiTheme.levels.flyout as string) + 1}; + pointer-events: auto; + `; + + const inactiveFlyout = css` + ${side === 'left' + ? logicalCSS('right', '100vw') + : logicalCSS('left', '100vw')} + transform: translateX(${side === 'left' + ? 'calc(-100vw - 100%)' + : 'calc(100vw + 100%)'}); + `; + + switch (activeStage) { + case STAGE_OPENING: + return [activeFlyout, openingTransition]; + + case STAGE_ACTIVE: + return [activeFlyout, noTransition]; + + case STAGE_CLOSING: + case STAGE_BACKGROUNDING: + return [inactiveTransition]; + + case STAGE_INACTIVE: + case STAGE_BACKGROUNDED: + return [inactiveFlyout, noTransition]; + + case STAGE_RETURNING: + return [activeFlyout, returningTransition]; + } + }, + managedFlyout: css` + /* Base 3D context for all managed flyouts */ + perspective: 1000px; + transform-style: preserve-3d; + `, + }; +}; diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx new file mode 100644 index 00000000000..3f895590a34 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_managed.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ + +import React from 'react'; +import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../../../test/rtl'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiManagedFlyout } from './flyout_managed'; +import { EuiFlyoutManager } from './provider'; +import { + LEVEL_CHILD, + LEVEL_MAIN, + PROPERTY_FLYOUT, + PROPERTY_LEVEL, +} from './const'; + +// Mock base flyout to a simple div to avoid complex internals +jest.mock('../flyout.component', () => { + const React = require('react'); + return { + EuiFlyoutComponent: React.forwardRef(function MockFlyout( + props: any, + ref: any + ) { + return React.createElement('div', { + ref, + ...props, + 'data-test-subj': 'managed-flyout', + onClick: () => props.onClose && props.onClose({} as any), + }); + }), + }; +}); + +// Mock hooks that would otherwise depend on ResizeObserver or animation timing +jest.mock('./hooks', () => ({ + useFlyoutManagerReducer: () => ({ + state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + }), + useFlyoutManager: () => ({ + state: { sessions: [], flyouts: [], layoutMode: 'side-by-side' }, + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), + }), + useIsFlyoutActive: () => true, + useHasChildFlyout: () => false, + useParentFlyoutSize: () => 'm', + useFlyoutLayoutMode: () => 'side-by-side', + useFlyoutId: (id?: string) => id ?? 'generated-id', +})); + +// Mock validation helpers to be deterministic +jest.mock('./validation', () => ({ + validateManagedFlyoutSize: () => undefined, + validateSizeCombination: () => undefined, + createValidationErrorMessage: (e: any) => String(e), + isNamedSize: () => true, +})); + +// Mock resize observer hook to return a fixed width +jest.mock('../../observer/resize_observer', () => ({ + useResizeObserver: () => ({ width: 480 }), +})); + +describe('EuiManagedFlyout', () => { + const renderInProvider = (ui: React.ReactElement) => + render({ui}); + + it('renders and sets managed data attributes', () => { + const { getByTestSubject } = renderInProvider( + {}} + /> + ); + + const el = getByTestSubject('managed-flyout'); + expect(el).toHaveAttribute(PROPERTY_FLYOUT); + expect(el).toHaveAttribute(PROPERTY_LEVEL, LEVEL_MAIN); + }); + + it('calls onClose prop when onClose is invoked', () => { + const onClose = jest.fn(); + + const { getByTestSubject } = renderInProvider( + + ); + + act(() => { + userEvent.click(getByTestSubject('managed-flyout')); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('registers child flyout and sets data-level child', () => { + const { getByTestSubject } = renderInProvider( + {}} /> + ); + + expect(getByTestSubject('managed-flyout')).toHaveAttribute( + PROPERTY_LEVEL, + LEVEL_CHILD + ); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx new file mode 100644 index 00000000000..71a1eba2e32 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx @@ -0,0 +1,162 @@ +/* + * 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, { useEffect, useRef } from 'react'; +import { + EuiFlyoutComponent, + EuiFlyoutComponentProps, +} from '../flyout.component'; +import { + useFlyoutManager as _useFlyoutManager, + useIsFlyoutActive, + useParentFlyoutSize, + useFlyoutLayoutMode, + useFlyoutId, +} from './hooks'; +import { useEuiMemoizedStyles } from '../../../services'; +import { useResizeObserver } from '../../observer/resize_observer'; +import { euiManagedFlyoutStyles } from './flyout_managed.styles'; +import { EuiFlyoutMenuContext } from '../flyout_menu_context'; +import { + validateManagedFlyoutSize, + validateSizeCombination, + createValidationErrorMessage, + isNamedSize, +} from './validation'; +import { + LEVEL_CHILD, + PROPERTY_FLYOUT, + PROPERTY_LAYOUT_MODE, + PROPERTY_LEVEL, +} from './const'; +import { EuiFlyoutIsManagedProvider } from './context'; +import { EuiFlyoutLevel } from './types'; +import { useFlyoutActivityStage } from './activity_stage'; + +/** + * Props for `EuiManagedFlyout`, the internal persistent flyout used by + * the manager. Extends base flyout props and requires a `level` to + * distinguish `main` vs `child` behavior. + */ +export interface EuiManagedFlyoutProps extends EuiFlyoutComponentProps { + level: EuiFlyoutLevel; +} + +const useFlyoutManager = () => { + const context = _useFlyoutManager(); + if (!context) { + throw new Error('EuiManagedFlyout must be used within an EuiFlyoutManager'); + } + return context; +}; + +/** + * Persistent managed flyout rendered inside the provider. Handles: + * - registration/unregistration with the manager + * - size validation and parent/child size compatibility + * - width tracking for responsive layouts + * - lifecycle stage transitions and data attributes for styling + */ +export const EuiManagedFlyout = ({ + id, + onClose: onCloseProp, + level, + size, + css: customCss, + ...props +}: EuiManagedFlyoutProps) => { + const flyoutId = useFlyoutId(id); + const flyoutRef = useRef(null); + + const { addFlyout, closeFlyout, setFlyoutWidth } = useFlyoutManager(); + + const isActive = useIsFlyoutActive(flyoutId); + const parentSize = useParentFlyoutSize(flyoutId); + + // Get layout mode for responsive behavior + const layoutMode = useFlyoutLayoutMode(); + + const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles); + + // Validate size and add flyout + useEffect(() => { + // Validate that managed flyouts use named sizes (s, m, l) + const sizeTypeError = validateManagedFlyoutSize(size, flyoutId, level); + + if (sizeTypeError) { + throw new Error(createValidationErrorMessage(sizeTypeError)); + } + + // For child flyouts, validate parent-child combinations + if ( + level === LEVEL_CHILD && + parentSize && + isNamedSize(size) && + isNamedSize(parentSize) + ) { + const combinationError = validateSizeCombination(parentSize, size); + if (combinationError) { + combinationError.flyoutId = flyoutId; + combinationError.level = level; + throw new Error(createValidationErrorMessage(combinationError)); + } + } + + addFlyout(flyoutId, level, size as string); + return () => { + closeFlyout(flyoutId); + }; + }, [size, flyoutId, level, parentSize, addFlyout, closeFlyout]); + + // Track width changes for flyouts + const { width } = useResizeObserver( + isActive ? flyoutRef.current : null, + 'width' + ); + + const onClose = (event: MouseEvent | TouchEvent | KeyboardEvent) => { + onCloseProp(event); + closeFlyout(flyoutId); + }; + + // Update width in manager state when it changes + useEffect(() => { + if (isActive && width) { + setFlyoutWidth(flyoutId, width); + } + }, [flyoutId, level, isActive, width, setFlyoutWidth]); + + const { activityStage, onAnimationEnd } = useFlyoutActivityStage({ + flyoutId, + level, + }); + + return ( + + + + + + ); +}; diff --git a/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx new file mode 100644 index 00000000000..8425210aef1 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_manager.stories.tsx @@ -0,0 +1,438 @@ +/* + * 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 { action } from '@storybook/addon-actions'; + +import { + EuiButton, + EuiCode, + EuiCodeBlock, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutMenu, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, +} from '../..'; +import { EuiFlyout, EuiFlyoutProps } from '../flyout'; +import { useFlyoutManager } from './hooks'; +import { _EuiFlyoutSide, DEFAULT_SIDE, FLYOUT_SIDES } from '../const'; + +const meta: Meta = { + title: 'Layout/EuiFlyout/Flyout Manager', + component: EuiFlyout, +}; + +export default meta; + +interface ECommerceContentProps { + itemQuantity: number; +} + +interface ShoppingCartProps + extends ECommerceContentProps, + Pick { + onQuantityChange: (delta: number) => void; +} + +const ShoppingCartFlyout = ({ + itemQuantity, + onQuantityChange, + onClose, + ownFocus, + side, +}: ShoppingCartProps) => { + const [isItemDetailsOpen, setIsItemDetailsOpen] = useState(false); + const [isReviewCartOpen, setIsReviewCartOpen] = useState(false); + + return ( + + + + +

Item: Flux Capacitor

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

Review your order

+

Item: Flux Capacitor

+

Quantity: {itemQuantity}

+
+ + {orderConfirmed ? ( + +

Order confirmed!

+
+ ) : ( + setOrderConfirmed(true)} + fill + color="accent" + > + Confirm purchase + + )} +
+ + {!orderConfirmed && ( + { + action('go back')(); + // goBack(); + }} + color="danger" + > + Go back + + )}{' '} + ) => + props.onClose(e.nativeEvent) + } + color="danger" + > + Close + + +
+ ); +}; + +interface ItemDetailsProps + extends ECommerceContentProps, + Pick {} + +const ItemDetailsFlyout = ({ + onClose, + itemQuantity, + id = 'item-details-flyout', + side = DEFAULT_SIDE, +}: ItemDetailsProps) => { + return ( + + + + +

+ Item: Flux Capacitor +

+

+ Selected quantity: {itemQuantity} +

+

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

+
+
+ + ) => + onClose(e.nativeEvent) + } + color="danger" + > + Close details + + +
+ ); +}; + +const BasicExampleComponent = ({ + side = DEFAULT_SIDE, +}: { + side?: _EuiFlyoutSide; +}) => { + const [shoppingCartOwnFocus, setShoppingCartOwnFocus] = useState(false); + const [isShoppingCartOpen, setIsShoppingCartOpen] = useState(false); + const [isReviewCartOpen, setIsReviewCartOpen] = useState(false); + const [isItemDetailsOpen, setIsItemDetailsOpen] = useState(false); + const [itemQuantity, setItemQuantity] = useState(1); + const context = useFlyoutManager(); + + return ( + <> + + + + setIsShoppingCartOpen(true)}> + Shopping cart + + setIsReviewCartOpen(true)}> + Review order + + setIsItemDetailsOpen(true)}> + Item details + + + + + + + + + +

Flyouts

+
+ + + + Shopping cart + + + This flyout always starts a new session,{' '} + {`session={true}`}. + + + Review order + + + This flyout always starts a new session,{' '} + {`session={true}`}. + + It is rendered by the button above, but also from within + the Shopping Cart flyout. + + + Item details + + + This flyout is a regular flyout. + + It is rendered by the button above, but also from within + the Shopping Cart flyout. + + If rendered from here, and{' '} + no session is active, it is rendered as a{' '} + regular flyout. + + If rendered from here, and a session{' '} + is active, it is rendered as a new{' '} + main flyout as a new session. + + If rendered from within the Shopping Cart + flyout, it will be rendered as a child{' '} + flyout. + + +
+ + +

Current State

+
+ + {JSON.stringify(context?.state, null, 2)} + +
+
+
+ + + + +

Summary

+
+ + +
+ This example demonstrates the different ways a flyout can + be rendered. + + The session prop is used to control + whether a flyout is rendered as a new session. + + The determination of whether a flyout is rendered as a{' '} + main or child flyout + is based on the presence of an active session,{' '} + and if the flyout is rendered from within a + managed flyout. + + This change means the relationship between the main and + child flyout, as well as the history of which main flyouts + have been opened, are + implicitly derived + {' '} + from the React structure. + + So from a DX perspective, no one need wonder if they + should create a MainFlyout or + ChildFlyout, or check what may already + be open... the way its structured and the{' '} + session prop handle it all. +
+
+
+ + +

Known issues

+
+ + + Animation + + If a main flyout is opened with{' '} + {`ownFocus={true}`}, the child flyout + animation is inconsistent: it flies in above the + main flyout. + + This is due to the EuiMask surrounding + the main flyout, preventing the child flyout from being + rendered below it. + + + setShoppingCartOwnFocus(e.target.checked) + } + /> + + +
+
+
+
+
+
+ {isShoppingCartOpen && ( + setIsShoppingCartOpen(false)} + onQuantityChange={(delta: number) => + setItemQuantity(itemQuantity + delta) + } + itemQuantity={itemQuantity} + ownFocus={shoppingCartOwnFocus} + side={side} + /> + )} + {isReviewCartOpen && ( + setIsReviewCartOpen(false)} + itemQuantity={itemQuantity} + side={side} + /> + )} + {isItemDetailsOpen && ( + setIsItemDetailsOpen(false)} + itemQuantity={itemQuantity} + side={side} + /> + )} + + ); +}; + +export const BasicExample: StoryObj = { + render: (args) => , + args: { + side: 'right', + }, + argTypes: { + side: { + control: 'radio', + options: FLYOUT_SIDES, + }, + }, +}; diff --git a/packages/eui/src/components/flyout/manager/hooks.test.tsx b/packages/eui/src/components/flyout/manager/hooks.test.tsx new file mode 100644 index 00000000000..ea6e0db4a39 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/hooks.test.tsx @@ -0,0 +1,367 @@ +/* + * 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 { renderHook } from '../../../test/rtl'; +import { act } from '@testing-library/react'; +import { useFlyoutManagerReducer, useFlyoutId } from './hooks'; +import { LEVEL_MAIN, LEVEL_CHILD } from './const'; + +// Mock the warnOnce service but keep other actual exports (e.g., useGeneratedHtmlId) +jest.mock('../../../services', () => { + const actual = jest.requireActual('../../../services'); + return { + ...actual, + warnOnce: jest.fn(), + }; +}); + +// Mock the useFlyout selector +jest.mock('./selectors', () => ({ + useFlyout: jest.fn(), + useIsFlyoutRegistered: jest.fn(), +})); + +describe('flyout manager hooks', () => { + const { + useFlyout: mockUseFlyout, + useIsFlyoutRegistered: mockUseIsFlyoutRegistered, + } = jest.requireMock('./selectors'); + + beforeEach(() => { + mockUseFlyout.mockClear(); + mockUseIsFlyoutRegistered.mockClear(); + }); + + describe('useFlyoutManagerReducer', () => { + it('should return initial state and bound action creators', () => { + const { result } = renderHook(() => useFlyoutManagerReducer()); + + expect(result.current.state).toEqual({ + sessions: [], + flyouts: [], + layoutMode: 'side-by-side', + }); + expect(typeof result.current.dispatch).toBe('function'); + expect(typeof result.current.addFlyout).toBe('function'); + expect(typeof result.current.closeFlyout).toBe('function'); + expect(typeof result.current.setActiveFlyout).toBe('function'); + expect(typeof result.current.setFlyoutWidth).toBe('function'); + }); + + it('should accept custom initial state', () => { + const customInitialState = { + sessions: [], + flyouts: [], + layoutMode: 'stacked' as const, + }; + + const { result } = renderHook(() => + useFlyoutManagerReducer(customInitialState) + ); + + expect(result.current.state.layoutMode).toBe('stacked'); + }); + + it('should dispatch actions correctly', () => { + const { result } = renderHook(() => useFlyoutManagerReducer()); + + act(() => { + result.current.addFlyout('main-1', LEVEL_MAIN, 'l'); + }); + + expect(result.current.state.flyouts).toHaveLength(1); + expect(result.current.state.flyouts[0]).toEqual({ + flyoutId: 'main-1', + level: LEVEL_MAIN, + size: 'l', + activityStage: 'opening', + }); + expect(result.current.state.sessions).toHaveLength(1); + }); + + it('should handle multiple actions in sequence', () => { + const { result } = renderHook(() => useFlyoutManagerReducer()); + + act(() => { + result.current.addFlyout('main-1', LEVEL_MAIN); + result.current.addFlyout('child-1', LEVEL_CHILD); + result.current.setActiveFlyout('child-1'); + result.current.setFlyoutWidth('main-1', 600); + result.current.setFlyoutWidth('child-1', 400); + }); + + expect(result.current.state.flyouts).toHaveLength(2); + expect(result.current.state.sessions[0].child).toBe('child-1'); + expect(result.current.state.flyouts[0].width).toBe(600); + expect(result.current.state.flyouts[1].width).toBe(400); + }); + + it('should maintain action creator stability across renders', () => { + const { result, rerender } = renderHook(() => useFlyoutManagerReducer()); + + const initialAddFlyout = result.current.addFlyout; + const initialCloseFlyout = result.current.closeFlyout; + + rerender(); + + expect(result.current.addFlyout).toBe(initialAddFlyout); + expect(result.current.closeFlyout).toBe(initialCloseFlyout); + }); + + it('should handle complex state transitions', () => { + const { result } = renderHook(() => useFlyoutManagerReducer()); + + // Create a complex scenario + act(() => { + // Add main flyout + result.current.addFlyout('main-1', LEVEL_MAIN, 'l'); + // Add child flyout + result.current.addFlyout('child-1', LEVEL_CHILD, 'm'); + // Set child as active + result.current.setActiveFlyout('child-1'); + // Update widths + result.current.setFlyoutWidth('main-1', 600); + result.current.setFlyoutWidth('child-1', 400); + // Close child flyout + result.current.closeFlyout('child-1'); + // Close main flyout + result.current.closeFlyout('main-1'); + }); + + expect(result.current.state.flyouts).toHaveLength(0); + expect(result.current.state.sessions).toHaveLength(0); + }); + }); + + describe('useFlyoutId', () => { + it('should return provided flyout ID when it is not registered', () => { + mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available + const { result } = renderHook(() => useFlyoutId('existing-id')); + + expect(mockUseIsFlyoutRegistered).toHaveBeenCalledWith('existing-id'); + expect(result.current).toBe('existing-id'); + }); + + it('should generate deterministic ID when no ID is provided', () => { + const { result } = renderHook(() => useFlyoutId()); + + expect(result.current).toMatch(/^flyout-/); + expect(typeof result.current).toBe('string'); + }); + + it('should generate deterministic ID when provided ID is empty', () => { + const { result } = renderHook(() => useFlyoutId('')); + + expect(result.current).toMatch(/^flyout-/); + }); + + it('should generate deterministic ID when provided ID is undefined', () => { + const { result } = renderHook(() => useFlyoutId(undefined)); + + expect(result.current).toMatch(/^flyout-/); + }); + + it('should maintain ID consistency across renders', () => { + mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available + const { result, rerender } = renderHook(() => useFlyoutId('stable-id')); + + const initialId = result.current; + rerender(); + rerender(); + + expect(result.current).toBe(initialId); + }); + + it('should handle different IDs for different components', () => { + mockUseIsFlyoutRegistered.mockReturnValue(false); // IDs are available + const { result: result1 } = renderHook(() => useFlyoutId('id-1')); + const { result: result2 } = renderHook(() => useFlyoutId('id-2')); + + expect(result1.current).toBe('id-1'); + expect(result2.current).toBe('id-2'); + }); + + it('should handle generated IDs for different components', () => { + const { result: result1 } = renderHook(() => useFlyoutId()); + const { result: result2 } = renderHook(() => useFlyoutId()); + + expect(result1.current).not.toBe(result2.current); + expect(result1.current).toMatch(/^flyout-/); + expect(result2.current).toMatch(/^flyout-/); + }); + + it('should handle ID conflicts gracefully', () => { + // Mock that the ID is already registered (conflict) + mockUseIsFlyoutRegistered.mockReturnValue(true); + + const { result } = renderHook(() => useFlyoutId('conflict-id')); + + expect(result.current).toMatch(/^flyout-/); + expect(result.current).not.toBe('conflict-id'); + }); + + it('should handle multiple ID conflicts', () => { + // Mock multiple conflicts + mockUseIsFlyoutRegistered.mockReturnValue(true); + + const { result } = renderHook(() => useFlyoutId('conflict-1')); + + expect(result.current).toMatch(/^flyout-/); + expect(result.current).not.toBe('conflict-1'); + }); + + it('should handle special characters in provided IDs', () => { + mockUseIsFlyoutRegistered.mockReturnValue(false); // IDs are available + const specialIds = [ + 'flyout-1', + 'flyout_2', + 'flyout.3', + 'flyout-4', + 'FLYOUT-5', + 'Flyout-6', + ]; + + specialIds.forEach((id) => { + const { result } = renderHook(() => useFlyoutId(id)); + expect(result.current).toBe(id); + }); + }); + + it('should handle very long IDs', () => { + mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available + const longId = 'a'.repeat(1000); + const { result } = renderHook(() => useFlyoutId(longId)); + + expect(result.current).toBe(longId); + }); + + it('should handle empty string IDs', () => { + const { result } = renderHook(() => useFlyoutId('')); + + expect(result.current).toMatch(/^flyout-/); + }); + + it('should handle null IDs', () => { + const { result } = renderHook(() => useFlyoutId(null as any)); + + expect(result.current).toMatch(/^flyout-/); + }); + + it('should maintain ID stability when input changes', () => { + // First call with no ID - generates one + const { result, rerender } = renderHook(() => useFlyoutId()); + const firstId = result.current; + + // Re-render with same input (no ID) + rerender(); + expect(result.current).toBe(firstId); + + // Re-render with different input (still no ID) + rerender(); + expect(result.current).toBe(firstId); + }); + + it('should not change ID when provided ID changes', () => { + const { result, rerender } = renderHook(({ id }) => useFlyoutId(id), { + initialProps: { id: undefined as string | undefined }, + }); + + const generatedId = result.current; + expect(generatedId).toMatch(/^flyout-/); + + // Change to provided ID + mockUseIsFlyoutRegistered.mockReturnValue(false); + rerender({ id: 'provided-id' }); + + expect(result.current).toBe(generatedId); + expect(result.current).not.toBe('provided-id'); + }); + }); + + describe('hook integration', () => { + it('should work together with reducer', () => { + mockUseIsFlyoutRegistered.mockReturnValue(false); // ID is available + const { result: reducerResult } = renderHook(() => + useFlyoutManagerReducer() + ); + const { result: idResult } = renderHook(() => useFlyoutId('test-id')); + + act(() => { + reducerResult.current.addFlyout(idResult.current, LEVEL_MAIN); + }); + + expect(reducerResult.current.state.flyouts).toHaveLength(1); + expect(reducerResult.current.state.flyouts[0].flyoutId).toBe('test-id'); + }); + + it('should handle multiple flyouts with generated IDs', () => { + const { result: reducerResult } = renderHook(() => + useFlyoutManagerReducer() + ); + const { result: idResult1 } = renderHook(() => useFlyoutId()); + const { result: idResult2 } = renderHook(() => useFlyoutId()); + + act(() => { + reducerResult.current.addFlyout(idResult1.current, LEVEL_MAIN); + reducerResult.current.addFlyout(idResult2.current, LEVEL_CHILD); + }); + + expect(reducerResult.current.state.flyouts).toHaveLength(2); + expect(reducerResult.current.state.sessions).toHaveLength(1); + expect(reducerResult.current.state.sessions[0].child).toBe( + idResult2.current + ); + }); + }); + + describe('edge cases', () => { + it('should handle rapid state changes', () => { + const { result } = renderHook(() => useFlyoutManagerReducer()); + + act(() => { + // Rapidly add and remove flyouts + for (let i = 0; i < 10; i++) { + result.current.addFlyout(`flyout-${i}`, LEVEL_MAIN); + result.current.closeFlyout(`flyout-${i}`); + } + }); + + expect(result.current.state.flyouts).toHaveLength(0); + expect(result.current.state.sessions).toHaveLength(0); + }); + + it('should handle concurrent ID generation', () => { + const results = []; + for (let i = 0; i < 5; i++) { + const { result } = renderHook(() => useFlyoutId()); + results.push(result.current); + } + + // All IDs should be unique + const uniqueIds = new Set(results); + expect(uniqueIds.size).toBe(5); + + // All IDs should follow the pattern + results.forEach((id) => { + expect(id).toMatch(/^flyout-/); + }); + }); + + it('should handle undefined initial state gracefully', () => { + const { result } = renderHook(() => + useFlyoutManagerReducer(undefined as any) + ); + + expect(result.current.state).toEqual({ + sessions: [], + flyouts: [], + layoutMode: 'side-by-side', + }); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/hooks.ts b/packages/eui/src/components/flyout/manager/hooks.ts new file mode 100644 index 00000000000..591f425c72e --- /dev/null +++ b/packages/eui/src/components/flyout/manager/hooks.ts @@ -0,0 +1,122 @@ +/* + * 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 { useCallback, useContext, useReducer, useRef } from 'react'; + +import { warnOnce, useGeneratedHtmlId } from '../../../services'; + +import { flyoutManagerReducer, initialState } from './reducer'; +import { + addFlyout as addFlyoutAction, + closeFlyout as closeFlyoutAction, + setActiveFlyout as setActiveFlyoutAction, + setFlyoutWidth as setFlyoutWidthAction, +} from './actions'; +import { + type EuiFlyoutLevel, + type EuiFlyoutManagerState, + type FlyoutManagerApi, +} from './types'; +import { EuiFlyoutManagerContext } from './provider'; +import { LEVEL_MAIN } from './const'; +import { useIsFlyoutRegistered } from './selectors'; + +// Ensure uniqueness across multiple hook instances, including in test envs +let autoGeneratedFlyoutIdCounter = 0; + +export { + useIsFlyoutActive, + useHasActiveSession, + useCurrentSession, + useCurrentMainFlyout, + useCurrentChildFlyout, + useFlyoutWidth, + useParentFlyoutSize, + useHasChildFlyout, +} from './selectors'; + +export { useFlyoutLayoutMode } from './layout_mode'; + +export { useIsInManagedFlyout } from './context'; + +// Convenience selector for a flyout's activity stage +export type { EuiFlyoutActivityStage } from './types'; + +/** + * Hook that provides the flyout manager reducer and bound action creators. + * Accepts an optional initial state (mainly for tests or custom setups). + */ +export function useFlyoutManagerReducer( + initial: EuiFlyoutManagerState = initialState +): FlyoutManagerApi { + const [state, dispatch] = useReducer(flyoutManagerReducer, initial); + + const addFlyout = useCallback( + (flyoutId: string, level: EuiFlyoutLevel = LEVEL_MAIN, size?: string) => + dispatch(addFlyoutAction(flyoutId, level, size)), + [] + ); + const closeFlyout = useCallback( + (flyoutId: string) => dispatch(closeFlyoutAction(flyoutId)), + [] + ); + const setActiveFlyout = useCallback( + (flyoutId: string | null) => dispatch(setActiveFlyoutAction(flyoutId)), + [] + ); + const setFlyoutWidth = useCallback( + (flyoutId: string, width: number) => + dispatch(setFlyoutWidthAction(flyoutId, width)), + [] + ); + + return { + state, + dispatch, + addFlyout, + closeFlyout, + setActiveFlyout, + setFlyoutWidth, + }; +} + +/** Access the flyout manager context (state and actions). */ +export const useFlyoutManager = () => useContext(EuiFlyoutManagerContext); + +/** + * Stable flyout ID utility. Uses the passed `id` if provided and not already registered, + * otherwise generates a deterministic ID for the component's lifetime. + * The ID remains stable across re-renders to maintain consistency in effects and other hooks. + */ +export const useFlyoutId = (flyoutId?: string) => { + const defaultId = useGeneratedHtmlId({ prefix: 'flyout-' }); + const isRegistered = useIsFlyoutRegistered(flyoutId); + + // Use ref to maintain ID stability across re-renders + const componentIdRef = useRef(undefined); + + if (!componentIdRef.current) { + // Determine the ID to use + if (!flyoutId) { + // No ID provided, generate a new one + componentIdRef.current = `${defaultId}-${++autoGeneratedFlyoutIdCounter}`; + } else if (isRegistered) { + // ID is provided but already registered, generate a new one + warnOnce( + `flyout-id-${flyoutId}`, + `Flyout with ID ${flyoutId} already registered; using new ID ${defaultId}` + ); + componentIdRef.current = `${defaultId}-${++autoGeneratedFlyoutIdCounter}`; + } else { + // ID is provided and not registered, use it + componentIdRef.current = flyoutId; + } + } + + return componentIdRef.current; +}; diff --git a/packages/eui/src/components/flyout/manager/index.ts b/packages/eui/src/components/flyout/manager/index.ts new file mode 100644 index 00000000000..d8ea716ee56 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/index.ts @@ -0,0 +1,48 @@ +/* + * 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. + */ + +/** + * Convenience re-exports of bound action creators for external usage. + */ +export { + addFlyout as addFlyoutAction, + closeFlyout as closeFlyoutAction, + setActiveFlyout as setActiveFlyoutAction, + setFlyoutWidth as setFlyoutWidthAction, + setActivityStage as setActivityStageAction, +} from './actions'; + +/** Reducer and default state for the flyout manager. */ +export { flyoutManagerReducer, initialState } from './reducer'; + +/** Provider component exposing the Flyout Manager API via context. */ +export { EuiFlyoutManager } from './provider'; + +/** + * Hooks for reading manager state and derived information. + */ +/** + * Selectors and derived state hooks for managed flyouts. + */ +export { + useCurrentChildFlyout, + useCurrentMainFlyout, + useCurrentSession, + useFlyoutId, + useFlyoutLayoutMode, + useFlyoutManager, + useFlyoutWidth, + useHasChildFlyout, + useIsFlyoutActive, + useIsInManagedFlyout, + useHasActiveSession, + useParentFlyoutSize, +} from './hooks'; + +export { EuiFlyoutChild, type EuiFlyoutChildProps } from './flyout_child'; +export { EuiFlyoutMain, type EuiFlyoutMainProps } from './flyout_main'; diff --git a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx new file mode 100644 index 00000000000..f79edfc9ac3 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx @@ -0,0 +1,612 @@ +/* + * 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 { renderHook } from '../../../test/rtl'; +import { render, act } from '@testing-library/react'; +import { useEuiTheme } from '../../../services'; +import { setLayoutMode } from './actions'; +import { + useCurrentChildFlyout, + useCurrentMainFlyout, + useCurrentSession, + useFlyoutWidth, +} from './selectors'; +import { useFlyoutManager } from './hooks'; +import { LAYOUT_MODE_SIDE_BY_SIDE, LAYOUT_MODE_STACKED } from './const'; +import { + useApplyFlyoutLayoutMode, + getWidthFromSize, + useFlyoutLayoutMode, +} from './layout_mode'; + +// Mock dependencies +jest.mock('../../../services', () => ({ + useEuiTheme: jest.fn(), +})); + +jest.mock('./actions', () => ({ + setLayoutMode: jest.fn(), +})); + +jest.mock('./selectors', () => ({ + useCurrentChildFlyout: jest.fn(), + useCurrentMainFlyout: jest.fn(), + useCurrentSession: jest.fn(), + useFlyoutWidth: jest.fn(), +})); + +jest.mock('./hooks', () => ({ + useFlyoutManager: jest.fn(), +})); + +jest.mock('./provider', () => ({ + EuiFlyoutManager: ({ children }: { children: React.ReactNode }) => children, +})); + +// Mock window methods +const mockWindow = { + innerWidth: 1200, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + requestAnimationFrame: jest.fn(() => { + return rafId++; + }), + cancelAnimationFrame: jest.fn(), +}; + +let rafId = 1; + +Object.defineProperty(window, 'innerWidth', { + writable: true, + value: mockWindow.innerWidth, +}); + +Object.defineProperty(window, 'addEventListener', { + writable: true, + value: mockWindow.addEventListener, +}); + +Object.defineProperty(window, 'removeEventListener', { + writable: true, + value: mockWindow.removeEventListener, +}); + +Object.defineProperty(window, 'requestAnimationFrame', { + writable: true, + value: mockWindow.requestAnimationFrame, +}); + +Object.defineProperty(window, 'cancelAnimationFrame', { + writable: true, + value: mockWindow.cancelAnimationFrame, +}); + +const mockUseEuiTheme = useEuiTheme as jest.Mock; +const mockSetLayoutMode = setLayoutMode as jest.Mock; +const mockUseCurrentChildFlyout = useCurrentChildFlyout as jest.Mock; +const mockUseCurrentMainFlyout = useCurrentMainFlyout as jest.Mock; +const mockUseCurrentSession = useCurrentSession as jest.Mock; +const mockUseFlyoutWidth = useFlyoutWidth as jest.Mock; +const mockUseFlyoutManager = useFlyoutManager as jest.Mock; + +describe('layout_mode', () => { + const getResizeHandler = () => { + const call = mockWindow.addEventListener.mock.calls.find( + (args) => args[0] === 'resize' + ); + return call ? call[1] : undefined; + }; + beforeEach(() => { + jest.clearAllMocks(); + rafId = 1; + + // Default mocks + mockUseEuiTheme.mockReturnValue({ + euiTheme: { + breakpoint: { s: 768 }, + }, + }); + + mockSetLayoutMode.mockReturnValue({ + type: 'ACTION_SET_LAYOUT_MODE', + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'm', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 's', + }); + + mockUseFlyoutWidth + .mockReturnValueOnce(600) // parent width + .mockReturnValueOnce(300); // child width + + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }, + dispatch: jest.fn(), + }); + }); + + describe('useFlyoutLayoutMode', () => { + it('returns layout mode from context when available', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_STACKED, + }, + }); + + const { result } = renderHook(() => useFlyoutLayoutMode()); + expect(result.current).toBe(LAYOUT_MODE_STACKED); + }); + + it('returns default LAYOUT_MODE_SIDE_BY_SIDE when context is null', () => { + mockUseFlyoutManager.mockReturnValue(null); + + const { result } = renderHook(() => useFlyoutLayoutMode()); + expect(result.current).toBe(LAYOUT_MODE_SIDE_BY_SIDE); + }); + + it('returns default LAYOUT_MODE_SIDE_BY_SIDE when state is undefined', () => { + mockUseFlyoutManager.mockReturnValue({ + state: undefined, + }); + + const { result } = renderHook(() => useFlyoutLayoutMode()); + expect(result.current).toBe(LAYOUT_MODE_SIDE_BY_SIDE); + }); + }); + + describe('getWidthFromSize', () => { + beforeEach(() => { + // Reset window.innerWidth for each test + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + }); + + it('returns number values as-is', () => { + expect(getWidthFromSize(500)).toBe(500); + expect(getWidthFromSize(0)).toBe(0); + expect(getWidthFromSize(1000)).toBe(1000); + }); + + it('parses numeric strings', () => { + expect(getWidthFromSize('500')).toBe(500); + expect(getWidthFromSize('0')).toBe(0); + expect(getWidthFromSize('1000')).toBe(1000); + }); + + it('calculates size s as 25% of viewport width', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + expect(getWidthFromSize('s')).toBe(300); // 1200 * 0.25 + }); + + it('calculates size m as 50% of viewport width', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + expect(getWidthFromSize('m')).toBe(600); // 1200 * 0.5 + }); + + it('calculates size l as 75% of viewport width', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + expect(getWidthFromSize('l')).toBe(900); // 1200 * 0.75 + }); + + it('handles different viewport widths', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 800, + }); + expect(getWidthFromSize('s')).toBe(200); // 800 * 0.25 + expect(getWidthFromSize('m')).toBe(400); // 800 * 0.5 + expect(getWidthFromSize('l')).toBe(600); // 800 * 0.75 + }); + + it('returns 0 for unknown size strings', () => { + expect(getWidthFromSize('unknown')).toBe(0); + expect(getWidthFromSize('xl')).toBe(0); + expect(getWidthFromSize('')).toBe(0); + }); + + it('returns 0 for non-numeric strings', () => { + expect(getWidthFromSize('abc')).toBe(0); + expect(getWidthFromSize('')).toBe(0); + }); + + it('parses partial numeric strings', () => { + expect(getWidthFromSize('12abc')).toBe(12); + expect(getWidthFromSize('500px')).toBe(500); + }); + + it('handles edge cases', () => { + expect(getWidthFromSize(null as any)).toBe(0); + expect(getWidthFromSize(undefined as any)).toBe(0); + expect(getWidthFromSize('')).toBe(0); + }); + }); + + describe('useApplyFlyoutLayoutMode', () => { + let TestComponent: React.FC; + + beforeEach(() => { + TestComponent = () => { + useApplyFlyoutLayoutMode(); + return
Test
; + }; + }); + + it('sets up window resize listener on mount', () => { + render(); + + expect(mockWindow.addEventListener).toHaveBeenCalledWith( + 'resize', + expect.any(Function) + ); + }); + + it('removes window resize listener on unmount', () => { + const { unmount } = render(); + unmount(); + + expect(mockWindow.removeEventListener).toHaveBeenCalledWith( + 'resize', + expect.any(Function) + ); + }); + + it('cancels animation frame on unmount if one is active', () => { + const { unmount } = render(); + + // Simulate a resize event to create an active animation frame + const resizeHandler = getResizeHandler()!; + act(() => { + resizeHandler(); + }); + + unmount(); + + // Should cancel the animation frame that was created + expect(mockWindow.cancelAnimationFrame).toHaveBeenCalledWith(1); + }); + + it('sets layout mode to STACKED when window is too small', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 500, // Below 768 * 1.4 = 1075.2 + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }, + dispatch: mockDispatch, + }); + + render(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_STACKED) + ); + }); + + it('does not change layout mode when window is small but already STACKED', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 500, + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_STACKED, + }, + dispatch: mockDispatch, + }); + + render(); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('switches to SIDE_BY_SIDE when no child flyout exists and currently in STACKED mode', () => { + // Set window width to be large enough to not trigger the small window check + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: null, + }); + + // Reset the mock to return undefined for widths since no child exists + mockUseFlyoutWidth.mockReset(); + mockUseFlyoutWidth.mockReturnValue(undefined); // This will be used for both parent and child calls + + const mockDispatch = jest.fn(); + const mockContext = { + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }; + + mockUseFlyoutManager.mockReturnValue(mockContext); + + render(); + + // The hook should switch to SIDE_BY_SIDE when no child exists and currently in STACKED mode + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('switches to SIDE_BY_SIDE when flyout widths are not available and currently in STACKED mode', () => { + // Set window width to be large enough to not trigger the small window check + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Ensure both width calls return undefined + mockUseFlyoutWidth.mockReset(); + mockUseFlyoutWidth.mockReturnValue(undefined); + + const mockDispatch = jest.fn(); + const mockContext = { + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }; + + mockUseFlyoutManager.mockReturnValue(mockContext); + + render(); + + // The hook should switch to SIDE_BY_SIDE when widths are not available and currently in STACKED mode + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('switches to SIDE_BY_SIDE when combined width is below threshold and currently in STACKED mode', () => { + // Set window width to be large enough to not trigger the small window check + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Combined width: 600 + 300 = 900px + // Percentage: (900 / 1200) * 100 = 75% + // Since 75% <= 85% threshold, should switch to SIDE_BY_SIDE + + // Reset the mock to return the expected widths + mockUseFlyoutWidth.mockReset(); + mockUseFlyoutWidth + .mockReturnValueOnce(600) // parent width + .mockReturnValueOnce(300); // child width + + const mockDispatch = jest.fn(); + const mockContext = { + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }; + + mockUseFlyoutManager.mockReturnValue(mockContext); + + render(); + + // The hook should switch to SIDE_BY_SIDE when combined width is below threshold + // and currently in STACKED mode + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('switches to STACKED when combined width exceeds 95% threshold', () => { + // Set window width to be large enough to not trigger the small window check + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Set up session with both main and child flyouts + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + // Set up flyout objects + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'm', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 's', + }); + + // Set a very wide combined width + mockUseFlyoutWidth.mockReset(); + mockUseFlyoutWidth + .mockReturnValueOnce(800) // parent width + .mockReturnValueOnce(400); // child width + + // Combined width: 800 + 400 = 1200px + // Percentage: (1200 / 1200) * 100 = 100% + // Since 100% >= 95% threshold, should switch to STACKED + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }, + dispatch: mockDispatch, + }); + + render(); + + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_STACKED) + ); + }); + + it('switches to SIDE_BY_SIDE when using size-based width calculation and currently in STACKED mode', () => { + // Set window width to be large enough to not trigger the small window check + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Ensure both width calls return undefined so the hook falls back to size-based calculation + mockUseFlyoutWidth.mockReset(); + mockUseFlyoutWidth.mockReturnValue(undefined); + + const mockDispatch = jest.fn(); + const mockContext = { + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }; + + mockUseFlyoutManager.mockReturnValue(mockContext); + + render(); + + // Should calculate based on sizes: m (50%) + s (25%) = 75% of viewport + // 75% <= 85% threshold, so should switch to SIDE_BY_SIDE + // when currently in STACKED mode + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('does not dispatch when layout mode is already correct', () => { + // Set window width to be large enough to not trigger the small window check + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Set up a scenario where the layout mode should remain the same + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: null, // No child flyout + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }, + dispatch: mockDispatch, + }); + + render(); + + // Already in SIDE_BY_SIDE and no child exists - no action should dispatch + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('handles null context gracefully', () => { + mockUseFlyoutManager.mockReturnValue(null); + + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles missing dispatch gracefully', () => { + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }, + dispatch: undefined, + }); + + expect(() => { + render(); + }).not.toThrow(); + }); + + it('debounces resize events using requestAnimationFrame', () => { + render(); + + // Simulate first resize event + const resizeHandler = getResizeHandler()!; + act(() => { + resizeHandler(); + }); + + // Should call requestAnimationFrame and get ID 1 + expect(mockWindow.requestAnimationFrame).toHaveBeenCalled(); + + // Simulate second resize event before the first one executes + act(() => { + resizeHandler(); + }); + + // Should cancel the previous animation frame (ID 1) and create a new one (ID 2) + expect(mockWindow.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(mockWindow.requestAnimationFrame).toHaveBeenCalledTimes(2); + }); + + it('updates window width state on resize', () => { + render(); + + // Simulate resize event + const resizeHandler = getResizeHandler()!; + + // Change window width + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 800, + }); + + act(() => { + resizeHandler(); + }); + + // The hook should re-evaluate with new window width + expect(mockWindow.requestAnimationFrame).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/layout_mode.ts b/packages/eui/src/components/flyout/manager/layout_mode.ts new file mode 100644 index 00000000000..df72b39e1c6 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/layout_mode.ts @@ -0,0 +1,188 @@ +/* + * 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, { useEffect, useState } from 'react'; +import { useEuiTheme } from '../../../services'; +import { setLayoutMode } from './actions'; +import { + useCurrentChildFlyout, + useCurrentMainFlyout, + useCurrentSession, + useFlyoutWidth, +} from './selectors'; +import { useFlyoutManager } from './hooks'; +import { LAYOUT_MODE_SIDE_BY_SIDE, LAYOUT_MODE_STACKED } from './const'; +import { EuiFlyoutLayoutMode } from './types'; + +/** + * Hook to handle responsive layout mode for managed flyouts. + * Decides whether to place flyouts side-by-side or stacked based on + * viewport width and flyout widths/sizes. + */ +export const useApplyFlyoutLayoutMode = () => { + const { euiTheme } = useEuiTheme(); + const context = useFlyoutManager(); + + const currentSession = useCurrentSession(); + const parentFlyoutId = currentSession?.main; + const childFlyoutId = currentSession?.child; + + const parentFlyout = useCurrentMainFlyout(); + const childFlyout = useCurrentChildFlyout(); + + const parentWidth = useFlyoutWidth(parentFlyoutId); + const childWidth = useFlyoutWidth(childFlyoutId); + + const [windowWidth, setWindowWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : Infinity + ); + + const setMode = React.useCallback( + (layoutMode: EuiFlyoutLayoutMode) => { + if (context?.dispatch && layoutMode !== context.state.layoutMode) { + context.dispatch(setLayoutMode(layoutMode)); + } + }, + [context] + ); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + let rafId = 0; + + const handleResize = () => { + if (rafId) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => setWindowWidth(window.innerWidth)); + }; + + window.addEventListener('resize', handleResize); + + return () => { + if (rafId) { + cancelAnimationFrame(rafId); + } + window.removeEventListener('resize', handleResize); + }; + }, []); + + useEffect(() => { + if (!context) { + return; + } + + const currentLayoutMode = context.state.layoutMode; + + // Thresholds to prevent thrashing near the breakpoint. + const THRESHOLD_TO_SIDE_BY_SIDE = 85; + const THRESHOLD_TO_STACKED = 95; + + // If the window is too small, set the mode to stacked. + // + // The value is based on the maximum width of a flyout in + // `composeFlyoutSizing` in `flyout.styles.ts` multiplied + // by 2 (open flyouts side-by-side). + if (windowWidth < Math.round(euiTheme.breakpoint.s * 1.4)) { + if (currentLayoutMode !== LAYOUT_MODE_STACKED) { + setMode(LAYOUT_MODE_STACKED); + } + return; + } + + if (!childFlyoutId) { + if (currentLayoutMode !== LAYOUT_MODE_SIDE_BY_SIDE) + setMode(LAYOUT_MODE_SIDE_BY_SIDE); + return; + } + + let parentWidthValue = parentWidth; + let childWidthValue = childWidth; + + if (!parentWidthValue && parentFlyout?.size) { + parentWidthValue = getWidthFromSize(parentFlyout.size); + } + + if (!childWidthValue && childFlyout?.size) { + childWidthValue = getWidthFromSize(childFlyout.size); + } + + if (!parentWidthValue || !childWidthValue) { + if (currentLayoutMode !== LAYOUT_MODE_SIDE_BY_SIDE) + setMode(LAYOUT_MODE_SIDE_BY_SIDE); + return; + } + + const combinedWidth = parentWidthValue + childWidthValue; + const combinedWidthPercentage = (combinedWidth / windowWidth) * 100; + let newLayoutMode: EuiFlyoutLayoutMode; + + if (currentLayoutMode === LAYOUT_MODE_STACKED) { + newLayoutMode = + combinedWidthPercentage <= THRESHOLD_TO_SIDE_BY_SIDE + ? LAYOUT_MODE_SIDE_BY_SIDE + : LAYOUT_MODE_STACKED; + } else { + newLayoutMode = + combinedWidthPercentage >= THRESHOLD_TO_STACKED + ? LAYOUT_MODE_STACKED + : LAYOUT_MODE_SIDE_BY_SIDE; + } + + if (currentLayoutMode !== newLayoutMode) { + setMode(newLayoutMode); + } + }, [ + windowWidth, + context, + parentWidth, + setMode, + childWidth, + childFlyoutId, + parentFlyout?.size, + childFlyout?.size, + euiTheme, + ]); +}; + +/** Convert a flyout `size` value to a pixel width using theme breakpoints. */ +export const getWidthFromSize = (size: string | number): number => { + if (typeof size === 'number') { + return size; + } + + if (typeof size === 'string') { + const parsed = parseInt(size, 10); + + if (!Number.isNaN(parsed)) { + return parsed; + } + + // Size is a function of a percentage of `vw`, defined in `composeFlyoutSizing` in `flyout.styles.ts` + switch (size) { + case 's': + return Math.round(window.innerWidth * 0.25); + case 'm': + return Math.round(window.innerWidth * 0.5); + case 'l': + return Math.round(window.innerWidth * 0.75); + default: + break; + } + } + return 0; +}; + +/** Current layout mode for managed flyouts (`side-by-side` or `stacked`). */ +export const useFlyoutLayoutMode = () => { + const context = useFlyoutManager(); + return context?.state?.layoutMode || LAYOUT_MODE_SIDE_BY_SIDE; +}; diff --git a/packages/eui/src/components/flyout/manager/provider.test.tsx b/packages/eui/src/components/flyout/manager/provider.test.tsx new file mode 100644 index 00000000000..472d6dd1e2b --- /dev/null +++ b/packages/eui/src/components/flyout/manager/provider.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 } from '../../../test/rtl'; +import { render as renderBase } from '@testing-library/react'; +import { requiredProps } from '../../../test/required_props'; +import { + EuiFlyoutManager, + EuiFlyoutManagerContext, + useFlyoutManager, +} from './provider'; + +describe('EuiFlyoutManager', () => { + it('renders', () => { + const { container } = render( + +
+ + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('provides context value to children', () => { + const TestComponent = () => { + const context = useFlyoutManager(); + return ( +
+ {context ? 'Context Available' : 'No Context'} +
+ ); + }; + + const { getByTestSubject } = render( + + + + ); + + expect(getByTestSubject('context-value')).toHaveTextContent( + 'Context Available' + ); + }); +}); + +describe('useFlyoutManager', () => { + it('returns null when used outside of provider', () => { + const TestComponent = () => { + const context = useFlyoutManager(); + return ( +
+ {context ? 'Hook Context Available' : 'No Hook Context'} +
+ ); + }; + + // Use regular render to avoid EUI provider wrapper + const { getByTestId } = renderBase(); + + expect(getByTestId('hook-test')).toHaveTextContent('No Hook Context'); + }); +}); + +describe('EuiFlyoutManagerContext', () => { + it('is a valid React context', () => { + expect(EuiFlyoutManagerContext.Provider).toBeDefined(); + expect(EuiFlyoutManagerContext.Consumer).toBeDefined(); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/provider.tsx b/packages/eui/src/components/flyout/manager/provider.tsx new file mode 100644 index 00000000000..d99583cfd56 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/provider.tsx @@ -0,0 +1,44 @@ +/* + * 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 } from 'react'; +import { useFlyoutManagerReducer } from './hooks'; +import { useApplyFlyoutLayoutMode } from './layout_mode'; +import { FlyoutManagerApi } from './types'; + +/** + * React context that exposes the Flyout Manager API (state + actions). + */ +export const EuiFlyoutManagerContext = createContext( + null +); + +/** + * Provides the Flyout Manager API via context and runs layout-mode logic. + */ +export const EuiFlyoutManager: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const api = useFlyoutManagerReducer(); + return ( + + {children} + + ); +}; + +const EuiFlyoutManagerContainer: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + useApplyFlyoutLayoutMode(); + return <>{children}; +}; + +/** Hook to access the Flyout Manager API from context. */ +export const useFlyoutManager = (): FlyoutManagerApi | null => + useContext(EuiFlyoutManagerContext); diff --git a/packages/eui/src/components/flyout/manager/reducer.test.ts b/packages/eui/src/components/flyout/manager/reducer.test.ts new file mode 100644 index 00000000000..2b9e2c9bedb --- /dev/null +++ b/packages/eui/src/components/flyout/manager/reducer.test.ts @@ -0,0 +1,403 @@ +/* + * 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. + */ + +/* + * 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 the election, the Elastic License 2.0 or the Server Side Public License, v 1. + */ + +import { flyoutManagerReducer, initialState } from './reducer'; +import { + addFlyout, + closeFlyout, + setActiveFlyout, + setFlyoutWidth, + setLayoutMode, + setActivityStage, +} from './actions'; +import { + LAYOUT_MODE_SIDE_BY_SIDE, + LAYOUT_MODE_STACKED, + LEVEL_MAIN, + LEVEL_CHILD, + STAGE_OPENING, + STAGE_ACTIVE, + STAGE_INACTIVE, + STAGE_BACKGROUNDING, + STAGE_BACKGROUNDED, + STAGE_RETURNING, + STAGE_CLOSING, +} from './const'; + +describe('flyoutManagerReducer', () => { + describe('initial state', () => { + it('should return initial state', () => { + expect(flyoutManagerReducer(undefined, {} as any)).toEqual(initialState); + }); + + it('should have correct initial values', () => { + expect(initialState).toEqual({ + sessions: [], + flyouts: [], + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }); + }); + }); + + describe('ACTION_ADD', () => { + it('should add a main flyout and create a new session', () => { + const action = addFlyout('main-1', LEVEL_MAIN, 'm'); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState.flyouts).toHaveLength(1); + expect(newState.flyouts[0]).toEqual({ + flyoutId: 'main-1', + level: LEVEL_MAIN, + size: 'm', + activityStage: STAGE_OPENING, + }); + + expect(newState.sessions).toHaveLength(1); + expect(newState.sessions[0]).toEqual({ + main: 'main-1', + child: null, + }); + }); + + it('should add a child flyout to the most recent session', () => { + // First add a main flyout + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + + // Then add a child flyout + const action = addFlyout('child-1', LEVEL_CHILD); + state = flyoutManagerReducer(state, action); + + expect(state.flyouts).toHaveLength(2); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].child).toBe('child-1'); + }); + + it('should ignore duplicate flyout IDs', () => { + const action1 = addFlyout('main-1', LEVEL_MAIN); + let state = flyoutManagerReducer(initialState, action1); + + const action2 = addFlyout('main-1', LEVEL_MAIN); + state = flyoutManagerReducer(state, action2); + + expect(state.flyouts).toHaveLength(1); + expect(state.sessions).toHaveLength(1); + }); + + it('should not add child flyout when no session exists', () => { + const action = addFlyout('child-1', LEVEL_CHILD); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + + it('should handle multiple sessions correctly', () => { + // Add first main flyout + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + + // Add child to first session + state = flyoutManagerReducer(state, addFlyout('child-1', LEVEL_CHILD)); + + // Add second main flyout (should create new session) + state = flyoutManagerReducer(state, addFlyout('main-2', LEVEL_MAIN)); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions[0]).toEqual({ main: 'main-1', child: 'child-1' }); + expect(state.sessions[1]).toEqual({ main: 'main-2', child: null }); + }); + }); + + describe('ACTION_CLOSE', () => { + it('should close a main flyout and remove its session', () => { + // Setup: add main flyout with child + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + state = flyoutManagerReducer(state, addFlyout('child-1', LEVEL_CHILD)); + + // Close main flyout + const action = closeFlyout('main-1'); + state = flyoutManagerReducer(state, action); + + // When main flyout is closed, all related flyouts should be removed + expect(state.flyouts).toHaveLength(0); + expect(state.sessions).toHaveLength(0); + }); + + it('should close a child flyout and clear child reference', () => { + // Setup: add main flyout with child + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + state = flyoutManagerReducer(state, addFlyout('child-1', LEVEL_CHILD)); + + // Close child flyout + const action = closeFlyout('child-1'); + state = flyoutManagerReducer(state, action); + + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-1'); + expect(state.sessions[0].child).toBe(null); + }); + + it('should handle closing non-existent flyout', () => { + const action = closeFlyout('non-existent'); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + + it('should handle closing child flyout when no sessions exist', () => { + const action = closeFlyout('child-1'); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + }); + + describe('ACTION_SET_ACTIVE', () => { + it('should set active child flyout for current session', () => { + // Setup: add main flyout + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + + const action = setActiveFlyout('child-1'); + state = flyoutManagerReducer(state, action); + + expect(state.sessions[0].child).toBe('child-1'); + }); + + it('should clear active child flyout when null is passed', () => { + // Setup: add main flyout with child + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + state = flyoutManagerReducer(state, addFlyout('child-1', LEVEL_CHILD)); + + const action = setActiveFlyout(null); + state = flyoutManagerReducer(state, action); + + expect(state.sessions[0].child).toBe(null); + }); + + it('should do nothing when no sessions exist', () => { + const action = setActiveFlyout('child-1'); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + }); + + describe('ACTION_SET_WIDTH', () => { + it('should update flyout width', () => { + // Setup: add flyout + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + + const action = setFlyoutWidth('main-1', 400); + state = flyoutManagerReducer(state, action); + + expect(state.flyouts[0].width).toBe(400); + }); + + it('should not affect other flyouts', () => { + // Setup: add two flyouts + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + state = flyoutManagerReducer(state, addFlyout('main-2', LEVEL_MAIN)); + + const action = setFlyoutWidth('main-1', 400); + state = flyoutManagerReducer(state, action); + + expect(state.flyouts[0].width).toBe(400); + expect(state.flyouts[1].width).toBeUndefined(); + }); + + it('should handle non-existent flyout ID gracefully', () => { + const action = setFlyoutWidth('non-existent', 400); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState).toEqual(initialState); + }); + }); + + describe('ACTION_SET_LAYOUT_MODE', () => { + it('should update layout mode to stacked', () => { + const action = setLayoutMode(LAYOUT_MODE_STACKED); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState.layoutMode).toBe(LAYOUT_MODE_STACKED); + }); + + it('should update layout mode to side-by-side', () => { + // First change to stacked + let state = flyoutManagerReducer( + initialState, + setLayoutMode(LAYOUT_MODE_STACKED) + ); + + // Then change back to side-by-side + const action = setLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE); + state = flyoutManagerReducer(state, action); + + expect(state.layoutMode).toBe(LAYOUT_MODE_SIDE_BY_SIDE); + }); + }); + + describe('ACTION_SET_ACTIVITY_STAGE', () => { + it('should update flyout activity stage', () => { + // Setup: add flyout + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + + const action = setActivityStage('main-1', STAGE_ACTIVE); + state = flyoutManagerReducer(state, action); + + expect(state.flyouts[0].activityStage).toBe(STAGE_ACTIVE); + }); + + it('should handle all activity stages', () => { + // Setup: add flyout + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + + const stages = [ + STAGE_OPENING, + STAGE_ACTIVE, + STAGE_INACTIVE, + STAGE_BACKGROUNDING, + STAGE_BACKGROUNDED, + STAGE_RETURNING, + STAGE_CLOSING, + ]; + + stages.forEach((stage) => { + const action = setActivityStage('main-1', stage as any); + state = flyoutManagerReducer(state, action); + expect(state.flyouts[0].activityStage).toBe(stage); + }); + }); + + it('should not affect other flyouts', () => { + // Setup: add two flyouts + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', LEVEL_MAIN) + ); + state = flyoutManagerReducer(state, addFlyout('main-2', LEVEL_MAIN)); + + const action = setActivityStage('main-1', STAGE_ACTIVE); + state = flyoutManagerReducer(state, action); + + expect(state.flyouts[0].activityStage).toBe(STAGE_ACTIVE); + expect(state.flyouts[1].activityStage).toBe(STAGE_OPENING); + }); + }); + + describe('default case', () => { + it('should return current state for unknown actions', () => { + const unknownAction = { type: 'UNKNOWN_ACTION' } as any; + const newState = flyoutManagerReducer(initialState, unknownAction); + + expect(newState).toEqual(initialState); + }); + }); + + describe('complex scenarios', () => { + it('should handle complete flyout lifecycle', () => { + let state = initialState; + + // 1. Add main flyout + state = flyoutManagerReducer(state, addFlyout('main-1', LEVEL_MAIN, 'l')); + expect(state.flyouts).toHaveLength(1); + expect(state.sessions).toHaveLength(1); + + // 2. Add child flyout + state = flyoutManagerReducer( + state, + addFlyout('child-1', LEVEL_CHILD, 'm') + ); + expect(state.flyouts).toHaveLength(2); + expect(state.sessions[0].child).toBe('child-1'); + + // 3. Set child as active + state = flyoutManagerReducer(state, setActiveFlyout('child-1')); + expect(state.sessions[0].child).toBe('child-1'); + + // 4. Update widths + state = flyoutManagerReducer(state, setFlyoutWidth('main-1', 600)); + state = flyoutManagerReducer(state, setFlyoutWidth('child-1', 400)); + + // 5. Update activity stages + state = flyoutManagerReducer( + state, + setActivityStage('main-1', STAGE_ACTIVE) + ); + state = flyoutManagerReducer( + state, + setActivityStage('child-1', STAGE_ACTIVE) + ); + + // 6. Close child flyout + state = flyoutManagerReducer(state, closeFlyout('child-1')); + expect(state.flyouts).toHaveLength(1); + expect(state.sessions[0].child).toBe(null); + + // 7. Close main flyout + state = flyoutManagerReducer(state, closeFlyout('main-1')); + expect(state.flyouts).toHaveLength(0); + expect(state.sessions).toHaveLength(0); + }); + + it('should handle multiple sessions with children', () => { + let state = initialState; + + // Session 1: main + child + state = flyoutManagerReducer(state, addFlyout('main-1', LEVEL_MAIN)); + state = flyoutManagerReducer(state, addFlyout('child-1', LEVEL_CHILD)); + + // Session 2: main only + state = flyoutManagerReducer(state, addFlyout('main-2', LEVEL_MAIN)); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions[0]).toEqual({ main: 'main-1', child: 'child-1' }); + expect(state.sessions[1]).toEqual({ main: 'main-2', child: null }); + + // Close first session's main flyout + state = flyoutManagerReducer(state, closeFlyout('main-1')); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].main).toBe('main-2'); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/reducer.ts b/packages/eui/src/components/flyout/manager/reducer.ts new file mode 100644 index 00000000000..fbbbb9671db --- /dev/null +++ b/packages/eui/src/components/flyout/manager/reducer.ts @@ -0,0 +1,196 @@ +/* + * 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 { + ACTION_ADD, + ACTION_CLOSE, + ACTION_SET_ACTIVE, + ACTION_SET_LAYOUT_MODE, + ACTION_SET_WIDTH, + ACTION_SET_ACTIVITY_STAGE, + Action, +} from './actions'; +import { LAYOUT_MODE_SIDE_BY_SIDE, LEVEL_MAIN, STAGE_OPENING } from './const'; +import { + EuiFlyoutManagerState, + FlyoutSession, + EuiManagedFlyoutState, +} from './types'; + +/** + * Default flyout manager state used to initialize the reducer. + */ +export const initialState: EuiFlyoutManagerState = { + sessions: [], + flyouts: [], + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, +}; + +/** + * Reducer handling all flyout manager actions and state transitions. + */ +export function flyoutManagerReducer( + state: EuiFlyoutManagerState = initialState, + action: Action +): EuiFlyoutManagerState { + switch (action.type) { + // Register a flyout. + // - Ignore duplicates by `flyoutId`. + // - For a `main` flyout, start a new session { main, child: null }. + // - For a `child` flyout, attach it to the most recent session; if no + // session exists, do nothing (invalid child without a parent). + case ACTION_ADD: { + const { flyoutId, level, size } = action; + + if (state.flyouts.some((f) => f.flyoutId === flyoutId)) { + return state; + } + + const newFlyoutState: EuiManagedFlyoutState = { + level, + flyoutId, + size, + activityStage: STAGE_OPENING, + }; + const newFlyouts: EuiManagedFlyoutState[] = [ + ...state.flyouts, + newFlyoutState, + ]; + + if (level === LEVEL_MAIN) { + const newSession: FlyoutSession = { main: flyoutId, child: null }; + return { + ...state, + sessions: [...state.sessions, newSession], + flyouts: newFlyouts, + }; + } + + if (state.sessions.length === 0) { + return state; + } + + const updatedSessions = [...state.sessions]; + const currentSessionIndex = updatedSessions.length - 1; + + updatedSessions[currentSessionIndex] = { + ...updatedSessions[currentSessionIndex], + child: flyoutId, + }; + + return { ...state, sessions: updatedSessions, flyouts: newFlyouts }; + } + + // Unregister a flyout and update sessions accordingly. + // - When closing a `main` flyout, drop its entire session and all associated flyouts. + // - When closing a `child` flyout, clear the child pointer on the most + // recent session if it matches. + case ACTION_CLOSE: { + const removedFlyout = state.flyouts.find( + (f) => f.flyoutId === action.flyoutId + ); + + if (!removedFlyout) { + return state; + } + + if (removedFlyout.level === LEVEL_MAIN) { + // Find the session that contains this main flyout + const sessionToRemove = state.sessions.find( + (session) => session.main === action.flyoutId + ); + + if (sessionToRemove) { + // Remove all flyouts associated with this session (main + child) + const flyoutsToRemove = new Set([action.flyoutId]); + if (sessionToRemove.child) { + flyoutsToRemove.add(sessionToRemove.child); + } + + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + + const newSessions = state.sessions.filter( + (session) => session.main !== action.flyoutId + ); + + return { ...state, sessions: newSessions, flyouts: newFlyouts }; + } + } + + // Handle child flyout closing (existing logic) + const newFlyouts = state.flyouts.filter( + (f) => f.flyoutId !== action.flyoutId + ); + + if (state.sessions.length === 0) { + return { ...state, flyouts: newFlyouts }; + } + + const updatedSessions = [...state.sessions]; + const currentSessionIndex = updatedSessions.length - 1; + + if (updatedSessions[currentSessionIndex].child === action.flyoutId) { + updatedSessions[currentSessionIndex] = { + ...updatedSessions[currentSessionIndex], + child: null, + }; + } + + return { ...state, sessions: updatedSessions, flyouts: newFlyouts }; + } + + // Mark the provided flyout ID as the active child for the latest session. + case ACTION_SET_ACTIVE: { + // No-op when no session exists. + if (state.sessions.length === 0) { + return state; + } + + const updatedSessions = [...state.sessions]; + const currentSessionIndex = updatedSessions.length - 1; + + updatedSessions[currentSessionIndex] = { + ...updatedSessions[currentSessionIndex], + child: action.flyoutId, + }; + + return { ...state, sessions: updatedSessions }; + } + + // Persist a flyout's measured width (px). Used for responsive layout + // calculations, e.g., deciding stacked vs side-by-side. + case ACTION_SET_WIDTH: { + const { flyoutId, width } = action; + const updatedFlyouts = state.flyouts.map((flyout) => + flyout.flyoutId === flyoutId ? { ...flyout, width } : flyout + ); + return { ...state, flyouts: updatedFlyouts }; + } + + // Switch global layout mode for managed flyouts. + case ACTION_SET_LAYOUT_MODE: { + return { ...state, layoutMode: action.layoutMode }; + } + + // Update a flyout's activity stage in state + case ACTION_SET_ACTIVITY_STAGE: { + const { flyoutId } = action; + const updatedFlyouts = state.flyouts.map((flyout) => + flyout.flyoutId === flyoutId + ? { ...flyout, activityStage: action.activityStage } + : flyout + ); + return { ...state, flyouts: updatedFlyouts }; + } + + default: + return state; + } +} diff --git a/packages/eui/src/components/flyout/manager/selectors.test.tsx b/packages/eui/src/components/flyout/manager/selectors.test.tsx new file mode 100644 index 00000000000..a618bcb2a8a --- /dev/null +++ b/packages/eui/src/components/flyout/manager/selectors.test.tsx @@ -0,0 +1,541 @@ +/* + * 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 { renderHook } from '../../../test/rtl'; +import { + useSession, + useHasActiveSession, + useIsFlyoutActive, + useFlyout, + useCurrentSession, + useCurrentMainFlyout, + useCurrentChildFlyout, + useFlyoutWidth, + useParentFlyoutSize, + useHasChildFlyout, +} from './selectors'; +import { EuiFlyoutManager, useFlyoutManager } from './provider'; +import { useFlyoutManagerReducer } from './hooks'; + +import { LEVEL_MAIN, LEVEL_CHILD } from './const'; + +// Mock the hooks module to avoid circular dependencies +jest.mock('./hooks', () => ({ + useFlyoutManagerReducer: jest.fn(), +})); + +// Mock the provider context +jest.mock('./provider', () => ({ + EuiFlyoutManager: ({ children }: { children: React.ReactNode }) => children, + useFlyoutManager: jest.fn(), +})); + +// Test wrapper component +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return {children}; +}; + +// Mock data +const mockState = { + sessions: [ + { main: 'main-1', child: 'child-1' }, + { main: 'main-2', child: null }, + ], + flyouts: [ + { flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l', width: 600 }, + { flyoutId: 'child-1', level: LEVEL_CHILD, size: 'm', width: 400 }, + { flyoutId: 'main-2', level: LEVEL_MAIN, size: 's', width: 300 }, + ], + layoutMode: 'side-by-side' as const, +}; + +const mockApi = { + state: mockState, + dispatch: jest.fn(), + addFlyout: jest.fn(), + closeFlyout: jest.fn(), + setActiveFlyout: jest.fn(), + setFlyoutWidth: jest.fn(), +}; + +describe('flyout manager selectors', () => { + beforeEach(() => { + jest.clearAllMocks(); + const mockUseFlyoutManagerReducer = useFlyoutManagerReducer as jest.Mock; + const mockUseFlyoutManager = useFlyoutManager as jest.Mock; + mockUseFlyoutManagerReducer.mockReturnValue(mockApi); + mockUseFlyoutManager.mockReturnValue(mockApi); + }); + + describe('useSession', () => { + it('should return session when flyout ID matches main', () => { + const { result } = renderHook(() => useSession('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ main: 'main-1', child: 'child-1' }); + }); + + it('should return session when flyout ID matches child', () => { + const { result } = renderHook(() => useSession('child-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ main: 'main-1', child: 'child-1' }); + }); + + it('should return null when flyout ID does not match any session', () => { + const { result } = renderHook(() => useSession('non-existent'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + + it('should return null when no flyout ID is provided', () => { + const { result } = renderHook(() => useSession(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + + it('should return null when flyout ID is null', () => { + const { result } = renderHook(() => useSession(null), { + wrapper: TestWrapper, + }); + + // The selector treats null as a literal value to search for + // It finds the session where child: null matches flyoutId: null + expect(result.current).toEqual({ main: 'main-2', child: null }); + }); + }); + + describe('useHasActiveSession', () => { + it('should return true when there are active sessions', () => { + const { result } = renderHook(() => useHasActiveSession(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(true); + }); + + it('should return false when there are no sessions', () => { + const emptyState = { ...mockState, sessions: [] }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + + const { result } = renderHook(() => useHasActiveSession(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(false); + }); + }); + + describe('useIsFlyoutActive', () => { + it('should return true when flyout is main in current session', () => { + const { result } = renderHook(() => useIsFlyoutActive('main-2'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(true); + }); + + it('should return true when flyout is child in current session', () => { + const { result } = renderHook(() => useIsFlyoutActive('child-1'), { + wrapper: TestWrapper, + }); + + // child-1 is not in the current session (main-2 with no child) + // It's in the previous session (main-1 with child-1) + expect(result.current).toBe(false); + }); + + it('should return false when flyout is not in current session', () => { + const { result } = renderHook(() => useIsFlyoutActive('non-existent'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(false); + }); + + it('should return false when flyout is in previous session', () => { + const { result } = renderHook(() => useIsFlyoutActive('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(false); + }); + }); + + describe('useFlyout', () => { + it('should return flyout when ID exists', () => { + const { result } = renderHook(() => useFlyout('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ + flyoutId: 'main-1', + level: LEVEL_MAIN, + size: 'l', + width: 600, + }); + }); + + it('should return null when flyout ID does not exist', () => { + const { result } = renderHook(() => useFlyout('non-existent'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + + it('should return null when no flyout ID is provided', () => { + const { result } = renderHook(() => useFlyout(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + + it('should return null when flyout ID is null', () => { + const { result } = renderHook(() => useFlyout(null), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + }); + + describe('useCurrentSession', () => { + it('should return the most recent session', () => { + const { result } = renderHook(() => useCurrentSession(), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ main: 'main-2', child: null }); + }); + + it('should return null when no sessions exist', () => { + const emptyState = { ...mockState, sessions: [] }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + + const { result } = renderHook(() => useCurrentSession(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + }); + + describe('useCurrentMainFlyout', () => { + it('should return the main flyout of current session', () => { + const { result } = renderHook(() => useCurrentMainFlyout(), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ + flyoutId: 'main-2', + level: LEVEL_MAIN, + size: 's', + width: 300, + }); + }); + + it('should return null when no current session exists', () => { + const emptyState = { ...mockState, sessions: [] }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + + const { result } = renderHook(() => useCurrentMainFlyout(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + }); + + describe('useCurrentChildFlyout', () => { + it('should return the child flyout of current session', () => { + // Change current session to one with a child + const stateWithChildCurrent = { + ...mockState, + sessions: [ + { main: 'main-2', child: null }, + { main: 'main-1', child: 'child-1' }, // Make this the current session + ], + }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: stateWithChildCurrent, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: stateWithChildCurrent, + }); + + const { result } = renderHook(() => useCurrentChildFlyout(), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ + flyoutId: 'child-1', + level: LEVEL_CHILD, + size: 'm', + width: 400, + }); + }); + + it('should return null when current session has no child', () => { + const { result } = renderHook(() => useCurrentChildFlyout(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + + it('should return null when no current session exists', () => { + const emptyState = { ...mockState, sessions: [] }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + + const { result } = renderHook(() => useCurrentChildFlyout(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeNull(); + }); + }); + + describe('useFlyoutWidth', () => { + it('should return flyout width when it exists', () => { + const { result } = renderHook(() => useFlyoutWidth('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(600); + }); + + it('should return undefined when flyout has no width', () => { + const stateWithoutWidth = { + ...mockState, + flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN, size: 'l' }], + }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: stateWithoutWidth, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: stateWithoutWidth, + }); + + const { result } = renderHook(() => useFlyoutWidth('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined when flyout does not exist', () => { + const { result } = renderHook(() => useFlyoutWidth('non-existent'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined when no flyout ID is provided', () => { + const { result } = renderHook(() => useFlyoutWidth(), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeUndefined(); + }); + }); + + describe('useParentFlyoutSize', () => { + it('should return parent flyout size for child flyout', () => { + const { result } = renderHook(() => useParentFlyoutSize('child-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe('l'); + }); + + it('should return undefined when child flyout has no parent', () => { + const { result } = renderHook(() => useParentFlyoutSize('non-existent'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeUndefined(); + }); + + it('should return undefined when parent flyout has no size', () => { + const stateWithoutSize = { + ...mockState, + flyouts: [ + { flyoutId: 'main-1', level: LEVEL_MAIN }, + { flyoutId: 'child-1', level: LEVEL_CHILD }, + ], + }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: stateWithoutSize, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: stateWithoutSize, + }); + + const { result } = renderHook(() => useParentFlyoutSize('child-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBeUndefined(); + }); + }); + + describe('useHasChildFlyout', () => { + it('should return true when main flyout has a child', () => { + const { result } = renderHook(() => useHasChildFlyout('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(true); + }); + + it('should return false when main flyout has no child', () => { + const { result } = renderHook(() => useHasChildFlyout('main-2'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(false); + }); + + it('should return false when flyout ID does not exist', () => { + const { result } = renderHook(() => useHasChildFlyout('non-existent'), { + wrapper: TestWrapper, + }); + + expect(result.current).toBe(false); + }); + + it('should return false when flyout is not a main flyout', () => { + const { result } = renderHook(() => useHasChildFlyout('child-1'), { + wrapper: TestWrapper, + }); + + // The selector checks if the flyout ID has a session with a child + // Since child-1 is in a session with child: 'child-1', it returns true + expect(result.current).toBe(true); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle empty flyouts array', () => { + const emptyState = { ...mockState, flyouts: [] }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: emptyState, + }); + + const { result: flyoutResult } = renderHook(() => useFlyout('main-1'), { + wrapper: TestWrapper, + }); + const { result: widthResult } = renderHook( + () => useFlyoutWidth('main-1'), + { + wrapper: TestWrapper, + } + ); + + expect(flyoutResult.current).toBeNull(); + expect(widthResult.current).toBeUndefined(); + }); + + it('should handle malformed flyout data gracefully', () => { + const malformedState = { + ...mockState, + flyouts: [ + { flyoutId: 'main-1' }, // Missing required properties + ], + }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: malformedState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: malformedState, + }); + + const { result } = renderHook(() => useFlyout('main-1'), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ flyoutId: 'main-1' }); + }); + + it('should handle sessions with missing flyout references', () => { + const invalidState = { + ...mockState, + sessions: [{ main: 'main-1', child: 'non-existent-child' }], + flyouts: [{ flyoutId: 'main-1', level: LEVEL_MAIN }], + }; + (useFlyoutManagerReducer as jest.Mock).mockReturnValue({ + ...mockApi, + state: invalidState, + }); + (useFlyoutManager as jest.Mock).mockReturnValue({ + ...mockApi, + state: invalidState, + }); + + const { result } = renderHook(() => useSession('non-existent-child'), { + wrapper: TestWrapper, + }); + + expect(result.current).toEqual({ + main: 'main-1', + child: 'non-existent-child', + }); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/selectors.ts b/packages/eui/src/components/flyout/manager/selectors.ts new file mode 100644 index 00000000000..436d3c53f33 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/selectors.ts @@ -0,0 +1,88 @@ +/* + * 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 { useFlyoutManager } from './provider'; + +export const useSession = (flyoutId?: string | null) => { + const context = useFlyoutManager(); + + if (!context) { + return null; + } + + return ( + context.state.sessions.find( + (s) => s.main === flyoutId || s.child === flyoutId + ) || null + ); +}; + +/** True when any managed flyout session is currently active. */ +export const useHasActiveSession = () => !!useCurrentSession(); + +/** True if the given `flyoutId` is the main or child flyout in the latest session. */ +export const useIsFlyoutActive = (flyoutId: string) => { + const currentSession = useCurrentSession(); + return ( + currentSession?.main === flyoutId || currentSession?.child === flyoutId + ); +}; + +export const useFlyout = (flyoutId?: string | null) => { + const context = useFlyoutManager(); + if (!context || !flyoutId) { + return null; + } + return context.state.flyouts.find((f) => f.flyoutId === flyoutId) || null; +}; + +export const useIsFlyoutRegistered = (flyoutId?: string | null) => { + const context = useFlyoutManager(); + if (!context || !flyoutId) { + return false; + } + return context.state.flyouts.some((f) => f.flyoutId === flyoutId); +}; + +/** The most recent flyout session or `null` if none. */ +export const useCurrentSession = () => { + const context = useFlyoutManager(); + if (!context) return null; + return context.state.sessions[context.state.sessions.length - 1] || null; +}; + +/** The registered state of the current session's main flyout, if present. */ +export const useCurrentMainFlyout = () => { + const currentSession = useCurrentSession(); + const mainFlyoutId = currentSession?.main; + return useFlyout(mainFlyoutId); +}; + +/** The registered state of the current session's child flyout, if present. */ +export const useCurrentChildFlyout = () => { + const currentSession = useCurrentSession(); + const childFlyoutId = currentSession?.child; + return useFlyout(childFlyoutId); +}; + +/** The measured width (px) of the specified flyout, or `null` if unknown. */ +export const useFlyoutWidth = (flyoutId?: string | null) => + useFlyout(flyoutId)?.width; + +/** The configured size of the parent (main) flyout for a given child flyout ID. */ +export const useParentFlyoutSize = (childFlyoutId: string) => { + const session = useSession(childFlyoutId); + const parentFlyout = useFlyout(session?.main); + return parentFlyout?.size; +}; + +/** True if the provided `flyoutId` is the main flyout and it currently has a child. */ +export const useHasChildFlyout = (flyoutId: string) => { + const session = useSession(flyoutId); + return !!session?.child; +}; diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts new file mode 100644 index 00000000000..97a0f843298 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/types.ts @@ -0,0 +1,72 @@ +/* + * 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 type { Action } from './actions'; + +import { + STAGE_CLOSING, + STAGE_OPENING, + STAGE_ACTIVE, + STAGE_INACTIVE, + STAGE_BACKGROUNDING, + STAGE_BACKGROUNDED, + STAGE_RETURNING, + LAYOUT_MODE_SIDE_BY_SIDE, + LAYOUT_MODE_STACKED, + LEVEL_CHILD, + LEVEL_MAIN, +} from './const'; + +// Core domain types for the flyout manager + +export type EuiFlyoutLayoutMode = + | typeof LAYOUT_MODE_STACKED + | typeof LAYOUT_MODE_SIDE_BY_SIDE; + +export type EuiFlyoutLevel = typeof LEVEL_MAIN | typeof LEVEL_CHILD; + +export type EuiFlyoutActivityStage = + | typeof STAGE_OPENING + | typeof STAGE_ACTIVE + | typeof STAGE_INACTIVE + | typeof STAGE_BACKGROUNDING + | typeof STAGE_BACKGROUNDED + | typeof STAGE_RETURNING + | typeof STAGE_CLOSING; + +export interface EuiManagedFlyoutState { + flyoutId: string; + level: EuiFlyoutLevel; + width?: number; + size?: string; + activityStage?: EuiFlyoutActivityStage; +} + +export interface FlyoutSession { + main: string; + child: string | null; +} + +export interface EuiFlyoutManagerState { + sessions: FlyoutSession[]; + flyouts: EuiManagedFlyoutState[]; + layoutMode: EuiFlyoutLayoutMode; +} + +/** + * Public API surface provided through React context. + * Kept intentionally decoupled from action types to avoid module cycles. + */ +export interface FlyoutManagerApi { + state: EuiFlyoutManagerState; + dispatch: (action: Action) => void; + addFlyout: (flyoutId: string, level?: EuiFlyoutLevel, size?: string) => void; + closeFlyout: (flyoutId: string) => void; + setActiveFlyout: (flyoutId: string | null) => void; + setFlyoutWidth: (flyoutId: string, width: number) => void; +} diff --git a/packages/eui/src/components/flyout/manager/validation.test.ts b/packages/eui/src/components/flyout/manager/validation.test.ts new file mode 100644 index 00000000000..73b83f2bf25 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/validation.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { + isNamedSize, + validateManagedFlyoutSize, + validateSizeCombination, + validateFlyoutSize, + createValidationErrorMessage, + FlyoutSizeValidationError, +} from './validation'; + +describe('Flyout Size Validation', () => { + describe('isNamedSize', () => { + it('should return true for named sizes', () => { + expect(isNamedSize('s')).toBe(true); + expect(isNamedSize('m')).toBe(true); + expect(isNamedSize('l')).toBe(true); + }); + + it('should return false for non-named sizes', () => { + expect(isNamedSize('xs')).toBe(false); + expect(isNamedSize('xl')).toBe(false); + expect(isNamedSize('100px')).toBe(false); + expect(isNamedSize(100)).toBe(false); + expect(isNamedSize('50%')).toBe(false); + }); + }); + + describe('validateManagedFlyoutSize', () => { + it('should return null for valid named sizes', () => { + expect(validateManagedFlyoutSize('s', 'test-id', 'main')).toBeNull(); + expect(validateManagedFlyoutSize('m', 'test-id', 'child')).toBeNull(); + expect(validateManagedFlyoutSize('l', 'test-id', 'main')).toBeNull(); + }); + + it('should return error for non-named sizes', () => { + const error = validateManagedFlyoutSize('100px', 'test-id', 'main'); + expect(error).toEqual({ + type: 'INVALID_SIZE_TYPE', + message: + 'Managed flyouts must use named sizes (s, m, l). Received: 100px', + flyoutId: 'test-id', + level: 'main', + size: '100px', + }); + }); + }); + + describe('validateSizeCombination', () => { + it('should return null for valid combinations', () => { + expect(validateSizeCombination('s', 'm')).toBeNull(); + expect(validateSizeCombination('s', 'l')).toBeNull(); + expect(validateSizeCombination('m', 's')).toBeNull(); + expect(validateSizeCombination('m', 'l')).toBeNull(); + }); + + it('should return error when parent and child are both m', () => { + const error = validateSizeCombination('m', 'm'); + expect(error).toEqual({ + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent and child flyouts cannot both be size "m"', + size: 'm', + }); + }); + + it('should return error when parent is l and there is a child', () => { + const error = validateSizeCombination('l', 's'); + expect(error).toEqual({ + type: 'INVALID_SIZE_COMBINATION', + message: + 'Parent flyouts cannot be size "l" when there is a child flyout', + size: 'l', + }); + }); + }); + + describe('validateFlyoutSize', () => { + it('should validate managed flyout size type', () => { + const error = validateFlyoutSize('100px', 'test-id', 'main'); + expect(error?.type).toBe('INVALID_SIZE_TYPE'); + }); + + it('should validate size combinations for child flyouts', () => { + // Parent and child both 'm' should fail + const error1 = validateFlyoutSize('m', 'child-id', 'child', 'm'); + expect(error1?.type).toBe('INVALID_SIZE_COMBINATION'); + expect(error1?.message).toContain( + 'Parent and child flyouts cannot both be size "m"' + ); + + // Parent 'l' with child should fail + const error2 = validateFlyoutSize('s', 'child-id', 'child', 'l'); + expect(error2?.type).toBe('INVALID_SIZE_COMBINATION'); + expect(error2?.message).toContain( + 'Parent flyouts cannot be size "l" when there is a child flyout' + ); + }); + + it('should return null for valid child flyout combinations', () => { + expect(validateFlyoutSize('s', 'child-id', 'child', 'm')).toBeNull(); + expect(validateFlyoutSize('l', 'child-id', 'child', 'm')).toBeNull(); + expect(validateFlyoutSize('s', 'child-id', 'child', 's')).toBeNull(); + }); + }); + + describe('createValidationErrorMessage', () => { + it('should create error message for invalid size type', () => { + const error: FlyoutSizeValidationError = { + type: 'INVALID_SIZE_TYPE', + message: + 'Managed flyouts must use named sizes (s, m, l). Received: 100px', + flyoutId: 'test-id', + level: 'main', + size: '100px', + }; + + const message = createValidationErrorMessage(error); + expect(message).toBe( + 'EuiFlyout validation error: Managed flyouts must use named sizes (s, m, l). Received: 100px' + ); + }); + + it('should create error message for invalid size combination', () => { + const error: FlyoutSizeValidationError = { + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent and child flyouts cannot both be size "m"', + size: 'm', + }; + + const message = createValidationErrorMessage(error); + expect(message).toBe( + 'EuiFlyout validation error: Parent and child flyouts cannot both be size "m"' + ); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/validation.ts b/packages/eui/src/components/flyout/manager/validation.ts new file mode 100644 index 00000000000..d1203b997d8 --- /dev/null +++ b/packages/eui/src/components/flyout/manager/validation.ts @@ -0,0 +1,128 @@ +/* + * 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, FLYOUT_SIZES } from '../const'; +import { EuiFlyoutComponentProps } from '../flyout.component'; +import { LEVEL_CHILD } from './const'; +import { EuiFlyoutLevel } from './types'; + +/** + * Business rules for flyout sizes: + * - Managed flyouts should only accept "named" sizes (s, m, l) + * - Parent and child can't both be 'm' + * - Parent can't be 'l' if there is a child + */ + +export interface FlyoutSizeValidationError { + type: 'INVALID_SIZE_TYPE' | 'INVALID_SIZE_COMBINATION'; + message: string; + flyoutId?: string; + level?: EuiFlyoutLevel; + size?: string; // Allow any string for error reporting +} + +/** + * Checks if a size is a named size (s, m, l) + */ +export function isNamedSize(size: unknown): size is EuiFlyoutSize { + return FLYOUT_SIZES.includes(size as EuiFlyoutSize); +} + +/** + * Validates that a managed flyout only uses named sizes + */ +export function validateManagedFlyoutSize( + size: EuiFlyoutComponentProps['size'], + flyoutId: string, + level: EuiFlyoutLevel +): FlyoutSizeValidationError | null { + if (!isNamedSize(size)) { + return { + type: 'INVALID_SIZE_TYPE', + message: `Managed flyouts must use named sizes (s, m, l). Received: ${size}`, + flyoutId, + level, + size: `${size}`, + }; + } + return null; +} + +/** + * Validates size combinations for parent-child flyouts + */ +export function validateSizeCombination( + parentSize: EuiFlyoutSize, + childSize: EuiFlyoutSize +): FlyoutSizeValidationError | null { + // Parent and child can't both be 'm' + if (parentSize === 'm' && childSize === 'm') { + return { + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent and child flyouts cannot both be size "m"', + size: childSize, + }; + } + + // Parent can't be 'l' if there is a child + if (parentSize === 'l') { + return { + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent flyouts cannot be size "l" when there is a child flyout', + size: parentSize, + }; + } + + return null; +} + +/** + * Comprehensive validation for flyout size rules + */ +export function validateFlyoutSize( + size: EuiFlyoutComponentProps['size'], + flyoutId: string, + level: EuiFlyoutLevel, + parentSize?: EuiFlyoutSize +): FlyoutSizeValidationError | null { + // First validate that managed flyouts use named sizes + const sizeTypeError = validateManagedFlyoutSize(size, flyoutId, level); + if (sizeTypeError) { + return sizeTypeError; + } + + // If this is a child flyout and we have parent size, validate combination + if (level === LEVEL_CHILD && parentSize && isNamedSize(size)) { + const combinationError = validateSizeCombination(parentSize, size); + if (combinationError) { + combinationError.flyoutId = flyoutId; + combinationError.level = level; + return combinationError; + } + } + + return null; +} + +/** + * Creates a user-friendly error message for validation errors + */ +export function createValidationErrorMessage( + error: FlyoutSizeValidationError +): string { + const prefix = `EuiFlyout validation error: `; + + switch (error.type) { + case 'INVALID_SIZE_TYPE': + return `${prefix}${error.message}`; + case 'INVALID_SIZE_COMBINATION': + return `${prefix}${error.message}`; + default: + return `${prefix}Unknown validation error`; + } +} diff --git a/packages/eui/src/components/flyout/sessions/README.md b/packages/eui/src/components/flyout/sessions/README.md deleted file mode 100644 index 1fc4eca9c82..00000000000 --- a/packages/eui/src/components/flyout/sessions/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# 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. -* **`activeFlyoutGroup`**: `EuiFlyoutSessionGroup` - The currently active flyout group. - -## `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: EuiFlyoutSessionOpenMainOptions)`: Opens a new main flyout. If a flyout is already open, it adds the new one to a history stack. -* `openChildFlyout(options: EuiFlyoutSessionOpenChildOptions)`: Opens a new child flyout to the left of the main flyout. -* `openFlyoutGroup(options: EuiFlyoutSessionOpenGroupOptions)`: Opens a group containing a main flyout and a child flyout. -* `closeChildFlyout()`: Closes the currently open child flyout. -* `goBack()`: If there's a previous flyout in the history stack, it will be shown. -* `closeSession()`: 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, - EuiFlyoutHeader, - EuiTitle, - 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', - meta: { title: 'My Flyout' }, - }); - }; - - 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 = (flyoutContext: EuiFlyoutSessionRenderContext<{ title: string }>) => ( - <> - - -

{flyoutContext.meta.title}

-
-
- - -

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 deleted file mode 100644 index ef30c5cbc37..00000000000 --- a/packages/eui/src/components/flyout/sessions/flyout_provider.stories.tsx +++ /dev/null @@ -1,938 +0,0 @@ -/* - * 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, useEffect, useCallback } from 'react'; - -import { - EuiButton, - EuiCodeBlock, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiRadioGroup, - EuiRadioGroupOption, - EuiSpacer, - EuiText, - EuiTitle, -} from '../../index'; - -import { - EuiFlyoutSessionProvider, - useEuiFlyoutSessionContext, -} from './flyout_provider'; -import type { - EuiFlyoutSessionHistoryState, - EuiFlyoutSessionOpenChildOptions, - EuiFlyoutSessionOpenGroupOptions, - EuiFlyoutSessionOpenMainOptions, - EuiFlyoutSessionOpenManagedOptions, - EuiFlyoutSessionRenderContext, - EuiFlyoutSessionGroup, -} from './types'; -import { useEuiFlyoutSession } from './use_eui_flyout'; - -const loggerAction = action('flyout-session-log'); - -const meta: Meta = { - title: 'Layout/EuiFlyout/Flyout System', - component: EuiFlyoutSessionProvider, -}; - -export default meta; - -/** Helper component for displaying internal raw state */ -const InternalState: React.FC<{ - state: EuiFlyoutSessionHistoryState; -}> = ({ state }) => { - return ( - <> - -

Internal state

-
- - {JSON.stringify(state, null, 2)} - - - ); -}; - -/** - * --------------------------------------------------- - * Ecommerce Shopping Cart Example (advanced use case) - * --------------------------------------------------- - */ - -interface ECommerceContentProps { - itemQuantity: number; -} -interface ShoppingCartContentProps extends ECommerceContentProps { - onQuantityChange: (delta: number) => void; -} -interface ItemDetailsContentProps extends ECommerceContentProps {} -interface ReviewOrderContentProps extends ECommerceContentProps {} -interface OrderConfirmedContentProps 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, (ECommerceApp), we're using metadata within the renderMainFlyoutContent - * function as a conditional to determine which component to render in the main flyout. - */ -interface ECommerceAppMeta { - ecommerceMainFlyoutKey?: 'shoppingCart' | 'reviewOrder' | 'orderConfirmed'; -} - -const ShoppingCartContent: React.FC = ({ - itemQuantity, - onQuantityChange, -}) => { - const { - openChildFlyout, - openManagedFlyout, - isChildFlyoutOpen, - closeChildFlyout, - closeSession, - } = useEuiFlyoutSession(); - - const handleOpenItemDetails = () => { - const options: EuiFlyoutSessionOpenChildOptions = { - title: 'Item details', - size: 's', - flyoutProps: { - className: 'itemDetailsFlyoutChild', - 'aria-label': 'Item details', - onClose: () => { - loggerAction('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 - }, - }, - }; - openChildFlyout(options); - }; - - const handleProceedToReview = () => { - const options: EuiFlyoutSessionOpenManagedOptions = { - title: 'Review order', - hideTitle: true, // title will only show in the history popover - size: 'm', - meta: { ecommerceMainFlyoutKey: 'reviewOrder' }, - flyoutProps: { - type: 'push', - className: 'reviewOrderFlyoutMain', - 'aria-label': 'Review order', - onClose: () => { - loggerAction('Review order 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 - }, - }, - }; - openManagedFlyout(options); - }; - - return ( - <> - - -

Shopping cart

-
-
- - -

Item: Flux Capacitor

-
- - {isChildFlyoutOpen ? '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, openManagedFlyout, closeSession } = useEuiFlyoutSession(); - - return ( - <> - - -

Review order

-
-
- - -

Review your order

-

Item: Flux Capacitor

-

Quantity: {itemQuantity}

-
- - - 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 - -
- - { - loggerAction('Go back button clicked'); - goBack(); - }} - color="danger" - > - Go back - {' '} - - Close - - - - ); -}; - -const ItemDetailsContent: React.FC = ({ - itemQuantity, -}) => { - const { closeChildFlyout } = useEuiFlyoutSession(); - return ( - <> - - -

- Item: Flux Capacitor -

-

- Selected quantity: {itemQuantity} -

-

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

-
-
- - - Close details - - - - ); -}; - -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 { - openManagedFlyout, - goBack, - isFlyoutOpen, - canGoBack, - isChildFlyoutOpen, - closeChildFlyout, - closeSession, - } = useEuiFlyoutSession(); - const { state } = useEuiFlyoutSessionContext(); // Use internal hook for displaying raw state - - const handleCloseOrGoBack = () => { - if (canGoBack) { - goBack(); - } else { - closeSession(); - } - }; - const handleOpenShoppingCart = () => { - const options: EuiFlyoutSessionOpenManagedOptions = { - title: 'Shopping cart', - hideTitle: true, // title will only show in the history popover - size: 'm', - meta: { ecommerceMainFlyoutKey: 'shoppingCart' }, - flyoutProps: { - type: 'push', - pushMinBreakpoint: 'xs', - className: 'shoppingCartFlyoutMain', - 'aria-label': 'Shopping cart', - onClose: (event) => { - loggerAction('Shopping cart onClose triggered', event); - closeSession(); // If we add an onClose handler to the main flyout, we have to call closeSession within it for the flyout to actually close - }, - }, - }; - openManagedFlyout(options); - }; - - return ( - <> - - Open shopping cart - - - - Close child flyout - - - - Close/Go back - - - - - ); -}; - -const ECommerceApp: 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 || {}; - - switch (ecommerceMainFlyoutKey) { - case 'orderConfirmed': - return ; - case 'reviewOrder': - return ; - case 'shoppingCart': - return ( - - ); - } - - loggerAction( - 'renderMainFlyoutContent: Unknown flyout key', - meta?.ecommerceMainFlyoutKey - ); - return null; - }; - - // Render function for CHILD flyout content - const renderChildFlyoutContent = () => { - 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'); - }} - > - - - ); -}; - -export const ECommerceWithHistory: StoryObj = { - name: 'Advanced Use Case', - parameters: { - loki: { - skip: true, - }, - }, - render: () => { - return ; - }, -}; - -/** - * -------------------------------------- - * 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) - * -------------------------------------- - */ - -const GroupOpenerControls: React.FC<{ - mainFlyoutType: 'push' | 'overlay'; - mainFlyoutSize: 's' | 'm'; -}> = ({ mainFlyoutType, mainFlyoutSize }) => { - const { - openFlyoutGroup, - isFlyoutOpen, - isChildFlyoutOpen, - closeSession, - closeChildFlyout, - } = useEuiFlyoutSession(); - const { state } = useEuiFlyoutSessionContext(); // Use internal hook for displaying raw state - - const handleOpenGroup = () => { - // make the child flyout size be different than the main - let childFlyoutSize: 's' | 'm' = 's'; - if (mainFlyoutSize === 's') { - childFlyoutSize = 'm'; - } - const options: EuiFlyoutSessionOpenGroupOptions = { - main: { - title: 'Group opener, main flyout', - size: mainFlyoutSize, - flyoutProps: { - type: mainFlyoutType, - pushMinBreakpoint: 'xs', - ownFocus: true, - outsideClickCloses: true, - className: 'groupOpenerMainFlyout', - 'aria-label': 'Main flyout', - onClose: () => { - loggerAction('Group opener main flyout onClose triggered'); - closeSession(); - }, - }, - }, - child: { - title: 'Group opener, child flyout', - size: childFlyoutSize, - flyoutProps: { - className: 'groupOpenerChildFlyout', - 'aria-label': 'Child flyout', - onClose: () => { - loggerAction('Group opener child flyout onClose triggered'); - closeChildFlyout(); - }, - }, - }, - }; - 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 - - - )} - - - - ); -}; - -const GroupOpenerApp: React.FC = () => { - const [mainFlyoutType, setMainFlyoutType] = useState<'push' | 'overlay'>( - 'push' - ); - const mainFlyoutTypeRadios = [ - { id: 'push', label: 'Push' }, - { id: 'overlay', label: 'Overlay' }, - ]; - - const [mainFlyoutSize, setMainFlyoutSize] = useState<'s' | 'm'>('s'); - const mainFlyoutSizeRadios = [ - { id: 's', label: 'Small' }, - { id: 'm', label: 'Medium' }, - ]; - - const MainFlyoutContent = () => { - const { closeSession } = useEuiFlyoutSession(); - return ( - <> - - -

- 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 ( - <> - - -

- 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 ( - <> - -

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

-
- - setMainFlyoutType(id as typeof mainFlyoutType)} - name="mainFlyoutType" - legend={{ children: 'Main flyout type' }} - /> - - setMainFlyoutSize(id as typeof mainFlyoutSize)} - name="mainFlyoutSize" - legend={{ children: 'Main flyout size' }} - /> - - { - loggerAction('FlyoutGroup flyouts have been unmounted'); - }} - > - - - - ); -}; - -export const WithGroupOpener: StoryObj = { - name: 'Group Opener', - parameters: { - loki: { - skip: true, - }, - }, - render: () => { - return ; - }, -}; - -/** - * --------------------------------------------------- - * Basic Flyout - * --------------------------------------------------- - */ - -const BasicFlyoutControls: React.FC<{ - flyoutType: 'push' | 'overlay'; - mainFlyoutSize: 's' | 'm'; -}> = ({ flyoutType, mainFlyoutSize }) => { - const { openFlyout, isFlyoutOpen, isChildFlyoutOpen, closeSession } = - useEuiFlyoutSession(); - const { state } = useEuiFlyoutSessionContext(); // Use internal hook for displaying raw state - - const handleOpenFlyout = () => { - const options: EuiFlyoutSessionOpenMainOptions = { - flyoutProps: { - type: flyoutType, - onClose: () => { - loggerAction('Basic flyout onClose triggered'); - closeSession(); - }, - }, - size: mainFlyoutSize, - }; - openFlyout(options); - }; - return ( - <> - - Open flyout - - - - - ); -}; - -const BasicFlyoutApp: React.FC = () => { - const [flyoutType, setFlyoutType] = useState<'push' | 'overlay'>('overlay'); - const typeRadios: EuiRadioGroupOption[] = [ - { id: 'push', label: 'Push' }, - { id: 'overlay', label: 'Overlay' }, - ]; - - const [mainFlyoutSize, setMainFlyoutSize] = useState<'s' | 'm'>('m'); - const sizeRadios: EuiRadioGroupOption[] = [ - { id: 's', label: 'Small' }, - { id: 'm', label: 'Medium' }, - ]; - - const renderMainFlyoutContent = () => { - return ( - <> - - -

Main Flyout

-
-
- - -

- This is the main flyout content. This was opened using the{' '} - openFlyout function. -

-
-
- - ); - }; - return ( - <> - -

- This demo shows how to use the backwards compatible{' '} - openFlyout function to open both main and child flyouts. -

-
- - setFlyoutType(id as 'push' | 'overlay')} - legend={{ children: 'Flyout type' }} - name="statefulFlyoutTypeToggle" - /> - - setMainFlyoutSize(id as 's' | 'm')} - legend={{ children: 'Flyout size' }} - name="statefulFlyoutSizeToggle" - /> - - { - loggerAction('Flyout has been unmounted'); - }} - > - - - - - ); -}; - -export const BasicFlyout: StoryObj = { - render: () => { - return ; - }, -}; diff --git a/packages/eui/src/components/flyout/sessions/flyout_provider.tsx b/packages/eui/src/components/flyout/sessions/flyout_provider.tsx deleted file mode 100644 index c2a06224d7f..00000000000 --- a/packages/eui/src/components/flyout/sessions/flyout_provider.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * 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, - 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, - EuiFlyoutSessionProviderComponentProps, - EuiFlyoutSessionRenderContext, -} from './types'; - -interface FlyoutSessionContextProps { - state: EuiFlyoutSessionHistoryState; - dispatch: React.Dispatch; - onUnmount?: EuiFlyoutSessionProviderComponentProps['onUnmount']; - historyFilter: EuiFlyoutSessionProviderComponentProps['historyFilter']; -} - -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, - historyFilter, - onUnmount, -}) => { - 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 = () => { - dispatch({ type: 'CLOSE_SESSION' }); - }; - - const handleCloseChild = () => { - 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; - - if (activeFlyoutGroup) { - const renderContext: EuiFlyoutSessionRenderContext = { - activeFlyoutGroup, - meta: activeFlyoutGroup.meta, - }; - mainFlyoutContentNode = renderMainFlyoutContent(renderContext); - - if (activeFlyoutGroup.isChildOpen && renderChildFlyoutContent) { - childFlyoutContentNode = renderChildFlyoutContent(renderContext); - } 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 && ( - - {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 deleted file mode 100644 index ee89b36a4a7..00000000000 --- a/packages/eui/src/components/flyout/sessions/flyout_reducer.ts +++ /dev/null @@ -1,277 +0,0 @@ -/* - * 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, - }, - }; -}; - -/** - * 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 - */ -export function flyoutReducer( - state: EuiFlyoutSessionHistoryState, - action: EuiFlyoutSessionAction -): EuiFlyoutSessionHistoryState { - switch (action.type) { - case 'OPEN_MAIN_FLYOUT': { - const { size, flyoutProps, meta } = action.payload; - const newHistory = [...state.history]; - - if (state.activeFlyoutGroup) { - newHistory.unshift(state.activeFlyoutGroup); - } - - const newActiveGroup: EuiFlyoutSessionGroup = { - isMainOpen: true, - isChildOpen: false, - config: { - mainSize: size, - mainFlyoutProps: flyoutProps, - }, - 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 { - 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, title, meta } = action.payload; - const updatedActiveGroup: EuiFlyoutSessionGroup = { - ...state.activeFlyoutGroup, - isChildOpen: true, - config: { - ...state.activeFlyoutGroup.config, // retain main flyout config - childTitle: title, - childSize: size, - childFlyoutProps: flyoutProps, - }, - meta: mergeMeta(state.activeFlyoutGroup?.meta, 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.unshift(state.activeFlyoutGroup); - } - - // Create the new active group with both main and child flyouts open - const newActiveGroup: EuiFlyoutSessionGroup = { - 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: mergeMeta(state.activeFlyoutGroup?.meta, 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; - } - - const updatedActiveGroup: EuiFlyoutSessionGroup = { - ...state.activeFlyoutGroup, - isChildOpen: false, - config: { - ...state.activeFlyoutGroup.config, - childFlyoutProps: {}, - }, - }; - - return { - history: state.history, - activeFlyoutGroup: applySizeConstraints(updatedActiveGroup), - }; - } - - 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; - - // Restore from history or return to initial state - if (state.history.length > 0) { - const newHistory = [...state.history]; - const previousGroup = newHistory.shift(); - 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 } = action.payload; - - const updatedActiveGroup: EuiFlyoutSessionGroup = { - ...state.activeFlyoutGroup, - config: { - ...state.activeFlyoutGroup.config, - ...configChanges, - }, - }; - - const finalUpdatedActiveGroup = applySizeConstraints(updatedActiveGroup); - - return { - ...state, - activeFlyoutGroup: finalUpdatedActiveGroup, - }; - } - - case 'CLOSE_SESSION': - // Remove the active group and close the session - 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 deleted file mode 100644 index 444a3e4f37a..00000000000 --- a/packages/eui/src/components/flyout/sessions/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 { - EuiFlyoutSessionApi, - EuiFlyoutSessionConfig, - EuiFlyoutSessionOpenChildOptions, - EuiFlyoutSessionOpenGroupOptions, - EuiFlyoutSessionOpenMainOptions, - EuiFlyoutSessionOpenManagedOptions, - EuiFlyoutSessionProviderComponentProps, - EuiFlyoutSessionRenderContext, -} from './types'; - -export { useEuiFlyoutSession } from './use_eui_flyout'; 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 deleted file mode 100644 index ba3d51e9234..00000000000 --- a/packages/eui/src/components/flyout/sessions/managed_flyout_menu.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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 deleted file mode 100644 index ebeea27647f..00000000000 --- a/packages/eui/src/components/flyout/sessions/managed_flyout_menu.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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 deleted file mode 100644 index 6da9ab62c4a..00000000000 --- a/packages/eui/src/components/flyout/sessions/types.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* - * 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 type { EuiFlyoutProps, EuiFlyoutSize } from '../flyout'; -import type { EuiFlyoutChildProps } from '../flyout_child'; - -/** - * Configuration used for setting display options for main and child flyouts in a session. - */ -export interface EuiFlyoutSessionConfig { - mainSize: EuiFlyoutSize; - mainTitle?: string; - hideMainTitle?: boolean; - childSize?: 's' | 'm'; - childTitle?: string; - mainFlyoutProps?: Partial>; - childFlyoutProps?: Partial>; - /** - * Indicates if the flyout was opened with openManagedFlyout or openFlyout - */ - isManaged?: boolean; -} - -/** - * Options that control a main flyout in a session - */ -export interface EuiFlyoutSessionOpenMainOptions { - size: EuiFlyoutSize; - flyoutProps?: EuiFlyoutSessionConfig['mainFlyoutProps']; - /** - * Caller-defined data - */ - meta?: Meta; -} - -export interface EuiFlyoutSessionOpenManagedOptions { - size: EuiFlyoutSize; - flyoutProps?: EuiFlyoutSessionConfig['mainFlyoutProps']; - /** - * Title to display in top menu bar and in the options of the history popover - */ - title: string; - /** - * Allows title to be hidden from top menu bar. If this is true, - * the title will only be used for the history popover - */ - hideTitle?: boolean; - /** - * Caller-defined data - */ - meta?: Meta; -} - -/** - * Options that control a child flyout in a session - */ -export interface EuiFlyoutSessionOpenChildOptions { - size: 's' | 'm'; - flyoutProps?: EuiFlyoutSessionConfig['childFlyoutProps']; - title: string; - /** - * Caller-defined data - */ - meta?: Meta; -} - -/** - * Options for opening both a main flyout and child flyout simultaneously - */ -export interface EuiFlyoutSessionOpenGroupOptions { - main: EuiFlyoutSessionOpenManagedOptions; - child: EuiFlyoutSessionOpenChildOptions; - /** - * Caller-defined data - */ - meta?: Meta; // Shared meta for both flyouts -} - -/** - * 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; - /** - * Caller-defined data - */ - 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; - }; - } - | { - type: 'OPEN_MAIN_FLYOUT'; - payload: EuiFlyoutSessionOpenMainOptions; - } - | { - type: 'OPEN_MANAGED_FLYOUT'; - payload: EuiFlyoutSessionOpenManagedOptions; - } - | { - type: 'OPEN_CHILD_FLYOUT'; - payload: EuiFlyoutSessionOpenChildOptions; - } - | { - type: 'OPEN_FLYOUT_GROUP'; - payload: EuiFlyoutSessionOpenGroupOptions; - } - | { type: 'GO_BACK' } - | { type: 'GO_TO_HISTORY_ITEM'; index: number } - | { type: 'CLOSE_CHILD_FLYOUT' } - | { type: 'CLOSE_SESSION' }; - -/** - * Flyout session context managed by `EuiFlyoutSessionProvider`, and passed to the `renderMainFlyoutContent` and `renderChildFlyoutContent` functions. - */ -export interface EuiFlyoutSessionRenderContext { - activeFlyoutGroup: EuiFlyoutSessionGroup | null; - /** - * Caller-defined data - */ - 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; - 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; - closeChildFlyout: () => void; - goBack: () => void; - closeSession: () => void; - isFlyoutOpen: boolean; - isChildFlyoutOpen: boolean; - canGoBack: boolean; -} 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 deleted file mode 100644 index f2b6c145050..00000000000 --- a/packages/eui/src/components/flyout/sessions/use_eui_flyout.test.tsx +++ /dev/null @@ -1,446 +0,0 @@ -/* - * 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 { fireEvent, render, screen } from '@testing-library/react'; -import React from 'react'; - -import { EuiFlyoutSessionProvider } from './flyout_provider'; -import type { - EuiFlyoutSessionOpenChildOptions, - EuiFlyoutSessionOpenGroupOptions, - EuiFlyoutSessionOpenMainOptions, -} from './types'; -import { useEuiFlyoutSession } 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; - onGoBack?: () => void; - onCloseSession?: () => void; -} - -const TestComponent: React.FC = ({ - onOpenFlyout, - onOpenChildFlyout, - onOpenFlyoutGroup, - onGoBack, - onCloseSession, -}) => { - const { - openFlyout, - openChildFlyout, - openFlyoutGroup, - closeChildFlyout, - goBack, - isFlyoutOpen, - isChildFlyoutOpen, - canGoBack, - closeSession, - } = 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; - onUnmount?: () => void; - } -> = ({ children, onUnmount }) => { - 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( - 'Cannot 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( - 'Cannot go back' - ); - }); - - test('closeChildFlyout closes only the child flyout', () => { - render( - - - - ); - - // Open both flyouts - fireEvent.click(screen.getByTestId('openFlyoutGroupButton')); - - // Then close the child flyout - fireEvent.click(screen.getByTestId('closeChildFlyoutButton')); - - 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 cannot go back as there's no more history - expect(screen.getByTestId('canGoBackStatus').textContent).toBe( - 'Cannot go back' - ); - - // The go back button is now disabled, so a click won't do anything. - // The flyout should still be open. - fireEvent.click(screen.getByTestId('goBackButton')); - expect(onGoBack).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('flyoutStatus').textContent).toBe( - 'Flyout is open' - ); - }); - - test('closeSession closes all flyouts', () => { - const onCloseSession = jest.fn(); - render( - - - - ); - - // Open both flyouts - fireEvent.click(screen.getByTestId('openFlyoutGroupButton')); - - // Close Session should close everything - fireEvent.click(screen.getByTestId('closeSessionButton')); - - expect(onCloseSession).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' - ); - }); - - describe('onUnmount callback', () => { - test('is called when all flyouts are closed via closeSession', () => { - const onUnmount = jest.fn(); - render( - - - - ); - - // Open a flyout group - fireEvent.click(screen.getByTestId('openFlyoutGroupButton')); - expect(screen.getByTestId('flyoutStatus').textContent).toBe( - 'Flyout is open' - ); - expect(onUnmount).not.toHaveBeenCalled(); - - // Close Session, which should close all flyouts and trigger onUnmount - fireEvent.click(screen.getByTestId('closeSessionButton')); - expect(screen.getByTestId('flyoutStatus').textContent).toBe( - 'Flyout is closed' - ); - expect(onUnmount).toHaveBeenCalledTimes(1); - }); - - test('is not called when a flyout is opened', () => { - const onUnmount = jest.fn(); - render( - - - - ); - - fireEvent.click(screen.getByTestId('openFlyoutButton')); - expect(onUnmount).not.toHaveBeenCalled(); - }); - - test('is not called while there are still flyouts in the history stack', () => { - const onUnmount = jest.fn(); - render( - - - - ); - - // Open two flyouts to create a history stack - fireEvent.click(screen.getByTestId('openFlyoutButton')); - fireEvent.click(screen.getByTestId('openFlyoutButton')); - expect(screen.getByTestId('canGoBackStatus').textContent).toBe( - 'Can go back' - ); - expect(onUnmount).not.toHaveBeenCalled(); - - // Go back once, which leaves one flyout open - fireEvent.click(screen.getByTestId('goBackButton')); - expect(screen.getByTestId('flyoutStatus').textContent).toBe( - 'Flyout is open' - ); - expect(onUnmount).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts b/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts deleted file mode 100644 index 67a15a531f0..00000000000 --- a/packages/eui/src/components/flyout/sessions/use_eui_flyout.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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 { useEffect, useRef } from 'react'; -import { useEuiFlyoutSessionContext } from './flyout_provider'; -import type { - EuiFlyoutSessionApi, - EuiFlyoutSessionOpenChildOptions, - EuiFlyoutSessionOpenGroupOptions, - EuiFlyoutSessionOpenMainOptions, - EuiFlyoutSessionOpenManagedOptions, -} from './types'; - -/** - * Hook for accessing the flyout API - * @public - */ -export function useEuiFlyoutSession(): EuiFlyoutSessionApi { - const { state, dispatch, onUnmount } = useEuiFlyoutSessionContext(); - const isInitialMount = useRef(true); - - useEffect(() => { - // When there is no active flyout, we should call the onUnmount callback. - // Ensure this is not called on the initial render, only on subsequent state changes. - if (isInitialMount.current) { - isInitialMount.current = false; - } else if (state.activeFlyoutGroup === null) { - onUnmount?.(); - } - }, [state.activeFlyoutGroup, onUnmount]); - - /** - * Open a "plain" main flyout without an automatic top menu bar - */ - const openFlyout = (options: EuiFlyoutSessionOpenMainOptions) => { - dispatch({ - type: 'OPEN_MAIN_FLYOUT', - payload: options, - }); - }; - - /** - * 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( - 'useEuiFlyoutApi: Cannot open child flyout when main flyout is not open.' - ); - return; - } - dispatch({ - type: 'OPEN_CHILD_FLYOUT', - payload: options, - }); - }; - - /** - * Open a pair of managed main and child flyouts - */ - const openFlyoutGroup = (options: EuiFlyoutSessionOpenGroupOptions) => { - dispatch({ - type: 'OPEN_FLYOUT_GROUP', - payload: options, - }); - }; - - const closeChildFlyout = () => { - dispatch({ type: 'CLOSE_CHILD_FLYOUT' }); - }; - - const goBack = () => { - dispatch({ type: 'GO_BACK' }); - }; - - const closeSession = () => { - dispatch({ type: 'CLOSE_SESSION' }); - }; - - const isFlyoutOpen = !!state.activeFlyoutGroup?.isMainOpen; - - const isChildFlyoutOpen = !!state.activeFlyoutGroup?.isChildOpen; - - const canGoBack = !!state.history.length; - - return { - openFlyout, - openManagedFlyout, - openChildFlyout, - openFlyoutGroup, - closeChildFlyout, - goBack, - closeSession, - isFlyoutOpen, - isChildFlyoutOpen, - canGoBack, - }; -} diff --git a/packages/eui/src/components/overlay_mask/overlay_mask.styles.ts b/packages/eui/src/components/overlay_mask/overlay_mask.styles.ts index 445462e62b6..973747e3645 100644 --- a/packages/eui/src/components/overlay_mask/overlay_mask.styles.ts +++ b/packages/eui/src/components/overlay_mask/overlay_mask.styles.ts @@ -24,7 +24,8 @@ export const euiOverlayMaskStyles = ({ align-items: center; justify-content: center; ${logicalCSS('padding-bottom', '10vh')} - animation: ${euiAnimFadeIn} ${euiTheme.animation.fast} ease-in; + animation: ${euiAnimFadeIn} ${euiTheme.animation.fast} ease-in forwards; + animation-iteration-count: 1; background: ${highContrastMode ? euiTheme.components.overlayMaskBackgroundHighContrast : euiTheme.components.overlayMaskBackground}; @@ -36,4 +37,7 @@ export const euiOverlayMaskStyles = ({ z-index: ${euiTheme.levels.maskBelowHeader}; ${logicalCSS('top', 'var(--euiFixedHeadersOffset, 0)')} `, + noAnimation: css` + animation: none; + `, }); diff --git a/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx b/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx index 63ba82704c0..6399aa96c9f 100644 --- a/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx +++ b/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx @@ -45,7 +45,7 @@ describe('EuiOverlayMask', () => { baseElement.querySelector('.euiOverlayMask')!.className; expect(getClassName()).toMatchInlineSnapshot( - `"euiOverlayMask css-9s0dmo-euiOverlayMask-aboveHeader hello"` + `"euiOverlayMask css-rfqaz-euiOverlayMask-aboveHeader hello"` ); rerender( @@ -54,7 +54,7 @@ describe('EuiOverlayMask', () => { ); expect(getClassName()).toMatchInlineSnapshot( - `"euiOverlayMask css-1wv6ei5-euiOverlayMask-belowHeader world"` + `"euiOverlayMask css-1qgdr70-euiOverlayMask-belowHeader world"` ); }); diff --git a/packages/eui/src/components/overlay_mask/overlay_mask.tsx b/packages/eui/src/components/overlay_mask/overlay_mask.tsx index 1a2519425d1..78a8d8ce5ae 100644 --- a/packages/eui/src/components/overlay_mask/overlay_mask.tsx +++ b/packages/eui/src/components/overlay_mask/overlay_mask.tsx @@ -12,7 +12,9 @@ import React, { MutableRefObject, ReactNode, Ref, + useCallback, useEffect, + useRef, useState, } from 'react'; import { cx } from '@emotion/css'; @@ -52,6 +54,7 @@ export const EuiOverlayMask: FunctionComponent = ({ maskRef, ...rest }) => { + const hasRendered = useRef(false); const [overlayMaskNode, setOverlayMaskNode] = useState( null ); @@ -59,10 +62,16 @@ export const EuiOverlayMask: FunctionComponent = ({ setOverlayMaskNode, maskRef, ]); + + const handleAnimationEnd = useCallback(() => { + hasRendered.current = true; + }, []); + const styles = useEuiMemoizedStyles(euiOverlayMaskStyles); const cssStyles = cx([ styles.euiOverlayMask, styles[`${headerZindexLocation}Header`], + hasRendered.current && styles.noAnimation, ]); useEffect(() => { @@ -77,7 +86,8 @@ export const EuiOverlayMask: FunctionComponent = ({ overlayMaskNode.setAttribute(key, rest[key]!); } }); - }, [overlayMaskNode]); // eslint-disable-line react-hooks/exhaustive-deps + overlayMaskNode.addEventListener('animationend', handleAnimationEnd); + }, [overlayMaskNode, handleAnimationEnd]); // eslint-disable-line react-hooks/exhaustive-deps // Note: Use `classList.add/remove` instead of setting the entire `className` // so as not to override any existing classes set by `EuiPortal` diff --git a/packages/eui/src/components/provider/provider.tsx b/packages/eui/src/components/provider/provider.tsx index 0224674f4b0..be5b58b9086 100644 --- a/packages/eui/src/components/provider/provider.tsx +++ b/packages/eui/src/components/provider/provider.tsx @@ -33,6 +33,7 @@ import { EuiComponentDefaults, EuiComponentDefaultsProvider, } from './component_defaults'; +import { EuiFlyoutManager } from '../flyout/manager'; const isEmotionCacheObject = ( obj: EmotionCache | Object @@ -164,7 +165,7 @@ export const EuiProvider = ({ )} - {children} + {children} diff --git a/packages/eui/src/services/theme/provider.test.tsx b/packages/eui/src/services/theme/provider.test.tsx index 08d56593b6b..c43a0d7c43e 100644 --- a/packages/eui/src/services/theme/provider.test.tsx +++ b/packages/eui/src/services/theme/provider.test.tsx @@ -201,7 +201,7 @@ describe('EuiThemeProvider', () => { ); expect(container.textContent).toEqual('l'); - expect(resizeListenerCount).toEqual(1); + expect(resizeListenerCount).toEqual(2); }); }); @@ -239,7 +239,7 @@ describe('EuiThemeProvider', () => { ); expect(container.textContent).toEqual('xl'); - expect(resizeListenerCount).toEqual(1); + expect(resizeListenerCount).toEqual(2); }); }); }); diff --git a/packages/website/docs/components/containers/flyout/index.mdx b/packages/website/docs/components/containers/flyout/index.mdx index ac8674d7089..4a229d22a1b 100644 --- a/packages/website/docs/components/containers/flyout/index.mdx +++ b/packages/website/docs/components/containers/flyout/index.mdx @@ -1061,9 +1061,8 @@ Use `EuiFlyoutChild` to create a nested flyout that aligns to the left edge of a ## Props import docgen from '@elastic/eui-docgen/dist/components/flyout'; -import flyoutDocgen from '@elastic/eui-docgen/dist/components/flyout/flyout.json'; - + @@ -1073,4 +1072,3 @@ import flyoutDocgen from '@elastic/eui-docgen/dist/components/flyout/flyout.json - From baaa271a16dbd4fd46fff4136bad3bd8df96e581 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 4 Sep 2025 13:57:22 -0700 Subject: [PATCH 03/30] [Flyout System] Support size="fill" (#8982) --- .../flyout/__snapshots__/flyout.test.tsx.snap | 49 +++ packages/eui/src/components/flyout/const.ts | 2 +- .../components/flyout/flyout.component.tsx | 72 ++++- .../components/flyout/flyout.styles.test.tsx | 223 ++++++++++++++ .../src/components/flyout/flyout.styles.ts | 134 +++++++- .../manager/flyout_fill_mode.stories.tsx | 281 +++++++++++++++++ .../src/components/flyout/manager/index.ts | 3 + .../flyout/manager/layout_mode.test.tsx | 285 ++++++++++++++++++ .../components/flyout/manager/layout_mode.ts | 15 + .../flyout/manager/validation.test.ts | 41 ++- .../components/flyout/manager/validation.ts | 15 +- 11 files changed, 1099 insertions(+), 21 deletions(-) create mode 100644 packages/eui/src/components/flyout/flyout.styles.test.tsx create mode 100644 packages/eui/src/components/flyout/manager/flyout_fill_mode.stories.tsx diff --git a/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap b/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap index 82f4c313eeb..07f31c2b389 100644 --- a/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap +++ b/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap @@ -1050,6 +1050,55 @@ exports[`EuiFlyout props size accepts custom number 1`] = ` `; +exports[`EuiFlyout props size fill is rendered 1`] = ` +[ +
+
+
+ +
+
+
, +] +`; + exports[`EuiFlyout props size l is rendered 1`] = ` { + if (!flyoutId || !currentSession) { + return { + isMainFlyout: false, + siblingFlyoutId: null, + hasValidSession: false, + sessionForWidth: null, + }; + } + + const siblingFlyoutId = + currentSession.main === flyoutId + ? currentSession.child + : currentSession.main; + + return { + siblingFlyoutId, + hasValidSession: true, + sessionForWidth: currentSession, + }; + }, [flyoutId, currentSession]); + + // Destructure for easier use + const { siblingFlyoutId } = flyoutIdentity; + const hasChildFlyout = currentSession?.child != null; const isChildFlyout = isInManagedContext && hasChildFlyout && currentSession?.child === id; @@ -310,21 +346,29 @@ export const EuiFlyoutComponent = forwardRef( [onClose, isPushed, shouldCloseOnEscape] ); + const siblingFlyoutWidth = useFlyoutWidth(siblingFlyoutId); + /** * Set inline styles */ const inlineStyles = useMemo(() => { - const widthStyle = - !isEuiFlyoutSizeNamed(size) && logicalStyle('width', size); - const maxWidthStyle = - typeof maxWidth !== 'boolean' && logicalStyle('max-width', maxWidth); - - return { - ...style, - ...widthStyle, - ...maxWidthStyle, - }; - }, [style, maxWidth, size]); + const composedStyles = composeFlyoutInlineStyles( + size, + layoutMode, + siblingFlyoutId, + siblingFlyoutWidth || null, + maxWidth + ); + + return { ...style, ...composedStyles }; + }, [ + style, + size, + layoutMode, + siblingFlyoutId, + siblingFlyoutWidth, + maxWidth, + ]); const styles = useEuiMemoizedStyles(euiFlyoutStyles); const cssStyles = [ diff --git a/packages/eui/src/components/flyout/flyout.styles.test.tsx b/packages/eui/src/components/flyout/flyout.styles.test.tsx new file mode 100644 index 00000000000..5d703cdc291 --- /dev/null +++ b/packages/eui/src/components/flyout/flyout.styles.test.tsx @@ -0,0 +1,223 @@ +/* + * 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. + */ + +/* + * 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; 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 } from '../../test/rtl'; +import { useEuiTheme } from '../../services'; +import { euiFlyoutStyles, composeFlyoutInlineStyles } from './flyout.styles'; + +// Mock the flyout constants +jest.mock('./const', () => ({ + isEuiFlyoutSizeNamed: jest.fn((size: string | number) => { + return ['s', 'm', 'l', 'fill'].includes(size as string); + }), +})); + +describe('flyout.styles', () => { + describe('euiFlyoutStyles', () => { + const TestComponent = () => { + const { euiTheme } = useEuiTheme(); + const styles = euiFlyoutStyles({ + euiTheme, + colorMode: 'LIGHT', + modifications: {}, + highContrastMode: false, + }); + return
{JSON.stringify(styles)}
; + }; + + it('should include fill size styles', () => { + const { getByTestId } = render(); + const stylesText = getByTestId('styles').textContent; + expect(stylesText).toContain('fill'); + }); + + it('should apply correct fill size CSS', () => { + const { getByTestId } = render(); + const stylesText = getByTestId('styles').textContent; + expect(stylesText).toContain('90vw'); + }); + + it('should include all named size styles', () => { + const { getByTestId } = render(); + const stylesText = getByTestId('styles').textContent; + expect(stylesText).toContain('s'); + expect(stylesText).toContain('m'); + expect(stylesText).toContain('l'); + expect(stylesText).toContain('fill'); + }); + }); + + describe('composeFlyoutInlineStyles - basic functionality', () => { + it('should handle custom width values (non-named sizes)', () => { + const result = composeFlyoutInlineStyles( + '400px', + 'stacked', + null, + null, + undefined + ); + expect(result).toEqual({ inlineSize: '400px' }); + }); + + it('should handle fill size in stacked mode', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'stacked', + null, + null, + undefined + ); + expect(result).toEqual({}); + }); + + it('should calculate dynamic width for fill size in side-by-side mode', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'side-by-side', + 'sibling-id', + 300, + undefined + ); + expect(result).toEqual({ + inlineSize: 'calc(90vw - 300px)', + minInlineSize: '0', + }); + }); + + it('should handle maxWidth for non-fill sizes', () => { + const result = composeFlyoutInlineStyles('m', 'stacked', null, null, 800); + expect(result).toEqual({ + maxInlineSize: '800px', + }); + }); + + it('should not apply dynamic styles when not fill size', () => { + const result = composeFlyoutInlineStyles( + 'm', + 'side-by-side', + 'sibling-id', + 300, + undefined + ); + expect(result).toEqual({}); + }); + + it('should not apply dynamic styles when not side-by-side mode', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'stacked', + 'sibling-id', + 300, + undefined + ); + expect(result).toEqual({}); + }); + }); + + describe('composeFlyoutInlineStyles - maxWidth handling', () => { + it('should handle maxWidth for fill size without sibling', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'stacked', + null, + null, + 600 + ); + expect(result).toEqual({ + maxInlineSize: '600px', + minInlineSize: 'min(600px, 90vw)', + }); + }); + + it('should handle maxWidth for fill size with sibling', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'side-by-side', + 'sibling-id', + 300, + 600 + ); + expect(result).toEqual({ + inlineSize: 'calc(90vw - 300px)', + maxInlineSize: 'min(600px, calc(90vw - 300px))', + minInlineSize: 'min(600px, calc(90vw - 300px))', + }); + }); + + it('should handle string maxWidth values', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'stacked', + null, + null, + '50%' + ); + expect(result).toEqual({ + maxInlineSize: '50%', + minInlineSize: 'min(50%, 90vw)', + }); + }); + + it('should handle boolean maxWidth (should be ignored)', () => { + const result = composeFlyoutInlineStyles( + 'fill', + 'stacked', + null, + null, + true + ); + // Boolean maxWidth should be ignored, but the function still processes it + // because the condition `if (isFill && maxWidth)` evaluates to true for boolean true + expect(result).toEqual({ + maxInlineSize: true, + minInlineSize: undefined, + }); + }); + + it('should handle fill size with maxWidth but no sibling in side-by-side mode', () => { + // This tests the case where we're in side-by-side mode but there's no sibling + const result = composeFlyoutInlineStyles( + 'fill', + 'side-by-side', + null, + null, + 600 + ); + expect(result).toEqual({ + maxInlineSize: '600px', + minInlineSize: 'min(600px, 90vw)', + }); + }); + + it('should handle maxWidth with sibling but no dynamic width calculation', () => { + // This tests the case where maxWidth is provided but dynamic width calculation + // is not applied (e.g., not fill size, not side-by-side, etc.) + const result = composeFlyoutInlineStyles( + 'm', + 'side-by-side', + 'sibling-id', + 300, + 600 + ); + expect(result).toEqual({ + maxInlineSize: '600px', + }); + }); + }); +}); diff --git a/packages/eui/src/components/flyout/flyout.styles.ts b/packages/eui/src/components/flyout/flyout.styles.ts index 1f32ce56431..fe377921c76 100644 --- a/packages/eui/src/components/flyout/flyout.styles.ts +++ b/packages/eui/src/components/flyout/flyout.styles.ts @@ -9,13 +9,18 @@ import { css, keyframes } from '@emotion/react'; import { euiShadowXLarge } from '@elastic/eui-theme-common'; -import { _EuiFlyoutPaddingSize, EuiFlyoutSize } from './const'; +import { + _EuiFlyoutPaddingSize, + EuiFlyoutSize, + isEuiFlyoutSizeNamed, +} from './const'; import { PROPERTY_FLYOUT } from './manager/const'; import { euiCanAnimate, euiMaxBreakpoint, euiMinBreakpoint, logicalCSS, + logicalStyles, mathWithUnits, } from '../../global_styling'; import { UseEuiTheme } from '../../services'; @@ -87,6 +92,9 @@ export const euiFlyoutStyles = (euiThemeContext: UseEuiTheme) => { l: css` ${composeFlyoutSizing(euiThemeContext, 'l')} `, + fill: css` + ${composeFlyoutSizing(euiThemeContext, 'fill')} + `, noMaxWidth: css` ${logicalCSS('max-width', 'none')} `, @@ -219,6 +227,14 @@ export const composeFlyoutSizing = ( width: '75vw', max: `${euiTheme.breakpoint.l}px`, }, + + // NOTE: These styles are for the flyout system in `stacked` layout mode. + // In `side-by-side` mode, @flyout.component.tsx uses inline styles. + fill: { + min: '90vw', + width: '90vw', + max: '90vw', + }, }; return ` @@ -281,3 +297,119 @@ const composeFlyoutPadding = ( } `; }; + +/** + * Helper for `composeFlyoutInlineStyles` + * Handles maxWidth prop overrides to ensure they take precedence over base CSS + */ +const composeMaxWidthOverrides = ( + maxWidth: boolean | number | string | undefined, + isFill: boolean | undefined +): React.CSSProperties => { + if (typeof maxWidth === 'boolean') { + return {}; + } + + const overrides: React.CSSProperties = { + maxWidth, + }; + + // For fill size flyouts, we need to override min-width to allow dynamic sizing + if (isFill) { + overrides.minWidth = '0'; + + // When maxWidth is provided for fill flyouts, we need to override the CSS rule + // that sets min-inline-size: 90vw. We calculate min(maxWidth, 90vw) to ensure + // the flyout respects both constraints and doesn't get stuck at 90vw minimum. + if (maxWidth) { + const maxWidthWithUnits = + typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth; + overrides.minWidth = `min(${maxWidthWithUnits}, 90vw)`; + } + } + + return logicalStyles(overrides); +}; + +/** + * Composes all inline styles for a flyout based on its configuration + */ +export const composeFlyoutInlineStyles = ( + size: EuiFlyoutSize | string | number, + layoutMode: 'side-by-side' | 'stacked', + siblingFlyoutId: string | null, + siblingFlyoutWidth: number | null, + maxWidth: boolean | number | string | undefined +): React.CSSProperties => { + // Handle custom width values (non-named sizes) + const customWidthStyles = !isEuiFlyoutSizeNamed(size) + ? logicalStyles({ width: size }) + : {}; + + const isFill = size === 'fill'; + + // Handle dynamic width calculation for fill size in side-by-side mode + const dynamicStyles = + isFill && + layoutMode === 'side-by-side' && + siblingFlyoutId && + siblingFlyoutWidth + ? logicalStyles({ + width: `calc(90vw - ${siblingFlyoutWidth}px)`, + minWidth: '0', + }) + : {}; + + // For fill flyouts with maxWidth, we need to ensure the minWidth override is applied + // to override the CSS rule that sets min-inline-size: 90vw + let minWidthOverride = {}; + if (isFill && maxWidth) { + if ( + layoutMode === 'side-by-side' && + siblingFlyoutId && + siblingFlyoutWidth && + dynamicStyles.inlineSize + ) { + // For fill flyouts with maxWidth and a sibling: min(maxWidth, calc(90vw - siblingWidth)) + const dynamicWidth = dynamicStyles.inlineSize; + const maxWidthWithUnits = + typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth; + minWidthOverride = { + minWidth: `min(${maxWidthWithUnits}, ${dynamicWidth})`, + }; + } else { + // For fill flyouts with maxWidth but no sibling: min(maxWidth, 90vw) + const maxWidthOverrides = composeMaxWidthOverrides(maxWidth, isFill); + minWidthOverride = { minWidth: maxWidthOverrides.minInlineSize }; + } + } + + // Calculate the final maxWidth based on conditions + let finalMaxWidth: string | undefined; + + if ( + maxWidth && + isFill && + layoutMode === 'side-by-side' && + siblingFlyoutId && + siblingFlyoutWidth && + dynamicStyles.inlineSize + ) { + // For fill flyouts with maxWidth and a sibling: min(maxWidth, calc(90vw - siblingWidth)) + const dynamicWidth = dynamicStyles.inlineSize; + const maxWidthWithUnits = + typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth; + finalMaxWidth = `min(${maxWidthWithUnits}, ${dynamicWidth})`; + } else if (maxWidth) { + // For all other cases with maxWidth: use the original maxWidth value + finalMaxWidth = + typeof maxWidth === 'number' ? `${maxWidth}px` : (maxWidth as string); + } + + return logicalStyles({ + ...customWidthStyles, + ...dynamicStyles, + ...minWidthOverride, + ...(finalMaxWidth ? { maxWidth: finalMaxWidth } : {}), + }); +}; diff --git a/packages/eui/src/components/flyout/manager/flyout_fill_mode.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_fill_mode.stories.tsx new file mode 100644 index 00000000000..4f410b51bba --- /dev/null +++ b/packages/eui/src/components/flyout/manager/flyout_fill_mode.stories.tsx @@ -0,0 +1,281 @@ +/* + * 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 { actions } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import React, { useState } from 'react'; + +import { LOKI_SELECTORS } from '../../../../.storybook/loki'; +import { EuiBreakpointSize } from '../../../services'; +import { EuiButton } from '../../button'; +import { EuiCode } from '../../code'; +import { EuiSpacer } from '../../spacer'; +import { EuiText } from '../../text'; +import { FLYOUT_TYPES, EuiFlyout } from '../flyout'; +import { EuiFlyoutBody } from '../flyout_body'; +import { EuiFlyoutMenu } from '../flyout_menu'; +import { EuiFlyoutChild, EuiFlyoutChildProps } from './flyout_child'; +import { useFlyoutLayoutMode } from './hooks'; + +type EuiFlyoutChildActualProps = Pick< + EuiFlyoutChildProps, + | 'aria-label' + | 'as' + | 'backgroundStyle' + | 'children' + | 'closeButtonProps' + | 'focusTrapProps' + | 'includeFixedHeadersInFocusTrap' + | 'includeSelectorInFocusTrap' + | 'maskProps' + | 'maxWidth' + | 'onClose' + | 'ownFocus' + | 'paddingSize' + | 'pushAnimation' + | 'side' + | 'size' + | 'style' +>; + +const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; + +const playgroundActions = actions('log'); + +type EuiFlyoutType = (typeof FLYOUT_TYPES)[number]; + +interface FlyoutChildStoryArgs extends EuiFlyoutChildActualProps { + mainSize?: 's' | 'm' | 'fill'; + childSize?: 's' | 'm' | 'fill'; + childBackgroundStyle?: 'default' | 'shaded'; + childMaxWidth?: number; + mainFlyoutType: EuiFlyoutType; + mainMaxWidth?: number; + outsideClickCloses?: boolean; + pushMinBreakpoint: EuiBreakpointSize; +} + +const meta: Meta = { + title: 'Layout/EuiFlyout/Flyout Manager', + component: EuiFlyoutChild, + argTypes: { + 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`, or `m`. If the main is `m`, the child can only be `s`.', + }, + childBackgroundStyle: { + options: ['default', 'shaded'], + control: { type: 'radio' }, + description: 'The background style of the child flyout.', + }, + childMaxWidth: { + control: { type: 'number' }, + description: 'The maximum width of the child flyout.', + }, + mainSize: { + options: ['s', 'm', 'fill'], + control: { type: 'radio' }, + description: + 'The size of the main (parent) flyout. If `m`, the child must be `s`. If `s`, the child can be `s`, or `m`.', + }, + mainFlyoutType: { + options: FLYOUT_TYPES, + control: { type: 'radio' }, + description: 'The type of the main flyout..', + }, + mainMaxWidth: { + control: { type: 'number' }, + description: 'The maximum width of the main flyout.', + }, + pushMinBreakpoint: { + options: breakpointSizes, + control: { type: 'select' }, + description: + 'Breakpoint at which the main flyout (if `type="push"`) will convert to an overlay flyout. Defaults to `xs`.', + }, + + /** + * Disabled props that are not relevant to the playground + */ + + // 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 + ['aria-label']: { table: { disable: true } }, + as: { table: { disable: true } }, + children: { table: { disable: true } }, + closeButtonProps: { table: { disable: true } }, + focusTrapProps: { table: { disable: true } }, + includeFixedHeadersInFocusTrap: { table: { disable: true } }, + includeSelectorInFocusTrap: { table: { disable: true } }, + maskProps: { table: { disable: true } }, + onClose: { table: { disable: true } }, + paddingSize: { table: { disable: true } }, + side: { table: { disable: true } }, + style: { table: { disable: true } }, + }, + args: { + mainSize: 'fill', + childSize: 'm', + childBackgroundStyle: 'default', + mainFlyoutType: 'overlay', + outsideClickCloses: false, + ownFocus: true, // Depends on `mainFlyoutType=overlay` + paddingSize: 'm', + pushAnimation: true, + pushMinBreakpoint: 'xs', + }, + parameters: { + loki: { + chromeSelector: LOKI_SELECTORS.portal, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const Session: React.FC = (args) => { + const { + mainSize, + childSize, + childBackgroundStyle, + childMaxWidth, + mainFlyoutType, + mainMaxWidth, + ...rest + } = args; + + const [isMainOpen, setIsMainOpen] = useState(true); + const [isChildOpen, setIsChildOpen] = useState(false); + + const openMain = () => { + setIsMainOpen(true); + playgroundActions.log('Parent flyout opened'); + }; + const closeMain = () => { + setIsMainOpen(false); + setIsChildOpen(false); + playgroundActions.log('Parent flyout closed'); + }; + const openChild = () => { + setIsChildOpen(true); + playgroundActions.log('Child flyout opened'); + }; + const closeChild = () => { + setIsChildOpen(false); + playgroundActions.log('Child flyout closed'); + }; + + return ( + <> + {isMainOpen ? ( + Close Main Flyout + ) : ( + Open Main Flyout + )} + + {isMainOpen && ( + + + + +

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 + )} +
+ {isChildOpen && ( + + + + +

This is the child 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? +

+
+
+
+ )} +
+ )} + + ); +}; + +const FillModeExampleComponent: React.FC = (args) => { + const layoutMode = useFlyoutLayoutMode(); + + return ( + <> + +

+ Current layout mode: {layoutMode} +

+

+ Fill sizing rules: When a flyout has size="fill", + it automatically expands to fill available space. +

    +
  • + In side-by-side mode, it calculates width as{' '} + calc(90vw - siblingWidth). +
  • +
  • + With maxWidth prop, it applies{' '} + min(maxWidth, calc(90vw - siblingWidth)) to + respect both constraints. +
  • +
+

+
+ + + + ); +}; + +export const FillMode: Story = { + render: (args) => , +}; diff --git a/packages/eui/src/components/flyout/manager/index.ts b/packages/eui/src/components/flyout/manager/index.ts index d8ea716ee56..b87e143705b 100644 --- a/packages/eui/src/components/flyout/manager/index.ts +++ b/packages/eui/src/components/flyout/manager/index.ts @@ -46,3 +46,6 @@ export { export { EuiFlyoutChild, type EuiFlyoutChildProps } from './flyout_child'; export { EuiFlyoutMain, type EuiFlyoutMainProps } from './flyout_main'; + +/** Utility functions for flyout sizing and layout. */ +export { getWidthFromSize } from './layout_mode'; diff --git a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx index f79edfc9ac3..e175d45232e 100644 --- a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx +++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx @@ -247,6 +247,14 @@ describe('layout_mode', () => { expect(getWidthFromSize('500px')).toBe(500); }); + it('calculates size fill as 90% of viewport width', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + expect(getWidthFromSize('fill')).toBe(1080); // 1200 * 0.9 + }); + it('handles edge cases', () => { expect(getWidthFromSize(null as any)).toBe(0); expect(getWidthFromSize(undefined as any)).toBe(0); @@ -609,4 +617,281 @@ describe('layout_mode', () => { expect(mockWindow.requestAnimationFrame).toHaveBeenCalled(); }); }); + + describe('useApplyFlyoutLayoutMode with fill size', () => { + let TestComponent: React.FC; + + beforeEach(() => { + TestComponent = () => { + useApplyFlyoutLayoutMode(); + return
Test
; + }; + }); + + it('should maintain side-by-side layout when parent is fill', () => { + // Set window width to be large enough (above breakpoint.s * 1.4) + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, // Above 768 * 1.4 = 1075.2 + }); + + // Mock flyout data with parent as fill + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'fill', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 's', + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }); + + render(); + + // Should maintain side-by-side layout for fill flyouts + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('should maintain side-by-side layout when child is fill', () => { + // Set window width to be large enough + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Mock flyout data with child as fill + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 's', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 'fill', + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }); + + render(); + + // Should maintain side-by-side layout for fill flyouts + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('should maintain side-by-side layout when both parent and child are fill', () => { + // Set window width to be large enough + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Mock flyout data with both as fill + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'fill', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 'fill', + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }); + + render(); + + // Should maintain side-by-side layout for fill flyouts + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('should stack when viewport is too small for fill flyouts', () => { + // Set window width to be too small (below breakpoint.s * 1.4) + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 800, // Below 768 * 1.4 = 1075.2 + }); + + // Mock flyout data with parent as fill + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'fill', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 's', + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, // Currently in SIDE_BY_SIDE mode + }, + dispatch: mockDispatch, + }); + + render(); + + // Should stack when viewport is too small + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_STACKED) + ); + }); + + it('should not dispatch when fill flyout is already in correct layout mode', () => { + // Set window width to be large enough + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Mock flyout data with parent as fill + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'fill', + }); + + mockUseCurrentChildFlyout.mockReturnValue({ + flyoutId: 'child-1', + level: 'child', + size: 's', + }); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: 'child-1', + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, // Already in correct mode + }, + dispatch: mockDispatch, + }); + + render(); + + // Should not dispatch when already in correct layout mode + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should handle fill flyout with no child', () => { + // Set window width to be large enough + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Mock flyout data with parent as fill and no child + mockUseCurrentMainFlyout.mockReturnValue({ + flyoutId: 'main-1', + level: 'main', + size: 'fill', + }); + + mockUseCurrentChildFlyout.mockReturnValue(null); + + mockUseCurrentSession.mockReturnValue({ + main: 'main-1', + child: null, + }); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_STACKED, // Currently in STACKED mode + }, + dispatch: mockDispatch, + }); + + render(); + + // Should maintain side-by-side layout for fill flyouts even without child + expect(mockDispatch).toHaveBeenCalledWith( + mockSetLayoutMode(LAYOUT_MODE_SIDE_BY_SIDE) + ); + }); + + it('should handle fill flyout with no session', () => { + // Set window width to be large enough + Object.defineProperty(window, 'innerWidth', { + writable: true, + value: 1200, + }); + + // Mock no session + mockUseCurrentSession.mockReturnValue(null); + mockUseCurrentMainFlyout.mockReturnValue(null); + mockUseCurrentChildFlyout.mockReturnValue(null); + + const mockDispatch = jest.fn(); + mockUseFlyoutManager.mockReturnValue({ + state: { + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + }, + dispatch: mockDispatch, + }); + + render(); + + // Should not dispatch when no session exists + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/eui/src/components/flyout/manager/layout_mode.ts b/packages/eui/src/components/flyout/manager/layout_mode.ts index df72b39e1c6..e1d04a4e33a 100644 --- a/packages/eui/src/components/flyout/manager/layout_mode.ts +++ b/packages/eui/src/components/flyout/manager/layout_mode.ts @@ -125,6 +125,19 @@ export const useApplyFlyoutLayoutMode = () => { const combinedWidthPercentage = (combinedWidth / windowWidth) * 100; let newLayoutMode: EuiFlyoutLayoutMode; + // Handle fill size flyouts: keep layout as side-by-side when fill flyout is present + // This allows fill flyouts to dynamically calculate their width based on sibling + if (parentFlyout?.size === 'fill' || childFlyout?.size === 'fill') { + // For fill flyouts, we want to maintain side-by-side layout to enable dynamic width calculation + // Only stack if the viewport is too small (below the small breakpoint) + if (windowWidth >= Math.round(euiTheme.breakpoint.s * 1.4)) { + if (currentLayoutMode !== LAYOUT_MODE_SIDE_BY_SIDE) { + setMode(LAYOUT_MODE_SIDE_BY_SIDE); + } + return; + } + } + if (currentLayoutMode === LAYOUT_MODE_STACKED) { newLayoutMode = combinedWidthPercentage <= THRESHOLD_TO_SIDE_BY_SIDE @@ -174,6 +187,8 @@ export const getWidthFromSize = (size: string | number): number => { return Math.round(window.innerWidth * 0.5); case 'l': return Math.round(window.innerWidth * 0.75); + case 'fill': + return Math.round(window.innerWidth * 0.9); default: break; } diff --git a/packages/eui/src/components/flyout/manager/validation.test.ts b/packages/eui/src/components/flyout/manager/validation.test.ts index 73b83f2bf25..f725eaf83c1 100644 --- a/packages/eui/src/components/flyout/manager/validation.test.ts +++ b/packages/eui/src/components/flyout/manager/validation.test.ts @@ -44,7 +44,7 @@ describe('Flyout Size Validation', () => { expect(error).toEqual({ type: 'INVALID_SIZE_TYPE', message: - 'Managed flyouts must use named sizes (s, m, l). Received: 100px', + 'Managed flyouts must use named sizes (s, m, l, fill). Received: 100px', flyoutId: 'test-id', level: 'main', size: '100px', @@ -58,6 +58,10 @@ describe('Flyout Size Validation', () => { expect(validateSizeCombination('s', 'l')).toBeNull(); expect(validateSizeCombination('m', 's')).toBeNull(); expect(validateSizeCombination('m', 'l')).toBeNull(); + expect(validateSizeCombination('s', 'fill')).toBeNull(); + expect(validateSizeCombination('m', 'fill')).toBeNull(); + expect(validateSizeCombination('fill', 's')).toBeNull(); + expect(validateSizeCombination('fill', 'm')).toBeNull(); }); it('should return error when parent and child are both m', () => { @@ -69,6 +73,15 @@ describe('Flyout Size Validation', () => { }); }); + it('should return error when parent and child are both fill', () => { + const error = validateSizeCombination('fill', 'fill'); + expect(error).toEqual({ + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent and child flyouts cannot both be size "fill"', + size: 'fill', + }); + }); + it('should return error when parent is l and there is a child', () => { const error = validateSizeCombination('l', 's'); expect(error).toEqual({ @@ -94,10 +107,17 @@ describe('Flyout Size Validation', () => { 'Parent and child flyouts cannot both be size "m"' ); - // Parent 'l' with child should fail - const error2 = validateFlyoutSize('s', 'child-id', 'child', 'l'); + // Parent and child both 'fill' should fail + const error2 = validateFlyoutSize('fill', 'child-id', 'child', 'fill'); expect(error2?.type).toBe('INVALID_SIZE_COMBINATION'); expect(error2?.message).toContain( + 'Parent and child flyouts cannot both be size "fill"' + ); + + // Parent 'l' with child should fail + const error3 = validateFlyoutSize('s', 'child-id', 'child', 'l'); + expect(error3?.type).toBe('INVALID_SIZE_COMBINATION'); + expect(error3?.message).toContain( 'Parent flyouts cannot be size "l" when there is a child flyout' ); }); @@ -106,6 +126,8 @@ describe('Flyout Size Validation', () => { expect(validateFlyoutSize('s', 'child-id', 'child', 'm')).toBeNull(); expect(validateFlyoutSize('l', 'child-id', 'child', 'm')).toBeNull(); expect(validateFlyoutSize('s', 'child-id', 'child', 's')).toBeNull(); + expect(validateFlyoutSize('fill', 'child-id', 'child', 'm')).toBeNull(); + expect(validateFlyoutSize('s', 'child-id', 'child', 'fill')).toBeNull(); }); }); @@ -138,5 +160,18 @@ describe('Flyout Size Validation', () => { 'EuiFlyout validation error: Parent and child flyouts cannot both be size "m"' ); }); + + it('should create error message for invalid fill size combination', () => { + const error: FlyoutSizeValidationError = { + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent and child flyouts cannot both be size "fill"', + size: 'fill', + }; + + const message = createValidationErrorMessage(error); + expect(message).toBe( + 'EuiFlyout validation error: Parent and child flyouts cannot both be size "fill"' + ); + }); }); }); diff --git a/packages/eui/src/components/flyout/manager/validation.ts b/packages/eui/src/components/flyout/manager/validation.ts index d1203b997d8..f22001d911a 100644 --- a/packages/eui/src/components/flyout/manager/validation.ts +++ b/packages/eui/src/components/flyout/manager/validation.ts @@ -13,8 +13,9 @@ import { EuiFlyoutLevel } from './types'; /** * Business rules for flyout sizes: - * - Managed flyouts should only accept "named" sizes (s, m, l) + * - Managed flyouts should only accept "named" sizes (s, m, l, fill) * - Parent and child can't both be 'm' + * - Parent and child can't both be 'fill' * - Parent can't be 'l' if there is a child */ @@ -42,9 +43,10 @@ export function validateManagedFlyoutSize( level: EuiFlyoutLevel ): FlyoutSizeValidationError | null { if (!isNamedSize(size)) { + const namedSizes = FLYOUT_SIZES.join(', '); return { type: 'INVALID_SIZE_TYPE', - message: `Managed flyouts must use named sizes (s, m, l). Received: ${size}`, + message: `Managed flyouts must use named sizes (${namedSizes}). Received: ${size}`, flyoutId, level, size: `${size}`, @@ -69,6 +71,15 @@ export function validateSizeCombination( }; } + // Parent and child can't both be 'fill' + if (parentSize === 'fill' && childSize === 'fill') { + return { + type: 'INVALID_SIZE_COMBINATION', + message: 'Parent and child flyouts cannot both be size "fill"', + size: childSize, + }; + } + // Parent can't be 'l' if there is a child if (parentSize === 'l') { return { From 225db6061b1ff2afe028e0f1f9055ebbd6688ee3 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Tue, 16 Sep 2025 13:03:35 +0200 Subject: [PATCH 04/30] [Flyout System] Support resizing flyouts (#8999) --- packages/eui/changelogs/upcoming/8999.md | 1 + .../collapsible_nav_beta.tsx | 2 +- .../flyout/__snapshots__/flyout.test.tsx.snap | 84 ++++---- ...les.ts => _flyout_resize_button.styles.ts} | 9 +- .../flyout/_flyout_resize_button.tsx | 54 +++++ .../components/flyout/flyout.component.tsx | 49 ++++- .../components/flyout/flyout_resizable.tsx | 192 +----------------- .../flyout/manager/flyout_child.stories.tsx | 16 ++ .../components/flyout/use_flyout_resizable.ts | 180 ++++++++++++++++ 9 files changed, 358 insertions(+), 229 deletions(-) create mode 100644 packages/eui/changelogs/upcoming/8999.md rename packages/eui/src/components/flyout/{flyout_resizable.styles.ts => _flyout_resize_button.styles.ts} (88%) create mode 100644 packages/eui/src/components/flyout/_flyout_resize_button.tsx create mode 100644 packages/eui/src/components/flyout/use_flyout_resizable.ts diff --git a/packages/eui/changelogs/upcoming/8999.md b/packages/eui/changelogs/upcoming/8999.md new file mode 100644 index 00000000000..ad7fd45868e --- /dev/null +++ b/packages/eui/changelogs/upcoming/8999.md @@ -0,0 +1 @@ +- Added a new optional `resizable` (boolean) prop to `EuiFlyout`. Resizability can now be controlled dynamically without the need to use `EuiFlyoutResizable`. diff --git a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx index 600a765fe31..e9c516e7844 100644 --- a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx +++ b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx @@ -35,7 +35,7 @@ import { EuiCollapsibleNavButton } from './collapsible_nav_button'; import { euiCollapsibleNavBetaStyles } from './collapsible_nav_beta.styles'; export type EuiCollapsibleNavBetaProps = CommonProps & - HTMLAttributes & + Omit, 'onResize'> & Pick< EuiFlyoutProps, // Extend only specific flyout props - EuiCollapsibleNav is much less customizable than EuiFlyout 'side' | 'focusTrapProps' | 'includeFixedHeadersInFocusTrap' diff --git a/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap b/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap index 07f31c2b389..f3806c8ba65 100644 --- a/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap +++ b/packages/eui/src/components/flyout/__snapshots__/flyout.test.tsx.snap @@ -1051,52 +1051,58 @@ exports[`EuiFlyout props size accepts custom number 1`] = ` `; exports[`EuiFlyout props size fill is rendered 1`] = ` -[ +
-
-
+
+
-
-
, -] +
+ `; exports[`EuiFlyout props size l is rendered 1`] = ` diff --git a/packages/eui/src/components/flyout/flyout_resizable.styles.ts b/packages/eui/src/components/flyout/_flyout_resize_button.styles.ts similarity index 88% rename from packages/eui/src/components/flyout/flyout_resizable.styles.ts rename to packages/eui/src/components/flyout/_flyout_resize_button.styles.ts index aea884d6144..921ece731ed 100644 --- a/packages/eui/src/components/flyout/flyout_resizable.styles.ts +++ b/packages/eui/src/components/flyout/_flyout_resize_button.styles.ts @@ -7,12 +7,11 @@ */ import { css } from '@emotion/react'; - -import { UseEuiTheme } from '../../services'; import { logicalCSS } from '../../global_styling'; +import { UseEuiTheme } from '../../services'; -export const euiFlyoutResizableButtonStyles = ({ euiTheme }: UseEuiTheme) => ({ - euiFlyoutResizableButton: css` +export const euiFlyoutResizeButtonStyles = ({ euiTheme }: UseEuiTheme) => ({ + root: css` position: absolute; `, overlay: { @@ -32,7 +31,7 @@ export const euiFlyoutResizableButtonStyles = ({ euiTheme }: UseEuiTheme) => ({ `, }, noOverlay: { - noOverlay: css` + root: css` margin-inline: 0; `, left: css` diff --git a/packages/eui/src/components/flyout/_flyout_resize_button.tsx b/packages/eui/src/components/flyout/_flyout_resize_button.tsx new file mode 100644 index 00000000000..818421e449f --- /dev/null +++ b/packages/eui/src/components/flyout/_flyout_resize_button.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { useEuiMemoizedStyles } from '../../services'; +import { + EuiResizableButton, + EuiResizableButtonProps, +} from '../resizable_container'; +import type { _EuiFlyoutType, _EuiFlyoutSide } from './const'; +import type { EuiFlyoutComponentProps } from './flyout.component'; +import { euiFlyoutResizeButtonStyles } from './_flyout_resize_button.styles'; + +type EuiFlyoutResizeButtonProps = Pick< + EuiResizableButtonProps, + 'onMouseDown' | 'onKeyDown' | 'onTouchStart' +> & { + type: _EuiFlyoutType; + side: _EuiFlyoutSide; + ownFocus: EuiFlyoutComponentProps['ownFocus']; + isPushed: boolean; +}; + +export const EuiFlyoutResizeButton = ({ + type, + side, + ownFocus, + isPushed, + ...resizableButtonProps +}: EuiFlyoutResizeButtonProps) => { + const hasOverlay = ownFocus && type === 'overlay'; + const styles = useEuiMemoizedStyles(euiFlyoutResizeButtonStyles); + + const cssStyles = [ + styles.root, + styles[type][side], + !hasOverlay && styles.noOverlay.root, + !hasOverlay && styles.noOverlay[side], + ]; + + return ( + + ); +}; diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index dbe1318fd61..906f7b9e080 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -67,6 +67,8 @@ import { isEuiFlyoutSizeNamed, } from './const'; import { useIsPushed } from './hooks'; +import { EuiFlyoutResizeButton } from './_flyout_resize_button'; +import { useEuiFlyoutResizable } from './use_flyout_resizable'; interface _EuiFlyoutComponentProps { onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; @@ -76,6 +78,11 @@ interface _EuiFlyoutComponentProps { * @default m */ size?: EuiFlyoutSize | CSSProperties['width']; + /** + * Sets the minimum width of the panel. + * Especially useful when set with `resizable = true`. + */ + minWidth?: number; /** * Sets the max-width of the panel, * set to `true` to use the default size, @@ -166,6 +173,17 @@ interface _EuiFlyoutComponentProps { * Specify additional css selectors to include in the focus trap. */ includeSelectorInFocusTrap?: string[] | string; + + /** + * Whether the flyout should be resizable. + * @default false + */ + resizable?: boolean; + + /** + * Optional callback that fires when the flyout is resized. + */ + onResize?: (width: number) => void; } const defaultElement = 'div'; @@ -199,7 +217,7 @@ export const EuiFlyoutComponent = forwardRef( onClose, ownFocus = true, side = DEFAULT_SIDE, - size = DEFAULT_SIZE, + size: _size = DEFAULT_SIZE, paddingSize = DEFAULT_PADDING_SIZE, maxWidth = false, style, @@ -213,6 +231,9 @@ export const EuiFlyoutComponent = forwardRef( includeSelectorInFocusTrap, 'aria-describedby': _ariaDescribedBy, id, + resizable = false, + minWidth, + onResize, ...rest } = usePropsWithComponentDefaults('EuiFlyout', props); @@ -225,6 +246,20 @@ export const EuiFlyoutComponent = forwardRef( const internalParentFlyoutRef = useRef(null); const isPushed = useIsPushed({ type, pushMinBreakpoint }); + const { + onMouseDown: onMouseDownResizableButton, + onKeyDown: onKeyDownResizableButton, + size, + setFlyoutRef, + } = useEuiFlyoutResizable({ + enabled: resizable, + minWidth, + maxWidth: typeof maxWidth === 'number' ? maxWidth : 0, + onResize, + side, + size: _size, + }); + /** * Setting up the refs on the actual flyout element in order to * accommodate for the `isPushed` state by adding padding to the body equal to the width of the element @@ -236,6 +271,7 @@ export const EuiFlyoutComponent = forwardRef( setResizeRef, ref, internalParentFlyoutRef, + setFlyoutRef, ]); const { width } = useResizeObserver(isPushed ? resizeRef : null, 'width'); @@ -534,6 +570,17 @@ export const EuiFlyoutComponent = forwardRef( side={side} /> )} + {resizable && ( + + )} {children} diff --git a/packages/eui/src/components/flyout/flyout_resizable.tsx b/packages/eui/src/components/flyout/flyout_resizable.tsx index 84dc5136ddb..f3ba68ddeb3 100644 --- a/packages/eui/src/components/flyout/flyout_resizable.tsx +++ b/packages/eui/src/components/flyout/flyout_resizable.tsx @@ -6,192 +6,18 @@ * Side Public License, v 1. */ -import React, { - forwardRef, - useState, - useEffect, - useRef, - useMemo, - useCallback, -} from 'react'; - -import { keys, useCombinedRefs, useEuiMemoizedStyles } from '../../services'; -import { EuiResizableButton } from '../resizable_container'; -import { getPosition } from '../resizable_container/helpers'; +import React, { forwardRef } from 'react'; import { EuiFlyout, EuiFlyoutProps } from './flyout'; -import { euiFlyoutResizableButtonStyles } from './flyout_resizable.styles'; -import { DEFAULT_SIDE, DEFAULT_TYPE } from './const'; export type EuiFlyoutResizableProps = { maxWidth?: number; - minWidth?: number; - /** - * Optional callback that fires on user resize with the new flyout width - */ - onResize?: (width: number) => void; -} & Omit; // If not omitted, the correct props don't show up in the docs prop table - -export const EuiFlyoutResizable = forwardRef( - ( - { - size, - maxWidth, - minWidth = 200, - onResize, - side = DEFAULT_SIDE, - type = DEFAULT_TYPE, - ownFocus = true, - children, - ...rest - }: EuiFlyoutResizableProps, - ref - ) => { - const hasOverlay = type === 'overlay' && ownFocus; - - const styles = useEuiMemoizedStyles(euiFlyoutResizableButtonStyles); - const cssStyles = [ - styles.euiFlyoutResizableButton, - styles[type][side], - !hasOverlay && styles.noOverlay.noOverlay, - !hasOverlay && styles.noOverlay[side], - ]; - - const getFlyoutMinMaxWidth = useCallback( - (width: number) => { - return Math.min( - Math.max(width, minWidth), - maxWidth || Infinity, - window.innerWidth - 20 // Leave some offset - ); - }, - [minWidth, maxWidth] - ); - - const [flyoutWidth, setFlyoutWidth] = useState(0); - const [callOnResize, setCallOnResize] = useState(false); - - // Must use state for the flyout ref in order for the useEffect to be correctly called after render - const [flyoutRef, setFlyoutRef] = useState(null); - const setRefs = useCombinedRefs([setFlyoutRef, ref]); - - useEffect(() => { - if (!flyoutWidth && flyoutRef) { - setCallOnResize(false); // Don't call `onResize` for non-user width changes - setFlyoutWidth(getFlyoutMinMaxWidth(flyoutRef.offsetWidth)); - } - }, [flyoutWidth, flyoutRef, getFlyoutMinMaxWidth]); - - // Update flyout width when consumers pass in a new `size` - useEffect(() => { - setCallOnResize(false); - // For string `size`s, resetting flyoutWidth to 0 will trigger the above useEffect's recalculation - setFlyoutWidth(typeof size === 'number' ? getFlyoutMinMaxWidth(size) : 0); - }, [size, getFlyoutMinMaxWidth]); - - // Initial numbers to calculate from, on resize drag start - const initialWidth = useRef(0); - const initialMouseX = useRef(0); - - // Account for flyout side and logical property direction - const direction = useMemo(() => { - let modifier = side === 'right' ? -1 : 1; - if (flyoutRef) { - const languageDirection = window.getComputedStyle(flyoutRef).direction; - if (languageDirection === 'rtl') modifier *= -1; - } - return modifier; - }, [side, flyoutRef]); - - const onMouseMove = useCallback( - (e: MouseEvent | TouchEvent) => { - const mouseOffset = getPosition(e, true) - initialMouseX.current; - const changedFlyoutWidth = - initialWidth.current + mouseOffset * direction; - - setFlyoutWidth(getFlyoutMinMaxWidth(changedFlyoutWidth)); - }, - [getFlyoutMinMaxWidth, direction] - ); - - const onMouseUp = useCallback(() => { - setCallOnResize(true); - initialMouseX.current = 0; - - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - window.removeEventListener('touchmove', onMouseMove); - window.removeEventListener('touchend', onMouseUp); - }, [onMouseMove]); - - const onMouseDown = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { - setCallOnResize(false); - initialMouseX.current = getPosition(e, true); - initialWidth.current = flyoutRef?.offsetWidth ?? 0; - - // Window event listeners instead of React events are used - // in case the user's mouse leaves the component - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - window.addEventListener('touchmove', onMouseMove); - window.addEventListener('touchend', onMouseUp); - }, - [flyoutRef, onMouseMove, onMouseUp] - ); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - setCallOnResize(true); - const KEYBOARD_OFFSET = 10; - - switch (e.key) { - case keys.ARROW_RIGHT: - e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise - setFlyoutWidth((flyoutWidth) => - getFlyoutMinMaxWidth(flyoutWidth + KEYBOARD_OFFSET * direction) - ); - break; - case keys.ARROW_LEFT: - e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise - setFlyoutWidth((flyoutWidth) => - getFlyoutMinMaxWidth(flyoutWidth - KEYBOARD_OFFSET * direction) - ); - } - }, - [getFlyoutMinMaxWidth, direction] - ); - - // To reduce unnecessary calls, only fire onResize callback: - // 1. After initial mount / on user width change events only - // 2. If not currently mouse dragging - useEffect(() => { - if (callOnResize) { - onResize?.(flyoutWidth); - } - }, [onResize, callOnResize, flyoutWidth]); - - return ( - - - {children} - - ); - } -); +} & Omit; // If not omitted, the correct props don't show up in the docs prop table + +export const EuiFlyoutResizable = forwardRef< + HTMLDivElement | HTMLElement, + EuiFlyoutResizableProps +>((props, ref) => { + return ; +}); EuiFlyoutResizable.displayName = 'EuiFlyoutResizable'; diff --git a/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx index d9195ad2cae..28c2ae874e7 100644 --- a/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_child.stories.tsx @@ -54,6 +54,8 @@ interface FlyoutChildStoryArgs extends EuiFlyoutChildActualProps { paddingSize?: 'none' | 's' | 'm' | 'l'; pushMinBreakpoint: EuiBreakpointSize; showFooter?: boolean; + mainFlyoutResizable?: boolean; + childFlyoutResizable?: boolean; } const breakpointSizes: EuiBreakpointSize[] = ['xs', 's', 'm', 'l', 'xl']; @@ -105,6 +107,14 @@ const meta: Meta = { description: 'Whether to show the flyout footer. If `false`, an `EuiFlyoutFooter` will not be rendered.', }, + mainFlyoutResizable: { + control: { type: 'boolean' }, + description: 'Whether the main flyout should be resizable.', + }, + childFlyoutResizable: { + control: { type: 'boolean' }, + description: 'Whether the child flyout should be resizable.', + }, // use "childBackgroundStyle" instead backgroundStyle: { table: { disable: true } }, @@ -135,6 +145,8 @@ const meta: Meta = { pushAnimation: true, pushMinBreakpoint: 'xs', showFooter: true, + mainFlyoutResizable: false, + childFlyoutResizable: false, }, parameters: { loki: { @@ -159,6 +171,8 @@ const StatefulFlyout: React.FC = ({ mainMaxWidth, childMaxWidth, showFooter, + mainFlyoutResizable, + childFlyoutResizable, ...args }) => { const [isMainOpen, setIsMainOpen] = useState(true); @@ -221,6 +235,7 @@ const StatefulFlyout: React.FC = ({ pushMinBreakpoint={pushMinBreakpoint} maxWidth={mainMaxWidth} ownFocus={false} + resizable={mainFlyoutResizable} {...args} onClose={closeMain} > @@ -249,6 +264,7 @@ const StatefulFlyout: React.FC = ({ backgroundStyle={childBackgroundStyle} maxWidth={childMaxWidth} ownFocus={false} + resizable={childFlyoutResizable} {...args} onClose={closeChild} > diff --git a/packages/eui/src/components/flyout/use_flyout_resizable.ts b/packages/eui/src/components/flyout/use_flyout_resizable.ts new file mode 100644 index 00000000000..d931788ecb5 --- /dev/null +++ b/packages/eui/src/components/flyout/use_flyout_resizable.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { keys } from '../../services'; +import { getPosition } from '../resizable_container/helpers'; +import type { EuiFlyoutResizableProps } from './flyout_resizable'; + +type UseEuiFlyoutResizable = Pick< + EuiFlyoutResizableProps, + 'onResize' | 'side' +> & { + enabled: boolean; + minWidth?: number; + maxWidth: number | undefined; + size: string | number; +}; + +/** + * @internal + */ +export const useEuiFlyoutResizable = ({ + enabled, + minWidth = 0, + maxWidth, + onResize, + side, + size: _size, +}: UseEuiFlyoutResizable) => { + const getFlyoutMinMaxWidth = useCallback( + (width: number) => { + return Math.min( + Math.max(width, minWidth), + maxWidth || Infinity, + window.innerWidth - 20 // Leave some offset + ); + }, + [minWidth, maxWidth] + ); + + const [flyoutWidth, setFlyoutWidth] = useState(0); + const [callOnResize, setCallOnResize] = useState(false); + + // Must use state for the flyout ref in order for the useEffect to be correctly called after render + const [flyoutRef, setFlyoutRef] = useState(null); + + useEffect(() => { + if (!flyoutWidth && flyoutRef) { + setCallOnResize(false); // Don't call `onResize` for non-user width changes + setFlyoutWidth(getFlyoutMinMaxWidth(flyoutRef.offsetWidth)); + } + }, [flyoutWidth, flyoutRef, getFlyoutMinMaxWidth]); + + // Update flyout width when consumers pass in a new `size` + useEffect(() => { + setCallOnResize(false); + // For string `size`s, resetting flyoutWidth to 0 will trigger the above useEffect's recalculation + setFlyoutWidth(typeof _size === 'number' ? getFlyoutMinMaxWidth(_size) : 0); + }, [_size, getFlyoutMinMaxWidth]); + + // Initial numbers to calculate from, on resize drag start + const initialWidth = useRef(0); + const initialMouseX = useRef(0); + + // Account for flyout side and logical property direction + const direction = useMemo(() => { + let modifier = side === 'right' ? -1 : 1; + if (flyoutRef) { + const languageDirection = window.getComputedStyle(flyoutRef).direction; + if (languageDirection === 'rtl') modifier *= -1; + } + return modifier; + }, [side, flyoutRef]); + + const onMouseMove = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!enabled) { + return; + } + + const mouseOffset = getPosition(e, true) - initialMouseX.current; + const changedFlyoutWidth = initialWidth.current + mouseOffset * direction; + + setFlyoutWidth(getFlyoutMinMaxWidth(changedFlyoutWidth)); + }, + [getFlyoutMinMaxWidth, direction, enabled] + ); + + const onMouseUp = useCallback(() => { + setCallOnResize(true); + + if (!enabled) { + return; + } + + initialMouseX.current = 0; + + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('touchmove', onMouseMove); + window.removeEventListener('touchend', onMouseUp); + }, [onMouseMove, enabled]); + + const onMouseDown = useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + setCallOnResize(false); + + if (!enabled) { + return; + } + + initialMouseX.current = getPosition(e, true); + initialWidth.current = flyoutRef?.offsetWidth ?? 0; + + // Window event listeners instead of React events are used + // in case the user's mouse leaves the component + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('touchmove', onMouseMove); + window.addEventListener('touchend', onMouseUp); + }, + [flyoutRef, onMouseMove, onMouseUp, enabled] + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + setCallOnResize(true); + + if (!enabled) { + return; + } + + const KEYBOARD_OFFSET = 10; + + switch (e.key) { + case keys.ARROW_RIGHT: + e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise + setFlyoutWidth((flyoutWidth) => + getFlyoutMinMaxWidth(flyoutWidth + KEYBOARD_OFFSET * direction) + ); + break; + case keys.ARROW_LEFT: + e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise + setFlyoutWidth((flyoutWidth) => + getFlyoutMinMaxWidth(flyoutWidth - KEYBOARD_OFFSET * direction) + ); + } + }, + [getFlyoutMinMaxWidth, direction, enabled] + ); + + // To reduce unnecessary calls, only fire onResize callback: + // 1. After initial mount / on user width change events only + // 2. If not currently mouse dragging + useEffect(() => { + if (callOnResize && enabled) { + onResize?.(flyoutWidth); + } + }, [onResize, callOnResize, flyoutWidth, enabled]); + + const size = useMemo(() => flyoutWidth || _size, [flyoutWidth, _size]); + + return { + onKeyDown, + onMouseDown, + setFlyoutRef, + size, + }; +}; From ecd35351eea886b983f85254e6bccf4d14b65520 Mon Sep 17 00:00:00 2001 From: Tomasz Kajtoch Date: Tue, 16 Sep 2025 17:40:53 +0200 Subject: [PATCH 05/30] [Flyout system] Improve flyout animations (#9025) --- .../src/eui_theme_borealis_dark.json | 2 +- .../src/eui_theme_borealis_light.json | 2 +- .../src/variables/_animation.ts | 2 +- .../global_styling/variables/_animations.scss | 2 +- .../collapsible_nav.test.tsx.snap | 100 +++++++------- .../collapsible_nav/collapsible_nav.spec.tsx | 3 +- .../collapsible_nav_beta.test.tsx.snap | 4 +- .../flyout/__snapshots__/flyout.test.tsx.snap | 126 +++++++++--------- .../components/flyout/flyout.component.tsx | 106 +++++++++++---- .../eui/src/components/flyout/flyout.spec.tsx | 9 +- .../src/components/flyout/flyout.stories.tsx | 20 +-- .../src/components/flyout/flyout.styles.ts | 87 +++++++++--- .../eui/src/components/flyout/flyout.test.tsx | 4 +- .../eui/src/components/flyout/flyout_menu.tsx | 4 +- .../flyout/manager/flyout_managed.tsx | 3 +- .../src/components/flyout/use_open_state.ts | 86 ++++++++++++ 16 files changed, 387 insertions(+), 173 deletions(-) create mode 100644 packages/eui/src/components/flyout/use_open_state.ts diff --git a/packages/eui-theme-borealis/src/eui_theme_borealis_dark.json b/packages/eui-theme-borealis/src/eui_theme_borealis_dark.json index ed01cd7edb3..e5b82e0a3f3 100644 --- a/packages/eui-theme-borealis/src/eui_theme_borealis_dark.json +++ b/packages/eui-theme-borealis/src/eui_theme_borealis_dark.json @@ -15,7 +15,7 @@ "euiContrastRatioGraphic": 3, "euiContrastRatioDisabled": 2, "euiAnimSlightBounce": "cubic-bezier(0.34, 1.61, 0.7, 1)", - "euiAnimSlightResistance": "cubic-bezier(0.694, 0.0482, 0.335, 1)", + "euiAnimSlightResistance": "cubic-bezier(0.32, 0.72, 0, 1)", "euiAnimSpeedExtraFast": "90ms", "euiAnimSpeedFast": "150ms", "euiAnimSpeedNormal": "250ms", diff --git a/packages/eui-theme-borealis/src/eui_theme_borealis_light.json b/packages/eui-theme-borealis/src/eui_theme_borealis_light.json index 941b63776a6..bd1f5ba48d5 100644 --- a/packages/eui-theme-borealis/src/eui_theme_borealis_light.json +++ b/packages/eui-theme-borealis/src/eui_theme_borealis_light.json @@ -15,7 +15,7 @@ "euiContrastRatioGraphic": 3, "euiContrastRatioDisabled": 2, "euiAnimSlightBounce": "cubic-bezier(0.34, 1.61, 0.7, 1)", - "euiAnimSlightResistance": "cubic-bezier(0.694, 0.0482, 0.335, 1)", + "euiAnimSlightResistance": "cubic-bezier(0.32, 0.72, 0, 1)", "euiAnimSpeedExtraFast": "90ms", "euiAnimSpeedFast": "150ms", "euiAnimSpeedNormal": "250ms", diff --git a/packages/eui-theme-borealis/src/variables/_animation.ts b/packages/eui-theme-borealis/src/variables/_animation.ts index 84933441db3..5a92ebe4f15 100644 --- a/packages/eui-theme-borealis/src/variables/_animation.ts +++ b/packages/eui-theme-borealis/src/variables/_animation.ts @@ -22,7 +22,7 @@ export const animation_speed: _EuiThemeAnimationSpeeds = { export const animation_ease: _EuiThemeAnimationEasings = { bounce: 'cubic-bezier(.34, 1.61, .7, 1)', - resistance: 'cubic-bezier(.694, .0482, .335, 1)', + resistance: 'cubic-bezier(.32, .72, 0, 1)', }; export const animation: _EuiThemeAnimation = { diff --git a/packages/eui-theme-common/src/global_styling/variables/_animations.scss b/packages/eui-theme-common/src/global_styling/variables/_animations.scss index 93b9daf1641..85b5d0c155a 100644 --- a/packages/eui-theme-common/src/global_styling/variables/_animations.scss +++ b/packages/eui-theme-common/src/global_styling/variables/_animations.scss @@ -1,7 +1,7 @@ // Animations $euiAnimSlightBounce: cubic-bezier(.34, 1.61, .7, 1) !default; -$euiAnimSlightResistance: cubic-bezier(.694, .0482, .335, 1) !default; +$euiAnimSlightResistance: cubic-bezier(.32, .72, 0, 1) !default; $euiAnimSpeedExtraFast: 90ms !default; $euiAnimSpeedFast: 150ms !default; diff --git a/packages/eui/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap b/packages/eui/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap index 1bd264a3928..b52b75952fb 100644 --- a/packages/eui/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap +++ b/packages/eui/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap @@ -14,7 +14,7 @@ exports[`EuiCollapsibleNav close button can be hidden 1`] = ` +
+
-
`; @@ -177,7 +179,7 @@ exports[`EuiCollapsibleNav props button 1`] = `
-
+ You are in a modal dialog. Press Escape or tap/click outside the dialog on the shadowed overlay to close. + +

+ +
+
`; @@ -56,107 +57,106 @@ exports[`EuiCollapsibleNav does not render if isOpen is false 1`] = `
`; exports[`EuiCollapsibleNav is rendered 1`] = `
-
-
+
+
+
+
`; exports[`EuiCollapsibleNav props accepts EuiFlyout props 1`] = `
-
-
+
+
+
`; @@ -167,105 +167,107 @@ exports[`EuiCollapsibleNav props button 1`] = ` aria-expanded="true" aria-pressed="true" /> -
-
+
+
+
+
`; exports[`EuiCollapsibleNav props dockedBreakpoint 1`] = `
-
-
+
+
+
+
`; @@ -295,53 +297,54 @@ exports[`EuiCollapsibleNav props isDocked 1`] = ` exports[`EuiCollapsibleNav props onClose 1`] = `
-
-
+
+
+
+
`; @@ -376,52 +379,53 @@ exports[`EuiCollapsibleNav props showButtonIfDocked 1`] = ` exports[`EuiCollapsibleNav props size 1`] = `
-
-
+
+
+
+
`; diff --git a/packages/eui/src/components/collapsible_nav/collapsible_nav.spec.tsx b/packages/eui/src/components/collapsible_nav/collapsible_nav.spec.tsx index f683fca2465..36eb11224af 100644 --- a/packages/eui/src/components/collapsible_nav/collapsible_nav.spec.tsx +++ b/packages/eui/src/components/collapsible_nav/collapsible_nav.spec.tsx @@ -64,11 +64,16 @@ describe('EuiCollapsibleNav', () => { expect(cy.get('#navSpec').should('not.exist')); }); - // TODO: Unskip in https://github.com/elastic/eui/issues/8989 - it.skip('closes the nav when the overlay mask is clicked', () => { + it('closes the nav when the overlay mask is clicked', () => { cy.realMount(