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

feat(ui): update Portal #465

Merged
merged 22 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
17b7c3d
Merge branch 'main' into new-portal
franzheidl Sep 25, 2024
de5d0f5
feat(ui): update PortalProvider WIP
franzheidl Sep 25, 2024
3f98f48
feat(ui): render wrapped portal content into portal root
franzheidl Sep 26, 2024
58ba9b6
feat(ui): update PortalProvider docs
franzheidl Sep 26, 2024
4a622cb
feat(ui): make usePortalRef hook work with new portal
franzheidl Sep 26, 2024
0160ea4
feat(ui): add test for usePortalRef hook
franzheidl Sep 26, 2024
ae66fa7
feat(ui): add some comments
franzheidl Sep 27, 2024
667e072
feat(ui): add more tests, rename story component
franzheidl Sep 27, 2024
bbf4604
feat(ui): add more comments
franzheidl Sep 27, 2024
b1f8a77
Merge branch 'main' into new-portal
franzheidl Sep 27, 2024
1f1d3b1
Merge branch 'main' into new-portal
franzheidl Oct 1, 2024
b629549
feat(ui): export portal context, too
franzheidl Oct 1, 2024
791aa61
feat(ui): export context, makePortalProvider more robust
franzheidl Oct 1, 2024
33c85cd
feat(ui): remove unnecessary async calls
franzheidl Oct 2, 2024
b8d6306
feat(ui): bring back license headers
franzheidl Oct 2, 2024
3f9143d
Merge branch 'main' into new-portal
franzheidl Oct 2, 2024
beb4b45
feat(ui): try to remove license headers again to push
franzheidl Oct 2, 2024
2a22205
feat(ui): add license headers again
franzheidl Oct 2, 2024
f271462
chore(ui): fix test that warns
barsukov Oct 7, 2024
edce969
chore(ui): wait for initializing portal provider
andypf Oct 7, 2024
7424a65
Merge branch 'main' into new-portal
barsukov Oct 7, 2024
2b4c0bf
Merge branch 'main' into new-portal
barsukov Oct 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ const Template = ({ closeOnConfirm, ...args }) => {
return (
<>
<Button label="Open Modal" variant="primary" onClick={open} />
<Modal open={isOpen} onCancel={close} onConfirm={closeOnConfirm ? close : null} {...args} />
<PortalProvider.Portal>
<Modal open={isOpen} onCancel={close} onConfirm={closeOnConfirm ? close : null} {...args} />
</PortalProvider.Portal>
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,142 @@
* SPDX-License-Identifier: Apache-2.0
*/

import React, { createContext, useContext, useEffect, useRef, useState } from "react"
import React, { createContext, useRef, useContext, useEffect, useState } from "react"
import PropTypes from "prop-types"
import { createPortal } from "react-dom"

const PortalContext = createContext()
const DEFAULT_PORTAL_ROOT_ID = "juno-portal-root"

export 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:
* ```
* <PortalProvider.Portal>
* <MyComponent />
* </PortalProvider.Portal>
* ```
*/
const Portal = ({ children = null }) => {
const rootRef = useContext(PortalContext)
const [isMounted, setIsMounted] = useState(false)

useEffect(() => {
if (rootRef?.current) {
setIsMounted(true)
}
}, [rootRef])

if (!isMounted) {
return null
}

const wrappedChildren = (
<div className={`juno-portal`} style={portalStyles}>
{children}
</div>
)
return createPortal(wrappedChildren, rootRef.current || document.body)
}

Portal.propTypes = {
/** The children to mount in a portal. Typically, these will be menus, modal dialogs, etc. */
children: PropTypes.node,
}

/** A hook that creates a portal container in the current portal root, and returns this newly created container as a node to use in other components:
* ```
* const portalRef = usePortalRef()
*
* createPortal(<MyComponent />, 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 rootRef = useContext(PortalContext)
const containerRef = useRef(null)
const [isReady, setIsReady] = useState(false)

useEffect(() => {
if (!ref) {
if (!rootRef || !rootRef?.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, a PortalProvider can be included manually."
)
return
}
if (ref.current) setInitialized(true)
}, [ref])
return ref?.current
}
// 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"
containerElement.classList.add("juno-portal")
rootRef.current.append(containerElement)
containerRef.current = containerElement
// mark the newly created container ready
setIsReady(true)

const Portal = ({ children }) => {
const ref = usePortalRef()
return ref ? createPortal(children, ref) : null
}
return () => {
// Clean up the portal element when unmounting:
if (containerRef.current && rootRef?.current) {
rootRef.current.removeChild(containerRef.current)
containerRef.current = null
}
}
}, [rootRef])
barsukov marked this conversation as resolved.
Show resolved Hide resolved

Portal.propTypes = {
children: PropTypes.any,
if (!containerRef?.current) {
return null
}
// return the current of the ref or null if not yet ready
return isReady ? containerRef.current : null
}

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.
*
* 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.
/** 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 = ({ className = "", id = "", children = null }) => {
const ref = useRef()
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 (
<PortalContext.Provider value={ref}>
{children}
<div className={`juno-portal-container ${className}`} id={id} ref={ref} />
<PortalContext.Provider value={portalRootRef}>
{isMounted && children}
<div className={`juno-portal-root ${className}`} id={id} ref={portalRootRef} style={portalRootStyles} />
</PortalContext.Provider>
)
}
// bind Portal to PortalProvider

// Bind Portal to PortalProvider:
PortalProvider.Portal = Portal
Portal.displayName = "PortalProvider.Portal"

PortalProvider.propTypes = {
barsukov marked this conversation as resolved.
Show resolved Hide resolved
/** Optionally a class name can be passed to the portal container which is the container where portals are created by PortalProvider */
/** Pass a custom className to the portal root container in which portals will be mounted */
className: PropTypes.string,
/** Optionally an id can be passed to the portal container which is the container where portals are created by PortalProvider */
/** Pass a custom id to the portal root container in which portals will be mounted */
id: PropTypes.string,
/** The PortalProvider must have children. It is typically used as a wrapper for the whole app. */
/** 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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
*/

import React from "react"
import ReactDOM from "react-dom"
import { createPortal } from "react-dom"
import PropTypes from "prop-types"
import { PortalProvider, usePortalRef } from "."
import { Message } from "../Message/index.js"
import { CodeBlock } from "../CodeBlock/index.js"
import { Message } from "../Message/"

export default {
title: "Layout/PortalProvider",
title: "WiP/PortalProvider",
component: PortalProvider,
subcomponents: { "PortalProvider.Portal": PortalProvider.Portal },
tags: ["autodocs"],
Expand All @@ -21,47 +21,53 @@ export default {
},
}

const Default = () => (
<PortalProvider>
<PortalProvider.Portal>
<Message title="Hi!" text="I'm inside the portal" />
</PortalProvider.Portal>
</PortalProvider>
)
const PortalMessage = () => {
const portalRef = usePortalRef()
if (!portalRef) return null
const content = <Message text="I'm inside a portal using the usePortalref hook in a custom component." />
return createPortal(content, portalRef)
}

const PortalRefContent = () => {
let portalRef = usePortalRef()
const Template = ({ children, ...args }) => <PortalProvider {...args}>{children}</PortalProvider>

return (
<>
{portalRef &&
ReactDOM.createPortal(
<CodeBlock>
{`
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) }
)
}`}
</CodeBlock>,
portalRef
)}
</>
)
export const WithPortalComponent = {
render: Template,
args: {
children: (
<PortalProvider.Portal>
<Message text="I'm inside a portal using the Portal component as provided by PortalProvider." />
</PortalProvider.Portal>
),
},
}

export const WithHook = {
render: Template,
args: {
children: (
<>
<span> Some non-portalled content</span>
<PortalMessage />
</>
),
},
}
/**
* PortalRef
*/
const PortalRef = () => (
<PortalProvider>
<PortalRefContent />
</PortalProvider>
)

/** The PortalProvider is the parent for all portals of a Juno app. */
export { Default as PortalComponent, PortalRef }
export const MultiplePortals = {
render: Template,
args: {
children: (
<>
<div>Some non-portaled content.</div>
<PortalProvider.Portal>
<Message text="I'm inside a portal using the Portal component as provided by PortalProvider." />
</PortalProvider.Portal>
<PortalMessage />
</>
),
},
}
Loading
Loading