diff --git a/src/annotation-context/__tests__/annotation-context.test.tsx b/src/annotation-context/__tests__/annotation-context.test.tsx index 7c6a1e0ac9..b0c003a3a4 100644 --- a/src/annotation-context/__tests__/annotation-context.test.tsx +++ b/src/annotation-context/__tests__/annotation-context.test.tsx @@ -261,6 +261,25 @@ test('trigger should have aria-expanded depending on open state', () => { expect(hotspot.findTrigger().getElement().getAttribute('aria-expanded')).toBe('false'); }); +test('propagates one-theme class to the portaled annotation when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + + try { + const { wrapper } = renderAnnotationContext(); + expect(wrapper.findAnnotation()!.findContent().getElement().closest('.awsui-one-theme')).not.toBeNull(); + } finally { + themeRoot.remove(); + } +}); + +test('does not stamp one-theme class on the portaled annotation when one theme is inactive', () => { + const { wrapper } = renderAnnotationContext(); + + expect(wrapper.findAnnotation()!.findContent().getElement().closest('.awsui-one-theme')).toBeNull(); +}); + test('annotation should have be labeled by header and step counter', () => { const { wrapper } = renderAnnotationContext( <> diff --git a/src/annotation-context/annotation/open-annotation.tsx b/src/annotation-context/annotation/open-annotation.tsx index ab91c711c6..3a5bcf11dd 100644 --- a/src/annotation-context/annotation/open-annotation.tsx +++ b/src/annotation-context/annotation/open-annotation.tsx @@ -5,6 +5,7 @@ import React, { useRef } from 'react'; import { Portal } from '@cloudscape-design/component-toolkit/internal'; import { HotspotProps } from '../../hotspot/interfaces'; +import { useOneThemePortalClass } from '../../internal/hooks/use-portal-mode-classes'; import { AnnotationContextProps } from '../interfaces'; import { AnnotationPopover } from './annotation-popover'; import AnnotationTrigger from './annotation-trigger'; @@ -61,6 +62,7 @@ export function OpenAnnotation({ i18nStrings, }: AnnotationProps) { const trackRef = useRef(null); + const portalClasses = useOneThemePortalClass(); return ( <> @@ -73,24 +75,26 @@ export function OpenAnnotation({ taskLocalStepIndex={taskLocalStepIndex} /> - + + + ); diff --git a/src/drawer/__tests__/drawer-position-and-placement.test.tsx b/src/drawer/__tests__/drawer-position-and-placement.test.tsx index ee97fa0d8e..5104eb4213 100644 --- a/src/drawer/__tests__/drawer-position-and-placement.test.tsx +++ b/src/drawer/__tests__/drawer-position-and-placement.test.tsx @@ -100,6 +100,25 @@ describe('position=fixed', () => { ); expect(el).toHaveStyle({ insetInlineStart: '8px', insetBlockStart: '4px', insetBlockEnd: '12px' }); }); + + test('propagates one-theme class to the fixed drawer when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + + try { + const el = getDrawerElement(); + expect(el).toHaveClass('awsui-one-theme'); + } finally { + themeRoot.remove(); + } + }); + + test('does not stamp one-theme class on the fixed drawer when one theme is inactive', () => { + const el = getDrawerElement(); + + expect(el).not.toHaveClass('awsui-one-theme'); + }); }); describe('position=sticky', () => { diff --git a/src/drawer/implementation.tsx b/src/drawer/implementation.tsx index 27a8e49230..f36f1dae4d 100644 --- a/src/drawer/implementation.tsx +++ b/src/drawer/implementation.tsx @@ -17,6 +17,7 @@ import { getAllFocusables, isFocusable } from '../internal/components/focus-lock import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useEffectOnUpdate } from '../internal/hooks/use-effect-on-update'; +import { useOneThemePortalClass } from '../internal/hooks/use-portal-mode-classes'; import { createWidgetizedComponent } from '../internal/widgets'; import InternalLiveRegion from '../live-region/internal'; import InternalStatusIndicator from '../status-indicator/internal'; @@ -63,6 +64,7 @@ export function DrawerImplementation({ const returnFocusTargetRef = useRef(null); const baseProps = getBaseProps(restProps); + const portalClasses = useOneThemePortalClass(); const isToolbar = useAppLayoutToolbarDesignEnabled(); const i18n = useInternalI18n('drawer'); const positionStyles = getPositionStyles({ position, placement, offset, stickyOffset, zIndex }); @@ -84,7 +86,8 @@ export function DrawerImplementation({ isToolbar && styles['with-toolbar'], !!footer && styles['with-footer'], closeAction && !hideCloseAction && styles['has-close-action'], - positionStyles.className + positionStyles.className, + position === 'fixed' && portalClasses ), }; 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 5b28eb99d1..d58cd95dae 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 @@ -109,3 +109,40 @@ test('resumes position updates when enabled after being disabled', async () => { expect(portalOverlay.style.height).toBe('20px'); }); }); + +test('propagates one-theme class to the portal overlay when one theme is active', async () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + const mockRef = createMockRef(); + + try { + render( + +
Overlay
+
+ ); + + const portalOverlay = document.querySelector(`.${styles['portal-overlay']}`)!; + await waitFor(() => { + expect(portalOverlay).toHaveClass('awsui-one-theme'); + }); + } finally { + themeRoot.remove(); + } +}); + +test('does not stamp one-theme class on the portal overlay when one theme is inactive', async () => { + const mockRef = createMockRef(); + + render( + +
Overlay
+
+ ); + + const portalOverlay = document.querySelector(`.${styles['portal-overlay']}`)!; + await waitFor(() => { + expect(portalOverlay).not.toHaveClass('awsui-one-theme'); + }); +}); diff --git a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx index ea6da98c4d..a0c774b716 100644 --- a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx +++ b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx @@ -10,6 +10,8 @@ import { Portal, } from '@cloudscape-design/component-toolkit/internal'; +import { useOneThemePortalClass } from '../../hooks/use-portal-mode-classes'; + import styles from './styles.css.js'; export default function PortalOverlay({ @@ -25,6 +27,7 @@ export default function PortalOverlay({ }) { const ref = useRef(null); const [container, setContainer] = useState(null); + const portalClasses = useOneThemePortalClass(); useLayoutEffect(() => { if (track.current) { @@ -90,7 +93,7 @@ export default function PortalOverlay({ {children} diff --git a/src/internal/components/sortable-area/__tests__/portal-mode.test.tsx b/src/internal/components/sortable-area/__tests__/portal-mode.test.tsx new file mode 100644 index 0000000000..edf9d6d505 --- /dev/null +++ b/src/internal/components/sortable-area/__tests__/portal-mode.test.tsx @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { render } from '@testing-library/react'; + +import SortableArea, { SortableAreaProps } from '../../../../../lib/components/internal/components/sortable-area'; +import useDragAndDropReorder from '../../../../../lib/components/internal/components/sortable-area/use-drag-and-drop-reorder'; + +import styles from '../../../../../lib/components/internal/components/sortable-area/styles.css.js'; + +jest.mock('@dnd-kit/core', () => ({ + ...jest.requireActual('@dnd-kit/core'), + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + DragOverlay: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+ ), +})); + +jest.mock('@dnd-kit/sortable', () => ({ + ...jest.requireActual('@dnd-kit/sortable'), + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + isDragging: false, + isSorting: false, + listeners: {}, + setNodeRef: jest.fn(), + transform: null, + attributes: { + 'aria-describedby': 'drag-handle', + 'aria-disabled': false, + }, + }), +})); + +jest.mock('../../../../../lib/components/internal/components/sortable-area/use-drag-and-drop-reorder'); + +interface Item { + id: string; + label: string; +} + +const items: readonly Item[] = [ + { id: '1', label: 'First' }, + { id: '2', label: 'Second' }, +]; +const itemDefinition: SortableAreaProps.ItemDefinition = { id: item => item.id, label: item => item.label }; +const mockedUseDragAndDropReorder = useDragAndDropReorder as jest.MockedFunction; + +beforeEach(() => { + mockedUseDragAndDropReorder.mockReturnValue({ + activeItemId: '1', + setActiveItemId: jest.fn(), + collisionDetection: jest.fn(), + coordinateGetter: jest.fn(), + handleKeyDown: jest.fn(), + sensors: [], + isKeyboard: { current: false }, + }); +}); + +test('propagates one-theme class to the drag overlay when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + + try { + render( + {}} + renderItem={({ item, className, ref }) => ( +
+ {item.label} +
+ )} + i18nStrings={{}} + /> + ); + + expect(document.querySelector(`.${styles['drag-overlay']}`)).toHaveClass('awsui-one-theme'); + } finally { + themeRoot.remove(); + } +}); + +test('does not stamp one-theme class on the drag overlay when one theme is inactive', () => { + render( + {}} + renderItem={({ item, className, ref }) => ( +
+ {item.label} +
+ )} + i18nStrings={{}} + /> + ); + + expect(document.querySelector(`.${styles['drag-overlay']}`)).not.toHaveClass('awsui-one-theme'); +}); diff --git a/src/internal/components/sortable-area/index.tsx b/src/internal/components/sortable-area/index.tsx index 77dc592d4a..4ee78b04a1 100644 --- a/src/internal/components/sortable-area/index.tsx +++ b/src/internal/components/sortable-area/index.tsx @@ -10,6 +10,7 @@ import clsx from 'clsx'; import { Portal } from '@cloudscape-design/component-toolkit/internal'; import { fireNonCancelableEvent } from '../../events'; +import { useOneThemePortalClass } from '../../hooks/use-portal-mode-classes'; import { joinStrings } from '../../utils/strings'; import { SortableAreaProps } from './interfaces'; import { EventName } from './keyboard-sensor/utilities/events'; @@ -37,6 +38,7 @@ export default function SortableArea({ const isDragging = activeItemId !== null; const announcements = useLiveAnnouncements({ items, itemDefinition, isDragging, ...i18nStrings }); const portalContainer = usePortalContainer(); + const portalClasses = useOneThemePortalClass(); return ( ({ {/* Make sure that the drag overlay is above the modal by assigning the z-index as inline style so that it prevails over dnd-kit's inline z-index of 999 */} { expect(keydownEvent.stopPropagation).toHaveBeenCalled(); expect(onDismiss).toHaveBeenCalled(); }); + + it('propagates one-theme class to the portaled tooltip when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + const trackRef = React.createRef(); + + try { + render( + <> +
+ {}} /> + + ); + + expect(createWrapper().findByClassName(tooltipStyles.root)!.getElement()).toHaveClass('awsui-one-theme'); + } finally { + themeRoot.remove(); + } + }); + + it('does not stamp one-theme class on the portaled tooltip when one theme is inactive', () => { + const trackRef = React.createRef(); + + render( + <> +
+ {}} /> + + ); + + expect(createWrapper().findByClassName(tooltipStyles.root)!.getElement()).not.toHaveClass('awsui-one-theme'); + }); }); diff --git a/src/internal/components/tooltip/index.tsx b/src/internal/components/tooltip/index.tsx index 048d832cd1..0dd3bc0b64 100644 --- a/src/internal/components/tooltip/index.tsx +++ b/src/internal/components/tooltip/index.tsx @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { useEffect } from 'react'; +import clsx from 'clsx'; import { Portal } from '@cloudscape-design/component-toolkit/internal'; @@ -8,6 +9,7 @@ import PopoverArrow from '../../../popover/arrow'; import PopoverBody from '../../../popover/body'; import PopoverContainer from '../../../popover/container'; import { PopoverProps } from '../../../popover/interfaces'; +import { useOneThemePortalClass } from '../../hooks/use-portal-mode-classes'; import { Transition } from '../transition'; import testUtilsStyles from '../../../tooltip/test-classes/styles.css.js'; @@ -41,6 +43,8 @@ export default function Tooltip({ hideOnOverscroll, onDismiss, }: TooltipProps) { + const portalClasses = useOneThemePortalClass(); + if (!trackKey && (typeof value === 'string' || typeof value === 'number')) { trackKey = value; } @@ -71,7 +75,11 @@ export default function Tooltip({ return ( -
+
{() => ( { expect(onDismissMock).toHaveBeenCalledWith(expect.objectContaining({ detail: { method: 'click-outside' } })); expect(wrapper.findContent()).toBeFalsy(); }); + + test('should propagate one-theme class to the portaled feature prompt when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + + try { + const { getByTestId, wrapper } = renderComponent(); + getByTestId('trigger-button').click(); + + expect(wrapper.findContent()!.getElement().closest('.awsui-one-theme')).not.toBeNull(); + } finally { + themeRoot.remove(); + } + }); + + test('should not stamp one-theme class on the portaled feature prompt when one theme is inactive', () => { + const { getByTestId, wrapper } = renderComponent(); + + getByTestId('trigger-button').click(); + + expect(wrapper.findContent()!.getElement().closest('.awsui-one-theme')).toBeNull(); + }); }); diff --git a/src/internal/do-not-use/feature-prompt/internal.tsx b/src/internal/do-not-use/feature-prompt/internal.tsx index 28f532369e..5a6356e173 100644 --- a/src/internal/do-not-use/feature-prompt/internal.tsx +++ b/src/internal/do-not-use/feature-prompt/internal.tsx @@ -13,6 +13,7 @@ import { getBaseProps } from '../../base-component'; import ResetContextsForModal from '../../context/reset-contexts-for-modal'; import { fireNonCancelableEvent } from '../../events'; import { InternalBaseComponentProps } from '../../hooks/use-base-component'; +import { useOneThemePortalClass } from '../../hooks/use-portal-mode-classes'; import { nodeBelongs } from '../../utils/node-belongs'; import { FeaturePromptProps } from './interfaces'; @@ -42,6 +43,7 @@ function InternalFeaturePrompt( const [show, setShow] = useState(false); const popoverBodyRef = useRef(null); + const portalClasses = useOneThemePortalClass(); useImperativeHandle(ref, () => ({ dismiss: () => { @@ -89,40 +91,42 @@ function InternalFeaturePrompt( {show && ( - - } - zIndex={7000} - renderWithPortal={true} - > - { - setShow(false); - fireNonCancelableEvent(onDismiss, { method }); - }} - onBlur={event => { - if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) { - setShow(false); - fireNonCancelableEvent(onDismiss, { method: 'blur' }); - } - }} - variant="feature-prompt" - overflowVisible="content" + + + } + zIndex={7000} + renderWithPortal={true} > - {content} - - - + { + setShow(false); + fireNonCancelableEvent(onDismiss, { method }); + }} + onBlur={event => { + if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) { + setShow(false); + fireNonCancelableEvent(onDismiss, { method: 'blur' }); + } + }} + variant="feature-prompt" + overflowVisible="content" + > + {content} + + + + )} diff --git a/src/internal/hooks/use-portal-mode-classes/index.ts b/src/internal/hooks/use-portal-mode-classes/index.ts index 682aa6b8a3..4cca0eea2a 100644 --- a/src/internal/hooks/use-portal-mode-classes/index.ts +++ b/src/internal/hooks/use-portal-mode-classes/index.ts @@ -24,3 +24,7 @@ export function usePortalModeClasses(ref: React.RefObject, options? [`awsui-context-${context}`]: context && !options?.resetVisualContext, }); } + +export function useOneThemePortalClass() { + return useOneTheme() ? 'awsui-one-theme' : ''; +} diff --git a/src/modal/__tests__/modal.test.tsx b/src/modal/__tests__/modal.test.tsx index 603c522502..6f7df8d198 100644 --- a/src/modal/__tests__/modal.test.tsx +++ b/src/modal/__tests__/modal.test.tsx @@ -96,6 +96,34 @@ describe('Modal component', () => { wrapper.findDismissButton().click(); expect(onDismissSpy).toHaveBeenCalled(); }); + + it('propagates one-theme class to the portaled root when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + const modalRoot = document.createElement('div'); + document.body.appendChild(modalRoot); + + try { + render(); + expect(createWrapper(modalRoot).findModal()!.getElement()).toHaveClass('awsui-one-theme'); + } finally { + modalRoot.remove(); + themeRoot.remove(); + } + }); + + it('does not stamp one-theme class on the portaled root when one theme is inactive', () => { + const modalRoot = document.createElement('div'); + document.body.appendChild(modalRoot); + + try { + render(); + expect(createWrapper(modalRoot).findModal()!.getElement()).not.toHaveClass('awsui-one-theme'); + } finally { + modalRoot.remove(); + } + }); }); describe('no paddings', () => { diff --git a/src/modal/internal.tsx b/src/modal/internal.tsx index 16761199a1..b485a7f552 100644 --- a/src/modal/internal.tsx +++ b/src/modal/internal.tsx @@ -27,6 +27,7 @@ import { fireNonCancelableEvent } from '../internal/events'; import { useContainerBreakpoints } from '../internal/hooks/container-queries'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useIntersectionObserver } from '../internal/hooks/use-intersection-observer'; +import { useOneThemePortalClass } from '../internal/hooks/use-portal-mode-classes'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { KeyCode } from '../internal/keycode'; import { SomeRequired } from '../internal/types'; @@ -78,14 +79,18 @@ type InternalModalProps = SomeRequired & }; export default function InternalModal({ modalRoot, getModalRoot, removeModalRoot, ...rest }: InternalModalProps) { + const portalClasses = useOneThemePortalClass(); + return ( - + ); } -type PortaledModalProps = Omit; +type PortaledModalProps = Omit & { + portalClasses: string; +}; // Separate component to prevent the Portal from getting in the way of refs, as it needs extra cycles to render the inner components. // useContainerQuery needs its targeted element to exist on the first render in order to work properly. @@ -108,6 +113,7 @@ function PortaledModal({ __subStepRef, __subStepFunnelProps, referrerId, + portalClasses, ...rest }: PortaledModalProps) { const instanceUniqueId = useUniqueId(); @@ -243,6 +249,7 @@ function PortaledModal({ {...__funnelProps} {...__funnelStepProps} className={clsx( + portalClasses, styles.root, { [styles.hidden]: !visible }, baseProps.className, diff --git a/src/tooltip/__tests__/tooltip.test.tsx b/src/tooltip/__tests__/tooltip.test.tsx index aef2dcd92b..d854a64bb3 100644 --- a/src/tooltip/__tests__/tooltip.test.tsx +++ b/src/tooltip/__tests__/tooltip.test.tsx @@ -110,6 +110,39 @@ describe('Tooltip', () => { expect(wrapper).not.toBeNull(); }); + it('propagates one-theme class to the portaled tooltip when one theme is active', () => { + const themeRoot = document.createElement('div'); + themeRoot.className = 'awsui-one-theme'; + document.body.appendChild(themeRoot); + const trackRef = React.createRef(); + + try { + render( + <> +
+ trackRef.current} /> + + ); + + expect(createWrapper().findTooltip()!.getElement()).toHaveClass('awsui-one-theme'); + } finally { + themeRoot.remove(); + } + }); + + it('does not stamp one-theme class on the portaled tooltip when one theme is inactive', () => { + const trackRef = React.createRef(); + + render( + <> +
+ trackRef.current} /> + + ); + + expect(createWrapper().findTooltip()!.getElement()).not.toHaveClass('awsui-one-theme'); + }); + it('updates tracked element when getTrack changes', () => { const element1 = document.createElement('div'); const element2 = document.createElement('div'); diff --git a/src/tooltip/internal.tsx b/src/tooltip/internal.tsx index 7556612d1d..cdee3bdf0a 100644 --- a/src/tooltip/internal.tsx +++ b/src/tooltip/internal.tsx @@ -9,6 +9,7 @@ import { getBaseProps } from '../internal/base-component'; import { Transition } from '../internal/components/transition'; import { fireNonCancelableEvent } from '../internal/events'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useOneThemePortalClass } from '../internal/hooks/use-portal-mode-classes'; import PopoverArrow from '../popover/arrow'; import PopoverBody from '../popover/body'; import PopoverContainer from '../popover/container'; @@ -36,6 +37,7 @@ export default function InternalTooltip({ }: InternalTooltipComponentProps) { const baseProps = getBaseProps(restProps); const trackRef = React.useRef(null); + const portalClasses = useOneThemePortalClass(); // Update the ref with the current tracked element React.useEffect(() => { @@ -71,7 +73,7 @@ export default function InternalTooltip({