Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core] feat(Portal): Add portalContainer context option #6260

Merged
merged 11 commits into from
Jul 10, 2023
Merged
75 changes: 36 additions & 39 deletions packages/core/src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { Classes, DISPLAYNAME_PREFIX, Props } from "../../common";
import { ValidationMap } from "../../common/context";
import * as Errors from "../../common/errors";
import { PortalContext } from "../../context/portal/portalProvider";
import { usePrevious } from "../../hooks/usePrevious";

export interface PortalProps extends Props {
/** Contents to send through the portal. */
Expand All @@ -35,7 +34,7 @@ export interface PortalProps extends Props {
/**
* The HTML element that children will be mounted to.
*
* @default document.body
* @default PortalContext.portalContainer ?? document.body
*/
container?: HTMLElement;

Expand Down Expand Up @@ -75,84 +74,82 @@ const PORTAL_LEGACY_CONTEXT_TYPES: ValidationMap<PortalLegacyContext> = {
*
* @see https://blueprintjs.com/docs/#core/components/portal
*/
export function Portal(props: PortalProps, legacyContext: PortalLegacyContext = {}) {
export function Portal(
{ className, stopPropagationEvents, container, onChildrenMount, children }: PortalProps,
legacyContext: PortalLegacyContext = {},
) {
const context = React.useContext(PortalContext);

const [hasMounted, setHasMounted] = React.useState(false);
const portalContainer = container ?? context.portalContainer ?? document?.body;
braeden marked this conversation as resolved.
Show resolved Hide resolved

const [portalElement, setPortalElement] = React.useState<HTMLElement>();

const createContainerElement = React.useCallback(() => {
const container = document.createElement("div");
container.classList.add(Classes.PORTAL);
maybeAddClass(container.classList, props.className); // directly added to this portal element
maybeAddClass(container.classList, context.portalClassName); // added via PortalProvider context
addStopPropagationListeners(container, props.stopPropagationEvents);
const createPortalElement = React.useCallback(() => {
const newPortalElement = document.createElement("div");
newPortalElement.classList.add(Classes.PORTAL);
maybeAddClass(newPortalElement.classList, className); // directly added to this portal element
maybeAddClass(newPortalElement.classList, context.portalClassName); // added via PortalProvider context
addStopPropagationListeners(newPortalElement, stopPropagationEvents);

// TODO: remove legacy context support in Blueprint v6.0
const { blueprintPortalClassName } = legacyContext;
const blueprintPortalClassName = legacyContext.blueprintPortalClassName;
if (blueprintPortalClassName != null && blueprintPortalClassName !== "") {
console.error(Errors.PORTAL_LEGACY_CONTEXT_API);
maybeAddClass(container.classList, blueprintPortalClassName); // added via legacy context
maybeAddClass(newPortalElement.classList, blueprintPortalClassName); // added via legacy context
}

return container;
}, [props.className, context.portalClassName]);
return newPortalElement;
}, [className, context.portalClassName, legacyContext.blueprintPortalClassName, stopPropagationEvents]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, thanks for fixing this dependency list


// create the container element & attach it to the DOM
React.useEffect(() => {
if (props.container == null) {
if (portalContainer == null) {
return;
}
const newPortalElement = createContainerElement();
props.container.appendChild(newPortalElement);
const newPortalElement = createPortalElement();
portalContainer.appendChild(newPortalElement);
setPortalElement(newPortalElement);
setHasMounted(true);

return () => {
removeStopPropagationListeners(newPortalElement, props.stopPropagationEvents);
removeStopPropagationListeners(newPortalElement, stopPropagationEvents);
newPortalElement.remove();
setHasMounted(false);
setPortalElement(undefined);
};
}, [props.container, createContainerElement]);
}, [portalContainer, createPortalElement, stopPropagationEvents]);

// wait until next successful render to invoke onChildrenMount callback
React.useEffect(() => {
if (hasMounted) {
props.onChildrenMount?.();
if (portalElement != null) {
onChildrenMount?.();
}
}, [hasMounted, props.onChildrenMount]);
}, [portalElement, onChildrenMount]);

// update className prop on portal DOM element when props change
const prevClassName = usePrevious(props.className);
React.useEffect(() => {
if (portalElement != null) {
maybeRemoveClass(portalElement.classList, prevClassName);
maybeAddClass(portalElement.classList, props.className);
maybeAddClass(portalElement.classList, className);
return () => maybeRemoveClass(portalElement.classList, className);
}
}, [props.className]);
return undefined;
}, [className, portalElement]);

// update stopPropagation listeners when props change
const prevStopPropagationEvents = usePrevious(props.stopPropagationEvents);
React.useEffect(() => {
if (portalElement != null) {
removeStopPropagationListeners(portalElement, prevStopPropagationEvents);
addStopPropagationListeners(portalElement, props.stopPropagationEvents);
addStopPropagationListeners(portalElement, stopPropagationEvents);
return () => removeStopPropagationListeners(portalElement, stopPropagationEvents);
}
}, [props.stopPropagationEvents]);
return undefined;
}, [portalElement, stopPropagationEvents]);

// Only render `children` once this component has mounted in a browser environment, so they are
// immediately attached to the DOM tree and can do DOM things like measuring or `autoFocus`.
// See long comment on componentDidMount in https://reactjs.org/docs/portals.html#event-bubbling-through-portals
if (typeof document === "undefined" || !hasMounted || portalElement == null) {
if (typeof document === "undefined" || portalElement == null) {
return null;
} else {
return ReactDOM.createPortal(props.children, portalElement);
return ReactDOM.createPortal(children, portalElement);
}
}
Portal.defaultProps = {
container: typeof document !== "undefined" ? document.body : undefined,
};

Portal.displayName = `${DISPLAYNAME_PREFIX}.Portal`;
// eslint-disable-next-line deprecation/deprecation
Portal.contextTypes = PORTAL_LEGACY_CONTEXT_TYPES;
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/context/portal/portalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import * as React from "react";
export interface PortalContextOptions {
/** Additional CSS classes to add to all `Portal` elements in this React context. */
portalClassName?: string;
/** The HTML element that all `Portal` elements in this React context will be mounted to. */
portalContainer?: HTMLElement;
}

/**
Expand All @@ -32,6 +34,17 @@ export const PortalContext = React.createContext<PortalContextOptions>({});
*
* @see https://blueprintjs.com/docs/#core/context/portal-provider
*/
export const PortalProvider = ({ children, ...options }: React.PropsWithChildren<PortalContextOptions>) => {
return <PortalContext.Provider value={options}>{children}</PortalContext.Provider>;
export const PortalProvider = ({
children,
portalClassName,
portalContainer,
}: React.PropsWithChildren<PortalContextOptions>) => {
const contextOptions = React.useMemo<PortalContextOptions>(
() => ({
portalClassName,
portalContainer,
}),
[portalClassName, portalContainer],
);
return <PortalContext.Provider value={contextOptions}>{children}</PortalContext.Provider>;
};