diff --git a/packages/core/karma.conf.js b/packages/core/karma.conf.js index b7f412b12e..7a18c530f9 100644 --- a/packages/core/karma.conf.js +++ b/packages/core/karma.conf.js @@ -11,12 +11,18 @@ module.exports = function (config) { "src/common/abstractComponent*", "src/common/abstractPureComponent*", "src/compatibility/*", + // HACKHACK: for karma upgrade only "src/common/refs.ts", + // HACKHACK: need to add hotkeys v2 tests "src/components/hotkeys/hotkeysDialog2.tsx", "src/components/hotkeys/hotkeysTarget2.tsx", "src/context/hotkeys/hotkeysProvider.tsx", + + // HACKHACK: see https://github.com/palantir/blueprint/issues/5511 + "src/components/portal/portal2.tsx", + "src/context/portal/portalProvider.tsx", ]; const baseConfig = createKarmaConfig({ diff --git a/packages/core/src/components/portal/portal.md b/packages/core/src/components/portal/portal.md index 31ed761f9e..2d5d935d13 100644 --- a/packages/core/src/components/portal/portal.md +++ b/packages/core/src/components/portal/portal.md @@ -9,16 +9,16 @@ For the most part, Portal is a thin wrapper around [`ReactDOM.createPortal`](htt @## React context (legacy) -
+

-Deprecated: use [PortalProvider](#core/context/portal-provider) +React legacy API

-This API is **deprecated since @blueprintjs/core v4.8.0** in favor of -[PortalProvider](#core/context/portal-provider), which uses the -[newer React context API](https://reactjs.org/docs/context.html). +This feature uses React's legacy context API. Support for the +[newer React context API](https://reactjs.org/docs/context.html) will be coming soon +in Blueprint v5.x.
@@ -27,12 +27,14 @@ To use them, supply a child context to a subtree that contains the Portals you w @interface PortalLegacyContext + -@interface PortalContextOptions + @## Props @@ -43,7 +45,7 @@ child of the ``. Portal is used inside [Overlay](#core/components/overlay) to actually overlay the content on the application. -
+

A note about responsive layouts

For a single-page app, if the `` is styled with `width: 100%` and `height: 100%`, a `Portal` diff --git a/packages/core/src/components/portal/portal.tsx b/packages/core/src/components/portal/portal.tsx index a1263f4182..6831ad3758 100644 --- a/packages/core/src/components/portal/portal.tsx +++ b/packages/core/src/components/portal/portal.tsx @@ -21,8 +21,6 @@ import * as Classes from "../../common/classes"; import { ValidationMap } from "../../common/context"; import * as Errors from "../../common/errors"; import { DISPLAYNAME_PREFIX, Props } from "../../common/props"; -import { PortalContext } from "../../context/portal/portalProvider"; -import { usePrevious } from "../../hooks/usePrevious"; // eslint-disable-next-line deprecation/deprecation export type PortalProps = IPortalProps; @@ -44,7 +42,11 @@ export interface IPortalProps extends Props { container?: HTMLElement; } -/** @deprecated use PortalProvider */ +export interface IPortalState { + hasMounted: boolean; +} + +/** @deprecated use PortalLegacyContext */ export type IPortalContext = PortalLegacyContext; export interface PortalLegacyContext { /** Additional CSS classes to add to all `Portal` elements in this React context. */ @@ -65,74 +67,64 @@ const REACT_CONTEXT_TYPES: ValidationMap = { * Use it when you need to circumvent DOM z-stacking (for dialogs, popovers, etc.). * Any class names passed to this element will be propagated to the new container element on document.body. */ -export function Portal(props: PortalProps, legacyContext: PortalLegacyContext = {}) { - const context = React.useContext(PortalContext); +export class Portal extends React.Component { + public static displayName = `${DISPLAYNAME_PREFIX}.Portal`; - const [hasMounted, setHasMounted] = React.useState(false); - const [portalElement, setPortalElement] = React.useState(); + public static contextTypes = REACT_CONTEXT_TYPES; - 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 + public static defaultProps: Partial = { + container: typeof document !== "undefined" ? document.body : undefined, + }; - const { blueprintPortalClassName } = legacyContext; - if (blueprintPortalClassName != null && blueprintPortalClassName !== "") { - console.error(Errors.PORTAL_LEGACY_CONTEXT_API); - maybeAddClass(container.classList, blueprintPortalClassName); // added via legacy context - } + public context: PortalLegacyContext = {}; - return container; - }, [props.className, context.portalClassName]); + public state: IPortalState = { hasMounted: false }; + + private portalElement: HTMLElement | null = null; + + public render() { + // 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" || !this.state.hasMounted || this.portalElement === null) { + return null; + } else { + return ReactDOM.createPortal(this.props.children, this.portalElement); + } + } - // create the container element & attach it to the DOM - React.useEffect(() => { - if (props.container == null) { + public componentDidMount() { + if (this.props.container == null) { return; } - const newPortalElement = createContainerElement(); - props.container.appendChild(newPortalElement); - setPortalElement(newPortalElement); - setHasMounted(true); - - return () => { - newPortalElement.remove(); - setHasMounted(false); - setPortalElement(undefined); - }; - }, [props.container, createContainerElement]); - - // wait until next successful render to invoke onChildrenMount callback - React.useEffect(() => { - if (hasMounted) { - props.onChildrenMount?.(); + this.portalElement = this.createContainerElement(); + this.props.container.appendChild(this.portalElement); + /* eslint-disable-next-line react/no-did-mount-set-state */ + this.setState({ hasMounted: true }, this.props.onChildrenMount); + } + + public componentDidUpdate(prevProps: PortalProps) { + // update className prop on portal DOM element + if (this.portalElement != null && prevProps.className !== this.props.className) { + maybeRemoveClass(this.portalElement.classList, prevProps.className); + maybeAddClass(this.portalElement.classList, this.props.className); } - }, [hasMounted, props.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); + } + + public componentWillUnmount() { + this.portalElement?.remove(); + } + + private createContainerElement() { + const container = document.createElement("div"); + container.classList.add(Classes.PORTAL); + maybeAddClass(container.classList, this.props.className); + if (this.context != null) { + maybeAddClass(container.classList, this.context.blueprintPortalClassName); } - }, [props.className]); - - // 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) { - return null; - } else { - return ReactDOM.createPortal(props.children, portalElement); + return container; } } -Portal.defaultProps = { - container: typeof document !== "undefined" ? document.body : undefined, -}; -Portal.displayName = `${DISPLAYNAME_PREFIX}.Portal`; -Portal.contextTypes = REACT_CONTEXT_TYPES; function maybeRemoveClass(classList: DOMTokenList, className?: string) { if (className != null && className !== "") { diff --git a/packages/core/src/components/portal/portal2.tsx b/packages/core/src/components/portal/portal2.tsx new file mode 100644 index 0000000000..781aa7198d --- /dev/null +++ b/packages/core/src/components/portal/portal2.tsx @@ -0,0 +1,152 @@ +/* + * Copyright 2022 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview This is the next version of , reimplemented as a function component. + * + * It supports both the newer React context API and the legacy context API. Support for the legacy context API + * will be removed in Blueprint v6.0. + * + * Portal2 is not currently used anywhere in Blueprint. We had to revert the change which updated the standard + * to use this implementation because of subtle breaks caused by interactions with the (long-deprecated) + * react-hot-loader library. To be safe, we've left Portal as a class component for now, and will promote this Portal2 + * implementation to be the standard Portal in Blueprint v5.0. + * + * @see https://github.com/palantir/blueprint/issues/5511 + */ + +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +import * as Classes from "../../common/classes"; +import { ValidationMap } from "../../common/context"; +import * as Errors from "../../common/errors"; +import { DISPLAYNAME_PREFIX, Props } from "../../common/props"; +import { PortalContext } from "../../context/portal/portalProvider"; +import { usePrevious } from "../../hooks/usePrevious"; +import type { PortalLegacyContext } from "./portal"; + +export interface Portal2Props extends Props { + /** Contents to send through the portal. */ + children: React.ReactNode; + + /** + * Callback invoked when the children of this `Portal` have been added to the DOM. + */ + onChildrenMount?: () => void; + + /** + * The HTML element that children will be mounted to. + * + * @default document.body + */ + container?: HTMLElement; +} + +const REACT_CONTEXT_TYPES: ValidationMap = { + blueprintPortalClassName: (obj: PortalLegacyContext, key: keyof PortalLegacyContext) => { + if (obj[key] != null && typeof obj[key] !== "string") { + return new Error(Errors.PORTAL_CONTEXT_CLASS_NAME_STRING); + } + return undefined; + }, +}; + +/** + * This component detaches its contents and re-attaches them to document.body. + * Use it when you need to circumvent DOM z-stacking (for dialogs, popovers, etc.). + * Any class names passed to this element will be propagated to the new container element on document.body. + */ +export function Portal2(props: Portal2Props, legacyContext: PortalLegacyContext = {}) { + const context = React.useContext(PortalContext); + + const [hasMounted, setHasMounted] = React.useState(false); + const [portalElement, setPortalElement] = React.useState(); + + 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 + + const { blueprintPortalClassName } = legacyContext; + if (blueprintPortalClassName != null && blueprintPortalClassName !== "") { + console.error(Errors.PORTAL_LEGACY_CONTEXT_API); + maybeAddClass(container.classList, blueprintPortalClassName); // added via legacy context + } + + return container; + }, [props.className, context.portalClassName]); + + // create the container element & attach it to the DOM + React.useEffect(() => { + if (props.container == null) { + return; + } + const newPortalElement = createContainerElement(); + props.container.appendChild(newPortalElement); + setPortalElement(newPortalElement); + setHasMounted(true); + + return () => { + newPortalElement.remove(); + setHasMounted(false); + setPortalElement(undefined); + }; + }, [props.container, createContainerElement]); + + // wait until next successful render to invoke onChildrenMount callback + React.useEffect(() => { + if (hasMounted) { + props.onChildrenMount?.(); + } + }, [hasMounted, props.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); + } + }, [props.className]); + + // 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) { + return null; + } else { + return ReactDOM.createPortal(props.children, portalElement); + } +} +Portal2.defaultProps = { + container: typeof document !== "undefined" ? document.body : undefined, +}; +Portal2.displayName = `${DISPLAYNAME_PREFIX}.Portal2`; +Portal2.contextTypes = REACT_CONTEXT_TYPES; + +function maybeRemoveClass(classList: DOMTokenList, className?: string) { + if (className != null && className !== "") { + classList.remove(...className.split(" ")); + } +} + +function maybeAddClass(classList: DOMTokenList, className?: string) { + if (className != null && className !== "") { + classList.add(...className.split(" ")); + } +} diff --git a/packages/core/test/portal/portalTests.tsx b/packages/core/test/portal/portalTests.tsx index 3acf0ef76d..18a635a210 100644 --- a/packages/core/test/portal/portalTests.tsx +++ b/packages/core/test/portal/portalTests.tsx @@ -96,7 +96,8 @@ describe("", () => { assert.isTrue(portalElement?.classList.contains(Classes.PORTAL)); }); - it("respects portalClassName on new context API", () => { + // HACKHACK, see https://github.com/palantir/blueprint/issues/5511 + it.skip("respects portalClassName on new context API", () => { const CLASS_TO_TEST = "bp-test-klass bp-other-class"; portal = mount(