Skip to content
Merged
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
131 changes: 62 additions & 69 deletions packages/eui/src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@
* Side Public License, v 1.
*/

/**
* NOTE: We can't test this component because Enzyme doesn't support rendering
* into portals.
*/

import React, {
import {
FunctionComponent,
Component,
ContextType,
ReactNode,
memo,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';

import { EuiNestedThemeContext } from '../../services';
import { usePropsWithComponentDefaults } from '../provider/component_defaults';

const usePortalEffect =
typeof document === 'undefined' ? useEffect : useLayoutEffect;

const INSERT_POSITIONS = ['after', 'before'] as const;
type EuiPortalInsertPosition = (typeof INSERT_POSITIONS)[number];
const insertPositions: Record<EuiPortalInsertPosition, InsertPosition> = {
Expand All @@ -45,84 +47,75 @@ export interface EuiPortalProps {
portalRef?: (ref: HTMLDivElement | null) => void;
}

export const EuiPortal: FunctionComponent<EuiPortalProps> = (props) => {
const propsWithDefaults = usePropsWithComponentDefaults('EuiPortal', props);
return <EuiPortalClass {...propsWithDefaults} />;
};
export const EuiPortal: FunctionComponent<EuiPortalProps> = memo((_props) => {
const props = usePropsWithComponentDefaults('EuiPortal', _props);
const { children, insert, portalRef: setPortalRef } = props;

interface EuiPortalState {
portalNode: HTMLDivElement | null;
}
const portalRef = useRef(setPortalRef);

export class EuiPortalClass extends Component<EuiPortalProps, EuiPortalState> {
static contextType = EuiNestedThemeContext;
declare context: ContextType<typeof EuiNestedThemeContext>;
const { hasDifferentColorFromGlobalTheme, colorClassName } = useContext(
EuiNestedThemeContext
);

constructor(props: EuiPortalProps) {
super(props);
const [portalNode, setPortalNode] = useState<HTMLDivElement | null>(null);

this.state = {
portalNode: null,
};
}

componentDidMount() {
const { insert } = this.props;

const portalNode = document.createElement('div');
portalNode.dataset.euiportal = 'true';
// Set the inherited color of the portal based on the wrapping EuiThemeProvider
const setThemeColor = (portalNode: HTMLDivElement) => {
if (hasDifferentColorFromGlobalTheme && insert == null) {
portalNode.classList.add(colorClassName);
}
};

const updatePortalRef = (ref: HTMLDivElement | null) => {
portalRef.current?.(ref);
};

useEffect(() => {
portalRef.current = setPortalRef;
}, [setPortalRef]);

/* Uses `useLayoutEffect` on client-side instead of `useEffect` to ensure the portal
node is created and inserted into the DOM synchronously. This matches the same timing
as the previous class component `componentDidMount` behavior.
Using `useEffect` would add an additional render cycle that would break expected
behavior of e.g. `@hello-pangea/dnd` which handles keyboard focus and doesn't expect
a rerender. This falls back to `useEffect` for SSR to avoid console errors. `useEffect` will
be a no-op, same as `componentDidMount` */
usePortalEffect(() => {
const node = document.createElement('div');
node.dataset.euiportal = 'true';

if (insert == null) {
// no insertion defined, append to body
document.body.appendChild(portalNode);
document.body.appendChild(node);
} else {
// inserting before or after an element
const { sibling, position } = insert;
sibling.insertAdjacentElement(insertPositions[position], portalNode);
sibling.insertAdjacentElement(insertPositions[position], node);
}

this.setThemeColor(portalNode);
this.updatePortalRef(portalNode);
setThemeColor(node);
updatePortalRef(node);

// Update state with portalNode to intentionally trigger component rerender
// and call createPortal with correct root element in render()
this.setState({
portalNode,
});
}

componentWillUnmount() {
const { portalNode } = this.state;
if (portalNode?.parentNode) {
portalNode.parentNode.removeChild(portalNode);
}
this.updatePortalRef(null);
}

// Set the inherited color of the portal based on the wrapping EuiThemeProvider
private setThemeColor(portalNode: HTMLDivElement) {
if (this.context) {
const { hasDifferentColorFromGlobalTheme, colorClassName } = this.context;
// and call createPortal with the correct root element
setPortalNode(node);

if (hasDifferentColorFromGlobalTheme && this.props.insert == null) {
portalNode.classList.add(colorClassName);
return () => {
if (node?.parentNode) {
node.parentNode.removeChild(node);
}
}
}

private updatePortalRef(ref: HTMLDivElement | null) {
if (this.props.portalRef) {
this.props.portalRef(ref);
}
}
updatePortalRef(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- on mount only
}, []);
Comment thread
mgadewoll marked this conversation as resolved.

render() {
const { portalNode } = this.state;
if (!portalNode) {
return null;
}

if (!portalNode) {
return null;
}
return createPortal(children, portalNode);
});

return createPortal(this.props.children, portalNode);
}
}
EuiPortal.displayName = 'EuiPortal';