From 119e44649d4de429c97db285cbafd6dee2fc33e1 Mon Sep 17 00:00:00 2001 From: Oleksandr Fediashov Date: Tue, 28 Feb 2023 17:42:39 +0100 Subject: [PATCH] feat: accept a className in `mountNode` in `Portal` --- ...-d5ac98ba-22cc-41a1-939d-77041a362442.json | 7 ++++ .../react-portal/etc/react-portal.api.md | 8 +++-- .../src/components/Portal/Portal.types.ts | 17 +++++----- .../src/components/Portal/usePortal.test.ts | 33 +++++++++++++++++++ .../src/components/Portal/usePortal.ts | 31 ++++++++++++++--- .../Portal/usePortalMountNode.test.ts | 23 +++++++++++++ .../components/Portal/usePortalMountNode.ts | 12 +++---- 7 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 change/@fluentui-react-portal-d5ac98ba-22cc-41a1-939d-77041a362442.json create mode 100644 packages/react-components/react-portal/src/components/Portal/usePortal.test.ts create mode 100644 packages/react-components/react-portal/src/components/Portal/usePortalMountNode.test.ts diff --git a/change/@fluentui-react-portal-d5ac98ba-22cc-41a1-939d-77041a362442.json b/change/@fluentui-react-portal-d5ac98ba-22cc-41a1-939d-77041a362442.json new file mode 100644 index 0000000000000..f5bf6f7f6a46e --- /dev/null +++ b/change/@fluentui-react-portal-d5ac98ba-22cc-41a1-939d-77041a362442.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: accept a className in mountNode in Portal", + "packageName": "@fluentui/react-portal", + "email": "olfedias@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-portal/etc/react-portal.api.md b/packages/react-components/react-portal/etc/react-portal.api.md index b5f176c23dcfb..1cfe2be65b02c 100644 --- a/packages/react-components/react-portal/etc/react-portal.api.md +++ b/packages/react-components/react-portal/etc/react-portal.api.md @@ -15,11 +15,15 @@ export const Portal: React_2.FC; // @public (undocumented) export type PortalProps = { children?: React_2.ReactNode; - mountNode?: HTMLElement | null; + mountNode?: HTMLElement | null | { + element?: HTMLElement | null; + className?: string; + }; }; // @public (undocumented) -export type PortalState = Pick & Required> & { +export type PortalState = Pick & { + mountNode: HTMLElement | null | undefined; virtualParentRootRef: React_2.MutableRefObject; }; diff --git a/packages/react-components/react-portal/src/components/Portal/Portal.types.ts b/packages/react-components/react-portal/src/components/Portal/Portal.types.ts index 590d42aafe59b..32cc3590138de 100644 --- a/packages/react-components/react-portal/src/components/Portal/Portal.types.ts +++ b/packages/react-components/react-portal/src/components/Portal/Portal.types.ts @@ -11,13 +11,14 @@ export type PortalProps = { * * @default a new element on document.body without any styling */ - mountNode?: HTMLElement | null; + mountNode?: HTMLElement | null | { element?: HTMLElement | null; className?: string }; }; -export type PortalState = Pick & - Required> & { - /** - * Ref to the root span element as virtual parent - */ - virtualParentRootRef: React.MutableRefObject; - }; +export type PortalState = Pick & { + mountNode: HTMLElement | null | undefined; + + /** + * Ref to the root span element as virtual parent + */ + virtualParentRootRef: React.MutableRefObject; +}; diff --git a/packages/react-components/react-portal/src/components/Portal/usePortal.test.ts b/packages/react-components/react-portal/src/components/Portal/usePortal.test.ts new file mode 100644 index 0000000000000..37e408db53a4a --- /dev/null +++ b/packages/react-components/react-portal/src/components/Portal/usePortal.test.ts @@ -0,0 +1,33 @@ +import { toMountNodeProps } from './usePortal'; + +describe('toMountNodeProps', () => { + it('handles HTMLElement', () => { + const element = document.createElement('div'); + + expect(toMountNodeProps(element)).toMatchObject({ + element, + }); + }); + + it('handles "null"', () => { + expect(toMountNodeProps(null)).toMatchObject({ + element: null, + }); + }); + + it('handles "undefined"', () => { + expect(toMountNodeProps(null)).toMatchObject({}); + }); + + it('handles objects', () => { + const element = document.createElement('div'); + + expect(toMountNodeProps({ element })).toMatchObject({ + element, + }); + expect(toMountNodeProps({ element, className: 'foo' })).toMatchObject({ + element, + className: 'foo', + }); + }); +}); diff --git a/packages/react-components/react-portal/src/components/Portal/usePortal.ts b/packages/react-components/react-portal/src/components/Portal/usePortal.ts index cb1c094391888..51eaaf54cabab 100644 --- a/packages/react-components/react-portal/src/components/Portal/usePortal.ts +++ b/packages/react-components/react-portal/src/components/Portal/usePortal.ts @@ -1,8 +1,29 @@ +import { isHTMLElement } from '@fluentui/react-utilities'; import * as React from 'react'; -import { usePortalMountNode } from './usePortalMountNode'; + import { setVirtualParent } from '../../virtualParent/index'; +import { usePortalMountNode } from './usePortalMountNode'; import type { PortalProps, PortalState } from './Portal.types'; +export function toMountNodeProps(mountNode: PortalProps['mountNode']): { + element?: HTMLElement | null; + className?: string; +} { + if (isHTMLElement(mountNode)) { + return { element: mountNode }; + } + + if (typeof mountNode === 'object') { + if (mountNode === null) { + return { element: null }; + } + + return mountNode; + } + + return {}; +} + /** * Create the state required to render Portal. * @@ -11,14 +32,14 @@ import type { PortalProps, PortalState } from './Portal.types'; * @param props - props from this instance of Portal */ export const usePortal_unstable = (props: PortalProps): PortalState => { - const { children, mountNode } = props; + const { element, className } = toMountNodeProps(props.mountNode); const virtualParentRootRef = React.useRef(null); - const fallbackMountNode = usePortalMountNode({ disabled: !!mountNode }); + const fallbackElement = usePortalMountNode({ disabled: !!element, className }); const state: PortalState = { - children, - mountNode: mountNode ?? fallbackMountNode, + children: props.children, + mountNode: element ?? fallbackElement, virtualParentRootRef, }; diff --git a/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.test.ts b/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.test.ts new file mode 100644 index 0000000000000..6dae9ad8e98bd --- /dev/null +++ b/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.test.ts @@ -0,0 +1,23 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePortalMountNode } from './usePortalMountNode'; + +describe('usePortalMountNode', () => { + it('creates an element and attaches it to "document.body"', () => { + const { result } = renderHook(() => usePortalMountNode({})); + + expect(result.current).toBeInstanceOf(HTMLDivElement); + expect(document.body.contains(result.current)).toBeTruthy(); + }); + + it('applies classes to an element', () => { + const { result } = renderHook(() => usePortalMountNode({ className: 'foo' })); + + expect(result.current?.classList).toContain('foo'); + }); + + it('does not create an element if is disabled', () => { + const { result } = renderHook(() => usePortalMountNode({ disabled: true })); + + expect(result.current).toBeNull(); + }); +}); diff --git a/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts b/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts index a627b31264db6..ffc2e73f903ad 100644 --- a/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts +++ b/packages/react-components/react-portal/src/components/Portal/usePortalMountNode.ts @@ -7,13 +7,15 @@ import { makeStyles, mergeClasses } from '@griffel/react'; import { useFocusVisible } from '@fluentui/react-tabster'; import { useDisposable } from 'use-disposable'; -const useInsertionEffect = (React as never)['useInsertion' + 'Effect'] as typeof React.useLayoutEffect; +const useInsertionEffect = (React as never)['useInsertion' + 'Effect'] as typeof React.useLayoutEffect | undefined; export type UsePortalMountNodeOptions = { /** * Since hooks cannot be called conditionally use this flag to disable creating the node */ disabled?: boolean; + + className?: string; }; const useStyles = makeStyles({ @@ -23,10 +25,8 @@ const useStyles = makeStyles({ }, }); -const reactMajorVersion = Number(React.version.split('.')[0]); - /** - * Creates a new element on a document.body to mount portals + * Creates a new element on a "document.body" to mount portals. */ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElement | null => { const { targetDocument, dir } = useFluent(); @@ -34,7 +34,7 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem const classes = useStyles(); const themeClassName = useThemeClassName(); - const className = mergeClasses(themeClassName, classes.root); + const className = mergeClasses(themeClassName, classes.root, options.className); const element = useDisposable(() => { if (targetDocument === undefined || options.disabled) { @@ -46,7 +46,7 @@ export const usePortalMountNode = (options: UsePortalMountNodeOptions): HTMLElem return [newElement, () => newElement.remove()]; }, [targetDocument]); - if (reactMajorVersion >= 18) { + if (useInsertionEffect) { // eslint-disable-next-line react-hooks/rules-of-hooks useInsertionEffect(() => { if (!element) {