diff --git a/packages/eui/changelogs/upcoming/7782.md b/packages/eui/changelogs/upcoming/7782.md
new file mode 100644
index 00000000000..d22126efd15
--- /dev/null
+++ b/packages/eui/changelogs/upcoming/7782.md
@@ -0,0 +1,5 @@
+- Added `EuiWindowProvider` that fixes interactivity of some components in scenarios where rendering is done via React Portals into another browser window or iframe. ([#7782](https://github.com/elastic/eui/pull/7782))
+
+**Dependency updates**
+
+- Updated `react-focus-on` to vX.Y.Z (TODO)
diff --git a/packages/eui/i18ntokens.json b/packages/eui/i18ntokens.json
index f60c35a7833..38de3a1b76b 100644
--- a/packages/eui/i18ntokens.json
+++ b/packages/eui/i18ntokens.json
@@ -4811,14 +4811,14 @@
"highlighting": "string",
"loc": {
"start": {
- "line": 336,
+ "line": 339,
"column": 14,
- "index": 10903
+ "index": 11143
},
"end": {
- "line": 339,
+ "line": 342,
"column": 16,
- "index": 11118
+ "index": 11358
}
},
"filepath": "src/components/flyout/flyout.tsx"
@@ -4829,14 +4829,14 @@
"highlighting": "string",
"loc": {
"start": {
- "line": 341,
+ "line": 344,
"column": 14,
- "index": 11151
+ "index": 11391
},
"end": {
- "line": 344,
+ "line": 347,
"column": 16,
- "index": 11329
+ "index": 11569
}
},
"filepath": "src/components/flyout/flyout.tsx"
@@ -4847,14 +4847,14 @@
"highlighting": "string",
"loc": {
"start": {
- "line": 347,
+ "line": 350,
"column": 14,
- "index": 11406
+ "index": 11646
},
"end": {
- "line": 350,
+ "line": 353,
"column": 16,
- "index": 11599
+ "index": 11839
}
},
"filepath": "src/components/flyout/flyout.tsx"
diff --git a/packages/eui/src-docs/src/routes.js b/packages/eui/src-docs/src/routes.js
index 8038e2fa687..1c78b1a6b35 100644
--- a/packages/eui/src-docs/src/routes.js
+++ b/packages/eui/src-docs/src/routes.js
@@ -237,6 +237,8 @@ import { TourExample } from './views/tour/tour_example';
import { WindowEventExample } from './views/window_event/window_event_example';
+import { WindowProviderExample } from './views/window_provider/window_provider_example';
+
import { Changelog } from './views/package/changelog';
import { I18nTokens } from './views/package/i18n_tokens';
@@ -667,6 +669,7 @@ const navigation = [
].map((example) => createExample(example)),
createTabbedPage(TextTruncateExample),
createExample(WindowEventExample),
+ createExample(WindowProviderExample),
],
},
{
diff --git a/packages/eui/src-docs/src/views/window_provider/props.tsx b/packages/eui/src-docs/src/views/window_provider/props.tsx
new file mode 100644
index 00000000000..1a00da4bbbd
--- /dev/null
+++ b/packages/eui/src-docs/src/views/window_provider/props.tsx
@@ -0,0 +1,9 @@
+import React, { FunctionComponent } from 'react';
+
+import { EuiWindowProviderProps } from '../../../../src/services/window_provider/provider';
+
+export const useEuiWindowProviderProps: FunctionComponent<
+ EuiWindowProviderProps
+> = () => {
+ return
;
+};
diff --git a/packages/eui/src-docs/src/views/window_provider/window_provider.tsx b/packages/eui/src-docs/src/views/window_provider/window_provider.tsx
new file mode 100644
index 00000000000..e4cc20da3c3
--- /dev/null
+++ b/packages/eui/src-docs/src/views/window_provider/window_provider.tsx
@@ -0,0 +1,167 @@
+import React, { useState } from 'react';
+import { createPortal } from 'react-dom';
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiHorizontalRule,
+ EuiLink,
+ EuiPanel,
+ EuiPopover,
+ EuiText,
+ EuiTitle,
+ EuiToolTip,
+} from '../../../../src/components';
+import { EuiWindowProvider } from '../../../../src/services';
+import createCache, { EmotionCache } from '@emotion/cache';
+import { CacheProvider } from '@emotion/react';
+import { WindowProvider } from 'react-style-singleton';
+
+export default () => {
+ const [childWindow, setChildWindow] = useState<
+ | {
+ type: 'open';
+ window: Window;
+ emotionCache: EmotionCache;
+ }
+ | { type: 'closed' }
+ >({ type: 'closed' });
+
+ const openWindow = () => {
+ const newWindow = window.open('', '_blank', `width=800,height=600`);
+
+ if (!newWindow) {
+ throw new Error('Could not open the window.');
+ }
+
+ newWindow.onbeforeunload = () => {
+ setChildWindow({ type: 'closed' });
+ };
+
+ copyStyles(newWindow);
+
+ const emotionCache = createCache({
+ key: 'child-window',
+ container: newWindow.document.head,
+ });
+
+ setChildWindow({ type: 'open', window: newWindow, emotionCache });
+ };
+
+ return (
+
+ Open new window
+ {childWindow.type === 'open' && (
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+const NewWindow = ({
+ windowHandle,
+ children,
+}: {
+ windowHandle: Window;
+ children: React.ReactNode;
+}) => {
+ return createPortal(children, windowHandle.document.body);
+};
+
+const WindowContents = () => {
+ const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+ return (
+ <>
+ {isFlyoutOpen ? (
+ {
+ setIsFlyoutOpen(false);
+ }}
+ paddingSize="m"
+ >
+
+
+ Flyout header
+
+
+
+ Flyout contents.
+
+
+ ) : null}
+
+
+
+ Interactivity example
+
+
+
+
+ {
+ setIsFlyoutOpen(true);
+ }}
+ >
+ Open flyout
+
+
+
+
+ {
+ setIsPopoverOpen(true);
+ }}
+ >
+ Popover example (click to open)
+
+ }
+ isOpen={isPopoverOpen}
+ closePopover={() => {
+ setIsPopoverOpen(false);
+ }}
+ >
+
+ Popover content that’s wider than the default width
+
+
+
+
+
+
+ Tooltip example (hover to open)
+
+
+ >
+ );
+};
+
+function copyStyles(targetWindow: Window) {
+ const collectedStyles: string[] = [];
+
+ const elements = [
+ ...document.head.querySelectorAll('link[data-react-helmet="true"]'),
+ ...document.head.getElementsByTagName('style'),
+ ];
+
+ elements.forEach((element) => {
+ collectedStyles.push(element.outerHTML);
+ });
+
+ targetWindow.document.head.innerHTML += collectedStyles.join('\r\n');
+}
diff --git a/packages/eui/src-docs/src/views/window_provider/window_provider_example.js b/packages/eui/src-docs/src/views/window_provider/window_provider_example.js
new file mode 100644
index 00000000000..a8f04f77316
--- /dev/null
+++ b/packages/eui/src-docs/src/views/window_provider/window_provider_example.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import { GuideSectionTypes } from '../../components';
+
+import { EuiCode } from '../../../../src/components';
+import WindowProviderDemo from './window_provider';
+import { EuiWindowProvider } from '../../../../src/services';
+const windowProviderSource = require('!!raw-loader!./window_provider');
+
+export const WindowProviderExample = {
+ title: 'Window provider',
+ sections: [
+ {
+ source: [
+ {
+ type: GuideSectionTypes.TSX,
+ code: windowProviderSource,
+ },
+ ],
+ text: (
+ <>
+
+ There might be situations when you need to render EUI components
+ inside iframes or in another window using React Portals. To ensure
+ that target components use the correct window and{' '}
+ document object, use the{' '}
+ EuiWindowProvider component.
+
+ >
+ ),
+ demo: ,
+ props: { EuiWindowProvider },
+ },
+ ],
+};
diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx
index 813961178e8..d4649b8320c 100644
--- a/packages/eui/src/components/flyout/flyout.tsx
+++ b/packages/eui/src/components/flyout/flyout.tsx
@@ -30,6 +30,7 @@ import {
useIsWithinMinBreakpoint,
useEuiMemoizedStyles,
useGeneratedHtmlId,
+ useEuiWindow,
} from '../../services';
import { logicalStyle } from '../../global_styling';
@@ -202,6 +203,8 @@ export const EuiFlyout = forwardRef(
) => {
const Element = as || defaultElement;
const maskRef = useRef(null);
+ const currentWindow = useEuiWindow();
+ const currentDocument = currentWindow?.document ?? document;
const windowIsLargeEnoughToPush =
useIsWithinMinBreakpoint(pushMinBreakpoint);
@@ -225,23 +228,23 @@ export const EuiFlyout = forwardRef(
const paddingSide =
side === 'left' ? 'paddingInlineStart' : 'paddingInlineEnd';
- document.body.style[paddingSide] = `${width}px`;
+ currentDocument.body.style[paddingSide] = `${width}px`;
return () => {
- document.body.style[paddingSide] = '';
+ currentDocument.body.style[paddingSide] = '';
};
}
- }, [isPushed, side, width]);
+ }, [isPushed, side, width, currentDocument.body.style]);
/**
* This class doesn't actually do anything by EUI, but is nice to add for consumers (JIC)
*/
useEffect(() => {
- document.body.classList.add('euiBody--hasFlyout');
+ currentDocument.body.classList.add('euiBody--hasFlyout');
return () => {
// Remove the hasFlyout class when the flyout is unmounted
- document.body.classList.remove('euiBody--hasFlyout');
+ currentDocument.body.classList.remove('euiBody--hasFlyout');
};
- }, []);
+ }, [currentDocument.body.classList]);
/**
* ESC key closes flyout (always?)
@@ -290,12 +293,12 @@ export const EuiFlyout = forwardRef(
* If not disabled, automatically add fixed EuiHeaders as shards
* to EuiFlyout focus traps, to prevent focus fighting
*/
- const flyoutToggle = useRef(document.activeElement);
+ const flyoutToggle = useRef(currentDocument.activeElement);
const [fixedHeaders, setFixedHeaders] = useState([]);
useEffect(() => {
if (includeFixedHeadersInFocusTrap) {
- const fixedHeaderEls = document.querySelectorAll(
+ const fixedHeaderEls = currentDocument.querySelectorAll(
'.euiHeader[data-fixed-header]'
);
setFixedHeaders(Array.from(fixedHeaderEls));
@@ -311,7 +314,7 @@ export const EuiFlyout = forwardRef(
// Clear existing headers if necessary, e.g. switching to `false`
setFixedHeaders((headers) => (headers.length ? [] : headers));
}
- }, [includeFixedHeadersInFocusTrap, resizeRef]);
+ }, [includeFixedHeadersInFocusTrap, resizeRef, currentDocument]);
const focusTrapProps: EuiFlyoutProps['focusTrapProps'] = useMemo(
() => ({
diff --git a/packages/eui/src/components/focus_trap/focus_trap.tsx b/packages/eui/src/components/focus_trap/focus_trap.tsx
index e71a892de1e..460b9c6626b 100644
--- a/packages/eui/src/components/focus_trap/focus_trap.tsx
+++ b/packages/eui/src/components/focus_trap/focus_trap.tsx
@@ -6,13 +6,22 @@
* Side Public License, v 1.
*/
-import React, { Component, FunctionComponent, CSSProperties } from 'react';
+import React, {
+ Component,
+ FunctionComponent,
+ CSSProperties,
+ ContextType,
+} from 'react';
import { FocusOn } from 'react-focus-on';
import { ReactFocusOnProps } from 'react-focus-on/dist/es5/types';
import { RemoveScrollBar } from 'react-remove-scroll-bar';
import { CommonProps } from '../common';
-import { findElementBySelectorOrRef, ElementTarget } from '../../services';
+import {
+ findElementBySelectorOrRef,
+ ElementTarget,
+ EuiWindowContext,
+} from '../../services';
import { usePropsWithComponentDefaults } from '../provider/component_defaults';
export type FocusTarget = ElementTarget;
@@ -105,6 +114,9 @@ class EuiFocusTrapClass extends Component {
gapMode: 'padding', // EUI defaults to padding because Kibana's body/layout CSS ignores `margin`
};
+ static contextType = EuiWindowContext;
+ declare context: ContextType;
+
state: State = {
hasBeenDisabledByClick: false,
};
@@ -129,7 +141,9 @@ class EuiFocusTrapClass extends Component {
// Programmatically sets focus on a nested DOM node; optional
setInitialFocus = (initialFocus?: FocusTarget) => {
if (!initialFocus) return;
- const node = findElementBySelectorOrRef(initialFocus);
+
+ const currentDocument = (this.context.window ?? window).document;
+ const node = findElementBySelectorOrRef(initialFocus, currentDocument);
if (!node) return;
// `data-autofocus` is part of the 'react-focus-on' API
node.setAttribute('data-autofocus', 'true');
@@ -143,13 +157,15 @@ class EuiFocusTrapClass extends Component {
};
addMouseupListener = () => {
- document.addEventListener('mouseup', this.onMouseupOutside);
- document.addEventListener('touchend', this.onMouseupOutside);
+ const currentDocument = (this.context.window ?? window).document;
+ currentDocument.addEventListener('mouseup', this.onMouseupOutside);
+ currentDocument.addEventListener('touchend', this.onMouseupOutside);
};
removeMouseupListener = () => {
- document.removeEventListener('mouseup', this.onMouseupOutside);
- document.removeEventListener('touchend', this.onMouseupOutside);
+ const currentDocument = (this.context.window ?? window).document;
+ currentDocument.removeEventListener('mouseup', this.onMouseupOutside);
+ currentDocument.removeEventListener('touchend', this.onMouseupOutside);
};
handleOutsideClick: ReactFocusOnProps['onClickOutside'] = (event) => {
diff --git a/packages/eui/src/components/modal/__snapshots__/modal.test.tsx.snap b/packages/eui/src/components/modal/__snapshots__/modal.test.tsx.snap
index 60b64fc75fa..61e3bb42805 100644
--- a/packages/eui/src/components/modal/__snapshots__/modal.test.tsx.snap
+++ b/packages/eui/src/components/modal/__snapshots__/modal.test.tsx.snap
@@ -4,7 +4,7 @@ exports[`EuiModal renders 1`] = `