Skip to content

Commit 2024709

Browse files
francineluccaCopilotCopilotadierkens
authored
chore: add portal context (#6815)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Adam Dierkens <adierkens@users.noreply.github.com>
1 parent 4d080aa commit 2024709

File tree

8 files changed

+237
-9
lines changed

8 files changed

+237
-9
lines changed

.changeset/cuddly-ads-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
chore: add PortalContext

packages/react/src/Portal/Portal.features.stories.tsx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import React from 'react'
1+
import React, {useEffect} from 'react'
22
import type {Meta} from '@storybook/react-vite'
3-
import {Portal, registerPortalRoot} from './Portal'
3+
import {Portal, PortalContext, registerPortalRoot} from './Portal'
44
import classes from './Portal.stories.module.css'
55
import {clsx} from 'clsx'
66

@@ -81,3 +81,70 @@ export const MultiplePortalRoots: React.FC<React.PropsWithChildren<Record<string
8181
</>
8282
)
8383
}
84+
85+
export const WithPortalContext = () => {
86+
const customContainerRef = React.useRef<HTMLDivElement>(null)
87+
const overrideContainerRef = React.useRef<HTMLDivElement>(null)
88+
const [mounted, setMounted] = React.useState(false)
89+
useEffect(() => {
90+
if (customContainerRef.current instanceof HTMLElement && overrideContainerRef.current instanceof HTMLElement) {
91+
registerPortalRoot(customContainerRef.current, 'custom-portal')
92+
registerPortalRoot(overrideContainerRef.current, 'override-portal')
93+
setMounted(true)
94+
}
95+
}, [])
96+
97+
return (
98+
<>
99+
<div className={clsx(classes.PortalContainer, classes.OuterContainer)}>
100+
<h3>Using PortalContext</h3>
101+
<p>This story demonstrates how to use PortalContext to control where Portal content is rendered.</p>
102+
103+
{/* Default Portal */}
104+
<div className={clsx(classes.PortalContainer, classes.DefaultSection)}>
105+
<strong>Default Portal (no context):</strong>
106+
{mounted ? (
107+
<Portal>
108+
<div className={classes.DefaultPortalContent}>Content in default portal</div>
109+
</Portal>
110+
) : null}
111+
</div>
112+
113+
{/* Portal with Context */}
114+
<div className={clsx(classes.PortalContainer, classes.ContextSection)}>
115+
<strong>Portal with PortalContext:</strong>
116+
<PortalContext.Provider value={{portalContainerName: 'custom-portal'}}>
117+
{mounted ? (
118+
<Portal>
119+
<div className={classes.ContextPortalContent}>Content in custom portal (via PortalContext)</div>
120+
</Portal>
121+
) : null}
122+
</PortalContext.Provider>
123+
</div>
124+
125+
{/* Override context with containerName prop */}
126+
<div className={clsx(classes.PortalContainer, classes.OverrideSection)}>
127+
<strong>Context + containerName prop override:</strong>
128+
<PortalContext.Provider value={{portalContainerName: 'custom-portal'}}>
129+
{mounted ? (
130+
<Portal containerName="override-portal">
131+
<div className={classes.OverridePortalContent}>Content overriding context with containerName prop</div>
132+
</Portal>
133+
) : null}
134+
</PortalContext.Provider>
135+
</div>
136+
</div>
137+
138+
{/* Custom portal containers */}
139+
<div className={classes.CustomPortalContainer}>
140+
<strong>Custom Portal Container:</strong>
141+
<div ref={customContainerRef} />
142+
</div>
143+
144+
<div className={classes.OverridePortalContainer}>
145+
<strong>Override Portal Container:</strong>
146+
<div ref={overrideContainerRef} />
147+
</div>
148+
</>
149+
)
150+
}

packages/react/src/Portal/Portal.stories.module.css

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,65 @@
99
.InnerContainer {
1010
background-color: var(--bgColor-success-muted);
1111
}
12+
13+
/* Portal Context Story Styles */
14+
.DefaultSection {
15+
background-color: var(--bgColor-accent-muted);
16+
margin: var(--base-size-8);
17+
padding: var(--base-size-8);
18+
}
19+
20+
.ContextSection {
21+
background-color: var(--bgColor-attention-muted);
22+
margin: var(--base-size-8);
23+
padding: var(--base-size-8);
24+
}
25+
26+
.OverrideSection {
27+
background-color: var(--bgColor-success-muted);
28+
margin: var(--base-size-8);
29+
padding: var(--base-size-8);
30+
}
31+
32+
.DefaultPortalContent {
33+
background-color: var(--bgColor-accent-emphasis);
34+
padding: var(--base-size-4);
35+
border: var(--borderWidth-thin) solid var(--borderColor-accent-emphasis);
36+
color: var(--fgColor-onEmphasis);
37+
}
38+
39+
.ContextPortalContent {
40+
background-color: var(--bgColor-attention-emphasis);
41+
padding: var(--base-size-4);
42+
border: var(--borderWidth-thin) solid var(--borderColor-attention-emphasis);
43+
color: var(--fgColor-onEmphasis);
44+
}
45+
46+
.OverridePortalContent {
47+
background-color: var(--bgColor-success-emphasis);
48+
padding: var(--base-size-4);
49+
border: var(--borderWidth-thin) solid var(--borderColor-success-emphasis);
50+
color: var(--fgColor-onEmphasis);
51+
}
52+
53+
.CustomPortalContainer {
54+
position: fixed;
55+
bottom: var(--base-size-8);
56+
left: var(--base-size-8);
57+
background-color: var(--bgColor-neutral-muted);
58+
padding: var(--base-size-8);
59+
border: var(--borderWidth-thick) solid var(--borderColor-attention-emphasis);
60+
border-radius: var(--borderRadius-medium);
61+
max-width: 200px;
62+
}
63+
64+
.OverridePortalContainer {
65+
position: fixed;
66+
bottom: var(--base-size-8);
67+
right: var(--base-size-8);
68+
background-color: var(--bgColor-neutral-muted);
69+
padding: var(--base-size-8);
70+
border: var(--borderWidth-thick) solid var(--borderColor-success-emphasis);
71+
border-radius: var(--borderRadius-medium);
72+
max-width: 200px;
73+
}

packages/react/src/Portal/Portal.test.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {describe, expect, it} from 'vitest'
2-
import Portal, {registerPortalRoot} from '../Portal/index'
2+
import Portal, {registerPortalRoot, PortalContext} from '../Portal/index'
33

44
import {render} from '@testing-library/react'
55
import BaseStyles from '../BaseStyles'
6+
import React from 'react'
67

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

101102
baseElement.innerHTML = ''
102103
})
104+
105+
it('renders into custom portal when PortalContext is supplied with portalContainerName', () => {
106+
// Create and register a custom portal root
107+
const customPortalRoot = document.createElement('div')
108+
customPortalRoot.id = 'customContextPortal'
109+
document.body.appendChild(customPortalRoot)
110+
registerPortalRoot(customPortalRoot, 'customContext')
111+
112+
const toRender = (
113+
<PortalContext.Provider value={{portalContainerName: 'customContext'}}>
114+
<Portal>context-portal-content</Portal>
115+
</PortalContext.Provider>
116+
)
117+
118+
render(toRender)
119+
120+
expect(customPortalRoot.textContent.trim()).toEqual('context-portal-content')
121+
122+
// Cleanup
123+
document.body.removeChild(customPortalRoot)
124+
})
125+
126+
it('renders into default portal when PortalContext does not specify portalContainerName', () => {
127+
const toRender = (
128+
<PortalContext.Provider value={{}}>
129+
<Portal>default-portal-content</Portal>
130+
</PortalContext.Provider>
131+
)
132+
133+
const {baseElement} = render(toRender)
134+
const generatedRoot = baseElement.querySelector('#__primerPortalRoot__')
135+
136+
expect(generatedRoot).toBeInstanceOf(HTMLElement)
137+
expect(generatedRoot?.textContent.trim()).toEqual('default-portal-content')
138+
139+
baseElement.innerHTML = ''
140+
})
141+
142+
it('renders into default portal when PortalContext portalContainerName is undefined', () => {
143+
const toRender = (
144+
<PortalContext.Provider value={{portalContainerName: undefined}}>
145+
<Portal>undefined-context-content</Portal>
146+
</PortalContext.Provider>
147+
)
148+
149+
const {baseElement} = render(toRender)
150+
const generatedRoot = baseElement.querySelector('#__primerPortalRoot__')
151+
152+
expect(generatedRoot).toBeInstanceOf(HTMLElement)
153+
expect(generatedRoot?.textContent.trim()).toEqual('undefined-context-content')
154+
155+
baseElement.innerHTML = ''
156+
})
157+
158+
it('containerName prop overrides PortalContext portalContainerName', () => {
159+
// Create and register custom portal roots
160+
const contextPortalRoot = document.createElement('div')
161+
contextPortalRoot.id = 'contextPortal'
162+
document.body.appendChild(contextPortalRoot)
163+
registerPortalRoot(contextPortalRoot, 'contextPortal')
164+
165+
const propPortalRoot = document.createElement('div')
166+
propPortalRoot.id = 'propPortal'
167+
document.body.appendChild(propPortalRoot)
168+
registerPortalRoot(propPortalRoot, 'propPortal')
169+
170+
const toRender = (
171+
<PortalContext.Provider value={{portalContainerName: 'contextPortal'}}>
172+
<Portal containerName="propPortal">prop-overrides-context</Portal>
173+
</PortalContext.Provider>
174+
)
175+
176+
render(toRender)
177+
178+
// Should render in the portal specified by the prop, not the context
179+
expect(propPortalRoot.textContent.trim()).toEqual('prop-overrides-context')
180+
expect(contextPortalRoot.textContent.trim()).toEqual('')
181+
182+
// Cleanup
183+
document.body.removeChild(contextPortalRoot)
184+
document.body.removeChild(propPortalRoot)
185+
})
103186
})

packages/react/src/Portal/Portal.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, {useContext} from 'react'
22
import {createPortal} from 'react-dom'
33
import useLayoutEffect from '../utils/useIsomorphicLayoutEffect'
44

@@ -43,6 +43,14 @@ function ensureDefaultPortal() {
4343
}
4444
}
4545

46+
/**
47+
* Provides the ability for component trees to override the portal root container for a sub-set of the experience.
48+
* The portal will prioritize the context value unless overridden by their own `containerName` prop, and fallback to the default root if neither are specified
49+
*/
50+
export const PortalContext = React.createContext<{
51+
portalContainerName?: string
52+
}>({})
53+
4654
export interface PortalProps {
4755
/**
4856
* Called when this portal is added to the DOM
@@ -66,6 +74,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
6674
onMount,
6775
containerName: _containerName,
6876
}) => {
77+
const {portalContainerName} = useContext(PortalContext)
6978
const elementRef = React.useRef<HTMLDivElement | null>(null)
7079
if (!elementRef.current) {
7180
const div = document.createElement('div')
@@ -80,7 +89,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
8089
const element = elementRef.current
8190

8291
useLayoutEffect(() => {
83-
let containerName = _containerName
92+
let containerName = _containerName ?? portalContainerName
8493
if (containerName === undefined) {
8594
containerName = DEFAULT_PORTAL_CONTAINER_NAME
8695
ensureDefaultPortal()
@@ -89,7 +98,7 @@ export const Portal: React.FC<React.PropsWithChildren<PortalProps>> = ({
8998

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

105114
return createPortal(children, element)
106115
}

packages/react/src/Portal/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {PortalProps} from './Portal'
2-
import {Portal, registerPortalRoot} from './Portal'
2+
import {Portal, registerPortalRoot, PortalContext} from './Portal'
33

44
export default Portal
55
export {registerPortalRoot}
66
export type {PortalProps}
7+
export {PortalContext}

packages/react/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
118118
"type PopoverContentProps",
119119
"type PopoverProps",
120120
"Portal",
121+
"PortalContext",
121122
"type PortalProps",
122123
"ProgressBar",
123124
"type ProgressBarItemProps",

packages/react/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export {default as PointerBox} from './PointerBox'
126126
export type {PointerBoxProps} from './PointerBox'
127127
export {default as Popover} from './Popover'
128128
export type {PopoverProps, PopoverContentProps} from './Popover'
129-
export {default as Portal, registerPortalRoot} from './Portal'
129+
export {default as Portal, registerPortalRoot, PortalContext} from './Portal'
130130
export type {PortalProps} from './Portal'
131131
export {ProgressBar} from './ProgressBar'
132132
export type {ProgressBarProps, ProgressBarItemProps} from './ProgressBar'

0 commit comments

Comments
 (0)