diff --git a/src/annotation-context/annotation/open-annotation.tsx b/src/annotation-context/annotation/open-annotation.tsx index 181bf9aad2..ab91c711c6 100644 --- a/src/annotation-context/annotation/open-annotation.tsx +++ b/src/annotation-context/annotation/open-annotation.tsx @@ -2,8 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useRef } from 'react'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + import { HotspotProps } from '../../hotspot/interfaces'; -import Portal from '../../internal/components/portal'; import { AnnotationContextProps } from '../interfaces'; import { AnnotationPopover } from './annotation-popover'; import AnnotationTrigger from './annotation-trigger'; diff --git a/src/button-dropdown/tooltip.tsx b/src/button-dropdown/tooltip.tsx index 6a92cddff3..32bc8c1e42 100644 --- a/src/button-dropdown/tooltip.tsx +++ b/src/button-dropdown/tooltip.tsx @@ -2,9 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import React, { KeyboardEventHandler, useRef, useState } from 'react'; -import { useReducedMotion } from '@cloudscape-design/component-toolkit/internal'; +import { Portal, useReducedMotion } from '@cloudscape-design/component-toolkit/internal'; -import Portal from '../internal/components/portal'; import { usePortalModeClasses } from '../internal/hooks/use-portal-mode-classes'; import Arrow from '../popover/arrow'; import PopoverBody from '../popover/body'; diff --git a/src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx b/src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx index f606d0afde..a543e937ea 100644 --- a/src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx +++ b/src/internal/components/drag-handle-wrapper/__tests__/portal-overlay.test.tsx @@ -11,6 +11,7 @@ import styles from '../../../../../lib/components/internal/components/drag-handl let isRtl = false; jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), getIsRtl: jest.fn(() => isRtl), getLogicalBoundingClientRect: jest.fn().mockReturnValue({ insetInlineStart: 2, diff --git a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx index 65485f67fa..67ae8123f8 100644 --- a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx +++ b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx @@ -6,10 +6,9 @@ import { getIsRtl, getLogicalBoundingClientRect, getScrollInlineStart, + Portal, } from '@cloudscape-design/component-toolkit/internal'; -import Portal from '../portal'; - import styles from './styles.css.js'; export default function PortalOverlay({ diff --git a/src/internal/components/portal/__tests__/portal.test.tsx b/src/internal/components/portal/__tests__/portal.test.tsx deleted file mode 100644 index 5fc80546e3..0000000000 --- a/src/internal/components/portal/__tests__/portal.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -// 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 '@cloudscape-design/component-toolkit/internal'; - -import Portal, { PortalProps } from '../../../../../lib/components/internal/components/portal'; - -function renderPortal(props: PortalProps) { - const { rerender, unmount } = render(); - return { unmount, rerender: (props: PortalProps) => rerender() }; -} - -jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ - ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), - 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).toContainHTML('

Hello!

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

Hello!

, container, getContainer, removeContainer }); - expect(container).toContainHTML('

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).toBeEmptyDOMElement(); - }); - - 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).toBeEmptyDOMElement(); - expect(document.body).toHaveTextContent('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(container).not.toBeInTheDocument(); - expect(getContainer).toHaveBeenCalled(); - - // wait a tick to resolve pending promises - await act(() => Promise.resolve()); - expect(container).toBeInTheDocument(); - expect(container).toContainElement(screen.queryByTestId('portal-content')); - - unmount(); - expect(removeContainer).toHaveBeenCalledWith(container); - expect(screen.queryByTestId('portal-content')).toBeFalsy(); - expect(container).not.toBeInTheDocument(); - }); - - 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(screen.getByTestId('portal-content')).toBeInTheDocument(); - 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(screen.getByTestId('portal-content')).toBeInTheDocument(); - 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')).toHaveTextContent('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')).toBeEmptyDOMElement(); - }); - }); -}); diff --git a/src/internal/components/portal/index.tsx b/src/internal/components/portal/index.tsx deleted file mode 100644 index 7001cc40c7..0000000000 --- a/src/internal/components/portal/index.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// 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 { warnOnce } from '@cloudscape-design/component-toolkit/internal'; - -import { isDevelopment } from '../../is-development'; - -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); -} diff --git a/src/internal/components/sortable-area/index.tsx b/src/internal/components/sortable-area/index.tsx index 54e1fd50a5..0eb2dbeb49 100644 --- a/src/internal/components/sortable-area/index.tsx +++ b/src/internal/components/sortable-area/index.tsx @@ -7,7 +7,8 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } import { CSS } from '@dnd-kit/utilities'; import clsx from 'clsx'; -import Portal from '../../components/portal'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + import { fireNonCancelableEvent } from '../../events'; import { joinStrings } from '../../utils/strings'; import { SortableAreaProps } from './interfaces'; diff --git a/src/internal/components/tooltip/index.tsx b/src/internal/components/tooltip/index.tsx index 59305467e5..82135e403f 100644 --- a/src/internal/components/tooltip/index.tsx +++ b/src/internal/components/tooltip/index.tsx @@ -2,11 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useEffect } from 'react'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + import PopoverArrow from '../../../popover/arrow'; import PopoverBody from '../../../popover/body'; import PopoverContainer from '../../../popover/container'; import { PopoverProps } from '../../../popover/interfaces'; -import Portal from '../portal'; import { Transition } from '../transition'; import styles from './styles.css.js'; diff --git a/src/internal/hooks/use-base-component/__tests__/use-base-component.test.tsx b/src/internal/hooks/use-base-component/__tests__/use-base-component.test.tsx index c4dae62f64..3cd93f7aca 100644 --- a/src/internal/hooks/use-base-component/__tests__/use-base-component.test.tsx +++ b/src/internal/hooks/use-base-component/__tests__/use-base-component.test.tsx @@ -3,10 +3,9 @@ import React, { useState } from 'react'; import { render } from '@testing-library/react'; -import { COMPONENT_METADATA_KEY } from '@cloudscape-design/component-toolkit/internal'; +import { COMPONENT_METADATA_KEY, Portal } from '@cloudscape-design/component-toolkit/internal'; import { Button } from '../../../../../lib/components'; -import Portal from '../../../../../lib/components/internal/components/portal'; import { PACKAGE_VERSION } from '../../../../../lib/components/internal/environment'; import useBaseComponent, { InternalBaseComponentProps, diff --git a/src/modal/internal.tsx b/src/modal/internal.tsx index c7a0d218a2..2b9d65ccf8 100644 --- a/src/modal/internal.tsx +++ b/src/modal/internal.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; import { useContainerQuery } from '@cloudscape-design/component-toolkit'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { InternalButton } from '../button/internal'; @@ -18,7 +19,6 @@ import { import { FunnelProps, useFunnel, useFunnelStep, useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; import { getBaseProps } from '../internal/base-component'; import FocusLock from '../internal/components/focus-lock'; -import Portal from '../internal/components/portal'; import { ButtonContext, ButtonContextProps } from '../internal/context/button-context'; import { ModalContext } from '../internal/context/modal-context'; import ResetContextsForModal from '../internal/context/reset-contexts-for-modal'; diff --git a/src/popover/internal.tsx b/src/popover/internal.tsx index 3e22356a03..118807ccb9 100644 --- a/src/popover/internal.tsx +++ b/src/popover/internal.tsx @@ -3,10 +3,11 @@ import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import clsx from 'clsx'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { getFirstFocusable } from '../internal/components/focus-lock/utils'; -import Portal from '../internal/components/portal'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; import ResetContextsForModal from '../internal/context/reset-contexts-for-modal'; import { useSingleTabStopNavigation } from '../internal/context/single-tab-stop-navigation-context'; diff --git a/src/table/body-cell/disabled-inline-editor.tsx b/src/table/body-cell/disabled-inline-editor.tsx index b6b1830a68..382d2ae6f2 100644 --- a/src/table/body-cell/disabled-inline-editor.tsx +++ b/src/table/body-cell/disabled-inline-editor.tsx @@ -3,8 +3,9 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + import Icon from '../../icon/internal'; -import Portal from '../../internal/components/portal'; import { useSingleTabStopNavigation } from '../../internal/context/single-tab-stop-navigation-context'; import useHiddenDescription from '../../internal/hooks/use-hidden-description'; import { usePortalModeClasses } from '../../internal/hooks/use-portal-mode-classes'; diff --git a/src/top-navigation/1.0-beta/internal.tsx b/src/top-navigation/1.0-beta/internal.tsx index 34f1ab3bb7..640d321447 100644 --- a/src/top-navigation/1.0-beta/internal.tsx +++ b/src/top-navigation/1.0-beta/internal.tsx @@ -3,8 +3,9 @@ import React from 'react'; import clsx from 'clsx'; +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + import { getBaseProps } from '../../internal/base-component'; -import Portal from '../../internal/components/portal'; import VisualContext from '../../internal/components/visual-context'; import { fireCancelableEvent, isPlainLeftClick } from '../../internal/events'; import { InternalBaseComponentProps } from '../../internal/hooks/use-base-component';