From de5d0f560e69198caa027743848bec6ef18fcbcc Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Wed, 25 Sep 2024 15:31:51 +0200 Subject: [PATCH 01/16] feat(ui): update PortalProvider WIP WiP: render portal root and individual containers for portals, leave interface intact --- .../PortalProvider.component.js | 110 ++++++++++-------- .../PortalProvider/PortalProvider.stories.js | 77 +++++------- .../PortalProvider/PortalProvider.test.js | 40 +------ .../src/components/PortalProvider/index.js | 5 - 4 files changed, 96 insertions(+), 136 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index 355ffcd4c..a7986976a 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -1,71 +1,89 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { createContext, useContext, useEffect, useRef, useState } from "react" +import React, { createContext, useRef, useContext, useLayoutEffect } from "react" import PropTypes from "prop-types" import { createPortal } from "react-dom" +const DEFAULT_PORTAL_ROOT_ID = "juno-portal-root" + const PortalContext = createContext() +const portalRootStyles = { + position: "absolute", + top: "0", + left: "0", +} + +const portalStyles = { + position: "relative", + zIndex: "1", +} + +/** A PortalProvider.Portal component to directly use from within other components: + * ``` + * + * + * + * ``` + */ +const Portal = ({ children = null }) => { + const rootRef = useContext(PortalContext) + console.log(rootRef) + const wrappedChildren = ( +
+ {children} +
+ ) + return createPortal(wrappedChildren, rootRef.current || document.body) +} + +/** A hook that creates a portal container in the current portal root, and returns a ref to this newly created container to use in other components: + * ``` + * const portalRef = usePortalRef() + * + * createPortal(, portalRef ? portalRef : document.body) + * ``` + * The ref to the portal container element can also be passed as a parameter to components that expect a reference element for positioning, such as Flatpickr / DateTimePickr + */ export function usePortalRef() { - const ref = useContext(PortalContext) - const [_, setInitialized] = useState(ref?.current) + const portalRootRef = useContext(PortalContext) + const containerRef = useRef(null) - useEffect(() => { - if (!ref) { + useLayoutEffect(() => { + if (!portalRootRef || !portalRootRef.current) { console.warn( - "usePortalRef should be called inside a PortalProvider! You are probably using a component that renders a portal, e.g. Modal or Select. Be sure that your app is wrapped in an AppShellProvider." + "usePortalRef must be called inside a PortalProvider. You are probably using a component that renders a portal, e.g. Modal or Select. Make sure your app is wrapped in an AppShellProvider. Alternatively, you can include a PortalProvider manually." ) return } - if (ref.current) setInitialized(true) - }, [ref]) - return ref?.current -} + const portalElement = document.createElement("div") + portalRootRef.current.append(portalElement) + containerRef.current = portalElement -const Portal = ({ children }) => { - const ref = usePortalRef() - return ref ? createPortal(children, ref) : null -} + return () => { + // Clean up the portal element when unmounting: + if (containerRef.current) { + portalRootRef.current.removeChild(containerRef.current) + containerRef.current = null + } + } + }, [portalRootRef]) -Portal.propTypes = { - children: PropTypes.any, + return containerRef.current } -Portal.propTypes = {} - -/** - * This provider acts as a container for portals. All portals within a Juno app should be added as children to this. - * The PortalProvider itself needs to be placed inside the Juno StyleProvider, otherwise styles might not be applied correctly on children of portals. - * - * The main task of the PortalProvider is to offer a place (portal) where certain components - * such as modals are mounted. Many existing libs place such components outside of the - * current application's DOM tree, because the control over creating and scheduling - * the components is not with the application but with the lib. This is not a problem - * as long as the application is in the global document tree. Once shadow root comes - * into play, it changes. In this case, such components are placed outside of the - * shadow root and individual app styles are not applied. The PortalProvider solves - * this problem by creating the portal that lives in the same DOM tree as the actual app. +/** A PortalProvider component that renders a portal root container and creates a context to expose a ref to this portal root container container: * - * The PortalProvider is appended at the top of the application tree and all lower - * components are children of it. This means that all children can access the portal. - * There are two ways you can do this. Via the ProtalProvider.Portal component or via - * a usePortalRef hook. While the component places all children in the portal, the hook - * returns a React reference object to the DOM element. */ -export const PortalProvider = ({ className = "", id = "", children = null }) => { - const ref = useRef() - +export const PortalProvider = ({ children = null, id = DEFAULT_PORTAL_ROOT_ID }) => { + const portalRootRef = useRef() return ( - + {children} -
+
) } -// bind Portal to PortalProvider + +// Bind Portal to PortalProvider: PortalProvider.Portal = Portal Portal.displayName = "PortalProvider.Portal" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index 949de9263..a843e65bc 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -1,16 +1,10 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - import React from "react" -import ReactDOM from "react-dom" -import { PortalProvider, usePortalRef } from "." -import { Message } from "../Message/index.js" -import { CodeBlock } from "../CodeBlock/index.js" +import PropTypes from "prop-types" +import { PortalProvider } from "." +import { Toast } from "../Toast/" export default { - title: "Layout/PortalProvider", + title: "WiP/PortalProvider", component: PortalProvider, subcomponents: { "PortalProvider.Portal": PortalProvider.Portal }, tags: ["autodocs"], @@ -19,49 +13,32 @@ export default { control: false, }, }, + decorators: [(story) => {story()}], } -const Default = () => ( - - - - - -) - -const PortalRefContent = () => { - let portalRef = usePortalRef() +const Template = ({ children, ...args }) => {children} - return ( - <> - {portalRef && - ReactDOM.createPortal( - - {` -import React from "react" -import ReactDOM from "react-dom" -import { usePortalRef } from "@cloudoperators/juno-ui-components" +Template.propTypes = { + children: PropTypes.node, +} -const MyComponent = () => { - const portalRef = usePortalRef() - return ( - { portalRef && ReactDOM.createPortal("I'm inside the portal",portalRef) } - ) -}`} - , - portalRef - )} - - ) +export const WithPortalComponent = { + render: Template, + args: { + children: , + }, } -/** - * PortalRef - */ -const PortalRef = () => ( - - - -) -/** The PortalProvider is the parent for all portals of a Juno app. */ -export { Default as PortalComponent, PortalRef } +// const Default = () => ( +// +// +// +// +// +// ) +// +// const PortalRef = () => ( +// +//
Something
+//
+// ) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 5266bd5bd..7661c3f3d 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -1,40 +1,10 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - import * as React from "react" -import { render, screen } from "@testing-library/react" -import { PortalProvider } from "./index" +import { render } from "@testing-library/react" +import { PortalProvider } from "./" -/** Tests need to be adjusted once we know how this will look */ describe("PortalProvider", () => { - test("renders a PortalProvider wrapper div", async () => { - render( - -
Hello
-
- ) - expect(document.querySelector(".juno-portal-container")).toBeInTheDocument() - }) - - test("renders a PortalProvider wrapper div with an id as passed", async () => { - render( - -
Hello
-
- ) - expect(document.querySelector("#my-portal-provider")).toBeInTheDocument() - }) - - test("renders other children as passed", async () => { - render( - - - - ) - expect(document.querySelector(".juno-portal-container")).toBeInTheDocument() - expect(screen.getByRole("button")).toBeInTheDocument() - expect(screen.getByRole("button")).toHaveTextContent("Hello") + test("renders a portal root container", async () => { + render() + expect(document.getElementById("juno-portal-root")).toBeInTheDocument() }) }) diff --git a/packages/ui-components/src/components/PortalProvider/index.js b/packages/ui-components/src/components/PortalProvider/index.js index 7d6daee4e..1f73d8968 100644 --- a/packages/ui-components/src/components/PortalProvider/index.js +++ b/packages/ui-components/src/components/PortalProvider/index.js @@ -1,6 +1 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - export { PortalProvider, usePortalRef } from "./PortalProvider.component" From 3f98f48de9ae2faa9adda2f920339d1370d20cf8 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Thu, 26 Sep 2024 15:08:44 +0200 Subject: [PATCH 02/16] feat(ui): render wrapped portal content into portal root --- .../src/components/Modal/Modal.stories.js | 4 ++- .../PortalProvider.component.js | 36 +++++++++++++------ .../PortalProvider/PortalProvider.stories.js | 11 ++---- .../PortalProvider/PortalProvider.test.js | 16 ++++++++- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/ui-components/src/components/Modal/Modal.stories.js b/packages/ui-components/src/components/Modal/Modal.stories.js index 2b3207ae1..f588042de 100644 --- a/packages/ui-components/src/components/Modal/Modal.stories.js +++ b/packages/ui-components/src/components/Modal/Modal.stories.js @@ -33,7 +33,9 @@ const Template = ({ closeOnConfirm, ...args }) => { return ( <> + + + ) + expect(document.getElementById("juno-portal-root")).toBeInTheDocument() + expect(screen.getByRole("button")).toBeInTheDocument() + const parentElement = screen.getByRole("button").parentElement + expect(parentElement).toHaveClass("juno-portal") + }) }) From 58ba9b6fe7f79975ef5617a195331a03f17a03f7 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Thu, 26 Sep 2024 16:02:24 +0200 Subject: [PATCH 03/16] feat(ui): update PortalProvider docs --- .../PortalProvider/PortalProvider.component.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index 161581da3..e40161522 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -47,6 +47,7 @@ const Portal = ({ children = null }) => { } Portal.propTypes = { + /** The children to mount in a portal. Typically, these will be menus, modal dialogs, etc. */ children: PropTypes.node, } @@ -85,8 +86,9 @@ export function usePortalRef() { return containerRef.current } -/** A PortalProvider component that helps using and managing portals. It renders a portal root container, creates a context to expose a ref the container, a `PortalProvider.Portal` component to render content into a portal, and a `usePortalRef` hook to render content into a portal. - * +/** A PortalProvider component that helps using and managing portals. + * It renders a portal root container, creates a context to expose a ref the container, a `PortalProvider.Portal` component to render content into a portal, and a `usePortalRef` hook to render content into a portal. + * Normally, there is no need to include `PortalProvider` manually, when using `AppShell` `PortalProvider` is already included in the app. */ export const PortalProvider = ({ children = null, className = "", id = DEFAULT_PORTAL_ROOT_ID }) => { const portalRootRef = useRef() @@ -111,3 +113,8 @@ PortalProvider.propTypes = { /** The children of the PortalProvider. Typically, this will be the whole app. */ children: PropTypes.node, } + +PortalProvider.Portal.propTypes = { + /** The children to mount in a portal. Typically, these will be menus, modal dialogs, etc. */ + children: PropTypes.node, +} From 4a622cb3b93a8571c752fd63f413fc8528ce7938 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Thu, 26 Sep 2024 17:37:43 +0200 Subject: [PATCH 04/16] feat(ui): make usePortalRef hook work with new portal --- .../PortalProvider.component.js | 33 +++++++----- .../PortalProvider/PortalProvider.stories.js | 51 +++++++++++++++---- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index e40161522..0e6aa9f73 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -1,4 +1,4 @@ -import React, { createContext, useRef, useContext, useEffect, useLayoutEffect, useState } from "react" +import React, { createContext, useRef, useContext, useEffect, useState } from "react" import PropTypes from "prop-types" import { createPortal } from "react-dom" @@ -60,30 +60,39 @@ Portal.propTypes = { * The ref to the portal container element can also be passed as a parameter to components that expect a reference element for positioning, such as Flatpickr / DateTimePickr. */ export function usePortalRef() { - const portalRootRef = useContext(PortalContext) + const rootRef = useContext(PortalContext) const containerRef = useRef(null) + const [isReady, setIsReady] = useState(false) - useLayoutEffect(() => { - if (!portalRootRef || !portalRootRef.current) { + useEffect(() => { + if (!rootRef || !rootRef.current) { console.warn( - "usePortalRef must be called inside a PortalProvider. You are probably using a component that renders a portal, e.g. Modal or Select. Make sure your app is wrapped in an AppShellProvider. Alternatively, you can include a PortalProvider manually." + "usePortalRef must be called inside a PortalProvider. You are probably using a component that renders a portal, e.g. Modal or Select. Make sure your app is wrapped in an AppShellProvider. Alternatively, a PortalProvider can be included manually." ) return } - const portalElement = document.createElement("div") - portalRootRef.current.append(portalElement) - containerRef.current = portalElement + const containerElement = document.createElement("div") + containerElement.style.position = "relative" + containerElement.style.zIndex = "1" + containerElement.classList.add("juno-portal") + rootRef.current.append(containerElement) + containerRef.current = containerElement + setIsReady(true) return () => { // Clean up the portal element when unmounting: - if (containerRef.current) { - portalRootRef.current.removeChild(containerRef.current) + if (containerRef.current && rootRef?.current) { + rootRef.current.removeChild(containerRef.current) containerRef.current = null } } - }, [portalRootRef]) + }, [rootRef]) + + if (!containerRef?.current) { + return null + } - return containerRef.current + return isReady ? containerRef.current : null } /** A PortalProvider component that helps using and managing portals. diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index 42f27ad9e..fae82764f 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -1,6 +1,7 @@ import React from "react" +import { createPortal } from "react-dom" import PropTypes from "prop-types" -import { PortalProvider } from "." +import { PortalProvider, usePortalRef } from "." import { Message } from "../Message/" export default { @@ -13,10 +14,9 @@ export default { control: false, }, }, - decorators: [(story) => {story()}], } -const Template = ({ children, ...args }) => {children} +const Template = ({ children, ...args }) => {children} Template.propTypes = { children: PropTypes.node, @@ -25,13 +25,44 @@ Template.propTypes = { export const WithPortalComponent = { render: Template, args: { - children: , + children: ( + + + + ), }, } -// -// const PortalRef = () => ( -// -//
Something
-//
-// ) +const PortalBox = () => { + const portalRef = usePortalRef() + if (!portalRef) return null + const content = + return createPortal(content, portalRef) +} + +export const WithHook = { + render: Template, + args: { + children: ( + <> + Some non-portalled content + + + ), + }, +} + +export const MultiplePortals = { + render: Template, + args: { + children: ( + <> +
Some non-portaled content.
+ + + + + + ), + }, +} From 0160ea44b12a12fd3f2321d07b1d92a4a58e1e53 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Thu, 26 Sep 2024 18:09:05 +0200 Subject: [PATCH 05/16] feat(ui): add test for usePortalRef hook --- .../PortalProvider/PortalProvider.test.js | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 96c92d8f4..1e9ff26be 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -1,6 +1,13 @@ import * as React from "react" +import { createPortal } from "react-dom" import { render, screen } from "@testing-library/react" -import { PortalProvider } from "./" +import { PortalProvider, usePortalRef } from "./" + +const TestComponent = () => { + const portalRef = usePortalRef() + if (!portalRef) return null + return createPortal(, portalRef) +} describe("PortalProvider", () => { test("renders a portal root container", async () => { @@ -8,7 +15,7 @@ describe("PortalProvider", () => { expect(document.getElementById("juno-portal-root")).toBeInTheDocument() }) - test("renders children wrapped into a .juno-portal container into a portal", async () => { + test("renders children wrapped into a .juno-portal container into a portal using PortalProvider.Portal", async () => { render( @@ -18,7 +25,26 @@ describe("PortalProvider", () => { ) expect(document.getElementById("juno-portal-root")).toBeInTheDocument() expect(screen.getByRole("button")).toBeInTheDocument() - const parentElement = screen.getByRole("button").parentElement - expect(parentElement).toHaveClass("juno-portal") + const parentEl = screen.getByRole("button").parentElement + expect(parentEl).toHaveClass("juno-portal") + expect(parentEl.parentElement).toHaveClass("juno-portal-root") + }) + + test("renders children wrapped into a .juno-portal-container into a portal using usePortalRef", async () => { + render( + + + + ) + expect(document.getElementById("juno-portal-root")).toBeInTheDocument() + expect(screen.getByRole("button")).toBeInTheDocument() + const parentEl = screen.getByRole("button").parentElement + expect(parentEl).toHaveClass("juno-portal") + expect(parentEl.parentElement).toHaveClass("juno-portal-root") }) + + // ("removes the parent element when the portalled content unmounts") + // ("renders individual .juno-portal containers for multiple portalled components") + // ("render parent using usePortalRef") + // ("keep parent ref in context") }) From ae66fa745057094aea4a49cc447061000062056b Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Fri, 27 Sep 2024 13:16:42 +0200 Subject: [PATCH 06/16] feat(ui): add some comments --- .../PortalProvider/PortalProvider.component.js | 11 +++++++++++ .../components/PortalProvider/PortalProvider.test.js | 1 - 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index 0e6aa9f73..da35563a6 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -16,6 +16,17 @@ const portalStyles = { position: "relative", zIndex: "1", } +/** Discuss/decide: + * add usePortal() hook? + * add onMount/onMount props? (Portal component only, not sure whether this would be possible with he hook(s)?) + */ + +/** Review: + * component behaviour over lifecycle: + * is context properly updated when root changes or unmounts? + * are containers properly updated when components calling them change? + * are containers properly removed when componetns unmount (e.g. when closing a modal?) + */ /** A PortalProvider.Portal component to directly use from within other components: * ``` diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 1e9ff26be..513f16b26 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -45,6 +45,5 @@ describe("PortalProvider", () => { // ("removes the parent element when the portalled content unmounts") // ("renders individual .juno-portal containers for multiple portalled components") - // ("render parent using usePortalRef") // ("keep parent ref in context") }) From 667e072a0ae2de7210293098417450e757351a79 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Fri, 27 Sep 2024 17:57:50 +0200 Subject: [PATCH 07/16] feat(ui): add more tests, rename story component --- .../PortalProvider/PortalProvider.stories.js | 6 +-- .../PortalProvider/PortalProvider.test.js | 37 +++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index fae82764f..0fb6e4dd0 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -33,7 +33,7 @@ export const WithPortalComponent = { }, } -const PortalBox = () => { +const PortalMessage = () => { const portalRef = usePortalRef() if (!portalRef) return null const content = @@ -46,7 +46,7 @@ export const WithHook = { children: ( <> Some non-portalled content - + ), }, @@ -61,7 +61,7 @@ export const MultiplePortals = { - + ), }, diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 513f16b26..053bbcfa5 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -12,7 +12,9 @@ const TestComponent = () => { describe("PortalProvider", () => { test("renders a portal root container", async () => { render() - expect(document.getElementById("juno-portal-root")).toBeInTheDocument() + const portalRoot = document.getElementById("juno-portal-root") + expect(portalRoot).toBeInTheDocument() + expect(portalRoot).toHaveClass("juno-portal-root") }) test("renders children wrapped into a .juno-portal container into a portal using PortalProvider.Portal", async () => { @@ -30,6 +32,11 @@ describe("PortalProvider", () => { expect(parentEl.parentElement).toHaveClass("juno-portal-root") }) + test("renders a portal root container with an id as passed", async () => { + render() + expect(document.getElementById("portal-provider-test-id")).toBeInTheDocument() + }) + test("renders children wrapped into a .juno-portal-container into a portal using usePortalRef", async () => { render( @@ -43,7 +50,29 @@ describe("PortalProvider", () => { expect(parentEl.parentElement).toHaveClass("juno-portal-root") }) - // ("removes the parent element when the portalled content unmounts") - // ("renders individual .juno-portal containers for multiple portalled components") - // ("keep parent ref in context") + test("renders individual juno portal containers for multiple portals", async () => { + render( + + + + + + + + + + + + ) + const portalContainers = document.querySelectorAll(".juno-portal") + expect(portalContainers.length).toBe(3) + }) + + test("renders an additional custom className as passed", async () => { + render() + const portalRoot = document.getElementById("juno-portal-root") + expect(portalRoot).toBeInTheDocument() + expect(portalRoot).toHaveClass("juno-portal-root") + expect(portalRoot).toHaveClass("my-custom-class") + }) }) From bbf46049391d38ad712d09f2ee5705ab20f8b6c8 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Fri, 27 Sep 2024 18:03:13 +0200 Subject: [PATCH 08/16] feat(ui): add more comments --- .../src/components/PortalProvider/PortalProvider.component.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index da35563a6..caeab80cf 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -82,12 +82,14 @@ export function usePortalRef() { ) return } + // create a portal container element and append it to the portal root container const containerElement = document.createElement("div") containerElement.style.position = "relative" containerElement.style.zIndex = "1" containerElement.classList.add("juno-portal") rootRef.current.append(containerElement) containerRef.current = containerElement + // mark the newly created container ready setIsReady(true) return () => { @@ -102,7 +104,7 @@ export function usePortalRef() { if (!containerRef?.current) { return null } - + // return the current of the ref or null if not yet ready return isReady ? containerRef.current : null } From b62954926504070ed0a65bc3a6a05e0c09e55c95 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Tue, 1 Oct 2024 16:15:17 +0200 Subject: [PATCH 09/16] feat(ui): export portal context, too --- packages/ui-components/src/components/PortalProvider/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-components/src/components/PortalProvider/index.js b/packages/ui-components/src/components/PortalProvider/index.js index 1f73d8968..85485444c 100644 --- a/packages/ui-components/src/components/PortalProvider/index.js +++ b/packages/ui-components/src/components/PortalProvider/index.js @@ -1 +1 @@ -export { PortalProvider, usePortalRef } from "./PortalProvider.component" +export { PortalContext, PortalProvider, usePortalRef } from "./PortalProvider.component" From 791aa612d2a560a144b4559b2fab66d77f97f235 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Tue, 1 Oct 2024 16:20:56 +0200 Subject: [PATCH 10/16] feat(ui): export context, makePortalProvider more robust --- .../PortalProvider.component.js | 27 ++++--- .../PortalProvider/PortalProvider.stories.js | 14 ++-- .../PortalProvider/PortalProvider.test.js | 70 ++++++++++++++++++- 3 files changed, 86 insertions(+), 25 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index caeab80cf..fe42338f2 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -4,7 +4,7 @@ import { createPortal } from "react-dom" const DEFAULT_PORTAL_ROOT_ID = "juno-portal-root" -const PortalContext = createContext() +export const PortalContext = createContext() const portalRootStyles = { position: "absolute", @@ -16,17 +16,6 @@ const portalStyles = { position: "relative", zIndex: "1", } -/** Discuss/decide: - * add usePortal() hook? - * add onMount/onMount props? (Portal component only, not sure whether this would be possible with he hook(s)?) - */ - -/** Review: - * component behaviour over lifecycle: - * is context properly updated when root changes or unmounts? - * are containers properly updated when components calling them change? - * are containers properly removed when componetns unmount (e.g. when closing a modal?) - */ /** A PortalProvider.Portal component to directly use from within other components: * ``` @@ -76,13 +65,13 @@ export function usePortalRef() { const [isReady, setIsReady] = useState(false) useEffect(() => { - if (!rootRef || !rootRef.current) { + if (!rootRef || !rootRef?.current) { console.warn( "usePortalRef must be called inside a PortalProvider. You are probably using a component that renders a portal, e.g. Modal or Select. Make sure your app is wrapped in an AppShellProvider. Alternatively, a PortalProvider can be included manually." ) return } - // create a portal container element and append it to the portal root container + // Create a portal container element, add the styles, and append it to the portal root container when the root container is ready: const containerElement = document.createElement("div") containerElement.style.position = "relative" containerElement.style.zIndex = "1" @@ -114,9 +103,17 @@ export function usePortalRef() { */ export const PortalProvider = ({ children = null, className = "", id = DEFAULT_PORTAL_ROOT_ID }) => { const portalRootRef = useRef() + const [isMounted, setIsMounted] = useState(false) + + // Wait for the ref to be set after the initial render in order to make sure the context will provide a valid ref that points to an existing portal-root node: + useEffect(() => { + if (portalRootRef.current) { + setIsMounted(true) + } + }, []) return ( - + {children}
diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index 0fb6e4dd0..fe388d88e 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -16,6 +16,13 @@ export default { }, } +const PortalMessage = () => { + const portalRef = usePortalRef() + if (!portalRef) return null + const content = + return createPortal(content, portalRef) +} + const Template = ({ children, ...args }) => {children} Template.propTypes = { @@ -33,13 +40,6 @@ export const WithPortalComponent = { }, } -const PortalMessage = () => { - const portalRef = usePortalRef() - if (!portalRef) return null - const content = - return createPortal(content, portalRef) -} - export const WithHook = { render: Template, args: { diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 053bbcfa5..8ca95ca4b 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -1,14 +1,27 @@ import * as React from "react" import { createPortal } from "react-dom" -import { render, screen } from "@testing-library/react" -import { PortalProvider, usePortalRef } from "./" +import { useContext } from "react" +import { render, screen, waitFor } from "@testing-library/react" +import { PortalContext, PortalProvider, usePortalRef } from "./" +// A test component that uses the usePortalRef hook to render content in a portal in portal root: const TestComponent = () => { const portalRef = usePortalRef() if (!portalRef) return null return createPortal(, portalRef) } +// A test component to consume the portal context and render the className of the portal root: +const TestConsumer = () => { + const portalRef = useContext(PortalContext) + if (!portalRef) return null + return ( + <> + portalRef?.current && (
{portalRef.current.className}
) + + ) +} + describe("PortalProvider", () => { test("renders a portal root container", async () => { render() @@ -68,7 +81,58 @@ describe("PortalProvider", () => { expect(portalContainers.length).toBe(3) }) - test("renders an additional custom className as passed", async () => { + test("removes the portal root container when unmounting", async () => { + const { unmount } = render( + + + + ) + expect(document.querySelector("#juno-portal-root")).toBeInTheDocument() + unmount() + expect(document.querySelector("#juno-portal-root")).not.toBeInTheDocument() + }) + + test("removes the juno portal container when a child component unmounts while leaving the portal root intact", async () => { + const { rerender } = render( + + + + ) + const portalRoot = document.querySelector("#juno-portal-root") + expect(portalRoot).toBeInTheDocument() + const portalContainer = document.querySelector(".juno-portal") + expect(portalContainer).toBeInTheDocument() + // Remove children, assert root element is still there, container elements rendered for child component is removed: + rerender() + expect(portalRoot).toBeInTheDocument() + expect(document.querySelector(".juno-portal")).not.toBeInTheDocument() + }) + + test("provides a ref to the rendered portal root container in context", async () => { + render( + + + + ) + const portalRefId = screen.getByTestId("portal-root-ref-id") + await waitFor(() => { + expect(portalRefId).toHaveTextContent("juno-portal-root") + expect(document.querySelector(".juno-portal-root")).toBeInTheDocument() + }) + }) + + test("logs a warning to the console when used outside of a PortalProvider", async () => { + let consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) + render() + await waitFor(() => { + expect(consoleWarnSpy).toHaveBeenCalledWith( + "usePortalRef must be called inside a PortalProvider. You are probably using a component that renders a portal, e.g. Modal or Select. Make sure your app is wrapped in an AppShellProvider. Alternatively, a PortalProvider can be included manually." + ) + }) + consoleWarnSpy.mockRestore() + }) + + test("renders an additional custom className to the portal root as passed", async () => { render() const portalRoot = document.getElementById("juno-portal-root") expect(portalRoot).toBeInTheDocument() From 33c85cd32b6b49d60a48565cab5da2ce535be5bd Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Wed, 2 Oct 2024 08:28:47 +0200 Subject: [PATCH 11/16] feat(ui): remove unnecessary async calls --- .../PortalProvider/PortalProvider.test.js | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 8ca95ca4b..57cd79022 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -23,14 +23,14 @@ const TestConsumer = () => { } describe("PortalProvider", () => { - test("renders a portal root container", async () => { + test("renders a portal root container", () => { render() const portalRoot = document.getElementById("juno-portal-root") expect(portalRoot).toBeInTheDocument() expect(portalRoot).toHaveClass("juno-portal-root") }) - test("renders children wrapped into a .juno-portal container into a portal using PortalProvider.Portal", async () => { + test("renders children wrapped into a .juno-portal container into a portal using PortalProvider.Portal", () => { render( @@ -45,12 +45,12 @@ describe("PortalProvider", () => { expect(parentEl.parentElement).toHaveClass("juno-portal-root") }) - test("renders a portal root container with an id as passed", async () => { + test("renders a portal root container with an id as passed", () => { render() expect(document.getElementById("portal-provider-test-id")).toBeInTheDocument() }) - test("renders children wrapped into a .juno-portal-container into a portal using usePortalRef", async () => { + test("renders children wrapped into a .juno-portal-container into a portal using usePortalRef", () => { render( @@ -63,7 +63,7 @@ describe("PortalProvider", () => { expect(parentEl.parentElement).toHaveClass("juno-portal-root") }) - test("renders individual juno portal containers for multiple portals", async () => { + test("renders individual juno portal containers for multiple portals", () => { render( @@ -81,7 +81,7 @@ describe("PortalProvider", () => { expect(portalContainers.length).toBe(3) }) - test("removes the portal root container when unmounting", async () => { + test("removes the portal root container when unmounting", () => { const { unmount } = render( @@ -132,7 +132,18 @@ describe("PortalProvider", () => { consoleWarnSpy.mockRestore() }) - test("renders an additional custom className to the portal root as passed", async () => { + // test("does not log a warning to the console when used inside a PortalProvider", async () => { + // let consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) + // render( + // + // + // + // ) + // expect(consoleWarnSpy).not.toHaveBeenCalled() + // consoleWarnSpy.mockRestore() + // }) + + test("renders an additional custom className to the portal root as passed", () => { render() const portalRoot = document.getElementById("juno-portal-root") expect(portalRoot).toBeInTheDocument() From b8d6306036e83b7d96fe39028168d64728f22c4e Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Wed, 2 Oct 2024 08:31:33 +0200 Subject: [PATCH 12/16] feat(ui): bring back license headers --- .../components/PortalProvider/PortalProvider.component.js | 5 +++++ .../src/components/PortalProvider/PortalProvider.stories.js | 5 +++++ .../src/components/PortalProvider/PortalProvider.test.js | 5 +++++ .../ui-components/src/components/PortalProvider/index.js | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index fe42338f2..be82592e8 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import React, { createContext, useRef, useContext, useEffect, useState } from "react" import PropTypes from "prop-types" import { createPortal } from "react-dom" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index fe388d88e..89280dbf7 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import React from "react" import { createPortal } from "react-dom" import PropTypes from "prop-types" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 57cd79022..63994cfb4 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import * as React from "react" import { createPortal } from "react-dom" import { useContext } from "react" diff --git a/packages/ui-components/src/components/PortalProvider/index.js b/packages/ui-components/src/components/PortalProvider/index.js index 85485444c..853d4f144 100644 --- a/packages/ui-components/src/components/PortalProvider/index.js +++ b/packages/ui-components/src/components/PortalProvider/index.js @@ -1 +1,6 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + export { PortalContext, PortalProvider, usePortalRef } from "./PortalProvider.component" From beb4b45ebfd0593f7dcf5e78182c4599ac183947 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Wed, 2 Oct 2024 08:38:21 +0200 Subject: [PATCH 13/16] feat(ui): try to remove license headers again to push --- .../components/PortalProvider/PortalProvider.component.js | 5 ----- .../src/components/PortalProvider/PortalProvider.stories.js | 5 ----- .../src/components/PortalProvider/PortalProvider.test.js | 5 ----- .../ui-components/src/components/PortalProvider/index.js | 5 ----- 4 files changed, 20 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index be82592e8..fe42338f2 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - import React, { createContext, useRef, useContext, useEffect, useState } from "react" import PropTypes from "prop-types" import { createPortal } from "react-dom" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index 89280dbf7..fe388d88e 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - import React from "react" import { createPortal } from "react-dom" import PropTypes from "prop-types" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 63994cfb4..57cd79022 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - import * as React from "react" import { createPortal } from "react-dom" import { useContext } from "react" diff --git a/packages/ui-components/src/components/PortalProvider/index.js b/packages/ui-components/src/components/PortalProvider/index.js index 853d4f144..85485444c 100644 --- a/packages/ui-components/src/components/PortalProvider/index.js +++ b/packages/ui-components/src/components/PortalProvider/index.js @@ -1,6 +1 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - export { PortalContext, PortalProvider, usePortalRef } from "./PortalProvider.component" From 2a22205406c424ae27c1558cca288d596db72da2 Mon Sep 17 00:00:00 2001 From: Franz Heidl Date: Wed, 2 Oct 2024 11:38:55 +0200 Subject: [PATCH 14/16] feat(ui): add license headers again --- .../components/PortalProvider/PortalProvider.component.js | 5 +++++ .../src/components/PortalProvider/PortalProvider.stories.js | 5 +++++ .../src/components/PortalProvider/PortalProvider.test.js | 5 +++++ .../ui-components/src/components/PortalProvider/index.js | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index fe42338f2..be82592e8 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import React, { createContext, useRef, useContext, useEffect, useState } from "react" import PropTypes from "prop-types" import { createPortal } from "react-dom" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js index fe388d88e..89280dbf7 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.stories.js @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import React from "react" import { createPortal } from "react-dom" import PropTypes from "prop-types" diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 57cd79022..63994cfb4 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import * as React from "react" import { createPortal } from "react-dom" import { useContext } from "react" diff --git a/packages/ui-components/src/components/PortalProvider/index.js b/packages/ui-components/src/components/PortalProvider/index.js index 85485444c..853d4f144 100644 --- a/packages/ui-components/src/components/PortalProvider/index.js +++ b/packages/ui-components/src/components/PortalProvider/index.js @@ -1 +1,6 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + export { PortalContext, PortalProvider, usePortalRef } from "./PortalProvider.component" From f271462d2f21fe1a9281ddfc64ff49e3b6e5ea6d Mon Sep 17 00:00:00 2001 From: Wowa Date: Mon, 7 Oct 2024 14:59:44 +0200 Subject: [PATCH 15/16] chore(ui): fix test that warns --- .../PortalProvider/PortalProvider.test.js | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js index 63994cfb4..f44dcce68 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.test.js @@ -22,7 +22,7 @@ const TestConsumer = () => { if (!portalRef) return null return ( <> - portalRef?.current && (
{portalRef.current.className}
) +
{portalRef?.current?.className}
) } @@ -137,16 +137,16 @@ describe("PortalProvider", () => { consoleWarnSpy.mockRestore() }) - // test("does not log a warning to the console when used inside a PortalProvider", async () => { - // let consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) - // render( - // - // - // - // ) - // expect(consoleWarnSpy).not.toHaveBeenCalled() - // consoleWarnSpy.mockRestore() - // }) + test("does not log a warning to the console when used inside a PortalProvider", () => { + let consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}) + render( + + + + ) + expect(consoleWarnSpy).not.toHaveBeenCalled() + consoleWarnSpy.mockRestore() + }) test("renders an additional custom className to the portal root as passed", () => { render() From edce9698286e9207feeec7f8781adbe257a9f527 Mon Sep 17 00:00:00 2001 From: d064310 Date: Mon, 7 Oct 2024 15:36:20 +0200 Subject: [PATCH 16/16] chore(ui): wait for initializing portal provider --- .../src/components/PortalProvider/PortalProvider.component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js index be82592e8..45768d3bf 100644 --- a/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js +++ b/packages/ui-components/src/components/PortalProvider/PortalProvider.component.js @@ -118,8 +118,8 @@ export const PortalProvider = ({ children = null, className = "", id = DEFAULT_P }, []) return ( - - {children} + + {isMounted && children}
)