diff --git a/packages/eui/src/components/flyout/README.md b/packages/eui/src/components/flyout/README.md index d41234da10c..ddad71b70ba 100644 --- a/packages/eui/src/components/flyout/README.md +++ b/packages/eui/src/components/flyout/README.md @@ -13,6 +13,7 @@ flowchart EuiFlyout --> EuiFlyoutComponent EuiFlyout --> |"session = 'start'"|EuiFlyoutMain --> EuiManagedFlyout --> EuiFlyoutComponent EuiFlyout --> |"session = 'inherit'"|EuiFlyoutChild --> EuiManagedFlyout --> EuiFlyoutComponent + EuiFlyout --> |"nested + session undefined"|EuiFlyoutChild ``` The core implementation of EuiFlyout lives in the internal [EuiFlyoutComponent](./flyout.component.tsx) file. @@ -20,9 +21,14 @@ It contains the main logic and UI for rendering flyouts. However, it's not the c that EUI consumers interact with directly. The EuiFlyout export actually comes from [`flyout.tsx`](./flyout.tsx) which is a thin logical -wrapper that conditionally handles session management when `session="start"`, -or renders the plain [EuiFlyoutComponent](./flyout.component.tsx) otherwise. -That structure provides a better business logic separation. +wrapper that conditionally routes to different implementations: +- `session="start"` → [EuiFlyoutMain](./manager/flyout_main.tsx) (creates new session) +- `session="inherit"` → [EuiFlyoutChild](./manager/flyout_child.tsx) (joins existing session) +- `session="never"` → [EuiFlyoutComponent](./flyout.component.tsx) (standard flyout) +- `session` undefined + nested inside parent → [EuiFlyoutChild](./manager/flyout_child.tsx) (auto-inherits) +- `session` undefined + not nested → [EuiFlyoutComponent](./flyout.component.tsx) (standard flyout) + +This structure provides better business logic separation and enables intuitive nested flyout behavior. ## Resizable flyouts diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index fd923eaa5b0..b2331e3c2a4 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -76,6 +76,7 @@ import { EuiFlyoutResizeButton } from './_flyout_resize_button'; import { useEuiFlyoutResizable } from './use_flyout_resizable'; import type { EuiFlyoutCloseEvent } from './types'; import { useEuiFlyoutZIndex } from './use_flyout_z_index'; +import { EuiFlyoutParentProvider } from './flyout_parent_context'; interface _EuiFlyoutComponentProps { /** @@ -712,7 +713,7 @@ export const EuiFlyoutComponent = forwardRef( onKeyDown={onKeyDownResizableButton} /> )} - {children} + {children} diff --git a/packages/eui/src/components/flyout/flyout.test.tsx b/packages/eui/src/components/flyout/flyout.test.tsx index 407ad179dd9..28b94f5197e 100644 --- a/packages/eui/src/components/flyout/flyout.test.tsx +++ b/packages/eui/src/components/flyout/flyout.test.tsx @@ -483,7 +483,31 @@ describe('EuiFlyout', () => { }); describe('flyout routing logic', () => { - it('routes to child flyout when session is undefined and there is an active session', () => { + it('routes to child flyout automatically when nested inside a parent flyout', () => { + const { getByTestSubject } = render( + + {}} + session="start" + flyoutMenuProps={{ title: 'Main Flyout' }} + data-test-subj="main-flyout" + > + {/* Child flyout nested inside parent - should auto-inherit */} + {}} data-test-subj="child-flyout" /> + + + ); + + // Main flyout should be rendered as managed + const mainFlyout = getByTestSubject('main-flyout'); + expect(mainFlyout).toHaveAttribute('data-managed-flyout-level', 'main'); + + // Child flyout should automatically become a managed child + const childFlyout = getByTestSubject('child-flyout'); + expect(childFlyout).toHaveAttribute('data-managed-flyout-level', 'child'); + }); + + it('routes to child flyout when session is explicitly "inherit" and there is an active session', () => { // First render with just the main flyout to establish a session const { rerender, getByTestSubject } = render( @@ -508,7 +532,7 @@ describe('EuiFlyout', () => { {}} data-test-subj="child-flyout" - // session is undefined (not explicitly set) + session="inherit" /> ); @@ -559,21 +583,54 @@ describe('EuiFlyout', () => { expect(flyout).not.toHaveAttribute('data-managed-flyout-level'); }); - it('routes to child flyout when in managed context and there is an active session', () => { - // First render with just the main flyout to establish a session - const { rerender, getByTestSubject } = render( + it('routes to standard flyout when session="never" explicitly set and there is an active session', () => { + const { getByTestSubject } = render( + {/* Create an active session */} {}} session="start" flyoutMenuProps={{ title: 'Main Flyout' }} data-test-subj="main-flyout" /> + {/* This flyout explicitly opts out of session management */} + {}} + session="never" + data-test-subj="standard-flyout" + /> ); - // Now render with the child flyout added - it should detect the active session - rerender( + // Should render as standard flyout (EuiFlyoutComponent) + const flyout = getByTestSubject('standard-flyout'); + expect(flyout).not.toHaveAttribute('data-managed-flyout-level'); + }); + + it('routes to standard flyout when not nested inside a parent flyout', () => { + const { getByTestSubject } = render( + + {/* Create an active session */} + {}} + session="start" + flyoutMenuProps={{ title: 'Main Flyout' }} + data-test-subj="main-flyout" + /> + {/* This flyout is not nested inside the parent, so it doesn't auto-inherit */} + {}} data-test-subj="standard-flyout" /> + + ); + + // Should render as standard flyout (EuiFlyoutComponent) + const flyout = getByTestSubject('standard-flyout'); + expect(flyout).not.toHaveAttribute('data-managed-flyout-level'); + }); + + it('routes to child flyout when session is explicitly "inherit" across React roots', () => { + // This test demonstrates cross-root behavior: child is not nested in JSX tree + // but can still inherit via explicit session="inherit" + const { getByTestSubject } = render( {}} @@ -581,15 +638,20 @@ describe('EuiFlyout', () => { flyoutMenuProps={{ title: 'Main Flyout' }} data-test-subj="main-flyout" /> + {/* Not nested, but using explicit session="inherit" */} {}} data-test-subj="child-flyout" - session={undefined} // Not explicitly set, should inherit + session="inherit" /> ); - // Should render as child flyout (EuiFlyoutChild) + // Main flyout should be managed + const mainFlyout = getByTestSubject('main-flyout'); + expect(mainFlyout).toHaveAttribute('data-managed-flyout-level', 'main'); + + // Child flyout should become managed via explicit inherit const flyout = getByTestSubject('child-flyout'); expect(flyout).toHaveAttribute('data-managed-flyout-level', 'child'); }); @@ -607,5 +669,58 @@ describe('EuiFlyout', () => { const flyout = getByTestSubject('flyout'); expect(flyout).not.toHaveAttribute('data-managed-flyout-level'); }); + + it('routes to standard flyout when session="inherit" but there is no active session', () => { + const { getByTestSubject } = render( + {}} + data-test-subj="flyout" + session="inherit" // Explicitly set to inherit, but no session to inherit from + /> + ); + + // Should gracefully degrade to standard flyout (EuiFlyoutComponent) when no session exists + const flyout = getByTestSubject('flyout'); + expect(flyout).not.toHaveAttribute('data-managed-flyout-level'); + }); + + it('routes to standard flyout when session="inherit" within Manager but no active session', () => { + const { getByTestSubject } = render( + + {}} + data-test-subj="flyout" + session="inherit" // Manager context exists but no main flyout has been created + /> + + ); + + // Should gracefully degrade to standard flyout when Manager exists but no session is active + const flyout = getByTestSubject('flyout'); + expect(flyout).not.toHaveAttribute('data-managed-flyout-level'); + }); + + it('routes to standard flyout when nested but parent uses session="never"', () => { + const { getByTestSubject } = render( + + {}} + session="never" + data-test-subj="parent-flyout" + > + {/* Nested, but parent is not managed, so no auto-inheritance */} + {}} data-test-subj="child-flyout" /> + + + ); + + // Parent should be standard flyout + const parentFlyout = getByTestSubject('parent-flyout'); + expect(parentFlyout).not.toHaveAttribute('data-managed-flyout-level'); + + // Child should also be standard flyout (no session to inherit from) + const childFlyout = getByTestSubject('child-flyout'); + expect(childFlyout).not.toHaveAttribute('data-managed-flyout-level'); + }); }); }); diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index 23a44f1e213..dd2c7408c43 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -16,6 +16,7 @@ import { import { EuiFlyoutChild, EuiFlyoutMain, useHasActiveSession } from './manager'; import { EuiFlyoutMenuContext } from './flyout_menu_context'; +import { useIsInsideParentFlyout } from './flyout_parent_context'; import { SESSION_INHERIT, SESSION_NEVER, SESSION_START } from './manager/const'; export type { @@ -38,12 +39,15 @@ export type EuiFlyoutProps = Omit< /** * Controls the way the session is managed for this flyout. * - `start`: Creates a new flyout session. Use this for the main flyout. - * - `inherit`: (default) Inherits an existing session if one is active, otherwise functions as a standard flyout. - * - `never`: Opts out of session management and always functions as a standard flyout. + * - `inherit`: Inherits an existing session if one is active, otherwise functions as a standard flyout. + * - `never`: Disregards session management and always functions as a standard flyout. + * + * When the `session` prop is undefined (not set), the flyout will automatically inherit from + * a parent flyout if it's nested inside one. Otherwise, it defaults to `never`. * * Check out [EuiFlyout session management](https://eui.elastic.co/docs/components/containers/flyout/session-management) * documentation to learn more. - * @default 'inherit' + * @default undefined (auto-inherit when nested, otherwise 'never') */ session?: | typeof SESSION_START @@ -63,14 +67,10 @@ export const EuiFlyout = forwardRef< HTMLDivElement | HTMLElement, EuiFlyoutProps<'div' | 'nav'> >((props, ref) => { - const { - as, - onClose, - onActive, - session = SESSION_INHERIT, - ...rest - } = usePropsWithComponentDefaults('EuiFlyout', props); - const hasActiveSession = useRef(useHasActiveSession()); + const { as, onClose, onActive, session, ...rest } = + usePropsWithComponentDefaults('EuiFlyout', props); + const hasActiveSession = useHasActiveSession(); + const isInsideParentFlyout = useIsInsideParentFlyout(); const isUnmanagedFlyout = useRef(false); /* @@ -79,9 +79,18 @@ export const EuiFlyout = forwardRef< * - session="inherit" + active session → Child flyout (auto-joins, works across React roots!) * - session="inherit" + no session → Standard flyout * - session="never" → Standard flyout (explicit opt-out) + * - session=undefined + inside parent + active session → Child flyout (auto-inherit) + * - session=undefined + not inside parent → Standard flyout (default behavior) */ - if (session !== SESSION_NEVER) { - if (session === SESSION_START) { + + // Determine effective session behavior when session is undefined + const effectiveSession = + session === undefined && isInsideParentFlyout && hasActiveSession + ? SESSION_INHERIT + : session ?? SESSION_NEVER; + + if (effectiveSession !== SESSION_NEVER) { + if (effectiveSession === SESSION_START) { // session=start: create new session if (isUnmanagedFlyout.current) { // TODO: @tkajtoch - We need to find a better way to handle the missing event. @@ -99,10 +108,7 @@ export const EuiFlyout = forwardRef< } // session=inherit: auto-join existing session as child - if ( - hasActiveSession.current && - (session === undefined || session === SESSION_INHERIT) - ) { + if (hasActiveSession && effectiveSession === SESSION_INHERIT) { return ( (false); + +/** + * Provider that wraps a flyout's children to indicate they're inside a parent flyout. + * Nested flyouts can use this to automatically default to session inheritance. + */ +export const EuiFlyoutParentProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + return ( + + {children} + + ); +}; + +/** + * Hook that returns `true` when called within a parent flyout's children. + * Used to automatically determine if a nested flyout should inherit the session. + */ +export const useIsInsideParentFlyout = () => useContext(EuiFlyoutParentContext); diff --git a/packages/eui/src/components/flyout/index.ts b/packages/eui/src/components/flyout/index.ts index d1ed2e57f60..b5c300815f0 100644 --- a/packages/eui/src/components/flyout/index.ts +++ b/packages/eui/src/components/flyout/index.ts @@ -32,3 +32,4 @@ export { EuiFlyoutMenu } from './flyout_menu'; // Hooks for using Manager-based flyouts export { useIsInManagedFlyout, useHasActiveSession } from './manager'; +export { useIsInsideParentFlyout } from './flyout_parent_context'; diff --git a/packages/eui/src/components/flyout/manager/README.md b/packages/eui/src/components/flyout/manager/README.md index a5b4fc45c42..3a4fed99c5d 100644 --- a/packages/eui/src/components/flyout/manager/README.md +++ b/packages/eui/src/components/flyout/manager/README.md @@ -22,6 +22,9 @@ alongside the main flyout. [EuiFlyoutChild](./flyout_child.tsx) renders [EuiManagedFlyout](./flyout_managed.tsx) and does state validation to ensure the child flyout is always rendered within a main flyout. +Child flyouts are created either by explicitly setting `session="inherit"` or automatically when a flyout +is nested inside a parent flyout's children (in the JSX tree) without an explicit `session` prop. + All child flyouts are of type `overlay`, and have `ownFocus` set to false, since that's handled separately. Child flyouts are positioned absolutely and moved to the side by the width of the main flyout, which is stored diff --git a/packages/eui/src/components/flyout/manager/const.ts b/packages/eui/src/components/flyout/manager/const.ts index af6341a2afe..b2044b611ec 100644 --- a/packages/eui/src/components/flyout/manager/const.ts +++ b/packages/eui/src/components/flyout/manager/const.ts @@ -8,9 +8,6 @@ /** * Allowed values for `session` prop to control the way the session is managed for a flyout. - * - `session="start"`: Creates a new flyout session. Use this for the main flyout. - * - `session="inherit"`: (default) Inherits an existing session if one is active, otherwise functions as a standard flyout. - * - `session="never"`: Opts out of session management and always functions as a standard flyout. */ export const SESSION_START = 'start'; export const SESSION_INHERIT = 'inherit'; diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx index 32437b1d81b..163473f96d0 100644 --- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx @@ -188,7 +188,7 @@ const FlyoutSession: React.FC = (props) => { }, { title: 'session', - description: 'start', + description: start, }, ]} /> @@ -214,7 +214,11 @@ const FlyoutSession: React.FC = (props) => { > -

This is the content of the child flyout of {title}.

+

+ This is the content of the child flyout of {title}. It + automatically inherits the session because it's nested + inside the parent. +

= (props) => { }, { title: 'session', - description: 'inherit', + description: ( + <> + inherit (auto) + + ), }, ]} /> @@ -293,6 +301,7 @@ const NonSessionFlyout: React.FC<{ size: string }> = ({ size }) => { {isFlyoutVisible && ( = ({ size }) => { ownFocus={flyoutOwnFocus} pushAnimation={true} onClose={flyoutOnClose} - session="never" side="left" > @@ -311,9 +319,9 @@ const NonSessionFlyout: React.FC<{ size: string }> = ({ size }) => {

- This is the content of a non-session flyout. We assure it will - never become a managed flyout by setting{' '} - {'session={never}'}. + This is the content of a non-session flyout. It is assured to + not be a managed flyout using the{' '} + {'session={never}'} behavior.

= ({ size }) => { listItems={[ { title: 'Flyout type', description: flyoutType }, { title: 'Size', description: 'm' }, - { title: 'session', description: 'never' }, + { + title: 'session', + description: ( + <> + never (using default) + + ), + }, ]} />
@@ -522,6 +537,7 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ {isOpen && ( { setIsOpenFlyoutA(false)} > - This flyout is rendered with the {'session="start"'} prop set which creates a new flyout session and marks this flyout as main. + This is Flyout A. This flyout is rendered with the {'session="start"'} prop set which created a new flyout session and marks this flyout as main. setName(e.target.value)} /> @@ -68,9 +64,9 @@ export default () => { > - This flyout is also rendered with the {'session="start"'} prop added which creates a second flyout session and marks this flyout as main in that session. + This is Flyout B. This flyout is also rendered with the {'session="start"'} prop added which creates a second flyout session and marks this flyout as main in that session.

- The first flyout still exists but is currently hidden since this one took precedence. You can jump back to it by clicking the Back button above if you'd like. + Flyout A exists but is currently hidden since this one took precedence. You can jump back to it by clicking the Back button above if you'd like.
setIsOpenFlyoutBChild((val) => !val)}> @@ -78,10 +74,15 @@ export default () => {
{isOpenFlyoutBChild && ( - setIsOpenFlyoutBChild(false)}> + setIsOpenFlyoutBChild(false)} + > - This is a child flyout of Flyout B. It belongs to the same session as Flyout B because it's rendered inside of it and doesn't have the session prop set. + This is a child flyout of Flyout B. It belongs to the same session as Flyout B because it's rendered inside of it (nested in the JSX tree).

If you close Flyout B - main, this flyout will close, too.
@@ -112,10 +113,15 @@ about compatibility issues. Each time you set `session="start"`, you create a new flyout session and mark the rendered flyout as [main](#main-flyouts). + To create a [child flyout](#child-flyouts) - a flyout that belongs to the same -session as the main flyout - you can set `session="inherit"` or omit the `session` prop entirely, -as "inherit" is the default behavior. -All bindings and configuration will be handled automatically. +session as the main flyout - either: +- Render it inside a main flyout's children (nested in the JSX tree), OR +- Set `session="inherit"` explicitly (required for cross-React-root scenarios) + +When a flyout is nested inside a parent flyout and doesn't have an explicit +`session` prop, it will automatically inherit the session. All bindings and +configuration will be handled automatically. ### Session title @@ -134,8 +140,8 @@ field of the `flyoutMenuProps` to set the title of the flyout. I'm the main flyout - {/* Render a new child flyout - notice the lack of the `session` prop */} ``` -To prevent a flyout from being a part of the session management system, you -can set `session="never"` which will render it as a regular unmanaged flyout. +To prevent a flyout from being a part of the session management system: +- Set `session="never"` explicitly, OR +- Don't nest it inside any parent flyout (non-nested flyouts default to standard behavior) + +To force a nested flyout to remain a standard flyout even when inside a parent, +explicitly set `session="never"`. This will render it as a regular unmanaged flyout. ```tsx <> @@ -186,9 +196,11 @@ of an alert. ### Child flyouts -Child flyouts are directly related to a main flyout in their session. -They're created by rendering a `EuiFlyout` **without the session prop** inside -a [main flyout](#main-flyouts) - a flyout with `session="start"` prop set. +Child flyouts are directly related to a main flyout in their session. They're +created by rendering an `EuiFlyout` inside a main flyout's children (nested in +the JSX tree), which causes it to automatically inherit the session. Alternatively, +you can explicitly set `session="inherit"` when rendering outside the parent's +JSX tree while an active [main flyout](#main-flyouts) (with `session="start"`) exists. They're meant to display secondary level information like the alert visualization.