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 (
+ <>
+ setVisible(!visible)}>
+ Toggle
+
+
+ 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);
+}