diff --git a/packages/ui/src/portal/index.tsx b/packages/ui/src/portal/index.tsx new file mode 100644 index 00000000..cf364b67 --- /dev/null +++ b/packages/ui/src/portal/index.tsx @@ -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({ + 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 ? ( +
+ {children} +
+ ) : ( + children + ); + + if (!portal) { + return {finalChildren}; + } + + return createPortal( + {finalChildren}, + portal + ); +} + +if (__DEV__) { + Portal.displayName = 'Portal'; +} diff --git a/packages/ui/src/portal/manager.tsx b/packages/ui/src/portal/manager.tsx new file mode 100644 index 00000000..532756d7 --- /dev/null +++ b/packages/ui/src/portal/manager.tsx @@ -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({ + 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(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 ( + + {children} +
+ + ); +} + +if (__DEV__) { + PortalManager.displayName = 'PortalManager'; +}