Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/eui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"unified": "^9.2.2",
"unist-util-visit": "^2.0.3",
"url-parse": "^1.5.10",
"use-sync-external-store": "^1.6.0",
Copy link
Member

Choose a reason for hiding this comment

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

Note to other reviewers: useSyncExternalStore is shipped with React starting from version 18.0. We need this shim for React 17 support

"uuid": "^8.3.0",
"vfile": "^4.2.1"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,16 @@ jest.mock('./flyout_managed', () => ({

// Keep layout/ID hooks deterministic
jest.mock('./hooks', () => ({
useFlyoutManagerReducer: () => ({
useFlyoutManager: () => ({
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(),
goBack: jest.fn(),
goToFlyout: jest.fn(),
historyItems: [],
}),
useHasChildFlyout: () => false,
useFlyoutId: (id?: string) => id ?? 'generated-id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,11 @@ const createMockFunctions = () => ({
setFlyoutWidth: jest.fn(),
goBack: jest.fn(),
goToFlyout: jest.fn(),
getHistoryItems: jest.fn(() => []),
historyItems: [],
});

// Mock hooks that would otherwise depend on ResizeObserver or animation timing
jest.mock('./hooks', () => ({
useFlyoutManagerReducer: () => ({
state: createMockState(),
...createMockFunctions(),
}),
useFlyoutManager: () => ({
state: createMockState(),
...createMockFunctions(),
Expand Down
14 changes: 10 additions & 4 deletions packages/eui/src/components/flyout/manager/flyout_managed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,13 @@ export const EuiManagedFlyout = ({
const flyoutId = useFlyoutId(id);
const flyoutRef = useRef<HTMLDivElement>(null);

const { addFlyout, closeFlyout, setFlyoutWidth, goBack, getHistoryItems } =
useFlyoutManager();
const {
addFlyout,
closeFlyout,
setFlyoutWidth,
goBack,
historyItems: _historyItems,
} = useFlyoutManager();
const parentSize = useParentFlyoutSize(flyoutId);
const layoutMode = useFlyoutLayoutMode();
const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles);
Expand Down Expand Up @@ -214,8 +219,9 @@ export const EuiManagedFlyout = ({

// Note: history controls are only relevant for main flyouts
const historyItems = useMemo(() => {
return level === LEVEL_MAIN ? getHistoryItems() : undefined;
}, [level, getHistoryItems]);
const result = level === LEVEL_MAIN ? _historyItems : undefined;
return result;
}, [level, _historyItems]);

const backButtonProps = useMemo(() => {
return level === LEVEL_MAIN ? { onClick: goBack } : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,25 @@

import { actions } from '@storybook/addon-actions';
import type { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';

import { LOKI_SELECTORS } from '../../../../.storybook/loki';
import { EuiBreakpointSize } from '../../../services';
import { EuiButton } from '../../button';
import { EuiCodeBlock } from '../../code';
import { EuiFlexGroup, EuiFlexItem } from '../../flex';
import { EuiPanel } from '../../panel';
import { EuiProvider } from '../../provider';
import { EuiSpacer } from '../../spacer';
import { EuiText } from '../../text';
import { EuiTitle } from '../../title';
import { FLYOUT_TYPES, EuiFlyout } from '../flyout';
import { EuiFlyoutBody } from '../flyout_body';
import { EuiFlyoutFooter } from '../flyout_footer';
import { EuiFlyoutChild, EuiFlyoutChildProps } from './flyout_child';
import { useFlyoutLayoutMode } from './hooks';
import { useFlyoutLayoutMode, useFlyoutManager } from './hooks';

type EuiFlyoutChildActualProps = Pick<
EuiFlyoutChildProps,
Expand Down Expand Up @@ -307,3 +314,138 @@ export const FlyoutChildDemo: Story = {
name: 'Playground',
render: (args) => <StatefulFlyout {...args} />,
};

const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => {
const [isOpen, setIsOpen] = useState(false);

return (
<EuiPanel hasBorder paddingSize="m" grow={false}>
<EuiTitle size="xs">
<h3>{id}</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiButton onClick={() => setIsOpen((prev) => !prev)}>
{isOpen ? 'Close flyout' : 'Open flyout'}
</EuiButton>
<EuiFlyout
id={`external-root-${id}`}
isOpen={isOpen}
session
size="m"
onClose={() => setIsOpen(false)}
flyoutMenuProps={{ title: `${id} flyout` }}
>
<EuiFlyoutBody>
<EuiText>
<p>
This flyout lives in a separate React root but shares the same
manager state. Closing it here should update all other flyout
menus and history.
</p>
</EuiText>
</EuiFlyoutBody>
</EuiFlyout>
</EuiPanel>
);
};

const MultiRootFlyoutDemo: React.FC<FlyoutChildStoryArgs> = (args) => {
const secondaryRootRef = useRef<HTMLDivElement | null>(null);
const tertiaryRootRef = useRef<HTMLDivElement | null>(null);
const mountedRootsRef = useRef<Root[]>([]);
const flyoutManager = useFlyoutManager();

useEffect(() => {
const timer = setTimeout(() => {
if (secondaryRootRef.current && tertiaryRootRef.current) {
const containers = [
{ container: secondaryRootRef.current, id: 'Secondary root' },
{ container: tertiaryRootRef.current, id: 'Tertiary root' },
];

mountedRootsRef.current = containers.map(({ container, id }) => {
const root = createRoot(container);
root.render(
<EuiProvider>
<ExternalRootFlyout id={id} />
</EuiProvider>
);
return root;
});
}
}, 100);

return () => {
clearTimeout(timer);
mountedRootsRef.current.forEach((root) => root.unmount());
mountedRootsRef.current = [];
};
}, []);

return (
<>
<EuiTitle size="s">
<h3>Primary React root</h3>
</EuiTitle>
<EuiSpacer size="m" />
<StatefulFlyout
{...args}
mainSize="m"
childSize="s"
mainFlyoutType="overlay"
outsideClickCloses={false}
ownFocus={true}
paddingSize="m"
pushAnimation={true}
pushMinBreakpoint="xs"
showFooter={true}
mainFlyoutResizable={false}
childFlyoutResizable={false}
/>
<EuiSpacer size="xl" />
<EuiTitle size="s">
<h3>Additional React roots</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
<p>
These flyouts are rendered in separate React roots but share the same
flyout manager state. Open/close any flyout and watch the shared state
update below.
</p>
</EuiText>
<EuiSpacer />
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<div ref={secondaryRootRef} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div ref={tertiaryRootRef} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
<EuiTitle size="s">
<h3>Shared manager state</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiCodeBlock language="json" isCopyable>
{JSON.stringify(
{
sessions: flyoutManager?.state.sessions,
flyouts: flyoutManager?.state.flyouts,
},
null,
2
)}
</EuiCodeBlock>
</>
);
};

export const MultiRootSyncPlayground: Story = {
name: 'Multi-root sync',
render: (args) => <MultiRootFlyoutDemo {...args} />,
parameters: {
layout: 'fullscreen',
},
};
Loading