Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/cuddly-ads-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

chore: add PortalContext
71 changes: 69 additions & 2 deletions packages/react/src/Portal/Portal.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import React, {useEffect} from 'react'
import type {Meta} from '@storybook/react-vite'
import {Portal, registerPortalRoot} from './Portal'
import {Portal, PortalContext, registerPortalRoot} from './Portal'
import classes from './Portal.stories.module.css'
import {clsx} from 'clsx'

Expand Down Expand Up @@ -81,3 +81,70 @@ export const MultiplePortalRoots: React.FC<React.PropsWithChildren<Record<string
</>
)
}

export const WithPortalContext = () => {
const customContainerRef = React.useRef<HTMLDivElement>(null)
const overrideContainerRef = React.useRef<HTMLDivElement>(null)
const [mounted, setMounted] = React.useState(false)
useEffect(() => {
if (customContainerRef.current instanceof HTMLElement && overrideContainerRef.current instanceof HTMLElement) {
registerPortalRoot(customContainerRef.current, 'custom-portal')
registerPortalRoot(overrideContainerRef.current, 'override-portal')
setMounted(true)
}
}, [])

return (
<>
<div className={clsx(classes.PortalContainer, classes.OuterContainer)}>
<h3>Using PortalContext</h3>
<p>This story demonstrates how to use PortalContext to control where Portal content is rendered.</p>

{/* Default Portal */}
<div className={clsx(classes.PortalContainer, classes.DefaultSection)}>
<strong>Default Portal (no context):</strong>
{mounted ? (
<Portal>
<div className={classes.DefaultPortalContent}>Content in default portal</div>
</Portal>
) : null}
</div>

{/* Portal with Context */}
<div className={clsx(classes.PortalContainer, classes.ContextSection)}>
<strong>Portal with PortalContext:</strong>
<PortalContext.Provider value={{portalContainerName: 'custom-portal'}}>
{mounted ? (
<Portal>
<div className={classes.ContextPortalContent}>Content in custom portal (via PortalContext)</div>
</Portal>
) : null}
</PortalContext.Provider>
</div>

{/* Override context with containerName prop */}
<div className={clsx(classes.PortalContainer, classes.OverrideSection)}>
<strong>Context + containerName prop override:</strong>
<PortalContext.Provider value={{portalContainerName: 'custom-portal'}}>
{mounted ? (
<Portal containerName="override-portal">
<div className={classes.OverridePortalContent}>Content overriding context with containerName prop</div>
</Portal>
) : null}
</PortalContext.Provider>
</div>
</div>

{/* Custom portal containers */}
<div className={classes.CustomPortalContainer}>
<strong>Custom Portal Container:</strong>
<div ref={customContainerRef} />
</div>

<div className={classes.OverridePortalContainer}>
<strong>Override Portal Container:</strong>
<div ref={overrideContainerRef} />
</div>
</>
)
}
62 changes: 62 additions & 0 deletions packages/react/src/Portal/Portal.stories.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,65 @@
.InnerContainer {
background-color: var(--bgColor-success-muted);
}

/* Portal Context Story Styles */
.DefaultSection {
background-color: var(--bgColor-accent-muted);
margin: var(--base-size-8);
padding: var(--base-size-8);
}

.ContextSection {
background-color: var(--bgColor-attention-muted);
margin: var(--base-size-8);
padding: var(--base-size-8);
}

.OverrideSection {
background-color: var(--bgColor-success-muted);
margin: var(--base-size-8);
padding: var(--base-size-8);
}

.DefaultPortalContent {
background-color: var(--bgColor-accent-emphasis);
padding: var(--base-size-4);
border: var(--borderWidth-thin) solid var(--borderColor-accent-emphasis);
color: var(--fgColor-onEmphasis);
}

.ContextPortalContent {
background-color: var(--bgColor-attention-emphasis);
padding: var(--base-size-4);
border: var(--borderWidth-thin) solid var(--borderColor-attention-emphasis);
color: var(--fgColor-onEmphasis);
}

.OverridePortalContent {
background-color: var(--bgColor-success-emphasis);
padding: var(--base-size-4);
border: var(--borderWidth-thin) solid var(--borderColor-success-emphasis);
color: var(--fgColor-onEmphasis);
}

.CustomPortalContainer {
position: fixed;
bottom: var(--base-size-8);
left: var(--base-size-8);
background-color: var(--bgColor-neutral-muted);
padding: var(--base-size-8);
border: var(--borderWidth-thick) solid var(--borderColor-attention-emphasis);
border-radius: var(--borderRadius-medium);
max-width: 200px;
}

.OverridePortalContainer {
position: fixed;
bottom: var(--base-size-8);
right: var(--base-size-8);
background-color: var(--bgColor-neutral-muted);
padding: var(--base-size-8);
border: var(--borderWidth-thick) solid var(--borderColor-success-emphasis);
border-radius: var(--borderRadius-medium);
max-width: 200px;
}
85 changes: 84 additions & 1 deletion packages/react/src/Portal/Portal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {describe, expect, it} from 'vitest'
import Portal, {registerPortalRoot} from '../Portal/index'
import Portal, {registerPortalRoot, PortalContext} from '../Portal/index'

import {render} from '@testing-library/react'
import BaseStyles from '../BaseStyles'
import React from 'react'

describe('Portal', () => {
it('renders a default portal into document.body (no BaseStyles present)', () => {
Expand Down Expand Up @@ -100,4 +101,86 @@ describe('Portal', () => {

baseElement.innerHTML = ''
})

it('renders into custom portal when PortalContext is supplied with portalContainerName', () => {
// Create and register a custom portal root
const customPortalRoot = document.createElement('div')
customPortalRoot.id = 'customContextPortal'
document.body.appendChild(customPortalRoot)
registerPortalRoot(customPortalRoot, 'customContext')

const toRender = (
<PortalContext.Provider value={{portalContainerName: 'customContext'}}>
<Portal>context-portal-content</Portal>
</PortalContext.Provider>
)

render(toRender)

expect(customPortalRoot.textContent.trim()).toEqual('context-portal-content')

// Cleanup
document.body.removeChild(customPortalRoot)
})

it('renders into default portal when PortalContext does not specify portalContainerName', () => {
const toRender = (
<PortalContext.Provider value={{}}>
<Portal>default-portal-content</Portal>
</PortalContext.Provider>
)

const {baseElement} = render(toRender)
const generatedRoot = baseElement.querySelector('#__primerPortalRoot__')

expect(generatedRoot).toBeInstanceOf(HTMLElement)
expect(generatedRoot?.textContent.trim()).toEqual('default-portal-content')

baseElement.innerHTML = ''
})

it('renders into default portal when PortalContext portalContainerName is undefined', () => {
const toRender = (
<PortalContext.Provider value={{portalContainerName: undefined}}>
<Portal>undefined-context-content</Portal>
</PortalContext.Provider>
)

const {baseElement} = render(toRender)
const generatedRoot = baseElement.querySelector('#__primerPortalRoot__')

expect(generatedRoot).toBeInstanceOf(HTMLElement)
expect(generatedRoot?.textContent.trim()).toEqual('undefined-context-content')

baseElement.innerHTML = ''
})

it('containerName prop overrides PortalContext portalContainerName', () => {
// Create and register custom portal roots
const contextPortalRoot = document.createElement('div')
contextPortalRoot.id = 'contextPortal'
document.body.appendChild(contextPortalRoot)
registerPortalRoot(contextPortalRoot, 'contextPortal')

const propPortalRoot = document.createElement('div')
propPortalRoot.id = 'propPortal'
document.body.appendChild(propPortalRoot)
registerPortalRoot(propPortalRoot, 'propPortal')

const toRender = (
<PortalContext.Provider value={{portalContainerName: 'contextPortal'}}>
<Portal containerName="propPortal">prop-overrides-context</Portal>
</PortalContext.Provider>
)

render(toRender)

// Should render in the portal specified by the prop, not the context
expect(propPortalRoot.textContent.trim()).toEqual('prop-overrides-context')
expect(contextPortalRoot.textContent.trim()).toEqual('')

// Cleanup
document.body.removeChild(contextPortalRoot)
document.body.removeChild(propPortalRoot)
})
})
17 changes: 13 additions & 4 deletions packages/react/src/Portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, {useContext} from 'react'
import {createPortal} from 'react-dom'
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'

Expand Down Expand Up @@ -43,6 +43,14 @@ function ensureDefaultPortal() {
}
}

/**
* Provides the ability for component trees to override the portal root container for a sub-set of the experience.
* The portal will prioritize the context value unless overridden by their own `containerName` prop, and fallback to the default root if neither are specified
*/
export const PortalContext = React.createContext<{
portalContainerName?: string
}>({})

export interface PortalProps {
/**
* Called when this portal is added to the DOM
Expand All @@ -66,6 +74,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
onMount,
containerName: _containerName,
}) => {
const {portalContainerName} = useContext(PortalContext)
const elementRef = React.useRef<HTMLDivElement | null>(null)
if (!elementRef.current) {
const div = document.createElement('div')
Expand All @@ -80,7 +89,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
const element = elementRef.current

useLayoutEffect(() => {
let containerName = _containerName
let containerName = _containerName ?? portalContainerName
if (containerName === undefined) {
containerName = DEFAULT_PORTAL_CONTAINER_NAME
ensureDefaultPortal()
Expand All @@ -89,7 +98,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({

if (!parentElement) {
throw new Error(
`Portal container '${_containerName}' is not yet registered. Container must be registered with registerPortal before use.`,
`Portal container '${containerName}' is not yet registered. Container must be registered with registerPortalRoot before use.`,
)
}
parentElement.appendChild(element)
Expand All @@ -100,7 +109,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
}
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element])
}, [element, _containerName, portalContainerName])

return createPortal(children, element)
}
3 changes: 2 additions & 1 deletion packages/react/src/Portal/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {PortalProps} from './Portal'
import {Portal, registerPortalRoot} from './Portal'
import {Portal, registerPortalRoot, PortalContext} from './Portal'

export default Portal
export {registerPortalRoot}
export type {PortalProps}
export {PortalContext}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"type PopoverContentProps",
"type PopoverProps",
"Portal",
"PortalContext",
"type PortalProps",
"ProgressBar",
"type ProgressBarItemProps",
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export {default as PointerBox} from './PointerBox'
export type {PointerBoxProps} from './PointerBox'
export {default as Popover} from './Popover'
export type {PopoverProps, PopoverContentProps} from './Popover'
export {default as Portal, registerPortalRoot} from './Portal'
export {default as Portal, registerPortalRoot, PortalContext} from './Portal'
export type {PortalProps} from './Portal'
export {ProgressBar} from './ProgressBar'
export type {ProgressBarProps, ProgressBarItemProps} from './ProgressBar'
Expand Down
Loading