Skip to content

Commit

Permalink
chore(react-portal): refactor portals creation
Browse files Browse the repository at this point in the history
  • Loading branch information
layershifter committed Oct 30, 2024
1 parent 3d044da commit 3b12e81
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 170 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
```ts

import type { ComponentState } from '@fluentui/react-utilities';
import { elementContains } from '@fluentui/react-utilities';
import * as React_2 from 'react';
import { setVirtualParent } from '@fluentui/react-utilities';
import type { Slot } from '@fluentui/react-utilities';

export { elementContains }

Expand All @@ -23,7 +25,7 @@ export type PortalProps = {
};

// @public (undocumented)
export type PortalState = Pick<PortalProps, 'children'> & {
export type PortalState = ComponentState<PortalInternalSlots> & Pick<PortalProps, 'children'> & {
mountNode: HTMLElement | null | undefined;
virtualParentRootRef: React_2.MutableRefObject<HTMLSpanElement | null>;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ import * as React from 'react';
import { Portal } from './Portal';

describe('Portal', () => {
/**
* Note: see more visual regression tests for Portal in /apps/vr-tests.
*/
it('renders a default state', () => {
const children = 'test';
const { getByText } = render(<Portal>{children}</Portal>);

expect(getByText(children)).toMatchSnapshot();
it('creates an element and attaches it to "document.body"', () => {
const { getByText } = render(<Portal>Test</Portal>);
const element = getByText('Test');

expect(document.body.children).toContain(element);
});

it('applies "dir" attribute based on a context value', () => {
Expand All @@ -33,13 +30,16 @@ describe('Portal', () => {
expect(getByText('RTL')).toHaveAttribute('dir', 'rtl');
});

it('applies "className"', () => {
const { getByText } = render(<Portal mountNode={{ className: 'foo' }}>Test</Portal>);

expect(getByText('Test')).toHaveClass('foo');
});

it('applies "zIndex" style', () => {
const { getByText } = render(<Portal>Test</Portal>);
const element = getByText('Test');

expect(element).toHaveStyle({
zIndex: 1000000,
});
expect(getByText('Test')).toHaveStyle({ zIndex: 1000000 });
});

it('should not set virtual parent if mount node contains virtual parent', () => {
Expand All @@ -59,4 +59,46 @@ describe('Portal', () => {
const mountNode = container.querySelector<HTMLSpanElement>('#container');
expect((getParent(mountNode) as HTMLElement).id).toBe('parent');
});

describe('mountNode', () => {
it('renders portal content into the specified mount node', () => {
const mountNode = document.createElement('div');

mountNode.id = 'mount-node';
document.body.appendChild(mountNode);

const { getByText } = render(
<Portal mountNode={mountNode}>
<span>Test</span>
</Portal>,
);
const portalEl = getByText('Test');

expect(portalEl).toBeInstanceOf(HTMLSpanElement);
expect(portalEl.parentElement).toBe(mountNode);
});

it('does not add attributes to a mount node', () => {
const mountNode = document.createElement('div');

mountNode.id = 'mount-node';
document.body.appendChild(mountNode);

render(
<Portal mountNode={mountNode}>
<span>Test</span>
</Portal>,
);

expect(mountNode).toMatchInlineSnapshot(`
<div
id="mount-node"
>
<span>
Test
</span>
</div>
`);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ComponentState, Slot } from '@fluentui/react-utilities';
import * as React from 'react';

export type PortalInternalSlots = {
root?: Slot<'div'>;
};

export type PortalProps = {
/**
* React children
Expand All @@ -14,11 +19,12 @@ export type PortalProps = {
mountNode?: HTMLElement | null | { element?: HTMLElement | null; className?: string };
};

export type PortalState = Pick<PortalProps, 'children'> & {
mountNode: HTMLElement | null | undefined;
export type PortalState = ComponentState<PortalInternalSlots> &
Pick<PortalProps, 'children'> & {
mountNode: HTMLElement | null | undefined;

/**
* Ref to the root span element as virtual parent
*/
virtualParentRootRef: React.MutableRefObject<HTMLSpanElement | null>;
};
/**
* Ref to the root span element as virtual parent
*/
virtualParentRootRef: React.MutableRefObject<HTMLSpanElement | null>;
};

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
/** @jsxRuntime automatic */
/** @jsxImportSource @fluentui/react-jsx-runtime */
import { assertSlots } from '@fluentui/react-utilities';
import * as ReactDOM from 'react-dom';
import * as React from 'react';
import type { PortalState } from './Portal.types';

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

/**
* Render the final JSX of Portal
*/
export const renderPortal_unstable = (state: PortalState): React.ReactElement => {
assertSlots<PortalInternalSlots>(state);

return (
<span hidden ref={state.virtualParentRootRef}>
{state.mountNode && ReactDOM.createPortal(state.children, state.mountNode)}
</span>
<>
<span hidden ref={state.virtualParentRootRef} />
{state.mountNode &&
ReactDOM.createPortal(state.root ? <state.root>{state.children}</state.root> : state.children, state.mountNode)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { setVirtualParent } from '@fluentui/react-utilities';
import { mergeClasses } from '@griffel/react';
import {
useFluent_unstable as useFluent,
useThemeClassName_unstable as useThemeClassName,
} from '@fluentui/react-shared-contexts';
import { useFocusVisible } from '@fluentui/react-tabster';
import { setVirtualParent, slot, useMergedRefs } from '@fluentui/react-utilities';
import * as React from 'react';

import { toMountNodeProps } from '../../utils/toMountNodeProps';
import { usePortalMountNode } from './usePortalMountNode';
import type { PortalProps, PortalState } from './Portal.types';
import { usePortalMountNodeStylesStyles } from './usePortalMountNodeStyles.styles';

/**
* Create the state required to render Portal.
Expand All @@ -15,17 +21,42 @@ import type { PortalProps, PortalState } from './Portal.types';
export const usePortal_unstable = (props: PortalProps): PortalState => {
const { element, className } = toMountNodeProps(props.mountNode);

const { dir, targetDocument } = useFluent();
const virtualParentRootRef = React.useRef<HTMLSpanElement>(null);
const fallbackElement = usePortalMountNode({ disabled: !!element, className });

const mountNode = element ?? fallbackElement;
const classes = usePortalMountNodeStylesStyles();
const themeClassName = useThemeClassName();

const ref = useMergedRefs(useFocusVisible<HTMLDivElement>());
const state: PortalState = {
components: {
root: 'div',
},

children: props.children,
mountNode,
root: slot.optional(
{
className: mergeClasses(themeClassName, classes.root, className),
dir,
ref,

'data-portal-node': true,
},
{ elementType: 'div' },
),

mountNode: targetDocument?.body,
virtualParentRootRef,
};

if (element) {
state.mountNode = element;
state.root = undefined;
}

React.useEffect(() => {
const mountNode = element ?? ref.current;

if (!mountNode) {
return;
}
Expand Down Expand Up @@ -80,7 +111,7 @@ export const usePortal_unstable = (props: PortalProps): PortalState => {
setVirtualParent(mountNode, undefined);
};
}
}, [virtualParentRootRef, mountNode]);
}, [virtualParentRootRef, element, ref]);

return state;
};

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const renderTagPicker_unstable = (state: TagPickerState, contexts: TagPic
<ActiveDescendantContextProvider value={contexts.activeDescendant}>
<ListboxProvider value={contexts.listbox}>
{state.trigger}
{state.inline ? state.popover : <Portal mountNode={state.mountNode}>{state.popover}</Portal>}
{state.popover &&
(state.inline ? state.popover : <Portal mountNode={state.mountNode}>{state.popover}</Portal>)}
</ListboxProvider>
</ActiveDescendantContextProvider>
</TagPickerContextProvider>
Expand Down

0 comments on commit 3b12e81

Please sign in to comment.