diff --git a/src/internal/index.ts b/src/internal/index.ts index 9c5abd0..442f8a6 100644 --- a/src/internal/index.ts +++ b/src/internal/index.ts @@ -36,3 +36,4 @@ export { export { isFocusable, getAllFocusables, getFirstFocusable, getLastFocusable } from './focus-lock-utils/utils'; export { default as handleKey } from './utils/handle-key'; export { default as circleIndex } from './utils/circle-index'; +export { default as Portal, PortalProps } from './portal'; diff --git a/src/internal/portal/__tests__/portal.test.tsx b/src/internal/portal/__tests__/portal.test.tsx new file mode 100644 index 0000000..b5c8b04 --- /dev/null +++ b/src/internal/portal/__tests__/portal.test.tsx @@ -0,0 +1,210 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { warnOnce } from '../../logging'; +import Portal, { PortalProps } from '../index'; + +function renderPortal(props: PortalProps) { + const { rerender, unmount } = render(); + return { unmount, rerender: (props: PortalProps) => rerender() }; +} + +jest.mock('../../logging', () => ({ + ...jest.requireActual('../../logging'), + warnOnce: jest.fn(), +})); + +afterEach(() => { + expect(warnOnce).not.toHaveBeenCalled(); + jest.clearAllMocks(); +}); + +describe('Portal', () => { + describe('when container is provided', () => { + let container: Element | undefined; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + container?.remove(); + }); + + test('renders to the container', () => { + renderPortal({ children:

Hello!

, container }); + expect(container?.innerHTML).toBe('

Hello!

'); + }); + + test('ignores getContainer property', () => { + const getContainer = jest.fn(); + const removeContainer = jest.fn(); + renderPortal({ children:

Hello!

, container, getContainer, removeContainer }); + expect(container!.innerHTML).toBe('

Hello!

'); + expect(getContainer).not.toHaveBeenCalled(); + expect(removeContainer).not.toHaveBeenCalled(); + }); + + test('cleans up react content inside container when unmounted', () => { + const { unmount } = renderPortal({ children:

Hello!

, container }); + unmount(); + expect(container!.innerHTML).toBe(''); + }); + + test('cleans up react content inside container if an explicit container is no longer provided', () => { + const { rerender } = renderPortal({ children:

Hello!

, container }); + rerender({ children:

Hello!

}); + expect(container!.innerHTML).toBe(''); + expect(document.body.textContent).toBe('Hello!'); + }); + }); + + describe('when getContainer/removeContainer property is provided', () => { + test('falls back to default if only getContainer is provided', () => { + const getContainer = jest.fn(); + renderPortal({ children:

Hello!

, getContainer }); + expect(getContainer).not.toHaveBeenCalled(); + expect(warnOnce).toHaveBeenCalledWith('portal', '`removeContainer` is required when `getContainer` is provided'); + jest.mocked(warnOnce).mockReset(); + }); + + test('falls back to default if only removeContainer is provided', () => { + const removeContainer = jest.fn(); + renderPortal({ children:

Hello!

, removeContainer }); + expect(removeContainer).not.toHaveBeenCalled(); + expect(warnOnce).toHaveBeenCalledWith('portal', '`getContainer` is required when `removeContainer` is provided'); + jest.mocked(warnOnce).mockReset(); + }); + + test('renders and cleans up async container', async () => { + const container = document.createElement('div'); + const getContainer = jest.fn(async () => { + await Promise.resolve(); + document.body.appendChild(container); + return container; + }); + const removeContainer = jest.fn(element => document.body.removeChild(element)); + const { unmount } = renderPortal({ + children:

Hello!

, + getContainer, + removeContainer, + }); + expect(screen.queryByTestId('portal-content')).toBeFalsy(); + expect(document.body.contains(container)).toBe(false); + expect(getContainer).toHaveBeenCalled(); + + // wait a tick to resolve pending promises + await act(() => Promise.resolve()); + expect(document.body.contains(container)).toBe(true); + expect(container.contains(screen.queryByTestId('portal-content'))).toBe(true); + + unmount(); + expect(removeContainer).toHaveBeenCalledWith(container); + expect(screen.queryByTestId('portal-content')).toBeFalsy(); + expect(document.body.contains(container)).toBe(false); + }); + + test('allows conditional change of getContainer/removeContainer', async () => { + function MovablePortal({ getContainer, removeContainer }: Pick) { + const [visible, setVisible] = useState(false); + return ( + <> + + +
portal content
+
+ + ); + } + + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const externalDocument = iframe.contentDocument!; + + const getContainer = jest.fn(() => { + const container = externalDocument.createElement('div'); + container.setAttribute('data-testid', 'dynamic-container'); + externalDocument.body.appendChild(container); + return Promise.resolve(container); + }); + + const removeContainer = jest.fn(() => { + const allContainers = externalDocument.querySelectorAll('[data-testid="dynamic-container"]'); + expect(allContainers).toHaveLength(1); + allContainers[0].remove(); + }); + + render(); + expect(document.body.contains(screen.getByTestId('portal-content'))).toBe(true); + expect(getContainer).not.toHaveBeenCalled(); + expect(removeContainer).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('toggle-portal')); + // wait a tick to resolve pending promises + await act(() => Promise.resolve()); + expect(screen.queryByTestId('portal-content')).toBeFalsy(); + expect(externalDocument.querySelector('[data-testid="portal-content"]')).toBeTruthy(); + expect(externalDocument.querySelectorAll('[data-testid="dynamic-container"]')).toHaveLength(1); + expect(getContainer).toHaveBeenCalledTimes(1); + expect(removeContainer).toHaveBeenCalledTimes(0); + + fireEvent.click(screen.getByTestId('toggle-portal')); + // wait a tick to resolve pending promises + await act(() => Promise.resolve()); + expect(document.body.contains(screen.getByTestId('portal-content'))).toBe(true); + expect(externalDocument.querySelector('[data-testid="portal-content"]')).toBeFalsy(); + expect(externalDocument.querySelectorAll('[data-testid="dynamic-container"]')).toHaveLength(0); + expect(getContainer).toHaveBeenCalledTimes(1); + expect(removeContainer).toHaveBeenCalledTimes(1); + }); + + describe('console logging', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + test('prints a warning if getContainer rejects a promise', async () => { + const getContainer = jest.fn(() => Promise.reject('Error for testing')); + const removeContainer = jest.fn(() => {}); + renderPortal({ + children:

Hello!

, + getContainer, + removeContainer, + }); + expect(screen.queryByTestId('portal-content')).toBeFalsy(); + expect(getContainer).toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + + await Promise.resolve(); + expect(screen.queryByTestId('portal-content')).toBeFalsy(); + expect(removeContainer).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith('[AwsUi] [portal]: failed to load portal root', 'Error for testing'); + }); + }); + }); + + describe('when a container is not provided', () => { + test('renders to a div under body', () => { + renderPortal({ children:

Hello!

}); + expect(document.querySelector('body > div > p')?.textContent).toBe('Hello!'); + }); + + test('removes container element when unmounted', () => { + const { unmount } = renderPortal({ children:

Hello!

}); + // The extra
is a wrapper element that react-testing-library creates. + expect(document.querySelectorAll('body > div').length).toBe(2); + unmount(); + expect(document.querySelectorAll('body > div').length).toBe(1); + expect(document.querySelector('body > div')?.innerHTML).toBe(''); + }); + }); +}); diff --git a/src/internal/portal/index.tsx b/src/internal/portal/index.tsx new file mode 100644 index 0000000..3582ea9 --- /dev/null +++ b/src/internal/portal/index.tsx @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useLayoutEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { isDevelopment } from '../is-development'; +import { warnOnce } from '../logging'; + +export interface PortalProps { + container?: null | Element; + getContainer?: () => Promise; + removeContainer?: (container: HTMLElement) => void; + children: React.ReactNode; +} + +function manageDefaultContainer(setState: React.Dispatch>) { + const newContainer = document.createElement('div'); + document.body.appendChild(newContainer); + setState(newContainer); + return () => { + document.body.removeChild(newContainer); + }; +} + +function manageAsyncContainer( + getContainer: () => Promise, + removeContainer: (container: HTMLElement) => void, + setState: React.Dispatch> +) { + let newContainer: HTMLElement; + getContainer().then( + container => { + newContainer = container; + setState(container); + }, + error => { + console.warn('[AwsUi] [portal]: failed to load portal root', error); + } + ); + return () => { + removeContainer(newContainer); + }; +} + +/** + * A safe react portal component that renders to a provided node. + * If a node isn't provided, it creates one under document.body. + */ +export default function Portal({ container, getContainer, removeContainer, children }: PortalProps) { + const [activeContainer, setActiveContainer] = useState(container ?? null); + + useLayoutEffect(() => { + if (container) { + setActiveContainer(container); + return; + } + if (isDevelopment) { + if (getContainer && !removeContainer) { + warnOnce('portal', '`removeContainer` is required when `getContainer` is provided'); + } + if (!getContainer && removeContainer) { + warnOnce('portal', '`getContainer` is required when `removeContainer` is provided'); + } + } + if (getContainer && removeContainer) { + return manageAsyncContainer(getContainer, removeContainer, setActiveContainer); + } + return manageDefaultContainer(setActiveContainer); + }, [container, getContainer, removeContainer]); + + return activeContainer && createPortal(children, activeContainer); +}