Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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<{
Comment thread
francinelucca marked this conversation as resolved.
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.`,
Comment thread
francinelucca marked this conversation as resolved.
)
}
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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