diff --git a/change/@fluentui-react-headless-components-preview-f9577e9e-d815-4074-9b9d-0853bf452276.json b/change/@fluentui-react-headless-components-preview-f9577e9e-d815-4074-9b9d-0853bf452276.json new file mode 100644 index 00000000000000..f7db6d7795ca9a --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-f9577e9e-d815-4074-9b9d-0853bf452276.json @@ -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" +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md b/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md index a247dfd61f5487..1ca9d942b9920d 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/dialog.api.md @@ -143,6 +143,7 @@ export type DialogSurfaceState = ComponentState & { unmountOnClose: boolean; modalType: DialogModalType; shouldRender: boolean; + mountNode?: HTMLElement; }; // @public diff --git a/packages/react-components/react-headless-components-preview/library/etc/portal.api.md b/packages/react-components/react-headless-components-preview/library/etc/portal.api.md new file mode 100644 index 00000000000000..963cf4a28a7fd3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/portal.api.md @@ -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; + +// @public (undocumented) +export type PortalProps = { + children?: React_2.ReactNode; + mountNode?: HTMLElement | null; +}; + +// @public (undocumented) +export type PortalState = Pick & { + mountNode: HTMLElement | null | undefined; + anchorRef: React_2.MutableRefObject; +}; + +// @public +export const renderPortal: (state: PortalState) => JSXElement; + +// @public +export const usePortal: (props: PortalProps) => PortalState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index bec5878905549f..a8454e710dacb8 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -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", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts index d9d4f400ef6179..24f6db6b4326dd 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/DialogSurface.types.ts @@ -44,4 +44,10 @@ export type DialogSurfaceState = ComponentState & { * 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; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx index 1f81afb1b5a30a..94ee4b5566c8a8 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/renderDialogSurface.tsx @@ -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'; @@ -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 => { @@ -31,5 +31,9 @@ export const renderDialogSurface = (state: DialogSurfaceState): JSXElement | nul ); - return state.modalType === 'non-modal' ? {content} : content; + if (state.modalType === 'non-modal') { + return {content}; + } + + return content; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts index 4335bb33a1ec12..8bedd6d43dcab0 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/DialogSurface/useDialogSurface.ts @@ -145,6 +145,7 @@ export const useDialogSurface = (props: DialogSurfaceProps, ref: React.Ref { + 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( + +
Content
+
, + ); + + const child = getByTestId('portal-child'); + expect(child.parentElement).toBe(baseElement); + }); + + it('renders children into the provided mountNode', () => { + const TestComponent = () => { + const [el, setEl] = React.useState(null); + return ( + <> +
+ {el && ( + +
Content
+
+ )} + + ); + }; + + const { getByTestId } = render(); + expect(getByTestId('portal-child').parentElement).toBe(getByTestId('mount')); + }); + + it('renders a hidden anchor span at the original tree location', () => { + const { container } = render( +
+ +
Content
+
+
, + ); + + const anchor = container.querySelector('[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(null); + return ( +
+ +
Content
+
+
+
+ ); + }; + + const { getByTestId } = render(); + const anchor = getByTestId('react-parent').querySelector(':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(null); + return ( +
+
+ {el && ( + +
Content
+
+ )} +
+
+ ); + }; + + const { getByTestId } = render(); + // 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')); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Portal/Portal.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Portal/Portal.tsx new file mode 100644 index 00000000000000..dcf9653c6c78bd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Portal/Portal.tsx @@ -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 = props => { + const state = usePortal(props); + return renderPortal(state); +}; + +Portal.displayName = 'Portal'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Portal/Portal.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Portal/Portal.types.ts new file mode 100644 index 00000000000000..669d45ba1275e7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Portal/Portal.types.ts @@ -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 & { + /** + * 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 `