diff --git a/packages/eui/i18ntokens.json b/packages/eui/i18ntokens.json index 79ff01f6af2..26b50768576 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/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`] = `
diff --git a/packages/eui/src/components/outside_click_detector/outside_click_detector.tsx b/packages/eui/src/components/outside_click_detector/outside_click_detector.tsx index e15a7c0cde6..ae544f9c9b8 100644 --- a/packages/eui/src/components/outside_click_detector/outside_click_detector.tsx +++ b/packages/eui/src/components/outside_click_detector/outside_click_detector.tsx @@ -13,8 +13,10 @@ import { EventHandler, MouseEvent as ReactMouseEvent, ReactElement, + ContextType, } from 'react'; import { htmlIdGenerator } from '../../services/accessibility'; +import { EuiWindowContext } from '../../services'; export interface EuiEvent extends Event { euiGeneratedBy: string[]; @@ -45,6 +47,9 @@ export class EuiOutsideClickDetector extends Component; + private id: string; private capturedDownIds: string[]; @@ -96,13 +101,15 @@ export class EuiOutsideClickDetector extends Component
@@ -18,7 +18,7 @@ exports[`EuiOverlayMask renders 1`] = `
({ - euiOverlayMask: css` - position: fixed; - ${logicalCSS('top', 0)} - ${logicalCSS('left', 0)} - ${logicalCSS('right', 0)} - ${logicalCSS('bottom', 0)} - display: flex; - align-items: center; - justify-content: center; - ${logicalCSS('padding-bottom', '10vh')} - animation: ${euiAnimFadeIn} ${euiTheme.animation.fast} ease-in; - background: ${transparentize(euiTheme.colors.ink, 0.5)}; - `, - aboveHeader: css` - z-index: ${euiTheme.levels.mask}; - `, - belowHeader: css` - z-index: ${euiTheme.levels.maskBelowHeader}; - ${logicalCSS('top', 'var(--euiFixedHeadersOffset, 0)')} - `, -}); +export const euiOverlayMaskStyles = ({ + euiTheme, + css, +}: { + euiTheme: UseEuiTheme['euiTheme']; + css: Emotion['css']; +}) => { + return { + euiOverlayMask: css` + position: fixed; + ${logicalCSS('top', 0)} + ${logicalCSS('left', 0)} + ${logicalCSS('right', 0)} + ${logicalCSS('bottom', 0)} + display: flex; + align-items: center; + justify-content: center; + ${logicalCSS('padding-bottom', '10vh')} + animation: ${euiAnimFadeIn} ${euiTheme.animation.fast} ease-in; + background: ${transparentize(euiTheme.colors.ink, 0.5)}; + `, + aboveHeader: css` + z-index: ${euiTheme.levels.mask}; + `, + belowHeader: css` + z-index: ${euiTheme.levels.maskBelowHeader}; + ${logicalCSS('top', 'var(--euiFixedHeadersOffset, 0)')} + `, + }; +}; diff --git a/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx b/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx index 53f3ce617d4..6b07593d584 100644 --- a/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx +++ b/packages/eui/src/components/overlay_mask/overlay_mask.test.tsx @@ -45,7 +45,7 @@ describe('EuiOverlayMask', () => { baseElement.querySelector('.euiOverlayMask')!.className; expect(getClassName()).toMatchInlineSnapshot( - `"euiOverlayMask css-1hzbeld-euiOverlayMask-aboveHeader hello"` + `"euiOverlayMask css-5vqto6 hello"` ); rerender( @@ -54,7 +54,7 @@ describe('EuiOverlayMask', () => { ); expect(getClassName()).toMatchInlineSnapshot( - `"euiOverlayMask css-1j0pa91-euiOverlayMask-belowHeader world"` + `"euiOverlayMask css-xdflgg world"` ); }); diff --git a/packages/eui/src/components/overlay_mask/overlay_mask.tsx b/packages/eui/src/components/overlay_mask/overlay_mask.tsx index 2bae2e37fb0..821950cdae2 100644 --- a/packages/eui/src/components/overlay_mask/overlay_mask.tsx +++ b/packages/eui/src/components/overlay_mask/overlay_mask.tsx @@ -15,10 +15,13 @@ import React, { useEffect, useState, } from 'react'; -import { cx } from '@emotion/css'; import { Global } from '@emotion/react'; import { CommonProps, keysOf } from '../common'; -import { useCombinedRefs, useEuiTheme } from '../../services'; +import { + useCombinedRefs, + useEuiTheme, + useEuiWindowEmotion, +} from '../../services'; import { EuiPortal } from '../portal'; import { euiOverlayMaskStyles } from './overlay_mask.styles'; import { euiOverlayMaskBodyStyles } from './overlay_mask_body.styles'; @@ -59,8 +62,9 @@ export const EuiOverlayMask: FunctionComponent = ({ setOverlayMaskNode, maskRef, ]); - const euiTheme = useEuiTheme(); - const styles = euiOverlayMaskStyles(euiTheme); + const { euiTheme } = useEuiTheme(); + const { css, cx } = useEuiWindowEmotion(); + const styles = euiOverlayMaskStyles({ euiTheme, css }); const cssStyles = cx([ styles.euiOverlayMask, styles[`${headerZindexLocation}Header`], diff --git a/packages/eui/src/components/popover/input_popover.tsx b/packages/eui/src/components/popover/input_popover.tsx index a852ec2a6a0..f067bdef3ce 100644 --- a/packages/eui/src/components/popover/input_popover.tsx +++ b/packages/eui/src/components/popover/input_popover.tsx @@ -22,7 +22,12 @@ import classnames from 'classnames'; import { tabbable } from 'tabbable'; import { logicalCSS } from '../../global_styling'; -import { keys, useCombinedRefs, useEuiTheme } from '../../services'; +import { + keys, + useCombinedRefs, + useEuiTheme, + useEuiWindow, +} from '../../services'; import { CommonProps } from '../common'; import { useResizeObserver } from '../observer/resize_observer'; import { EuiFocusTrap } from '../focus_trap'; @@ -84,6 +89,7 @@ export const EuiInputPopover: FunctionComponent = ({ const classes = classnames('euiInputPopover', className); const euiTheme = useEuiTheme(); const formMaxWidth = euiFormMaxWidth(euiTheme); + const currentWindow = useEuiWindow(); /** * Ref setup @@ -151,7 +157,8 @@ export const EuiInputPopover: FunctionComponent = ({ if (!tabbableItems.length) return; const tabbingFromLastItemInPopover = - document.activeElement === tabbableItems[tabbableItems.length - 1]; + (currentWindow ?? window).document.activeElement === + tabbableItems[tabbableItems.length - 1]; if (tabbingFromLastItemInPopover) { closePopover(); @@ -159,7 +166,13 @@ export const EuiInputPopover: FunctionComponent = ({ } } }, - [disableFocusTrap, ownFocus, closePopover, panelPropsOnKeyDown] + [ + disableFocusTrap, + ownFocus, + closePopover, + panelPropsOnKeyDown, + currentWindow, + ] ); /** @@ -193,20 +206,28 @@ export const EuiInputPopover: FunctionComponent = ({ // Kibana Cypress tests trigger a scroll event in many common situations when the options list div is appended // to the DOM; in testing it was always within 100ms, but setting a timeout here for 500ms to be safe const timeoutId = setTimeout(() => { - window.addEventListener('scroll', closePopoverOnScroll, { - passive: true, // for better performance as we won't call preventDefault - capture: true, // scroll events don't bubble, they must be captured instead - }); + (currentWindow ?? window).addEventListener( + 'scroll', + closePopoverOnScroll, + { + passive: true, // for better performance as we won't call preventDefault + capture: true, // scroll events don't bubble, they must be captured instead + } + ); }, 500); return () => { - window.removeEventListener('scroll', closePopoverOnScroll, { - capture: true, - }); + (currentWindow ?? window).removeEventListener( + 'scroll', + closePopoverOnScroll, + { + capture: true, + } + ); clearTimeout(timeoutId); }; } - }, [closeOnScroll, closePopover, panelEl, inputEl]); + }, [closeOnScroll, closePopover, panelEl, inputEl, currentWindow]); return ( { display: 'inline-block', }; + static contextType = EuiWindowContext; + declare context: ContextType; + static getDerivedStateFromProps( nextProps: Props, prevState: State @@ -366,14 +371,16 @@ export class EuiPopover extends Component { }; handleStrandedFocus = () => { - this.strandedFocusTimeout = window.setTimeout(() => { + const currentWindow = this.context.window ?? window; + this.strandedFocusTimeout = currentWindow.setTimeout(() => { // If `returnFocus` failed and focus was stranded, // attempt to manually restore focus to the toggle button. // The stranded focus is either in most cases on body but // it will be on the panel instead on mount when isOpen=true + const currentDocument = currentWindow.document; if ( - document.activeElement === document.body || - document.activeElement === this.panel + currentDocument.activeElement === currentDocument.body || + currentDocument.activeElement === this.panel ) { if (!this.button) return; @@ -408,7 +415,9 @@ export class EuiPopover extends Component { } // We need to set this state a beat after the render takes place, so that the CSS // transition can take effect. - this.closingTransitionAnimationFrame = window.requestAnimationFrame(() => { + this.closingTransitionAnimationFrame = ( + this.context.window ?? window + ).requestAnimationFrame(() => { this.setState({ isOpening: true, }); @@ -433,7 +442,7 @@ export class EuiPopover extends Component { ); clearTimeout(this.respositionTimeout); - this.respositionTimeout = window.setTimeout(() => { + this.respositionTimeout = (this.context.window ?? window).setTimeout(() => { this.setState({ isOpenStable: true }, () => { this.positionPopoverFixed(); }); @@ -450,7 +459,11 @@ export class EuiPopover extends Component { } if (this.props.repositionOnScroll) { - window.addEventListener('scroll', this.positionPopoverFixed, true); + (this.context.window ?? window).addEventListener( + 'scroll', + this.positionPopoverFixed, + true + ); } } @@ -474,9 +487,17 @@ export class EuiPopover extends Component { // update scroll listener if (prevProps.repositionOnScroll !== this.props.repositionOnScroll) { if (this.props.repositionOnScroll) { - window.addEventListener('scroll', this.positionPopoverFixed, true); + (this.context.window ?? window).addEventListener( + 'scroll', + this.positionPopoverFixed, + true + ); } else { - window.removeEventListener('scroll', this.positionPopoverFixed, true); + (this.context.window ?? window).removeEventListener( + 'scroll', + this.positionPopoverFixed, + true + ); } } @@ -484,7 +505,9 @@ export class EuiPopover extends Component { if (prevProps.isOpen && !this.props.isOpen) { // If the user has just closed the popover, queue up the removal of the content after the // transition is complete. - this.closingTransitionTimeout = window.setTimeout(() => { + this.closingTransitionTimeout = ( + this.context.window ?? window + ).setTimeout(() => { this.setState({ isClosing: false, }); @@ -493,7 +516,11 @@ export class EuiPopover extends Component { } componentWillUnmount() { - window.removeEventListener('scroll', this.positionPopoverFixed, true); + (this.context.window ?? window).removeEventListener( + 'scroll', + this.positionPopoverFixed, + true + ); clearTimeout(this.respositionTimeout); clearTimeout(this.strandedFocusTimeout); clearTimeout(this.closingTransitionTimeout); @@ -546,6 +573,7 @@ export class EuiPopover extends Component { returnBoundingBox: this.props.attachToAnchor, allowCrossAxis: this.props.repositionToCrossAxis, buffer: this.props.buffer, + currentWindow: this.context.window ?? window, }); // the popover's z-index must inherit from the button @@ -554,7 +582,11 @@ export class EuiPopover extends Component { const { zIndex: zIndexProp } = this.props; const zIndex = zIndexProp == null - ? getElementZIndex(this.button, this.panel) + 2000 + ? getElementZIndex( + this.button, + this.panel, + this.context.window ?? window + ) + 2000 : zIndexProp; const popoverStyles = { @@ -599,11 +631,17 @@ export class EuiPopover extends Component { openPosition: null, isOpenStable: false, }); - window.removeEventListener('resize', this.positionPopoverFluid); + (this.context.window ?? window).removeEventListener( + 'resize', + this.positionPopoverFluid + ); } else { // panel is coming into existence this.positionPopoverFluid(); - window.addEventListener('resize', this.positionPopoverFluid); + (this.context.window ?? window).addEventListener( + 'resize', + this.positionPopoverFluid + ); } }; diff --git a/packages/eui/src/components/popover/wrapping_popover.stories.tsx b/packages/eui/src/components/popover/wrapping_popover.stories.tsx index 238429e63d6..7fe5bb88963 100644 --- a/packages/eui/src/components/popover/wrapping_popover.stories.tsx +++ b/packages/eui/src/components/popover/wrapping_popover.stories.tsx @@ -24,6 +24,7 @@ import { EuiWrappingPopover, EuiWrappingPopoverProps, } from './wrapping_popover'; +import { useEuiWindow } from '../../services'; // NOTE: extended EuiPopoverProps are not resolved for some reason // so we are currently manually adding them back @@ -87,6 +88,7 @@ const StatefulPopover = ({ ...rest }: EuiWrappingPopoverProps) => { const [isOpen, setOpen] = useState(_isOpen); + const currentWindow = useEuiWindow(); const handleOnClose = () => { setOpen(false); @@ -115,7 +117,9 @@ const StatefulPopover = ({ {isOpen && ( diff --git a/packages/eui/src/components/portal/portal.tsx b/packages/eui/src/components/portal/portal.tsx index f4f24ba9d21..73de9d6bda5 100644 --- a/packages/eui/src/components/portal/portal.tsx +++ b/packages/eui/src/components/portal/portal.tsx @@ -19,7 +19,7 @@ import React, { } from 'react'; import { createPortal } from 'react-dom'; -import { EuiNestedThemeContext } from '../../services'; +import { EuiNestedThemeContext, useEuiWindow } from '../../services'; import { usePropsWithComponentDefaults } from '../provider/component_defaults'; const INSERT_POSITIONS = ['after', 'before'] as const; @@ -43,11 +43,18 @@ export interface EuiPortalProps { * Optional ref callback */ portalRef?: (ref: HTMLDivElement | null) => void; + /** + * Window object + */ + currentWindow?: Window; } export const EuiPortal: FunctionComponent = (props) => { const propsWithDefaults = usePropsWithComponentDefaults('EuiPortal', props); - return ; + const currentWindow = useEuiWindow(); + return ( + + ); }; interface EuiPortalState { @@ -67,14 +74,16 @@ export class EuiPortalClass extends Component { } componentDidMount() { - const { insert } = this.props; + const { insert, currentWindow } = this.props; - const portalNode = document.createElement('div'); + const portalNode = (currentWindow?.document ?? document).createElement( + 'div' + ); portalNode.dataset.euiportal = 'true'; if (insert == null) { // no insertion defined, append to body - document.body.appendChild(portalNode); + (currentWindow?.document ?? document).body.appendChild(portalNode); } else { // inserting before or after an element const { sibling, position } = insert; diff --git a/packages/eui/src/components/tool_tip/tool_tip.tsx b/packages/eui/src/components/tool_tip/tool_tip.tsx index 616ca2b5ab0..464f1a1ee20 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.tsx @@ -12,11 +12,17 @@ import React, { ReactNode, MouseEvent as ReactMouseEvent, HTMLAttributes, + ContextType, } from 'react'; import classNames from 'classnames'; import { CommonProps } from '../common'; -import { findPopoverPosition, htmlIdGenerator, keys } from '../../services'; +import { + EuiWindowContext, + findPopoverPosition, + htmlIdGenerator, + keys, +} from '../../services'; import { enqueueStateChange } from '../../services/react'; import { EuiResizeObserver } from '../observer/resize_observer'; import { EuiPortal } from '../portal'; @@ -142,6 +148,9 @@ export class EuiToolTip extends Component { display: 'inlineBlock', }; + static contextType = EuiWindowContext; + declare context: ContextType; + clearAnimationTimeout = () => { if (this.timeoutId) { this.timeoutId = clearTimeout(this.timeoutId) as undefined; @@ -151,14 +160,22 @@ export class EuiToolTip extends Component { componentDidMount() { this._isMounted = true; if (this.props.repositionOnScroll) { - window.addEventListener('scroll', this.positionToolTip, true); + (this.context.window ?? window).addEventListener( + 'scroll', + this.positionToolTip, + true + ); } } componentWillUnmount() { this.clearAnimationTimeout(); this._isMounted = false; - window.removeEventListener('scroll', this.positionToolTip, true); + (this.context.window ?? window).removeEventListener( + 'scroll', + this.positionToolTip, + true + ); } componentDidUpdate(prevProps: EuiToolTipProps, prevState: State) { @@ -169,9 +186,17 @@ export class EuiToolTip extends Component { // update scroll listener if (prevProps.repositionOnScroll !== this.props.repositionOnScroll) { if (this.props.repositionOnScroll) { - window.addEventListener('scroll', this.positionToolTip, true); + (this.context.window ?? window).addEventListener( + 'scroll', + this.positionToolTip, + true + ); } else { - window.removeEventListener('scroll', this.positionToolTip, true); + (this.context.window ?? window).removeEventListener( + 'scroll', + this.positionToolTip, + true + ); } } } @@ -180,7 +205,10 @@ export class EuiToolTip extends Component { // when the tooltip is visible, this checks if the anchor is still part of document // this fixes when the react root is removed from the dom without unmounting // https://github.com/elastic/eui/issues/1105 - if (document.body.contains(this.anchor) === false) { + if ( + (this.context.window?.document ?? document).body.contains(this.anchor) === + false + ) { // the anchor is no longer part of `document` this.hideToolTip(); } else { @@ -222,6 +250,7 @@ export class EuiToolTip extends Component { arrowWidth: 12, arrowBuffer: 4, }, + currentWindow: this.context.window ?? window, }); // If encroaching the right edge of the window: @@ -231,7 +260,8 @@ export class EuiToolTip extends Component { // To prevent this, we can orient from the right so that text line wrapping does not occur, negating // the second resizeObserver callback call. const windowWidth = - document.documentElement.clientWidth || window.innerWidth; + (this.context.window?.document ?? document).documentElement.clientWidth || + (this.context.window ?? window).innerWidth; const useRightValue = windowWidth / 2 < left; const toolTipStyles: ToolTipStyles = { diff --git a/packages/eui/src/components/tool_tip/tool_tip_popover.tsx b/packages/eui/src/components/tool_tip/tool_tip_popover.tsx index a561b30b7c1..9d22ebbb1e6 100644 --- a/packages/eui/src/components/tool_tip/tool_tip_popover.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip_popover.tsx @@ -16,7 +16,7 @@ import React, { } from 'react'; import classNames from 'classnames'; import { CommonProps } from '../common'; -import { useEuiTheme } from '../../services'; +import { useEuiTheme, useEuiWindow } from '../../services'; import { euiToolTipStyles } from './tool_tip.styles'; export type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left'; @@ -42,6 +42,7 @@ export const EuiToolTipPopover: FunctionComponent = ({ const popover = useRef(); const euiTheme = useEuiTheme(); + const currentWindow = useEuiWindow(); const styles = euiToolTipStyles(euiTheme); const cssStyles = [ styles.euiToolTip, @@ -64,14 +65,18 @@ export const EuiToolTipPopover: FunctionComponent = ({ }; useEffect(() => { - document.body.classList.add('euiBody-hasPortalContent'); - window.addEventListener('resize', updateDimensions); + (currentWindow?.document ?? document).body.classList.add( + 'euiBody-hasPortalContent' + ); + (currentWindow ?? window).addEventListener('resize', updateDimensions); return () => { - document.body.classList.remove('euiBody-hasPortalContent'); - window.removeEventListener('resize', updateDimensions); + (currentWindow?.document ?? document).body.classList.remove( + 'euiBody-hasPortalContent' + ); + (currentWindow ?? window).removeEventListener('resize', updateDimensions); }; - }, [updateDimensions]); + }, [updateDimensions, currentWindow]); const classes = classNames('euiToolTipPopover', className); diff --git a/packages/eui/src/services/findElement.ts b/packages/eui/src/services/findElement.ts index 202ef1411f9..2820b8f470e 100644 --- a/packages/eui/src/services/findElement.ts +++ b/packages/eui/src/services/findElement.ts @@ -13,10 +13,13 @@ */ export type ElementTarget = HTMLElement | string | (() => HTMLElement); -export const findElementBySelectorOrRef = (elementTarget?: ElementTarget) => { +export const findElementBySelectorOrRef = ( + elementTarget?: ElementTarget, + currentDocument?: Document +) => { let node = elementTarget instanceof HTMLElement ? elementTarget : null; if (typeof elementTarget === 'string') { - node = document.querySelector(elementTarget as string); + node = (currentDocument ?? document).querySelector(elementTarget as string); } else if (typeof elementTarget === 'function') { node = (elementTarget as () => HTMLElement)(); } diff --git a/packages/eui/src/services/index.ts b/packages/eui/src/services/index.ts index 20a9091c7f5..8ecbbbba97f 100644 --- a/packages/eui/src/services/index.ts +++ b/packages/eui/src/services/index.ts @@ -101,3 +101,10 @@ export { } from './transition'; export { EuiWindowEvent } from './window_event'; export { keys }; +export { + EuiWindowContext, + EuiWindowProvider, + useEuiWindow, + useEuiWindowEmotion, +} from './window_provider'; +export type { EuiWindowContextValue } from './window_provider'; diff --git a/packages/eui/src/services/popover/popover_positioning.test.ts b/packages/eui/src/services/popover/popover_positioning.test.ts index bae2c9d40ec..68597182eaf 100644 --- a/packages/eui/src/services/popover/popover_positioning.test.ts +++ b/packages/eui/src/services/popover/popover_positioning.test.ts @@ -486,6 +486,7 @@ describe('popover_positioning', () => { popover, container, offset: 7, + currentWindow: window, }) ).toEqual({ fit: 1, @@ -518,6 +519,7 @@ describe('popover_positioning', () => { popover, container, offset: 5, + currentWindow: window, }) ).toEqual({ fit: 1, @@ -550,6 +552,7 @@ describe('popover_positioning', () => { popover, container, offset: 5, + currentWindow: window, }) ).toEqual({ fit: 1, @@ -581,6 +584,7 @@ describe('popover_positioning', () => { popover, container, offset: 5, + currentWindow: window, }) ).toEqual({ fit: 1, @@ -612,6 +616,7 @@ describe('popover_positioning', () => { popover, container, offset: 5, + currentWindow: window, }) ).toEqual({ fit: 0, @@ -644,6 +649,7 @@ describe('popover_positioning', () => { popover, container, offset: 5, + currentWindow: window, }) ).toEqual({ fit: 1, @@ -680,6 +686,7 @@ describe('popover_positioning', () => { popover, container, offset: 7, + currentWindow: window, }) ).toEqual({ fit: 1, @@ -711,6 +718,7 @@ describe('popover_positioning', () => { popover, container, allowCrossAxis: false, + currentWindow: window, }) ).toEqual({ fit: 0.34, @@ -736,6 +744,7 @@ describe('popover_positioning', () => { returnBoundingBox: true, anchor, popover, + currentWindow: window, }) ).toEqual({ fit: 1, diff --git a/packages/eui/src/services/popover/popover_positioning.ts b/packages/eui/src/services/popover/popover_positioning.ts index 8cc1a131600..c8667032c08 100644 --- a/packages/eui/src/services/popover/popover_positioning.ts +++ b/packages/eui/src/services/popover/popover_positioning.ts @@ -76,6 +76,7 @@ interface FindPopoverPositionArgs { container?: HTMLElement; arrowConfig?: { arrowWidth: number; arrowBuffer: number }; returnBoundingBox?: boolean; + currentWindow: Window; } interface FindPopoverPositionResult { @@ -129,6 +130,7 @@ export function findPopoverPosition({ container, arrowConfig, returnBoundingBox, + currentWindow, }: FindPopoverPositionArgs): FindPopoverPositionResult { // find the screen-relative bounding boxes of the anchor, popover, and container const anchorBoundingBox = getElementBoundingBox(anchor); @@ -138,9 +140,11 @@ export function findPopoverPosition({ // window.(innerWidth|innerHeight) do not account for scrollbars // so prefer the clientWidth/clientHeight of the DOM if available const documentWidth = - document.documentElement.clientWidth || window.innerWidth; + (currentWindow ?? window).document.documentElement.clientWidth || + (currentWindow ?? window).innerWidth; const documentHeight = - document.documentElement.clientHeight || window.innerHeight; + (currentWindow ?? window).document.documentElement.clientHeight || + (currentWindow ?? window).innerHeight; const windowBoundingBox: EuiClientRect = { top: 0, right: documentWidth, @@ -224,8 +228,8 @@ export function findPopoverPosition({ bestPosition = { fit: screenCoordinates.fit, position: iterationPosition, - top: screenCoordinates.top + window.pageYOffset, - left: screenCoordinates.left + window.pageXOffset, + top: screenCoordinates.top + (currentWindow ?? window).pageYOffset, + left: screenCoordinates.left + (currentWindow ?? window).pageXOffset, arrow: screenCoordinates.arrow, }; @@ -738,7 +742,8 @@ export function intersectBoundingBoxes( */ export function getElementZIndex( element: HTMLElement, - cousin: HTMLElement + cousin: HTMLElement, + currentWindow: Window ): number { /** * finding the z-index of `element` is not the full story @@ -785,7 +790,7 @@ export function getElementZIndex( for (const node of nodesToInspect) { // get this node's z-index css value - const zIndex = window.document + const zIndex = (currentWindow ?? window).document .defaultView!.getComputedStyle(node) .getPropertyValue('z-index'); diff --git a/packages/eui/src/services/window_event/window_event.ts b/packages/eui/src/services/window_event/window_event.ts index 5c5488689a3..ca2d018937f 100644 --- a/packages/eui/src/services/window_event/window_event.ts +++ b/packages/eui/src/services/window_event/window_event.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { Component } from 'react'; +import { Component, ContextType } from 'react'; +import { EuiWindowContext } from '../window_provider'; type EventNames = keyof WindowEventMap; @@ -16,6 +17,9 @@ interface Props { } export class EuiWindowEvent extends Component> { + static contextType = EuiWindowContext; + declare context: ContextType; + componentDidMount() { this.addEvent(this.props); } @@ -35,11 +39,11 @@ export class EuiWindowEvent extends Component> { } addEvent({ event, handler }: Props) { - window.addEventListener(event, handler); + (this.context.window ?? window).addEventListener(event, handler); } removeEvent({ event, handler }: Props) { - window.removeEventListener(event, handler); + (this.context.window ?? window).removeEventListener(event, handler); } render() { diff --git a/packages/eui/src/services/window_provider/context.ts b/packages/eui/src/services/window_provider/context.ts new file mode 100644 index 00000000000..27e80ed4be0 --- /dev/null +++ b/packages/eui/src/services/window_provider/context.ts @@ -0,0 +1,24 @@ +/* + * 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 { css, cx } from '@emotion/css'; +import { createContext } from 'react'; + +// If 'window' field is undefined, fallback to global window object at runtime. +// This is compatible with jsdom tests. +export interface EuiWindowContextValue { + window?: Window; + css: typeof css; + cx: typeof cx; +} + +export const EuiWindowContext = createContext({ + window: undefined, + css, + cx, +}); diff --git a/packages/eui/src/services/window_provider/hooks.ts b/packages/eui/src/services/window_provider/hooks.ts new file mode 100644 index 00000000000..7e89ee64c59 --- /dev/null +++ b/packages/eui/src/services/window_provider/hooks.ts @@ -0,0 +1,23 @@ +/* + * 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 { useContext } from 'react'; +import { EuiWindowContext } from './context'; + +/** + * In SSR scenarios it can return undefined as no window is available. + */ +export function useEuiWindow() { + const context = useContext(EuiWindowContext); + return context.window ?? (typeof window !== 'undefined' ? window : undefined); +} + +export function useEuiWindowEmotion() { + const { css, cx } = useContext(EuiWindowContext); + return { css, cx }; +} diff --git a/packages/eui/src/services/window_provider/index.ts b/packages/eui/src/services/window_provider/index.ts new file mode 100644 index 00000000000..e8af62436c7 --- /dev/null +++ b/packages/eui/src/services/window_provider/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { EuiWindowProvider } from './provider'; +export type { EuiWindowContextValue } from './context'; +export { EuiWindowContext } from './context'; +export { useEuiWindow, useEuiWindowEmotion } from './hooks'; diff --git a/packages/eui/src/services/window_provider/provider.tsx b/packages/eui/src/services/window_provider/provider.tsx new file mode 100644 index 00000000000..df9634fba9c --- /dev/null +++ b/packages/eui/src/services/window_provider/provider.tsx @@ -0,0 +1,32 @@ +/* + * 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, { ReactNode } from 'react'; +import { EuiWindowContext } from './context'; +import createEmotion from '@emotion/css/create-instance'; + +export interface EuiWindowProviderProps { + window: Window; + children: ReactNode; +} + +export function EuiWindowProvider({ + window, + children, +}: EuiWindowProviderProps) { + const { css, cx } = createEmotion({ + key: 'eui-child-window', + container: window.document.head, + }); + + return ( + + {children} + + ); +}