feat: add portal component

This commit is contained in:
Thomas Osmonson
2020-04-13 18:00:13 -05:00
committed by Thomas Osmonson
parent b83bcbff13
commit e8bc22bb55
2 changed files with 206 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
import { createContext, isBrowser, __DEV__ } from '../utils';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { usePortalManager } from './manager';
import { useSafeLayoutEffect } from '../hooks';
type PortalContext = HTMLDivElement | null;
const [PortalCtxProvider, usePortalContext] = createContext<PortalContext>({
strict: false,
});
export interface PortalProps {
/**
* Function called when the portal mounts
*/
onMount?(): void;
/**
* Function called when the portal unmounts
*/
onUnmount?(): void;
/**
* Function that will be called to get the parent element
* that the portal will be attached to.
*/
container?: () => HTMLElement;
/**
* The content or node you'll like to portal
*/
children?: React.ReactNode;
}
/**
* Portal
*
* Declarative component used to render children into a DOM node
* that exists outside the DOM hierarchy of the parent component.
*
*/
export function Portal(props: PortalProps) {
const { onMount, onUnmount, children, container: containerProp } = props;
/**
* Generate the portal's dom node. We'll wrap the children
* in this dom node before mounting it.
*/
const [portal] = React.useState(() => {
if (isBrowser) {
const div = document.createElement('div');
div.className = 'portal';
return div;
}
// for ssr
return null;
});
/**
* This portal might be nested in another portal.
* Let's read from the portal context to check this.
*/
const parentPortal = usePortalContext();
/**
* If there's a PortalManager rendered, let's read from it.
* We use the portal manager to manage multiple portals
*/
const manager = usePortalManager();
const append = React.useCallback(
(container: HTMLElement | null) => {
// if user specified a mount node, do nothing.
if (!portal || !container) return;
// else, simply append component to the portal node
container.appendChild(portal);
},
[portal]
);
useSafeLayoutEffect(() => {
// get the custom container from the container prop
const mountNode = containerProp?.();
/**
* We need to know where to mount this portal, we have 4 options:
* - If a mountRef is specified, we'll use that as the container
* - If portal is nested, use the parent portal node as container.
* - If it's not nested, use the manager's node as container
* - else use document.body as containers
*/
const container = mountNode ?? parentPortal ?? manager?.node ?? document.body;
/**
* Append portal node to the computed container
*/
append(container);
onMount?.();
return () => {
onUnmount?.();
if (!portal) return;
if (container?.contains(portal)) {
container?.removeChild(portal);
}
};
}, [containerProp, portal, parentPortal, onMount, onUnmount, manager && manager.node, append]);
const finalChildren = manager?.zIndex ? (
<div className="portal-zIndex" style={{ zIndex: manager.zIndex }}>
{children}
</div>
) : (
children
);
if (!portal) {
return <React.Fragment>{finalChildren}</React.Fragment>;
}
return createPortal(
<PortalCtxProvider value={portal}>{finalChildren}</PortalCtxProvider>,
portal
);
}
if (__DEV__) {
Portal.displayName = 'Portal';
}

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { createContext, __DEV__ } from '../utils';
import { useSafeLayoutEffect, useForceUpdate } from '../hooks';
interface PortalManagerContext {
node: HTMLElement;
zIndex?: number;
}
const [PortalManagerCtxProvider, usePortalManager] = createContext<PortalManagerContext>({
strict: false,
});
export { usePortalManager };
export interface PortalManagerProps {
/**
* Child elements of the Portal manager
* Ideally, it should be at the top-level
* of your application
*/
children?: React.ReactNode;
/**
* [Z-Index war] If your has multiple elements
* with z-index clashing, you might need to
* apply a z-index to the Portal manager
*/
zIndex?: number;
}
/**
* PortalManager
*
* Used to manage multiple portals within an application.
* It must be render only once, at the root of your application.
*
* Inspired by BaseWeb's LayerManager component
*/
export function PortalManager(props: PortalManagerProps) {
const { children, zIndex } = props;
/**
* The element that wraps the stacked layers
*/
const ref = React.useRef<HTMLDivElement>(null);
const forceUpdate = useForceUpdate();
/**
* force an update on mount so the Provider works correctly
*/
useSafeLayoutEffect(() => {
forceUpdate();
}, []);
/**
* let's detect if use has mutiple instances of this component
*/
const parentManager = usePortalManager();
const context = {
node: parentManager?.node || ref.current,
zIndex,
};
return (
<PortalManagerCtxProvider value={context}>
{children}
<div className="portal-manager" ref={ref} />
</PortalManagerCtxProvider>
);
}
if (__DEV__) {
PortalManager.displayName = 'PortalManager';
}