= ({ size }) => {
};
const MultiSessionFlyoutDemo: React.FC = () => {
+ const parksHistoryKey = React.useRef(Symbol()).current;
+ const sanitationHistoryKey = React.useRef(Symbol()).current;
+
const listItems = [
{
- title: 'Session A: main size = s, child size = s',
+ title: 'Parks (shared history)',
description: (
-
+
+
+
+
+
+
+
+
+
+
+
),
},
{
- title: 'Session B: main size = m, child size = s',
+ title: 'Sanitation (shared history)',
description: (
-
+
+
+
+
+
+
+
+
+
+
+
),
},
{
- title: 'Session C: main size = s, child size = fill',
+ title: 'Permits (no historyKey: unique group)',
description: (
- ),
- },
- {
- title: 'Session D: main size = fill, child size = s',
- description: (
-
- ),
- },
- {
- title: 'Session E: main size = fill, child size = m',
- description: (
-
- ),
- },
- {
- title: 'Session F: main size = m, child size = fill (maxWidth 1000px)',
- description: (
-
),
},
@@ -507,6 +525,14 @@ const MultiSessionFlyoutDemo: React.FC = () => {
return (
<>
+
+
+ "Parks", "Sanitation", and "Permits" are
+ separate groups of scoped history. Navigating with the Back button and
+ the history menu does not cross over into other groups.
+
+
+
{
export const MultiSessionExample: StoryObj = {
name: 'Multi-session example',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Parks and Sanitation use a shared `historyKey` so Back/history are scoped to each domain. Permits omits `historyKey` so it is alone in its history group. Open flyouts from different sections to see that histories do not mix.',
+ },
+ },
+ },
render: () => ,
};
-const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({
- parentId,
-}) => {
+const ExternalRootChildFlyout: React.FC<{
+ historyKey: symbol;
+ parentId: string;
+}> = ({ historyKey, parentId }) => {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = () => {
@@ -586,13 +621,9 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({
};
return (
-
-
- Root within {parentId}
-
-
+ <>
- Open child flyout
+ {`Open ${parentId} child flyout in external root`}
{isOpen && (
= ({
flyoutMenuProps={{ title: `Child flyout of ${parentId}` }}
resizable={false}
data-test-subj="child-flyout-in-new-root"
+ historyKey={historyKey}
>
@@ -617,11 +649,14 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({
)}
-
+ >
);
};
-const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
+const ExternalRootFlyout: React.FC<{ id: string; historyKey: symbol }> = ({
+ id,
+ historyKey,
+}) => {
const [isOpen, setIsOpen] = useState(false);
const buttonContainerRef = useRef(null);
const buttonRootRef = useRef(null);
@@ -643,7 +678,7 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
const newRoot = createRoot(buttonContainerRef.current);
newRoot.render(
-
+
);
buttonRootRef.current = newRoot;
@@ -667,13 +702,9 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
}, []);
return (
-
-
- {id}
-
-
- setIsOpen((prev) => !prev)}>
- {isOpen ? 'Close flyout' : 'Open flyout'}
+ <>
+ setIsOpen((prev) => !prev)} disabled={isOpen}>
+ {`Open ${id} flyout`}
{isOpen && (
= ({ id }) => {
ownFocus={false}
flyoutMenuProps={{ title: `${id} flyout` }}
resizable={true}
+ historyKey={historyKey}
>
@@ -711,10 +743,12 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
)}
-
+ >
);
};
+const multiRootHistoryKey = Symbol('multiRootSharedHistory');
+
const MultiRootFlyoutDemo: React.FC = () => {
const secondaryRootRef = useRef(null);
const tertiaryRootRef = useRef(null);
@@ -735,7 +769,7 @@ const MultiRootFlyoutDemo: React.FC = () => {
const root = createRoot(container);
root.render(
-
+
);
return root;
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 418ccdd2f3f..4b7c4e425b3 100644
--- a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx
+++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx
@@ -85,7 +85,11 @@ const buildMockManagerState = ({
session = {
mainFlyoutId: 'main-1',
childFlyoutId: 'child-1' as string | null,
- } as { mainFlyoutId: string; childFlyoutId: string | null } | null,
+ } as {
+ mainFlyoutId: string;
+ childFlyoutId: string | null;
+ historyKey?: symbol;
+ } | null,
mainFlyout = {
flyoutId: 'main-1',
level: 'main' as const,
@@ -100,7 +104,9 @@ const buildMockManagerState = ({
} as { flyoutId: string; level: string; size: string; width?: number } | null,
} = {}) => ({
layoutMode,
- sessions: session ? [session] : [],
+ sessions: session
+ ? [{ ...session, historyKey: session.historyKey ?? Symbol() }]
+ : [],
flyouts: [mainFlyout, childFlyout].filter(Boolean),
});
diff --git a/packages/eui/src/components/flyout/manager/reducer.test.ts b/packages/eui/src/components/flyout/manager/reducer.test.ts
index fcc3d51985a..2e7278a7b16 100644
--- a/packages/eui/src/components/flyout/manager/reducer.test.ts
+++ b/packages/eui/src/components/flyout/manager/reducer.test.ts
@@ -73,14 +73,17 @@ describe('flyoutManagerReducer', () => {
});
expect(newState.sessions).toHaveLength(1);
- expect(newState.sessions[0]).toEqual({
- mainFlyoutId: 'main-1',
- childFlyoutId: null,
- childHistory: [],
- title: 'main',
- iconType: undefined,
- zIndex: 0,
- });
+ expect(newState.sessions[0]).toMatchInlineSnapshot(`
+ {
+ "childFlyoutId": null,
+ "childHistory": [],
+ "historyKey": Symbol(),
+ "iconType": undefined,
+ "mainFlyoutId": "main-1",
+ "title": "main",
+ "zIndex": 0,
+ }
+ `);
});
it('should store iconType on session when addFlyout is called with iconType', () => {
@@ -89,6 +92,7 @@ describe('flyoutManagerReducer', () => {
'Session A',
LEVEL_MAIN,
'm',
+ undefined,
'faceHappy'
);
const newState = flyoutManagerReducer(initialState, action);
@@ -183,6 +187,7 @@ describe('flyoutManagerReducer', () => {
'Child 1 Updated',
LEVEL_CHILD,
undefined,
+ undefined,
'starFilled'
)
);
@@ -214,22 +219,39 @@ describe('flyoutManagerReducer', () => {
);
expect(state.sessions).toHaveLength(2);
- expect(state.sessions[0]).toEqual({
- mainFlyoutId: 'main-1',
- childFlyoutId: 'child-1',
- childTitle: 'child',
- childIconType: undefined,
- childHistory: [],
- title: 'main',
- zIndex: 0,
- });
- expect(state.sessions[1]).toEqual({
- mainFlyoutId: 'main-2',
- childFlyoutId: null,
- childHistory: [],
- title: 'main',
- zIndex: 3,
- });
+ expect(state.sessions[0]).toMatchInlineSnapshot(`
+ {
+ "childFlyoutId": "child-1",
+ "childHistory": [],
+ "childIconType": undefined,
+ "childTitle": "child",
+ "historyKey": Symbol(),
+ "iconType": undefined,
+ "mainFlyoutId": "main-1",
+ "title": "main",
+ "zIndex": 0,
+ }
+ `);
+ expect(state.sessions[1]).toMatchInlineSnapshot(`
+ {
+ "childFlyoutId": null,
+ "childHistory": [],
+ "historyKey": Symbol(),
+ "iconType": undefined,
+ "mainFlyoutId": "main-2",
+ "title": "main",
+ "zIndex": 3,
+ }
+ `);
+ });
+
+ it('should store historyKey on session when addFlyout main is called with historyKey', () => {
+ const key = Symbol('shared');
+ const action = addFlyout('main-1', 'Session A', LEVEL_MAIN, 'm', key);
+ const newState = flyoutManagerReducer(initialState, action);
+
+ expect(newState.sessions).toHaveLength(1);
+ expect(newState.sessions[0].historyKey).toBe(key);
});
});
@@ -360,14 +382,15 @@ describe('flyoutManagerReducer', () => {
});
it('should close all sessions and preserve unmanaged flyouts', () => {
- // Setup: add managed and unmanaged flyouts
+ const historyKey = Symbol();
+ // Setup: add managed and unmanaged flyouts (same historyKey so closeAll closes both sessions)
let state = flyoutManagerReducer(
initialState,
- addFlyout('main-1', 'Main 1', LEVEL_MAIN)
+ addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
- addFlyout('main-2', 'Main 2', LEVEL_MAIN)
+ addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(state, addUnmanagedFlyout('unmanaged-1'));
@@ -375,7 +398,7 @@ describe('flyoutManagerReducer', () => {
expect(state.flyouts).toHaveLength(2);
expect(state.unmanagedFlyouts).toHaveLength(1);
- // Close all flyouts
+ // Close all flyouts (current history group = both sessions)
const action = closeAllFlyouts();
state = flyoutManagerReducer(state, action);
@@ -386,10 +409,11 @@ describe('flyoutManagerReducer', () => {
});
it('should close all sessions including child flyouts', () => {
- // Setup: add sessions with children
+ const historyKey = Symbol();
+ // Setup: add sessions with children (same historyKey)
let state = flyoutManagerReducer(
initialState,
- addFlyout('main-1', 'Main 1', LEVEL_MAIN)
+ addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
@@ -397,13 +421,13 @@ describe('flyoutManagerReducer', () => {
);
state = flyoutManagerReducer(
state,
- addFlyout('main-2', 'Main 2', LEVEL_MAIN)
+ addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey)
);
expect(state.sessions).toHaveLength(2);
expect(state.flyouts).toHaveLength(3);
- // Close all flyouts
+ // Close all flyouts (current group = both sessions)
const action = closeAllFlyouts();
state = flyoutManagerReducer(state, action);
@@ -420,15 +444,43 @@ describe('flyoutManagerReducer', () => {
expect(newState).toEqual(initialState);
});
+ it('should close only current history group when multiple groups exist', () => {
+ const keyA = Symbol();
+ const keyB = Symbol();
+ let state = flyoutManagerReducer(
+ initialState,
+ addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, keyA)
+ );
+ state = flyoutManagerReducer(
+ state,
+ addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, keyB)
+ );
+ state = flyoutManagerReducer(
+ state,
+ addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA)
+ );
+
+ expect(state.sessions).toHaveLength(3);
+
+ // closeAllFlyouts from top (main-3, keyA) removes only sessions with keyA: main-3 and main-1
+ state = flyoutManagerReducer(state, closeAllFlyouts());
+
+ expect(state.sessions).toHaveLength(1);
+ expect(state.sessions[0].mainFlyoutId).toBe('main-2');
+ expect(state.flyouts).toHaveLength(1);
+ expect(state.flyouts[0].flyoutId).toBe('main-2');
+ });
+
it('should reset currentZIndex value when all unmanaged and managed flyouts are closed', () => {
- // Setup: add managed and unmanaged flyouts
+ const historyKey = Symbol();
+ // Setup: add managed and unmanaged flyouts (same historyKey)
let state = flyoutManagerReducer(
initialState,
- addFlyout('main-1', 'Main 1', LEVEL_MAIN)
+ addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
- addFlyout('main-2', 'Main 2', LEVEL_MAIN)
+ addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(state, addUnmanagedFlyout('unmanaged-1'));
@@ -439,7 +491,7 @@ describe('flyoutManagerReducer', () => {
state = flyoutManagerReducer(state, closeUnmanagedFlyout('unmanaged-1'));
expect(state.currentZIndex).toEqual(8);
- // Close all flyouts, currentZIndex should reset to 0
+ // Close all flyouts (both sessions in group), currentZIndex should reset to 0
state = flyoutManagerReducer(state, closeAllFlyouts());
expect(state.currentZIndex).toBe(0);
});
@@ -606,20 +658,21 @@ describe('flyoutManagerReducer', () => {
describe('ACTION_GO_BACK', () => {
it('should remove the current session and its flyouts', () => {
- // Setup: create two sessions
+ const historyKey = Symbol();
+ // Setup: create two sessions (same historyKey so goBack only removes one)
let state = flyoutManagerReducer(
initialState,
- addFlyout('main-1', 'Session A', LEVEL_MAIN)
+ addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
- addFlyout('main-2', 'Session B', LEVEL_MAIN)
+ addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, historyKey)
);
expect(state.sessions).toHaveLength(2);
expect(state.flyouts).toHaveLength(2);
- // Go back (should remove Session B)
+ // Go back (should remove Session B only, same group)
const action = goBack();
state = flyoutManagerReducer(state, action);
@@ -631,14 +684,15 @@ describe('flyoutManagerReducer', () => {
});
it('should remove current session with child flyout', () => {
- // Setup: create session with child
+ const historyKey = Symbol();
+ // Setup: create session with child (same historyKey)
let state = flyoutManagerReducer(
initialState,
- addFlyout('main-1', 'Session A', LEVEL_MAIN)
+ addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
- addFlyout('main-2', 'Session B', LEVEL_MAIN)
+ addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
@@ -649,7 +703,7 @@ describe('flyoutManagerReducer', () => {
expect(state.sessions[1].childFlyoutId).toBe('child-2');
expect(state.flyouts).toHaveLength(3);
- // Go back (should remove Session B and its child)
+ // Go back (should remove Session B and its child only)
const action = goBack();
state = flyoutManagerReducer(state, action);
@@ -659,6 +713,39 @@ describe('flyoutManagerReducer', () => {
expect(state.flyouts[0].flyoutId).toBe('main-1');
});
+ it('should keep other groups and restore previous session in current group when going back', () => {
+ const keyA = Symbol();
+ const keyB = Symbol();
+ let state = flyoutManagerReducer(
+ initialState,
+ addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, keyA)
+ );
+ state = flyoutManagerReducer(
+ state,
+ addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, keyB)
+ );
+ state = flyoutManagerReducer(
+ state,
+ addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA)
+ );
+
+ expect(state.sessions).toHaveLength(3);
+
+ // goBack from main-3 (keyA): remove current session and bring prior keyA session to top.
+ // main-2 (keyB) remains in state and is restored when keyA group closes.
+ state = flyoutManagerReducer(state, goBack());
+
+ expect(state.sessions).toHaveLength(2);
+ expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([
+ 'main-2',
+ 'main-1',
+ ]);
+ expect(state.flyouts.map((f) => f.flyoutId)).toEqual([
+ 'main-1',
+ 'main-2',
+ ]);
+ });
+
it('should do nothing when no sessions exist', () => {
const action = goBack();
const newState = flyoutManagerReducer(initialState, action);
@@ -807,6 +894,41 @@ describe('flyoutManagerReducer', () => {
]);
});
+ it('should preserve intervening groups when navigating to prior session in current history group', () => {
+ const keyA = Symbol();
+ const keyB = Symbol();
+ let state = flyoutManagerReducer(
+ initialState,
+ addFlyout('main-1', 'Session A1', LEVEL_MAIN, undefined, keyA)
+ );
+ state = flyoutManagerReducer(
+ state,
+ addFlyout('main-2', 'Session B1', LEVEL_MAIN, undefined, keyB)
+ );
+ state = flyoutManagerReducer(
+ state,
+ addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA)
+ );
+
+ expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([
+ 'main-1',
+ 'main-2',
+ 'main-3',
+ ]);
+
+ // Navigate from A2 to A1: remove newer same-group session(s) only and keep B1.
+ state = flyoutManagerReducer(state, goToFlyout('main-1'));
+
+ expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([
+ 'main-2',
+ 'main-1',
+ ]);
+ expect(state.flyouts.map((f) => f.flyoutId)).toEqual([
+ 'main-1',
+ 'main-2',
+ ]);
+ });
+
it('should do nothing when target flyout does not exist', () => {
// Setup: create session
let state = flyoutManagerReducer(
@@ -1062,26 +1184,27 @@ describe('flyoutManagerReducer', () => {
});
it('should handle multiple sessions with children', () => {
+ const historyKey = Symbol();
let state = initialState;
- // Session 1: main + child
+ // Session 1: main + child (shared historyKey)
state = flyoutManagerReducer(
state,
- addFlyout('main-1', 'main', LEVEL_MAIN)
+ addFlyout('main-1', 'main', LEVEL_MAIN, undefined, historyKey)
);
state = flyoutManagerReducer(
state,
addFlyout('child-1', 'child', LEVEL_CHILD)
);
- // Session 2: main only
+ // Session 2: main only (same historyKey)
state = flyoutManagerReducer(
state,
- addFlyout('main-2', 'main', LEVEL_MAIN)
+ addFlyout('main-2', 'main', LEVEL_MAIN, undefined, historyKey)
);
expect(state.sessions).toHaveLength(2);
- expect(state.sessions[0]).toEqual({
+ expect(state.sessions[0]).toMatchObject({
mainFlyoutId: 'main-1',
childFlyoutId: 'child-1',
childTitle: 'child',
@@ -1090,7 +1213,7 @@ describe('flyoutManagerReducer', () => {
title: 'main',
zIndex: 0,
});
- expect(state.sessions[1]).toEqual({
+ expect(state.sessions[1]).toMatchObject({
mainFlyoutId: 'main-2',
childFlyoutId: null,
childHistory: [],
@@ -1098,7 +1221,7 @@ describe('flyoutManagerReducer', () => {
zIndex: 3,
});
- // Close first session's main flyout
+ // Close current history group (both sessions share key, so both close)
state = flyoutManagerReducer(state, closeAllFlyouts());
expect(state.sessions).toHaveLength(0);
diff --git a/packages/eui/src/components/flyout/manager/reducer.ts b/packages/eui/src/components/flyout/manager/reducer.ts
index 210628247b3..8243960b6a0 100644
--- a/packages/eui/src/components/flyout/manager/reducer.ts
+++ b/packages/eui/src/components/flyout/manager/reducer.ts
@@ -47,6 +47,35 @@ export const initialState: EuiFlyoutManagerState = {
unmanagedFlyouts: [],
};
+const addSessionFlyoutsToRemove = (
+ session: FlyoutSession,
+ flyoutsToRemove: Set
+) => {
+ flyoutsToRemove.add(session.mainFlyoutId);
+ if (session.childFlyoutId) {
+ flyoutsToRemove.add(session.childFlyoutId);
+ }
+ (session.childHistory ?? []).forEach((e) => flyoutsToRemove.add(e.flyoutId));
+};
+
+const moveHistoryGroupToTop = (
+ sessions: FlyoutSession[],
+ historyKey: symbol
+): FlyoutSession[] => {
+ const groupSessions: FlyoutSession[] = [];
+ const otherSessions: FlyoutSession[] = [];
+
+ sessions.forEach((session) => {
+ if (session.historyKey === historyKey) {
+ groupSessions.push(session);
+ } else {
+ otherSessions.push(session);
+ }
+ });
+
+ return [...otherSessions, ...groupSessions];
+};
+
/**
* Reducer handling all flyout manager actions and state transitions.
*/
@@ -93,7 +122,8 @@ export function flyoutManagerReducer(
// - 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, title, level, size, iconType, minWidth } = action;
+ const { flyoutId, title, level, size, historyKey, iconType, minWidth } =
+ action;
const isDuplicate = state.flyouts.some((f) => f.flyoutId === flyoutId);
const isIdempotentChild =
@@ -125,6 +155,7 @@ export function flyoutManagerReducer(
childFlyoutId: null,
childHistory: [],
zIndex: state.currentZIndex,
+ historyKey: historyKey ?? Symbol(),
};
return {
@@ -289,22 +320,41 @@ export function flyoutManagerReducer(
return { ...state, sessions: updatedSessions, flyouts: newFlyouts };
}
- // Unregister all flyouts.
+ // Unregister all flyouts (within the current history group only).
case ACTION_CLOSE_ALL: {
if (state.sessions.length === 0) {
return state;
}
- // Reset current z-index to 0 only if no unmanaged flyouts remain.
+ const currentSessionIndex = state.sessions.length - 1;
+ const currentSession = state.sessions[currentSessionIndex];
+ const currentKey = currentSession.historyKey;
+
+ // Remove all sessions that have the current historyKey (entire group)
+ const newSessions = state.sessions.filter(
+ (s) => s.historyKey !== currentKey
+ );
+ const flyoutsToRemove = new Set();
+ state.sessions.forEach((session) => {
+ if (session.historyKey === currentKey) {
+ addSessionFlyoutsToRemove(session, flyoutsToRemove);
+ }
+ });
+
+ const newFlyouts = state.flyouts.filter(
+ (f) => !flyoutsToRemove.has(f.flyoutId)
+ );
+
let newCurrentZIndex = state.currentZIndex;
- if (state.unmanagedFlyouts.length === 0) {
+ if (newSessions.length === 0 && state.unmanagedFlyouts.length === 0) {
newCurrentZIndex = 0;
}
return {
- ...initialState,
+ ...state,
+ sessions: newSessions,
+ flyouts: newFlyouts,
currentZIndex: newCurrentZIndex,
- unmanagedFlyouts: state.unmanagedFlyouts,
};
}
@@ -352,7 +402,7 @@ export function flyoutManagerReducer(
return { ...state, flyouts: updatedFlyouts };
}
- // Go back: pop child history when any, else pop current session
+ // Go back: pop child history when any, else pop current session (only within same historyKey).
case ACTION_GO_BACK: {
if (state.sessions.length === 0) {
return state;
@@ -381,18 +431,26 @@ export function flyoutManagerReducer(
}
// No child history: pop current session (main + all its children)
- const flyoutsToRemove = new Set([currentSession.mainFlyoutId]);
- if (currentSession.childFlyoutId) {
- flyoutsToRemove.add(currentSession.childFlyoutId);
- }
- (currentSession.childHistory ?? []).forEach((e) =>
- flyoutsToRemove.add(e.flyoutId)
+ const flyoutsToRemove = new Set();
+ addSessionFlyoutsToRemove(currentSession, flyoutsToRemove);
+
+ const sessionsWithoutCurrent = state.sessions.slice(
+ 0,
+ currentSessionIndex
+ );
+ const hasRemainingInCurrentGroup = sessionsWithoutCurrent.some(
+ (s) => s.historyKey === currentSession.historyKey
);
+ const newSessions = hasRemainingInCurrentGroup
+ ? moveHistoryGroupToTop(
+ sessionsWithoutCurrent,
+ currentSession.historyKey
+ )
+ : sessionsWithoutCurrent;
const newFlyouts = state.flyouts.filter(
(f) => !flyoutsToRemove.has(f.flyoutId)
);
- const newSessions = state.sessions.slice(0, currentSessionIndex);
return { ...state, sessions: newSessions, flyouts: newFlyouts };
}
@@ -449,23 +507,49 @@ export function flyoutManagerReducer(
return state; // Target flyout not found
}
+ const currentSession = state.sessions[currentSessionIndex];
+ const targetSession = state.sessions[targetSessionIndex];
+
+ // Group-local navigation: keep other history groups, remove only newer sessions in target's group,
+ // then bring that group to the top.
+ if (targetSession.historyKey === currentSession.historyKey) {
+ const flyoutsToRemove = new Set();
+ const sessionsAfterTargetInGroup = state.sessions.filter(
+ (session, index) =>
+ index > targetSessionIndex &&
+ session.historyKey === targetSession.historyKey
+ );
+
+ sessionsAfterTargetInGroup.forEach((session) => {
+ addSessionFlyoutsToRemove(session, flyoutsToRemove);
+ });
+
+ const sessionsWithoutRemoved = state.sessions.filter(
+ (session) =>
+ !sessionsAfterTargetInGroup.some(
+ (removed) => removed.mainFlyoutId === session.mainFlyoutId
+ )
+ );
+ const reorderedSessions = moveHistoryGroupToTop(
+ sessionsWithoutRemoved,
+ targetSession.historyKey
+ );
+ const newFlyouts = state.flyouts.filter(
+ (f) => !flyoutsToRemove.has(f.flyoutId)
+ );
+
+ return { ...state, sessions: reorderedSessions, flyouts: newFlyouts };
+ }
+
const sessionsToClose = state.sessions.slice(targetSessionIndex + 1);
const flyoutsToRemove = new Set();
-
sessionsToClose.forEach((session) => {
- flyoutsToRemove.add(session.mainFlyoutId);
- if (session.childFlyoutId) {
- flyoutsToRemove.add(session.childFlyoutId);
- }
- (session.childHistory ?? []).forEach((e) =>
- flyoutsToRemove.add(e.flyoutId)
- );
+ addSessionFlyoutsToRemove(session, flyoutsToRemove);
});
const newFlyouts = state.flyouts.filter(
(f) => !flyoutsToRemove.has(f.flyoutId)
);
-
const newSessions = state.sessions.slice(0, targetSessionIndex + 1);
return { ...state, sessions: newSessions, flyouts: newFlyouts };
diff --git a/packages/eui/src/components/flyout/manager/store.test.ts b/packages/eui/src/components/flyout/manager/store.test.ts
index 28ea1c88bce..90e56e00322 100644
--- a/packages/eui/src/components/flyout/manager/store.test.ts
+++ b/packages/eui/src/components/flyout/manager/store.test.ts
@@ -55,29 +55,55 @@ describe('Flyout Manager Store', () => {
it('should update references when sessions change', () => {
const store = getFlyoutManagerStore();
+ const historyKey = Symbol();
- // Add first flyout
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
+ // Add first flyout (with shared historyKey)
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const firstHistoryItems = store.historyItems;
- // Add second flyout (creates a new session)
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ // Add second flyout (same historyKey so they share history)
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const secondHistoryItems = store.historyItems;
// References should be different since sessions changed
expect(secondHistoryItems).not.toBe(firstHistoryItems);
- // Should have one history item (the first session)
+ // Should have one history item (the first session, same group)
expect(secondHistoryItems).toHaveLength(1);
expect(secondHistoryItems[0].title).toBe('First Flyout');
});
it('should create stable onClick handlers within the same session state', () => {
const store = getFlyoutManagerStore();
+ const historyKey = Symbol();
- // Add two flyouts to create history
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ // Add two flyouts (same historyKey) to create history
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const firstHistoryItems = store.historyItems;
const firstOnClick = firstHistoryItems[0].onClick;
@@ -95,15 +121,34 @@ describe('Flyout Manager Store', () => {
it('should properly compute history items with correct titles', () => {
const store = getFlyoutManagerStore();
+ const historyKey = Symbol();
- // Create multiple sessions
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-3', 'Third Flyout', LEVEL_MAIN);
+ // Create multiple sessions (same historyKey so they share history)
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-3',
+ 'Third Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const historyItems = store.historyItems;
- // Should have 2 history items (all previous sessions, in reverse order)
+ // Should have 2 history items (previous sessions in same group, reverse order)
expect(historyItems).toHaveLength(2);
expect(historyItems[0].title).toBe('Second Flyout');
expect(historyItems[1].title).toBe('First Flyout');
@@ -111,15 +156,23 @@ describe('Flyout Manager Store', () => {
it('should include iconType in history items when sessions were added with iconType', () => {
const store = getFlyoutManagerStore();
+ const historyKey = Symbol();
store.addFlyout(
'flyout-1',
'First Flyout',
LEVEL_MAIN,
undefined,
+ historyKey,
'faceHappy'
);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const historyItems = store.historyItems;
@@ -131,10 +184,23 @@ describe('Flyout Manager Store', () => {
it('should have functional onClick handlers', () => {
const store = getFlyoutManagerStore();
+ const historyKey = Symbol();
- // Create two sessions
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ // Create two sessions (same historyKey)
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const historyItems = store.historyItems;
@@ -147,10 +213,37 @@ describe('Flyout Manager Store', () => {
expect(store.getState().sessions[0].mainFlyoutId).toBe('flyout-1');
});
+ it('should keep intervening groups when history onClick navigates within a group', () => {
+ const store = getFlyoutManagerStore();
+ const keyA = Symbol();
+ const keyB = Symbol();
+
+ store.addFlyout('a-1', 'A1', LEVEL_MAIN, undefined, keyA);
+ store.addFlyout('b-1', 'B1', LEVEL_MAIN, undefined, keyB);
+ store.addFlyout('a-2', 'A2', LEVEL_MAIN, undefined, keyA);
+
+ expect(store.historyItems).toHaveLength(1);
+ expect(store.historyItems[0].title).toBe('A1');
+
+ // Navigate to A1 from A2 history item.
+ store.historyItems[0].onClick();
+
+ // B1 should still exist and be restored behind the active A group.
+ expect(store.getState().sessions.map((s) => s.mainFlyoutId)).toEqual([
+ 'b-1',
+ 'a-1',
+ ]);
+ expect(store.getState().flyouts.map((f) => f.flyoutId)).toEqual([
+ 'a-1',
+ 'b-1',
+ ]);
+ });
+
it('should include current session child history first, then previous main sessions (child items most recent first)', () => {
const store = getFlyoutManagerStore();
+ const historyKey = Symbol();
- store.addFlyout('main-1', 'Main', LEVEL_MAIN);
+ store.addFlyout('main-1', 'Main', LEVEL_MAIN, undefined, historyKey);
store.addFlyout('child-1', 'Child 1', LEVEL_CHILD);
store.addFlyout('child-2', 'Child 2', LEVEL_CHILD);
@@ -160,10 +253,10 @@ describe('Flyout Manager Store', () => {
expect(historyItems[0].title).toBe('Child 1');
expect(historyItems[0].onClick).toBeDefined();
- // Add a second main: current session becomes main-2 (no child). History = previous session's child breadcrumb (current child + child history), most recent first
- store.addFlyout('main-2', 'Main 2', LEVEL_MAIN);
+ // Add a second main (same historyKey): current session becomes main-2 (no child), so history = previous session in group (main-1 had children)
+ store.addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey);
const historyItems2 = store.historyItems;
- expect(historyItems2).toHaveLength(2); // Child 2 (main-1's current child), then Child 1 (main-1's child history)
+ expect(historyItems2).toHaveLength(2); // main-1 had Child 2 and Child 1 in history
expect(historyItems2[0].title).toBe('Child 2');
expect(historyItems2[1].title).toBe('Child 1');
});
@@ -180,6 +273,16 @@ describe('Flyout Manager Store', () => {
store.addFlyout('child-1', 'Child', LEVEL_CHILD);
expect(store.historyItems).toHaveLength(0);
});
+
+ it('should not share history when no historyKey is passed (each session gets unique Symbol)', () => {
+ const store = getFlyoutManagerStore();
+
+ store.addFlyout('main-1', 'First', LEVEL_MAIN);
+ store.addFlyout('main-2', 'Second', LEVEL_MAIN);
+
+ // Each session has its own Symbol, so no shared history - current session has no previous in its group
+ expect(store.historyItems).toHaveLength(0);
+ });
});
describe('store subscription', () => {
@@ -227,10 +330,23 @@ describe('Flyout Manager Store', () => {
it('should emit CLOSE_SESSION event when a session is removed by going back', () => {
const store = getFlyoutManagerStore();
const eventListener = jest.fn();
+ const historyKey = Symbol();
- // Create two sessions
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ // Create two sessions (same historyKey so goBack only removes one)
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const sessions = store.getState().sessions;
expect(sessions).toHaveLength(2);
@@ -238,10 +354,10 @@ describe('Flyout Manager Store', () => {
// Subscribe to events
const unsubscribe = store.subscribeToEvents(eventListener);
- // Go back one session
+ // Go back one session (within same history group)
store.goBack();
- // Should have emitted CLOSE_SESSION for the second session
+ // Should have emitted CLOSE_SESSION for the second session only
expect(eventListener).toHaveBeenCalledTimes(1);
expect(eventListener).toHaveBeenCalledWith({
type: 'CLOSE_SESSION',
@@ -254,11 +370,30 @@ describe('Flyout Manager Store', () => {
it('should emit CLOSE_SESSION event when navigating to a previous flyout', () => {
const store = getFlyoutManagerStore();
const eventListener = jest.fn();
+ const historyKey = Symbol();
- // Create three sessions
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-3', 'Third Flyout', LEVEL_MAIN);
+ // Create three sessions (same historyKey)
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-3',
+ 'Third Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const sessions = store.getState().sessions;
expect(sessions).toHaveLength(3);
@@ -287,19 +422,32 @@ describe('Flyout Manager Store', () => {
const store = getFlyoutManagerStore();
const eventListener1 = jest.fn();
const eventListener2 = jest.fn();
+ const historyKey = Symbol();
- store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ store.addFlyout(
+ 'flyout-1',
+ 'First Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const sessions = store.getState().sessions;
store.subscribeToEvents(eventListener1);
store.subscribeToEvents(eventListener2);
- // Go back one session
+ // Go back one session (same group)
store.goBack();
- // Both listeners should have been called
+ // Both listeners should have been called once (one session removed)
expect(eventListener1).toHaveBeenCalledTimes(1);
expect(eventListener1).toHaveBeenCalledWith({
type: 'CLOSE_SESSION',
@@ -340,20 +488,33 @@ describe('Flyout Manager Store', () => {
it('should emit CLOSE_SESSION events when all sessions are removed by closeAllFlyouts', () => {
const store = getFlyoutManagerStore();
const eventListener = jest.fn();
+ const historyKey = Symbol();
- // Create sessions
- store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN);
- store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN);
+ // Create sessions (same historyKey so closeAllFlyouts closes both)
+ store.addFlyout(
+ 'flyout-1',
+ 'Test Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
+ store.addFlyout(
+ 'flyout-2',
+ 'Second Flyout',
+ LEVEL_MAIN,
+ undefined,
+ historyKey
+ );
const sessions = store.getState().sessions;
expect(sessions).toHaveLength(2);
const unsubscribe = store.subscribeToEvents(eventListener);
- // Closing flyout will close all sessions
+ // closeAllFlyouts closes only current history group (both sessions share key)
store.closeAllFlyouts();
- // Should have emitted CLOSE_SESSION
+ // Should have emitted CLOSE_SESSION for both sessions
expect(eventListener).toHaveBeenCalledTimes(2);
expect(eventListener).toHaveBeenNthCalledWith(1, {
type: 'CLOSE_SESSION',
diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts
index 011cf23cd58..ee3b7026aa1 100644
--- a/packages/eui/src/components/flyout/manager/store.ts
+++ b/packages/eui/src/components/flyout/manager/store.ts
@@ -51,6 +51,7 @@ export interface FlyoutManagerStore {
title: string,
level?: EuiFlyoutLevel,
size?: string,
+ historyKey?: symbol,
iconType?: IconType,
minWidth?: number
) => void;
@@ -116,12 +117,21 @@ function createStore(
currentSessionIndex >= 0
? currentState.sessions[currentSessionIndex]
: null;
+
+ if (!currentSession) {
+ return [];
+ }
+
const previousSessions = currentState.sessions.slice(
0,
currentSessionIndex
);
+ // Only include sessions in the same history group (same historyKey reference)
+ const previousSessionsInGroup = previousSessions.filter(
+ (session) => session.historyKey === currentSession.historyKey
+ );
- const childHistory = currentSession?.childHistory ?? [];
+ const childHistory = currentSession.childHistory ?? [];
const childItems = [...childHistory].reverse().map((entry) => ({
title: entry.title,
iconType: entry.iconType,
@@ -130,14 +140,14 @@ function createStore(
},
}));
- // Previous sessions: list each session's current child then its child history (so all travelled entries appear)
+ // Previous sessions (same group): list each session's current child then its child history
const previousSessionItems: Array<{
title: string;
iconType?: IconType;
onClick: () => void;
}> = [];
- for (let i = previousSessions.length - 1; i >= 0; i--) {
- const session = previousSessions[i];
+ for (let i = previousSessionsInGroup.length - 1; i >= 0; i--) {
+ const session = previousSessionsInGroup[i];
const mainTitle = session.title;
const mainFlyoutId = session.mainFlyoutId;
const history = session.childHistory ?? [];
@@ -215,9 +225,17 @@ function createStore(
subscribe,
subscribeToEvents,
dispatch,
- addFlyout: (flyoutId, title, level, size, iconType, minWidth) =>
+ addFlyout: (flyoutId, title, level, size, historyKey, iconType, minWidth) =>
dispatch(
- addFlyoutAction(flyoutId, title, level, size, iconType, minWidth)
+ addFlyoutAction(
+ flyoutId,
+ title,
+ level,
+ size,
+ historyKey,
+ iconType,
+ minWidth
+ )
),
closeFlyout: (flyoutId) => dispatch(closeFlyoutAction(flyoutId)),
closeAllFlyouts: () => dispatch(closeAllFlyoutsAction()),
diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts
index 37ce31e077c..58cfa96fe6b 100644
--- a/packages/eui/src/components/flyout/manager/types.ts
+++ b/packages/eui/src/components/flyout/manager/types.ts
@@ -73,6 +73,8 @@ export interface FlyoutSession {
childIconType?: IconType;
/** Stack of child flyouts we navigated away from. */
childHistory: ChildHistoryEntry[];
+ /** Key that scopes this session's history; same Symbol reference = same history group. Always set (from action or Symbol()). */
+ historyKey: symbol;
}
export interface PushPaddingOffsets {
@@ -111,6 +113,7 @@ export interface FlyoutManagerApi {
title: string,
level?: EuiFlyoutLevel,
size?: string,
+ historyKey?: symbol,
iconType?: IconType,
minWidth?: number
) => void;
diff --git a/packages/website/docs/components/containers/flyout/_session_management.mdx b/packages/website/docs/components/containers/flyout/_session_management.mdx
index dff822fdab6..33baa441948 100644
--- a/packages/website/docs/components/containers/flyout/_session_management.mdx
+++ b/packages/website/docs/components/containers/flyout/_session_management.mdx
@@ -201,6 +201,191 @@ the history popover — it is not shown in the flyout's menu bar.
```
+##### Scoping history (`historyKey`)
+
+By default, every `session="start"` flyout gets its own isolated history group -
+the Back button and history popover only ever show entries from flyouts that are
+part of the same group.
+
+In some applications, multiple areas of the product can open `session="start"`
+flyouts independently (for example, an Alerts panel and a Logs panel that are
+both active at the same time). Without any extra configuration these areas each
+have their own history.
+
+In the example below, the two **Alerts** buttons share a `historyKey` so their
+flyouts form a single history group — use the Back button or history popover to
+navigate between them. The **Logs** button uses a separate key, so its flyout
+has its own isolated history.
+
+```tsx interactive
+import React, { useState } from "react";
+import {
+ EuiButton,
+ EuiCode,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiText,
+ EuiTitle,
+} from "@elastic/eui";
+
+const alertsKey = Symbol("alerts");
+const logsKey = Symbol("logs");
+
+export default () => {
+ const [showAlertOverview, setShowAlertOverview] = useState(false);
+ const [showAlertDetail, setShowAlertDetail] = useState(false);
+ const [showLogs, setShowLogs] = useState(false);
+
+ return (
+ <>
+