Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/eui/src/components/flyout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@ 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.
It contains the main logic and UI for rendering flyouts. However, it's not the component
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

Expand Down
3 changes: 2 additions & 1 deletion packages/eui/src/components/flyout/flyout.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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 {
/**
Expand Down Expand Up @@ -659,7 +660,7 @@ export const EuiFlyoutComponent = forwardRef(
onKeyDown={onKeyDownResizableButton}
/>
)}
{children}
<EuiFlyoutParentProvider>{children}</EuiFlyoutParentProvider>
</Element>
</EuiFocusTrap>
</EuiFlyoutOverlay>
Expand Down
133 changes: 124 additions & 9 deletions packages/eui/src/components/flyout/flyout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,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(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
>
{/* Child flyout nested inside parent - should auto-inherit */}
<EuiFlyout onClose={() => {}} data-test-subj="child-flyout" />
</EuiFlyout>
</EuiFlyoutManager>
);

// 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(
<EuiFlyoutManager>
Expand All @@ -463,7 +487,7 @@ describe('EuiFlyout', () => {
<EuiFlyout
onClose={() => {}}
data-test-subj="child-flyout"
// session is undefined (not explicitly set)
session="inherit"
/>
</EuiFlyoutManager>
);
Expand Down Expand Up @@ -514,37 +538,75 @@ 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(
<EuiFlyoutManager>
{/* Create an active session */}
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
/>
{/* This flyout explicitly opts out of session management */}
<EuiFlyout
onClose={() => {}}
session="never"
data-test-subj="standard-flyout"
/>
</EuiFlyoutManager>
);

// 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(
<EuiFlyoutManager>
{/* Create an active session */}
<EuiFlyout
onClose={() => {}}
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 */}
<EuiFlyout onClose={() => {}} data-test-subj="standard-flyout" />
</EuiFlyoutManager>
);

// 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(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
/>
{/* Not nested, but using explicit session="inherit" */}
<EuiFlyout
onClose={() => {}}
data-test-subj="child-flyout"
session={undefined} // Not explicitly set, should inherit
session="inherit"
/>
</EuiFlyoutManager>
);

// 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');
});
Expand All @@ -562,5 +624,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(
<EuiFlyout
onClose={() => {}}
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(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
data-test-subj="flyout"
session="inherit" // Manager context exists but no main flyout has been created
/>
</EuiFlyoutManager>
);

// 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(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="never"
data-test-subj="parent-flyout"
>
{/* Nested, but parent is not managed, so no auto-inheritance */}
<EuiFlyout onClose={() => {}} data-test-subj="child-flyout" />
</EuiFlyout>
</EuiFlyoutManager>
);

// 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');
});
});
});
40 changes: 23 additions & 17 deletions packages/eui/src/components/flyout/flyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -38,12 +39,15 @@ export type EuiFlyoutProps<T extends ElementType = 'div' | 'nav'> = 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
Expand All @@ -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);

/*
Expand All @@ -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.
Expand All @@ -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 (
<EuiFlyoutChild
{...rest}
Expand Down
38 changes: 38 additions & 0 deletions packages/eui/src/components/flyout/flyout_parent_context.tsx
Original file line number Diff line number Diff line change
@@ -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 React, { createContext, useContext } from 'react';

/**
* Context to track if we're inside a parent flyout's children.
* This allows nested flyouts to automatically inherit the session
* without requiring explicit `session="inherit"` prop.
*/
const EuiFlyoutParentContext = createContext<boolean>(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 (
<EuiFlyoutParentContext.Provider value={true}>
{children}
</EuiFlyoutParentContext.Provider>
);
};

/**
* 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);
1 change: 1 addition & 0 deletions packages/eui/src/components/flyout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 3 additions & 0 deletions packages/eui/src/components/flyout/manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions packages/eui/src/components/flyout/manager/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading