diff --git a/.changeset/cuddly-ads-behave.md b/.changeset/cuddly-ads-behave.md new file mode 100644 index 00000000000..5c3b6f07ecf --- /dev/null +++ b/.changeset/cuddly-ads-behave.md @@ -0,0 +1,5 @@ +--- +"@primer/react": minor +--- + +chore: add PortalContext diff --git a/packages/react/src/Portal/Portal.features.stories.tsx b/packages/react/src/Portal/Portal.features.stories.tsx index 0dfb807cc7a..4a91b0cad4d 100644 --- a/packages/react/src/Portal/Portal.features.stories.tsx +++ b/packages/react/src/Portal/Portal.features.stories.tsx @@ -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' @@ -81,3 +81,70 @@ export const MultiplePortalRoots: React.FC ) } + +export const WithPortalContext = () => { + const customContainerRef = React.useRef(null) + const overrideContainerRef = React.useRef(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 ( + <> +
+

Using PortalContext

+

This story demonstrates how to use PortalContext to control where Portal content is rendered.

+ + {/* Default Portal */} +
+ Default Portal (no context): + {mounted ? ( + +
Content in default portal
+
+ ) : null} +
+ + {/* Portal with Context */} +
+ Portal with PortalContext: + + {mounted ? ( + +
Content in custom portal (via PortalContext)
+
+ ) : null} +
+
+ + {/* Override context with containerName prop */} +
+ Context + containerName prop override: + + {mounted ? ( + +
Content overriding context with containerName prop
+
+ ) : null} +
+
+
+ + {/* Custom portal containers */} +
+ Custom Portal Container: +
+
+ +
+ Override Portal Container: +
+
+ + ) +} diff --git a/packages/react/src/Portal/Portal.stories.module.css b/packages/react/src/Portal/Portal.stories.module.css index 73850efb469..36163852772 100644 --- a/packages/react/src/Portal/Portal.stories.module.css +++ b/packages/react/src/Portal/Portal.stories.module.css @@ -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; +} diff --git a/packages/react/src/Portal/Portal.test.tsx b/packages/react/src/Portal/Portal.test.tsx index 7e6d64c3d05..7b4975b4616 100644 --- a/packages/react/src/Portal/Portal.test.tsx +++ b/packages/react/src/Portal/Portal.test.tsx @@ -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)', () => { @@ -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 = ( + + context-portal-content + + ) + + 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 = ( + + default-portal-content + + ) + + 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 = ( + + undefined-context-content + + ) + + 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 = ( + + prop-overrides-context + + ) + + 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) + }) }) diff --git a/packages/react/src/Portal/Portal.tsx b/packages/react/src/Portal/Portal.tsx index 896281a80dd..816b4c8437d 100644 --- a/packages/react/src/Portal/Portal.tsx +++ b/packages/react/src/Portal/Portal.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useContext} from 'react' import {createPortal} from 'react-dom' import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' @@ -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 @@ -66,6 +74,7 @@ export const Portal: React.FC> = ({ onMount, containerName: _containerName, }) => { + const {portalContainerName} = useContext(PortalContext) const elementRef = React.useRef(null) if (!elementRef.current) { const div = document.createElement('div') @@ -80,7 +89,7 @@ export const Portal: React.FC> = ({ const element = elementRef.current useLayoutEffect(() => { - let containerName = _containerName + let containerName = _containerName ?? portalContainerName if (containerName === undefined) { containerName = DEFAULT_PORTAL_CONTAINER_NAME ensureDefaultPortal() @@ -89,7 +98,7 @@ export const Portal: React.FC> = ({ 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) @@ -100,7 +109,7 @@ export const Portal: React.FC> = ({ } // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - }, [element]) + }, [element, _containerName, portalContainerName]) return createPortal(children, element) } diff --git a/packages/react/src/Portal/index.ts b/packages/react/src/Portal/index.ts index 485af9814ba..375dce81355 100644 --- a/packages/react/src/Portal/index.ts +++ b/packages/react/src/Portal/index.ts @@ -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} diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 7570eb50cdd..d34f9dce59c 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -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", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ad864dc1983..9578768eee8 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'