Skip to content

Commit 119e446

Browse files
committed
feat: accept a className in mountNode in Portal
1 parent 533fcd3 commit 119e446

7 files changed

Lines changed: 110 additions & 21 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: accept a className in mountNode in Portal",
4+
"packageName": "@fluentui/react-portal",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-portal/etc/react-portal.api.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ export const Portal: React_2.FC<PortalProps>;
1515
// @public (undocumented)
1616
export type PortalProps = {
1717
children?: React_2.ReactNode;
18-
mountNode?: HTMLElement | null;
18+
mountNode?: HTMLElement | null | {
19+
element?: HTMLElement | null;
20+
className?: string;
21+
};
1922
};
2023

2124
// @public (undocumented)
22-
export type PortalState = Pick<PortalProps, 'children'> & Required<Pick<PortalProps, 'mountNode'>> & {
25+
export type PortalState = Pick<PortalProps, 'children'> & {
26+
mountNode: HTMLElement | null | undefined;
2327
virtualParentRootRef: React_2.MutableRefObject<HTMLSpanElement | null>;
2428
};
2529

packages/react-components/react-portal/src/components/Portal/Portal.types.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ export type PortalProps = {
1111
*
1212
* @default a new element on document.body without any styling
1313
*/
14-
mountNode?: HTMLElement | null;
14+
mountNode?: HTMLElement | null | { element?: HTMLElement | null; className?: string };
1515
};
1616

17-
export type PortalState = Pick<PortalProps, 'children'> &
18-
Required<Pick<PortalProps, 'mountNode'>> & {
19-
/**
20-
* Ref to the root span element as virtual parent
21-
*/
22-
virtualParentRootRef: React.MutableRefObject<HTMLSpanElement | null>;
23-
};
17+
export type PortalState = Pick<PortalProps, 'children'> & {
18+
mountNode: HTMLElement | null | undefined;
19+
20+
/**
21+
* Ref to the root span element as virtual parent
22+
*/
23+
virtualParentRootRef: React.MutableRefObject<HTMLSpanElement | null>;
24+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { toMountNodeProps } from './usePortal';
2+
3+
describe('toMountNodeProps', () => {
4+
it('handles HTMLElement', () => {
5+
const element = document.createElement('div');
6+
7+
expect(toMountNodeProps(element)).toMatchObject({
8+
element,
9+
});
10+
});
11+
12+
it('handles "null"', () => {
13+
expect(toMountNodeProps(null)).toMatchObject({
14+
element: null,
15+
});
16+
});
17+
18+
it('handles "undefined"', () => {
19+
expect(toMountNodeProps(null)).toMatchObject({});
20+
});
21+
22+
it('handles objects', () => {
23+
const element = document.createElement('div');
24+
25+
expect(toMountNodeProps({ element })).toMatchObject({
26+
element,
27+
});
28+
expect(toMountNodeProps({ element, className: 'foo' })).toMatchObject({
29+
element,
30+
className: 'foo',
31+
});
32+
});
33+
});

packages/react-components/react-portal/src/components/Portal/usePortal.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1+
import { isHTMLElement } from '@fluentui/react-utilities';
12
import * as React from 'react';
2-
import { usePortalMountNode } from './usePortalMountNode';
3+
34
import { setVirtualParent } from '../../virtualParent/index';
5+
import { usePortalMountNode } from './usePortalMountNode';
46
import type { PortalProps, PortalState } from './Portal.types';
57

8+
export function toMountNodeProps(mountNode: PortalProps['mountNode']): {
9+
element?: HTMLElement | null;
10+
className?: string;
11+
} {
12+
if (isHTMLElement(mountNode)) {
13+
return { element: mountNode };
14+
}
15+
16+
if (typeof mountNode === 'object') {
17+
if (mountNode === null) {
18+
return { element: null };
19+
}
20+
21+
return mountNode;
22+
}
23+
24+
return {};
25+
}
26+
627
/**
728
* Create the state required to render Portal.
829
*
@@ -11,14 +32,14 @@ import type { PortalProps, PortalState } from './Portal.types';
1132
* @param props - props from this instance of Portal
1233
*/
1334
export const usePortal_unstable = (props: PortalProps): PortalState => {
14-
const { children, mountNode } = props;
35+
const { element, className } = toMountNodeProps(props.mountNode);
1536

1637
const virtualParentRootRef = React.useRef<HTMLSpanElement>(null);
17-
const fallbackMountNode = usePortalMountNode({ disabled: !!mountNode });
38+
const fallbackElement = usePortalMountNode({ disabled: !!element, className });
1839

1940
const state: PortalState = {
20-
children,
21-
mountNode: mountNode ?? fallbackMountNode,
41+
children: props.children,
42+
mountNode: element ?? fallbackElement,
2243
virtualParentRootRef,
2344
};
2445

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { usePortalMountNode } from './usePortalMountNode';
3+
4+
describe('usePortalMountNode', () => {
5+
it('creates an element and attaches it to "document.body"', () => {
6+
const { result } = renderHook(() => usePortalMountNode({}));
7+
8+
expect(result.current).toBeInstanceOf(HTMLDivElement);
9+
expect(document.body.contains(result.current)).toBeTruthy();
10+
});
11+
12+
it('applies classes to an element', () => {
13+
const { result } = renderHook(() => usePortalMountNode({ className: 'foo' }));
14+
15+
expect(result.current?.classList).toContain('foo');
16+
});
17+
18+
it('does not create an element if is disabled', () => {
19+
const { result } = renderHook(() => usePortalMountNode({ disabled: true }));
20+
21+
expect(result.current).toBeNull();
22+
});
23+
});

packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import { makeStyles, mergeClasses } from '@griffel/react';
77
import { useFocusVisible } from '@fluentui/react-tabster';
88
import { useDisposable } from 'use-disposable';
99

10-
const useInsertionEffect = (React as never)['useInsertion' + 'Effect'] as typeof React.useLayoutEffect;
10+
const useInsertionEffect = (React as never)['useInsertion' + 'Effect'] as typeof React.useLayoutEffect | undefined;
1111

1212
export type UsePortalMountNodeOptions = {
1313
/**
1414
* Since hooks cannot be called conditionally use this flag to disable creating the node
1515
*/
1616
disabled?: boolean;
17+
18+
className?: string;
1719
};
1820

1921
const useStyles = makeStyles({
@@ -23,18 +25,16 @@ const useStyles = makeStyles({
2325
},
2426
});
2527

26-
const reactMajorVersion = Number(React.version.split('.')[0]);
27-
2828
/**
29-
* Creates a new element on a document.body to mount portals
29+
* Creates a new element on a "document.body" to mount portals.
3030
*/
3131
export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElement | null => {
3232
const { targetDocument, dir } = useFluent();
3333
const focusVisibleRef = useFocusVisible<HTMLDivElement>() as React.MutableRefObject<HTMLElement | null>;
3434
const classes = useStyles();
3535
const themeClassName = useThemeClassName();
3636

37-
const className = mergeClasses(themeClassName, classes.root);
37+
const className = mergeClasses(themeClassName, classes.root, options.className);
3838

3939
const element = useDisposable(() => {
4040
if (targetDocument === undefined || options.disabled) {
@@ -46,7 +46,7 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem
4646
return [newElement, () => newElement.remove()];
4747
}, [targetDocument]);
4848

49-
if (reactMajorVersion >= 18) {
49+
if (useInsertionEffect) {
5050
// eslint-disable-next-line react-hooks/rules-of-hooks
5151
useInsertionEffect(() => {
5252
if (!element) {

0 commit comments

Comments
 (0)