Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat(react-headless-components-preview): add Portal component and use it for non-modal DialogSurface",
"packageName": "@fluentui/react-headless-components-preview",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export type DialogSurfaceState = ComponentState<DialogSurfaceSlots> & {
unmountOnClose: boolean;
modalType: DialogModalType;
shouldRender: boolean;
mountNode?: HTMLElement;
};

// @public
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## API Report File for "@fluentui/react-headless-components-preview"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import type { JSXElement } from '@fluentui/react-utilities';
import type * as React_2 from 'react';

// @public
export const Portal: React_2.FC<PortalProps>;

// @public (undocumented)
export type PortalProps = {
children?: React_2.ReactNode;
mountNode?: HTMLElement | null;
};

// @public (undocumented)
export type PortalState = Pick<PortalProps, 'children'> & {
mountNode: HTMLElement | null | undefined;
anchorRef: React_2.MutableRefObject<HTMLSpanElement | null>;
};

// @public
export const renderPortal: (state: PortalState) => JSXElement;

// @public
export const usePortal: (props: PortalProps) => PortalState;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@
"import": "./lib/popover.js",
"require": "./lib-commonjs/popover.js"
},
"./portal": {
"types": "./dist/portal.d.ts",
"node": "./lib-commonjs/portal.js",
"import": "./lib/portal.js",
"require": "./lib-commonjs/portal.js"
},
"./positioning": {
"types": "./dist/positioning.d.ts",
"node": "./lib-commonjs/positioning.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ export type DialogSurfaceState = ComponentState<DialogSurfaceSlots> & {
* browser run its native close-the-dialog focus restoration.
*/
shouldRender: boolean;

/**
* The DOM element to render the dialog into when `modalType` is `non-modal`.
* Ignored for modal dialogs, which are always rendered in the browser top layer.
*/
mountNode?: HTMLElement;
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */

import { Portal } from '@fluentui/react-portal';
import { assertSlots } from '@fluentui/react-utilities';
import type { JSXElement } from '@fluentui/react-utilities';
import { Portal } from '../../Portal';
import { DialogSurfaceContext } from '../dialogContext';
import type { DialogSurfaceSlots, DialogSurfaceState } from './DialogSurface.types';

Expand All @@ -12,10 +12,10 @@ import type { DialogSurfaceSlots, DialogSurfaceState } from './DialogSurface.typ
* Returns null when the dialog is closed and unmountOnClose is true.
* Provides DialogSurfaceContext=true so DialogTrigger inside defaults to action="close".
*
* Non-modal dialogs are rendered via a React portal into `document.body`.
* Unlike `showModal()`, `dialog.show()` does not enter the browser top layer, so the
* element is still subject to ancestor `overflow`, `clip-path`, and `transform`
* stacking constraints. Portalling to body moves it outside any such container.
* Non-modal dialogs are wrapped in a `Portal` so they escape ancestor stacking
* constraints (`overflow`, `clip-path`, `transform`). Unlike `showModal()`,
* `dialog.show()` does not enter the browser top layer. Modal/alert dialogs use
* `showModal()` and need no portal — they live in the top layer.
* React context (including DialogContext) is preserved across portals.
*/
export const renderDialogSurface = (state: DialogSurfaceState): JSXElement | null => {
Expand All @@ -31,5 +31,9 @@ export const renderDialogSurface = (state: DialogSurfaceState): JSXElement | nul
</DialogSurfaceContext.Provider>
);

return state.modalType === 'non-modal' ? <Portal>{content}</Portal> : content;
if (state.modalType === 'non-modal') {
return <Portal mountNode={state.mountNode}>{content}</Portal>;
}

return content;
};
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref<HTMLD
open,
unmountOnClose,
modalType,
mountNode: targetDocument?.body ?? undefined,
shouldRender,
root: slot.always(
getIntrinsicElementProps('dialog', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { getParent } from '@fluentui/react-utilities';
import { isConformant } from '../../testing/isConformant';
import { Portal } from './Portal';

describe('Portal', () => {
isConformant({
Component: Portal,
displayName: 'Portal',
disabledTests: ['component-handles-classname', 'component-has-root-ref', 'component-handles-ref'],
});

it('renders children into document.body by default', () => {
const { getByTestId, baseElement } = render(
<Portal>
<div data-testid="portal-child">Content</div>
</Portal>,
);

const child = getByTestId('portal-child');
expect(child.parentElement).toBe(baseElement);
});

it('renders children into the provided mountNode', () => {
const TestComponent = () => {
const [el, setEl] = React.useState<HTMLDivElement | null>(null);
return (
<>
<div data-testid="mount" ref={setEl} />
{el && (
<Portal mountNode={el}>
<div data-testid="portal-child">Content</div>
</Portal>
)}
</>
);
};

const { getByTestId } = render(<TestComponent />);
expect(getByTestId('portal-child').parentElement).toBe(getByTestId('mount'));
});

it('renders a hidden anchor span at the original tree location', () => {
const { container } = render(
<div data-testid="parent">
<Portal>
<div>Content</div>
</Portal>
</div>,
);

const anchor = container.querySelector<HTMLSpanElement>('[data-testid="parent"] > span');
expect(anchor).not.toBeNull();
expect(anchor).toHaveAttribute('hidden');
});

it('links the portal mountNode to the anchor via setVirtualParent', () => {
const TestComponent = () => {
const [mountNode, setMountNode] = React.useState<HTMLDivElement | null>(null);
return (
<div data-testid="react-parent">
<Portal mountNode={mountNode}>
<div>Content</div>
</Portal>
<div data-testid="mount" ref={setMountNode} />
</div>
);
};

const { getByTestId } = render(<TestComponent />);
const anchor = getByTestId('react-parent').querySelector<HTMLSpanElement>(':scope > span');

// Virtual parent of the mountNode is the anchor — walking up from the
// mountNode reaches the anchor, then the React parent.
expect(getParent(getByTestId('mount'))).toBe(anchor);
});

it('does not link virtual parent when mountNode already contains the anchor', () => {
const TestComponent = () => {
const [el, setEl] = React.useState<HTMLDivElement | null>(null);
return (
<div data-testid="virtual-parent-cycle">
<div data-testid="mount-container" ref={setEl}>
{el && (
<Portal mountNode={el}>
<div>Content</div>
</Portal>
)}
</div>
</div>
);
};

const { getByTestId } = render(<TestComponent />);
// mountNode contains the anchor span, so linking would create a parent cycle.
// We expect no virtual parent to be set; getParent falls back to the real DOM parent.
expect(getParent(getByTestId('mount-container'))).toBe(getByTestId('virtual-parent-cycle'));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import type * as React from 'react';

import { usePortal } from './usePortal';
import { renderPortal } from './renderPortal';
import type { PortalProps } from './Portal.types';

/**
* Portal renders its children into a DOM node that exists outside the DOM
* hierarchy of the parent component.
*
* Defaults to mounting on `targetDocument.body` from the parent Fluent context.
* Links the portalled subtree to its React parent via `setVirtualParent` so
* DOM-walking utilities (click-outside, focus traps, `elementContains`) keep
* working across the portal boundary.
*/
export const Portal: React.FC<PortalProps> = props => {
const state = usePortal(props);
return renderPortal(state);
};

Portal.displayName = 'Portal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type * as React from 'react';

export type PortalProps = {
/**
* Content to render into the portal mount node.
*/
children?: React.ReactNode;

/**
* The DOM element to mount portal content into.
*
* When omitted, defaults to `targetDocument.body` from the parent Fluent context,
* which makes the portal escape ancestor stacking constraints (`overflow`,
* `clip-path`, `transform`) on the original tree location.
*/
mountNode?: HTMLElement | null;
};

export type PortalState = Pick<PortalProps, 'children'> & {
/**
* Resolved mount node — either the user-provided `mountNode` or
* `targetDocument.body` from Fluent context. `undefined` on the server
* (or before hydration completes) so the render stays SSR-safe.
*/
mountNode: HTMLElement | null | undefined;

/**
* Ref to the anchor `<span hidden>` rendered at the original tree location.
*
* Used to link the mount node to its React parent via `setVirtualParent`,
* so utilities that walk the DOM (click-outside detection, focus traps,
* `elementContains`) treat the portalled subtree as logically nested under
* its React parent.
*/
// eslint-disable-next-line @typescript-eslint/no-deprecated
anchorRef: React.MutableRefObject<HTMLSpanElement | null>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { Portal } from './Portal';
export type { PortalProps, PortalState } from './Portal.types';
export { renderPortal } from './renderPortal';
export { usePortal } from './usePortal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import type { JSXElement } from '@fluentui/react-utilities';

import type { PortalState } from './Portal.types';

/**
* Render the final JSX of Portal.
*
* Renders a stable `<span hidden>` anchor at the original tree location and,
* when a `mountNode` is available, portals `children` into it.
*
* SSR safety: on the server `mountNode` is `undefined`, so only the anchor
* is rendered. On hydration the anchor still renders identically (the portal
* content lives at a different DOM location, which React doesn't compare),
* so no hydration mismatch is possible.
*/
export const renderPortal = (state: PortalState): JSXElement => {
return (
<span hidden ref={state.anchorRef}>
{state.mountNode && ReactDOM.createPortal(state.children, state.mountNode)}
</span>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';

import * as React from 'react';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
import { setVirtualParent } from '@fluentui/react-utilities';

import type { PortalProps, PortalState } from './Portal.types';

/**
* Create the state required to render Portal.
*
* Resolves the mount node (user-provided `mountNode` falls back to
* `targetDocument.body` from Fluent context) and links the anchor span at the
* original tree location to the mount node via `setVirtualParent`.
*
* The virtual parent link is what allows DOM-walking utilities (click-outside,
* focus traps, `elementContains`) to treat the portalled subtree as logically
* nested under its React parent — without it, the portal is detached from the
* tree-position perspective.
*/
export const usePortal = (props: PortalProps): PortalState => {
const { children, mountNode: mountNodeProp } = props;

const { targetDocument } = useFluent();
const anchorRef = React.useRef<HTMLSpanElement>(null);

const mountNode = mountNodeProp ?? targetDocument?.body;

React.useEffect(() => {
const anchor = anchorRef.current;
if (!mountNode || !anchor) {
return;
}

// Skip linking when the anchor already lives inside the mount node — happens
// when a consumer passes a custom mountNode that is itself a descendant of
// the React parent. Linking would create a parent cycle.
if (mountNode.contains(anchor)) {
return;
}

setVirtualParent(mountNode, anchor);

return () => {
setVirtualParent(mountNode, undefined);
};
}, [mountNode]);

return {
children,
mountNode,
anchorRef,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Portal, renderPortal, usePortal } from './components/Portal/index';
export type { PortalProps, PortalState } from './components/Portal/index';
Loading