Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const EuiCollapsibleNav: FunctionComponent<EuiCollapsibleNavProps> = ({
const flyout = (
<EuiFlyout
id={flyoutID}
session={false}
session="never"
css={cssStyles}
className={classes}
// Flyout props we set different defaults for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ const _EuiCollapsibleNavBeta: FunctionComponent<EuiCollapsibleNavBetaProps> = ({
aria-label={defaultAriaLabel}
{...rest} // EuiCollapsibleNav is significantly less permissive than EuiFlyout
id={flyoutID}
session={false}
session="never"
css={cssStyles}
className={classes}
size={width}
Expand Down
10 changes: 5 additions & 5 deletions packages/eui/src/components/flyout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

### `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`
- **Session flyouts**: When `session="start"` 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

#### `session` Prop Behavior
The `session` prop controls whether a flyout participates in the session management system:
- **`session={true}`**: Explicitly opt-in to session management. The flyout will be managed as a main flyout.
- **`session={false}`**: Explicitly opt-out of session management. The flyout will render as an unmanaged standard flyout, bypassing all session logic. This is useful for wrapper components like `EuiCollapsibleNav` that manage their own lifecycle.
- **`session={undefined}`** (default): Automatically participate in sessions if one is active. If no session is active, renders as a standard flyout.
- **`session="start"`**: Explicitly opt-in to session management. The flyout will be managed as a main flyout and create a new session.
- **`session="never"`**: Explicitly opt-out of session management. The flyout will render as an unmanaged standard flyout, bypassing all session logic. This is useful for wrapper components like `EuiCollapsibleNav` that manage their own lifecycle.
- **`session="inherit"`** (default): Automatically participate in sessions if one is active. If no session is active, renders as a standard flyout.

### `src/components/flyout/flyout.component.tsx`
The core flyout implementation with comprehensive functionality:
Expand Down Expand Up @@ -46,7 +46,7 @@ The central state management system for flyout sessions:
- **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.
Renders the primary flyout in a session. Currently a simple wrapper around `EuiManagedFlyout` with `session="start"`. 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:
Expand Down
162 changes: 89 additions & 73 deletions packages/eui/src/components/flyout/flyout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
FLYOUT_SIZES,
} from './flyout';
import { EuiProvider } from '../provider';
import { EuiFlyoutManager } from './manager';

jest.mock('../overlay_mask', () => ({
EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => (
Expand Down Expand Up @@ -439,114 +440,129 @@ describe('EuiFlyout', () => {
});

describe('flyout routing logic', () => {
// Mock the manager hooks to control routing behavior
const mockUseHasActiveSession = jest.fn();
const mockUseIsInManagedFlyout = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
// Mock the manager hooks
jest.doMock('./manager', () => ({
...jest.requireActual('./manager'),
useHasActiveSession: mockUseHasActiveSession,
useIsInManagedFlyout: mockUseIsInManagedFlyout,
}));
});

afterEach(() => {
jest.dontMock('./manager');
});

it('routes to child flyout when session is undefined and there is an active session', () => {
// Setup: There's an active session but flyout is not in managed context
mockUseHasActiveSession.mockReturnValue(true);
mockUseIsInManagedFlyout.mockReturnValue(false);
// First render with just the main flyout to establish a session
const { rerender, getByTestSubject } = render(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
/>
</EuiFlyoutManager>
);

const { getByTestSubject } = render(
<EuiFlyout
onClose={() => {}}
data-test-subj="child-flyout"
// session is undefined (not explicitly set)
/>
// Now render with the child flyout added - it should detect the active session
rerender(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
/>
<EuiFlyout
onClose={() => {}}
data-test-subj="child-flyout"
// session is undefined (not explicitly set)
/>
</EuiFlyoutManager>
);

// Should render as child flyout (EuiFlyoutChild)
const flyout = getByTestSubject('child-flyout');
expect(flyout).toBeInTheDocument();
const childFlyout = getByTestSubject('child-flyout');
expect(childFlyout).toHaveAttribute('data-managed-flyout-level', 'child');
});

it('routes to main flyout when session is explicitly true', () => {
// Setup: There's an active session and flyout is not in managed context
mockUseHasActiveSession.mockReturnValue(true);
mockUseIsInManagedFlyout.mockReturnValue(false);

const { getByTestSubject } = render(
<EuiFlyout
onClose={() => {}}
data-test-subj="main-flyout"
session={true} // Explicitly creating a new session
flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts
/>
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
data-test-subj="flyout"
session="start" // Explicitly creating a new session
flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts
/>
</EuiFlyoutManager>
);

// Should render as main flyout (EuiFlyoutMain)
const flyout = getByTestSubject('main-flyout');
expect(flyout).toBeInTheDocument();
const flyout = getByTestSubject('flyout');
expect(flyout).toHaveAttribute('data-managed-flyout-level', 'main');
});

it('routes to main flyout when session is explicitly false and there is an active session', () => {
// Setup: There's an active session and flyout is not in managed context
mockUseHasActiveSession.mockReturnValue(true);
mockUseIsInManagedFlyout.mockReturnValue(false);

it('routes to standard flyout when session is explicitly "never" and there is an active session', () => {
const { getByTestSubject } = render(
<EuiFlyout
onClose={() => {}}
data-test-subj="main-flyout"
session={false} // Explicitly not creating a new session, but still routes to main
flyoutMenuProps={{ title: 'Test Main Flyout' }} // Required for managed flyouts
/>
<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={() => {}}
data-test-subj="standard-flyout"
session="never" // Explicitly opts out of session management
/>
</EuiFlyoutManager>
);

// Should render as main flyout (EuiFlyoutMain)
const flyout = getByTestSubject('main-flyout');
expect(flyout).toBeInTheDocument();
// 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 in managed context and there is an active session', () => {
// Setup: There's an active session and flyout is in managed context
mockUseHasActiveSession.mockReturnValue(true);
mockUseIsInManagedFlyout.mockReturnValue(true);
// First render with just the main flyout to establish a session
const { rerender, getByTestSubject } = render(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
/>
</EuiFlyoutManager>
);

const { getByTestSubject } = render(
<EuiFlyout
onClose={() => {}}
data-test-subj="child-flyout"
session={undefined} // Not explicitly set
/>
// Now render with the child flyout added - it should detect the active session
rerender(
<EuiFlyoutManager>
<EuiFlyout
onClose={() => {}}
session="start"
flyoutMenuProps={{ title: 'Main Flyout' }}
data-test-subj="main-flyout"
/>
<EuiFlyout
onClose={() => {}}
data-test-subj="child-flyout"
session={undefined} // Not explicitly set, should inherit
/>
</EuiFlyoutManager>
);

// Should render as child flyout (EuiFlyoutChild)
const flyout = getByTestSubject('child-flyout');
expect(flyout).toBeInTheDocument();
expect(flyout).toHaveAttribute('data-managed-flyout-level', 'child');
});

it('routes to standard flyout when there is no active session', () => {
// Setup: No active session
mockUseHasActiveSession.mockReturnValue(false);
mockUseIsInManagedFlyout.mockReturnValue(false);

const { getByTestSubject } = render(
<EuiFlyout
onClose={() => {}}
data-test-subj="standard-flyout"
data-test-subj="flyout"
session={undefined} // Not explicitly set
/>
);

// Should render as standard flyout (EuiFlyoutComponent)
const flyout = getByTestSubject('standard-flyout');
expect(flyout).toBeInTheDocument();
// Should render as standard flyout (EuiFlyoutComponent) - no manager context
const flyout = getByTestSubject('flyout');
expect(flyout).not.toHaveAttribute('data-managed-flyout-level');
});
});
});
50 changes: 38 additions & 12 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 { SESSION_INHERIT, SESSION_NEVER, SESSION_START } from './manager/const';

export type {
EuiFlyoutSize,
Expand All @@ -34,29 +35,51 @@ export type EuiFlyoutProps<T extends ElementType = 'div' | 'nav'> = Omit<
EuiFlyoutComponentProps<T>,
'as'
> & {
session?: boolean;
/**
* 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.
* @default 'inherit'
*/
session?:
| typeof SESSION_START
| typeof SESSION_INHERIT
| typeof SESSION_NEVER;
/**
* Callback fired when the flyout becomes active/visible, which may happen programmatically from history navigation.
*/
onActive?: () => void;
/**
* The HTML element to render as the flyout container.
*/
as?: T;
};

export const EuiFlyout = forwardRef<
HTMLDivElement | HTMLElement,
EuiFlyoutProps<'div' | 'nav'>
>((props, ref) => {
const { session, as, onClose, onActive, ...rest } =
usePropsWithComponentDefaults('EuiFlyout', props);
const hasActiveSession = useHasActiveSession();
const {
as,
onClose,
onActive,
session = SESSION_INHERIT,
...rest
} = usePropsWithComponentDefaults('EuiFlyout', props);
const hasActiveSession = useRef(useHasActiveSession());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat!

const isUnmanagedFlyout = useRef(false);

/*
* Flyout routing logic:
* - session={true} → Main flyout (creates new session)
* - session={undefined} + active session → Child flyout (auto-joins, works across React roots!)
* - session={undefined} + no session → Standard flyout
* - session={false} → Standard flyout (explicit opt-out)
* - session="start" → Main flyout (creates new session)
* - 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)
*/
if (session !== false) {
if (session === true) {
if (session !== SESSION_NEVER) {
if (session === SESSION_START) {
// session=start: create new session
if (isUnmanagedFlyout.current) {
// TODO: @tkajtoch - We need to find a better way to handle the missing event.
onClose?.({} as any);
Expand All @@ -72,8 +95,11 @@ export const EuiFlyout = forwardRef<
);
}

// Auto-join existing session as child
if (hasActiveSession && session === undefined) {
// session=inherit: auto-join existing session as child
if (
hasActiveSession.current &&
(session === undefined || session === SESSION_INHERIT)
) {
return (
<EuiFlyoutChild
{...rest}
Expand Down
10 changes: 10 additions & 0 deletions packages/eui/src/components/flyout/manager/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
* Side Public License, v 1.
*/

/**
* 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';
export const SESSION_NEVER = 'never';

const PREFIX = 'data-managed-flyout';

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('EuiFlyoutChild', () => {
{isMainOpen && (
<EuiFlyout
id="main-flyout"
session={true}
session="start"
aria-label="Main flyout"
size="m"
onClose={() => {}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ const StatefulFlyout: React.FC<FlyoutChildStoryArgs> = ({

{isMainOpen && (
<EuiFlyout
session={true}
session="start"
id="flyout-manager-playground-main"
size={mainSize}
type={mainFlyoutType}
Expand Down
Loading