diff --git a/package.json b/package.json index bcf6427197d..6bb0da11891 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "regenerator-runtime": "0.13.3", "rehype-stringify": "^9.0.4", "rimraf": "^6.0.1", + "shadow-dom-testing-library": "^1.13.1", "sharp": "^0.33.5", "storybook": "^8.6.14", "storybook-dark-mode": "^4.0.2", diff --git a/packages/@react-aria/combobox/package.json b/packages/@react-aria/combobox/package.json index 8fbf1a90c14..61da09db182 100644 --- a/packages/@react-aria/combobox/package.json +++ b/packages/@react-aria/combobox/package.json @@ -28,6 +28,7 @@ "dependencies": { "@react-aria/focus": "^3.21.3", "@react-aria/i18n": "^3.12.14", + "@react-aria/interactions": "^3.25.6", "@react-aria/listbox": "^3.15.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/menu": "^3.19.4", diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index c8db04c19cc..cf8e98f5153 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -25,6 +25,7 @@ import {getChildNodes, getItemCount} from '@react-stately/collections'; import intlMessages from '../intl/*.json'; import {ListKeyboardDelegate, useSelectableCollection} from '@react-aria/selection'; import {privateValidationStateProp} from '@react-stately/form'; +import {useInteractOutside} from '@react-aria/interactions'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useMenuTrigger} from '@react-aria/menu'; import {useTextField} from '@react-aria/textfield'; @@ -180,10 +181,26 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta }; let onBlur = (e: FocusEvent) => { - let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; - let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget); + let blurFromButton = buttonRef?.current && nodeContains(buttonRef.current, e.relatedTarget as Element); + let blurIntoPopover = popoverRef.current && nodeContains(popoverRef.current, e.relatedTarget as Element); + + // Special handling for Shadow DOM: When focus moves into a shadow root portal, + // relatedTarget is retargeted to the shadow HOST, not the content inside. + // Check if relatedTarget is a shadow host that CONTAINS our popover. + let blurIntoShadowHostWithPopover = false; + if (!blurIntoPopover && e.relatedTarget && popoverRef.current) { + let relatedEl = e.relatedTarget as Element; + if ('shadowRoot' in relatedEl && (relatedEl as any).shadowRoot) { + // relatedTarget is a shadow host - check if popover is inside its shadow root + let shadowRoot = (relatedEl as any).shadowRoot; + if (nodeContains(shadowRoot, popoverRef.current) && !nodeContains(shadowRoot, inputRef.current)) { + blurIntoShadowHostWithPopover = true; + } + } + } + // Ignore blur if focused moved to the button(if exists) or into the popover. - if (blurFromButton || blurIntoPopover) { + if (blurFromButton || blurIntoPopover || blurIntoShadowHostWithPopover) { return; } @@ -360,6 +377,16 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta state.close(); } : undefined); + // Add interact outside handling for the popover to support Shadow DOM contexts + // where blur events don't fire when clicking non-focusable elements + useInteractOutside({ + ref: popoverRef, + onInteractOutside: () => { + state.setFocused(false); + }, + isDisabled: !state.isOpen + }); + return { labelProps, buttonProps: { diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 07626782dea..5261522feee 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -71,6 +71,24 @@ function isValidKey(e: KeyboardEvent) { function handleKeyboardEvent(e: KeyboardEvent) { hasEventBeforeFocus = true; if (!(openLink as any).isOpening && isValidKey(e)) { + // In Shadow DOM, e.target may be retargeted to the shadow host (e.g., a DIV). + // Use composedPath() to get the actual element inside the shadow root. + let actualTarget = e.composedPath?.()?.[0] as Element | undefined || e.target as Element; + + // Check if the actual target is a text input element + let isTextInputTarget = actualTarget instanceof HTMLInputElement && !nonTextInputTypes.has(actualTarget.type) || + actualTarget instanceof HTMLTextAreaElement || + (actualTarget instanceof HTMLElement && actualTarget.isContentEditable); + + // For text inputs, only Tab/Escape should trigger keyboard modality (focus visible) + // Other keys (typing content) should not show focus ring + let isFocusVisibleKey = FOCUS_VISIBLE_INPUT_KEYS[e.key]; + + // Skip setting keyboard modality for content keys in text inputs + if (isTextInputTarget && !isFocusVisibleKey) { + return; + } + currentModality = 'keyboard'; currentPointerType = 'keyboard'; triggerChangeHandlers('keyboard', e); @@ -310,11 +328,25 @@ function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: Handl // For keyboard events that occur on a non-input element that will move focus into input element (aka ArrowLeft going from Datepicker button to the main input group) // we need to rely on the user passing isTextInput into here. This way we can skip toggling focus visiblity for said input element - isTextInput = isTextInput || - (document.activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(document.activeElement.type)) || - document.activeElement instanceof IHTMLTextAreaElement || - (document.activeElement instanceof IHTMLElement && document.activeElement.isContentEditable); - return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]); + // + // In Shadow DOM, document.activeElement returns the shadow host, not the actual focused element. + // So we also check e.target (the actual event target) which correctly reflects the focused element + // even inside shadow roots. + let activeElement = document.activeElement; + let eventTarget = e?.target as Element | null; + + // Check both document.activeElement and event target for text input detection + // This handles Shadow DOM where activeElement is the host but target is the actual input + let activeIsTextInput = activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(activeElement.type) || + activeElement instanceof IHTMLTextAreaElement || + (activeElement instanceof IHTMLElement && activeElement.isContentEditable); + + let targetIsTextInput = eventTarget instanceof IHTMLInputElement && !nonTextInputTypes.has(eventTarget.type) || + eventTarget instanceof IHTMLTextAreaElement || + (eventTarget instanceof IHTMLElement && eventTarget.isContentEditable); + + isTextInput = isTextInput || activeIsTextInput || targetIsTextInput; + return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[(e as KeyboardEvent).key]); } /** diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 1faf3127c32..ca71d9e1088 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -61,7 +61,21 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { // We don't want to trigger onBlurWithin and then immediately onFocusWithin again // when moving focus inside the element. Only trigger if the currentTarget doesn't // include the relatedTarget (where focus is moving). - if (state.current.isFocusWithin && !nodeContains(e.currentTarget as Element, e.relatedTarget as Element)) { + let relatedTargetInside = nodeContains(e.currentTarget as Element, e.relatedTarget as Element); + + // Special handling for Shadow DOM: When focus moves into a shadow root, the relatedTarget + // is the shadow host, not the actual element inside. Check if the shadow host's shadow root + // contains the currentTarget (the overlay that's inside the shadow root). + if (!relatedTargetInside && e.relatedTarget && 'shadowRoot' in e.relatedTarget) { + let shadowHost = e.relatedTarget as Element; + let shadowRoot = (shadowHost as any).shadowRoot; + if (shadowRoot && nodeContains(shadowRoot, e.currentTarget as Element)) { + // Focus is moving within the same shadow root that contains the overlay + relatedTargetInside = true; + } + } + + if (state.current.isFocusWithin && !relatedTargetInside) { state.current.isFocusWithin = false; removeAllGlobalListeners(); diff --git a/packages/@react-aria/overlays/src/PortalProvider.tsx b/packages/@react-aria/overlays/src/PortalProvider.tsx index 1105191af35..be3c66b3196 100644 --- a/packages/@react-aria/overlays/src/PortalProvider.tsx +++ b/packages/@react-aria/overlays/src/PortalProvider.tsx @@ -15,6 +15,8 @@ import React, {createContext, JSX, ReactNode, useContext} from 'react'; export interface PortalProviderProps { /** Should return the element where we should portal to. Can clear the context by passing null. */ getContainer?: (() => HTMLElement | null) | null, + /** Returns the visual bounds of the container where overlays should be constrained. Used for shadow DOM and iframe scenarios. */ + getContainerBounds?: (() => DOMRect | null) | null, /** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */ children: ReactNode } @@ -27,10 +29,15 @@ export const PortalContext: React.Context = createCo * Sets the portal container for all overlay elements rendered by its children. */ export function UNSAFE_PortalProvider(props: PortalProviderProps): JSX.Element { - let {getContainer} = props; - let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext(); + let {getContainer, getContainerBounds} = props; + let {getContainer: ctxGetContainer, getContainerBounds: ctxGetContainerBounds} = useUNSAFE_PortalContext(); + return ( - + {props.children} ); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 763c376ced6..36d73d2319e 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow, nodeContains} from '@react-aria/utils'; +import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; interface AriaHideOutsideOptions { @@ -71,6 +71,27 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt } let acceptNode = (node: Element) => { + // Special handling for shadow hosts: If a shadow host contains a visible target, + // ensure it's not hidden (even if previously marked inert by parent overlays). + // Must check this BEFORE hiddenNodes check to handle nested overlay scenarios. + if ('shadowRoot' in node && (node as any).shadowRoot) { + let shadowRoot = (node as any).shadowRoot; + for (let target of visibleNodes) { + if (!shadowRoot.contains(target)) { + continue; + } + visibleNodes.add(node); + if (getHidden(node)) { + setHidden(node, false); + let count = refCountMap.get(node); + if (count && count > 0) { + refCountMap.set(node, count - 1); + } + } + return NodeFilter.FILTER_REJECT; + } + } + // Skip this node and its children if it is one of the target nodes, or a live announcer. // Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is // made for elements with role="row" since VoiceOver on iOS has issues hiding elements with role="row". @@ -93,8 +114,11 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt return NodeFilter.FILTER_ACCEPT; }; - let walker = document.createTreeWalker( - root, + let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; + let doc = getOwnerDocument(rootElement); + let walker = createShadowTreeWalker( + doc, + root || doc, NodeFilter.SHOW_ELEMENT, {acceptNode} ); diff --git a/packages/@react-aria/overlays/src/calculatePosition.ts b/packages/@react-aria/overlays/src/calculatePosition.ts index 07415fc045e..4780288fd82 100644 --- a/packages/@react-aria/overlays/src/calculatePosition.ts +++ b/packages/@react-aria/overlays/src/calculatePosition.ts @@ -58,7 +58,8 @@ interface PositionOpts { offset: number, crossOffset: number, maxHeight?: number, - arrowBoundaryOffset?: number + arrowBoundaryOffset?: number, + containerBounds?: DOMRect | null } type HeightGrowthDirection = 'top' | 'bottom'; @@ -105,7 +106,7 @@ const PARSED_PLACEMENT_CACHE = {}; let getVisualViewport = () => typeof document !== 'undefined' ? window.visualViewport : null; -function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null): Dimensions { +function getContainerDimensions(containerNode: Element, visualViewport: VisualViewport | null, containerBounds?: DOMRect | null): Dimensions { let width = 0, height = 0, totalWidth = 0, totalHeight = 0, top = 0, left = 0; let scroll: Position = {}; let isPinchZoomedIn = (visualViewport?.scale ?? 1) > 1; @@ -118,17 +119,32 @@ function getContainerDimensions(containerNode: Element, visualViewport: VisualVi let documentElement = document.documentElement; totalWidth = documentElement.clientWidth; totalHeight = documentElement.clientHeight; - width = visualViewport?.width ?? totalWidth; - height = visualViewport?.height ?? totalHeight; - scroll.top = documentElement.scrollTop || containerNode.scrollTop; - scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; - - // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with - // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate - // coordinates/values with respect to the visual viewport or with respect to the layout viewport. - if (visualViewport) { - top = visualViewport.offsetTop; - left = visualViewport.offsetLeft; + + // If container bounds are provided (e.g., from PortalProvider for shadow DOM/iframe scenarios), + // use those instead of calculating from window/document + if (containerBounds) { + width = containerBounds.width; + height = containerBounds.height; + top = containerBounds.top; + left = containerBounds.left; + // When using containerBounds, scroll should be relative to the container's position + scroll.top = 0; + scroll.left = 0; + } else { + // Default/legacy method: use visualViewport if available, otherwise use document dimensions + width = visualViewport?.width ?? totalWidth; + height = visualViewport?.height ?? totalHeight; + + scroll.top = documentElement.scrollTop || containerNode.scrollTop; + scroll.left = documentElement.scrollLeft || containerNode.scrollLeft; + + // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with + // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate + // coordinates/values with respect to the visual viewport or with respect to the layout viewport. + if (visualViewport) { + top = visualViewport.offsetTop; + left = visualViewport.offsetLeft; + } } } else { ({width, height, top, left} = getOffset(containerNode, false)); @@ -529,7 +545,8 @@ export function calculatePosition(opts: PositionOpts): PositionResult { crossOffset, maxHeight, arrowSize = 0, - arrowBoundaryOffset = 0 + arrowBoundaryOffset = 0, + containerBounds } = opts; let visualViewport = getVisualViewport(); @@ -556,8 +573,9 @@ export function calculatePosition(opts: PositionOpts): PositionResult { // a height/width that matches the visual viewport size rather than the body's height/width (aka for zoom it will be zoom adjusted size) // and a top/left that is adjusted as well (will return the top/left of the zoomed in viewport, or 0,0 for a non-zoomed body) // Otherwise this returns the height/width of a arbitrary boundary element, and its top/left with respect to the viewport (NOTE THIS MEANS IT DOESNT INCLUDE SCROLL) - let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport); - let containerDimensions = getContainerDimensions(container, visualViewport); + // If containerBounds are provided, use them to constrain the boundary dimensions (e.g., for shadow DOM containers) + let boundaryDimensions = getContainerDimensions(boundaryElement, visualViewport, containerBounds); + let containerDimensions = getContainerDimensions(container, visualViewport, containerBounds); // If the container is the HTML element wrapping the body element, the retrieved scrollTop/scrollLeft will be equal to the // body element's scroll. Set the container's scroll values to 0 since the overlay's edge position value in getDelta don't then need to be further offset // by the container scroll since they are essentially the same containing element and thus in the same coordinate system diff --git a/packages/@react-aria/overlays/src/containerBoundsUtils.ts b/packages/@react-aria/overlays/src/containerBoundsUtils.ts new file mode 100644 index 00000000000..91839579d56 --- /dev/null +++ b/packages/@react-aria/overlays/src/containerBoundsUtils.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; + +/** + * Applies container bounds positioning to a style object. + * When containerBounds are provided, positions the element relative to the container instead of the viewport. + */ +export function applyContainerBounds( + style: React.CSSProperties, + containerBounds: DOMRect | null | undefined, + options?: { + /** Whether to add flexbox centering (for modals). */ + center?: boolean + } +): void { + if (!containerBounds) { + return; + } + + const {center = false} = options || {}; + + // Set positioning relative to container bounds + style.position = 'fixed'; + style.top = containerBounds.top + 'px'; + style.left = containerBounds.left + 'px'; + style.width = containerBounds.width + 'px'; + style.height = containerBounds.height + 'px'; + + // Add flexbox centering if requested + if (center) { + style.display = 'flex'; + style.flexDirection = 'column'; + } +} + diff --git a/packages/@react-aria/overlays/src/index.ts b/packages/@react-aria/overlays/src/index.ts index cf37e048e7d..58c42380ea2 100644 --- a/packages/@react-aria/overlays/src/index.ts +++ b/packages/@react-aria/overlays/src/index.ts @@ -20,6 +20,8 @@ export {usePopover} from './usePopover'; export {useModalOverlay} from './useModalOverlay'; export {Overlay, useOverlayFocusContain} from './Overlay'; export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider'; +export {useIsInShadowRoot} from './useIsInShadowRoot'; +export {applyContainerBounds} from './containerBoundsUtils'; export type {AriaPositionProps, PositionAria} from './useOverlayPosition'; export type {AriaOverlayProps, OverlayAria} from './useOverlay'; diff --git a/packages/@react-aria/overlays/src/useIsInShadowRoot.ts b/packages/@react-aria/overlays/src/useIsInShadowRoot.ts new file mode 100644 index 00000000000..8a7568de282 --- /dev/null +++ b/packages/@react-aria/overlays/src/useIsInShadowRoot.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {isShadowRoot} from '@react-aria/utils'; +import {useMemo} from 'react'; +import {useUNSAFE_PortalContext} from './PortalProvider'; + +/** + * Checks if the current component is rendering inside a Shadow DOM. + * This is useful for conditionally applying styles or behaviors that are incompatible + * with Shadow DOM encapsulation, such as `isolation: isolate` which can interfere + * with stacking contexts for absolutely positioned overlays. + * + * @returns {boolean} True if rendering inside a Shadow DOM, false otherwise. + */ +export function useIsInShadowRoot(): boolean { + let {getContainer} = useUNSAFE_PortalContext(); + + return useMemo(() => { + // Check if the portal container is within a shadow root + if (getContainer) { + try { + let container = getContainer(); + if (container) { + let root = container.getRootNode?.(); + if (root && isShadowRoot(root)) { + return true; + } + } + } catch { + // Ignore errors, assume not in shadow root + } + } + return false; + }, [getContainer]); +} + diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index ee218a5b7ca..a7e9b59d711 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -17,6 +17,7 @@ import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; import {useLocale} from '@react-aria/i18n'; +import {useUNSAFE_PortalContext} from './PortalProvider'; export interface AriaPositionProps extends PositionProps { /** @@ -91,6 +92,7 @@ let visualViewport = typeof document !== 'undefined' ? window.visualViewport : n */ export function useOverlayPosition(props: AriaPositionProps): PositionAria { let {direction} = useLocale(); + let {getContainerBounds} = useUNSAFE_PortalContext(); let { arrowSize, targetRef, @@ -178,6 +180,9 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { overlay.style.maxHeight = (window.visualViewport?.height ?? window.innerHeight) + 'px'; } + // Get container bounds if available from PortalProvider + let containerBounds = getContainerBounds?.() || null; + let position = calculatePosition({ placement: translateRTL(placement, direction), overlayNode: overlayRef.current, @@ -190,7 +195,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { crossOffset, maxHeight, arrowSize: arrowSize ?? (arrowRef?.current ? getRect(arrowRef.current, true).width : 0), - arrowBoundaryOffset + arrowBoundaryOffset, + containerBounds }); if (!position.position) { diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index bb69beb6b08..19dc05c8a44 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -62,7 +62,7 @@ export const getActiveElement = (doc: Document = document): Element | null => { * ShadowDOM safe version of event.target. */ export function getEventTarget(event: T): Element { - if (shadowDOM() && (event.target as HTMLElement).shadowRoot) { + if (shadowDOM() && (event.target as HTMLElement)?.shadowRoot) { if (event.composedPath) { return event.composedPath()[0] as Element; } diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts index ed6f6f765e5..47870b0f829 100644 --- a/packages/@react-aria/utils/src/useViewportSize.ts +++ b/packages/@react-aria/utils/src/useViewportSize.ts @@ -21,9 +21,38 @@ interface ViewportSize { let visualViewport = typeof document !== 'undefined' && window.visualViewport; +// Lazy import to avoid circular dependency issues +// useUNSAFE_PortalContext is only used if available +let portalContextModule: typeof import('@react-aria/overlays') | null = null; +function getPortalContext() { + if (!portalContextModule) { + try { + portalContextModule = require('@react-aria/overlays'); + } catch { + return null; + } + } + return portalContextModule; +} + export function useViewportSize(): ViewportSize { let isSSR = useIsSSR(); - let [size, setSize] = useState(() => isSSR ? {width: 0, height: 0} : getViewportSize()); + let portalModule = getPortalContext(); + let getContainerBounds = portalModule?.useUNSAFE_PortalContext?.()?.getContainerBounds; + let containerBounds = getContainerBounds?.() || null; + + let [size, setSize] = useState(() => { + if (isSSR) { + return {width: 0, height: 0}; + } + + // If container bounds are provided, use those; otherwise use window viewport + if (containerBounds) { + return {width: containerBounds.width, height: containerBounds.height}; + } + + return getViewportSize(); + }); useEffect(() => { let updateSize = (newSize: ViewportSize) => { @@ -42,7 +71,22 @@ export function useViewportSize(): ViewportSize { return; } - updateSize(getViewportSize()); + setSize(size => { + // Re-measure container bounds if available, otherwise use window viewport + let newBounds = getContainerBounds?.(); + let newSize: ViewportSize; + + if (newBounds) { + newSize = {width: newBounds.width, height: newBounds.height}; + } else { + newSize = getViewportSize(); + } + + if (newSize.width === size.width && newSize.height === size.height) { + return size; + } + return newSize; + }); }; // When closing the keyboard, iOS does not fire the visual viewport resize event until the animation is complete. @@ -57,7 +101,21 @@ export function useViewportSize(): ViewportSize { // Wait one frame to see if a new element gets focused. frame = requestAnimationFrame(() => { if (!document.activeElement || !willOpenKeyboard(document.activeElement)) { - updateSize({width: window.innerWidth, height: window.innerHeight}); + setSize(size => { + let newSize: ViewportSize; + let newBounds = getContainerBounds?.(); + + if (newBounds) { + newSize = {width: newBounds.width, height: newBounds.height}; + } else { + newSize = {width: window.innerWidth, height: window.innerHeight}; + } + + if (newSize.width === size.width && newSize.height === size.height) { + return size; + } + return newSize; + }); } }); } @@ -82,7 +140,7 @@ export function useViewportSize(): ViewportSize { visualViewport.removeEventListener('resize', onResize); } }; - }, []); + }, [getContainerBounds]); return size; } diff --git a/packages/@react-spectrum/overlays/src/Modal.tsx b/packages/@react-spectrum/overlays/src/Modal.tsx index 6a8e34450c4..132c04966d3 100644 --- a/packages/@react-spectrum/overlays/src/Modal.tsx +++ b/packages/@react-spectrum/overlays/src/Modal.tsx @@ -9,8 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - -import {AriaModalOverlayProps, useModalOverlay} from '@react-aria/overlays'; +import {applyContainerBounds, AriaModalOverlayProps, useModalOverlay, useUNSAFE_PortalContext} from '@react-aria/overlays'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMRef, RefObject, StyleProps} from '@react-types/shared'; import modalStyles from '@adobe/spectrum-css-temp/components/modal/vars.css'; @@ -86,20 +85,47 @@ let ModalWrapper = forwardRef(function (props: ModalWrapperProps, ref: Forwarded ); let viewport = useViewportSize(); - let style: any = { + let {getContainerBounds} = useUNSAFE_PortalContext(); + let containerBounds = getContainerBounds?.(); + + let wrapperStyle: React.CSSProperties & { + '--spectrum-visual-viewport-height'?: string + } = { '--spectrum-visual-viewport-height': viewport.height + 'px' }; + + let modalStyle: React.CSSProperties | undefined = undefined; + + // If container bounds are provided, position the wrapper relative to the container + // This ensures modals are centered within the web component's bounds, not the full viewport + applyContainerBounds(wrapperStyle, containerBounds, {center: true}); + + // For fullscreen modals, override the fixed positioning to be relative to the wrapper + // The CSS has position: fixed with top/bottom/left/right: 40px, which positions relative to viewport + // We need to make it relative to the container instead + if (containerBounds && (typeVariant === 'fullscreen' || typeVariant === 'fullscreenTakeover')) { + modalStyle = { + position: 'absolute', + top: typeVariant === 'fullscreen' ? '40px' : '0', + left: typeVariant === 'fullscreen' ? '40px' : '0', + right: typeVariant === 'fullscreen' ? '40px' : '0', + bottom: typeVariant === 'fullscreen' ? '40px' : '0', + width: typeVariant === 'fullscreen' ? 'calc(100% - 80px)' : '100%', + height: typeVariant === 'fullscreen' ? 'calc(100% - 80px)' : '100%' + }; + } // Attach Transition's nodeRef to outer most wrapper for node.reflow: https://github.com/reactjs/react-transition-group/blob/c89f807067b32eea6f68fd6c622190d88ced82e2/src/Transition.js#L231 return (
-
+
{children}
diff --git a/packages/@react-spectrum/overlays/src/Underlay.tsx b/packages/@react-spectrum/overlays/src/Underlay.tsx index da9911834d8..83b04357586 100644 --- a/packages/@react-spectrum/overlays/src/Underlay.tsx +++ b/packages/@react-spectrum/overlays/src/Underlay.tsx @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - +import {applyContainerBounds, useUNSAFE_PortalContext} from '@react-aria/overlays'; import {classNames} from '@react-spectrum/utils'; import {isScrollable} from '@react-aria/utils'; import React, {JSX} from 'react'; @@ -21,20 +21,34 @@ interface UnderlayProps { } export function Underlay({isOpen, isTransparent, ...otherProps}: UnderlayProps): JSX.Element { + let {getContainerBounds} = useUNSAFE_PortalContext(); + let containerBounds = getContainerBounds?.(); + let pageHeight: number | undefined = undefined; if (typeof document !== 'undefined') { let scrollingElement = isScrollable(document.body) ? document.body : document.scrollingElement || document.documentElement; // Prevent Firefox from adding scrollbars when the page has a fractional height. let fractionalHeightDifference = scrollingElement.getBoundingClientRect().height % 1; pageHeight = scrollingElement.scrollHeight - fractionalHeightDifference; + + // If container bounds are provided, use those height instead + if (containerBounds) { + pageHeight = containerBounds.height; + } } + let style: React.CSSProperties = {height: pageHeight}; + + // If container bounds are provided, position the underlay relative to the container + applyContainerBounds(style, containerBounds); + return (
k === 'light' || k === 'dark').join(' ') }; + // Skip isolation: isolate when rendering in a shadow root to prevent + // stacking context issues with absolutely positioned overlays + if (isInShadowRoot && style.isolation === 'isolate') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let {isolation, ...restStyle} = style; + style = restStyle; + } + let hasWarned = useRef(false); useEffect(() => { if (direction && domRef.current) { diff --git a/packages/react-aria-components/src/Modal.tsx b/packages/react-aria-components/src/Modal.tsx index 5aeccea36a5..7c9bc55b015 100644 --- a/packages/react-aria-components/src/Modal.tsx +++ b/packages/react-aria-components/src/Modal.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaModalOverlayProps, DismissButton, Overlay, useIsSSR, useModalOverlay} from 'react-aria'; +import {applyContainerBounds, AriaModalOverlayProps, DismissButton, Overlay, useIsSSR, useModalOverlay, useUNSAFE_PortalContext} from 'react-aria'; import { ClassNameOrFunction, ContextValue, @@ -184,20 +184,37 @@ function ModalOverlayInner({UNSTABLE_portalContainer, ...props}: ModalOverlayInn }); let viewport = useViewportSize(); + let {getContainerBounds} = useUNSAFE_PortalContext(); + let pageHeight: number | undefined = undefined; + let containerBounds = getContainerBounds?.(); + if (typeof document !== 'undefined') { let scrollingElement = isScrollable(document.body) ? document.body : document.scrollingElement || document.documentElement; // Prevent Firefox from adding scrollbars when the page has a fractional height. let fractionalHeightDifference = scrollingElement.getBoundingClientRect().height % 1; pageHeight = scrollingElement.scrollHeight - fractionalHeightDifference; + + // If container bounds are provided, use those height instead + if (containerBounds) { + pageHeight = containerBounds.height; + } } - let style = { + // Build style object with CSS custom properties and positioning + let style: React.CSSProperties & { + '--visual-viewport-height'?: string, + '--page-height'?: string + } = { ...renderProps.style, '--visual-viewport-height': viewport.height + 'px', '--page-height': pageHeight !== undefined ? pageHeight + 'px' : undefined }; + // If container bounds are provided, position the overlay relative to the container + // This ensures modals render within the web component's bounds, not the full viewport + applyContainerBounds(style, containerBounds, {center: true}); + return (
{ let dialog = getByRole('dialog'); expect(dialog).toBeInTheDocument(); }); + + it('test overlay and overlay trigger inside the same shadow root to have interactable content', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + + const appContainer = document.createElement('div'); + appContainer.setAttribute('id', 'appRoot'); + shadowRoot.appendChild(appContainer); + + const portal = document.createElement('div'); + portal.id = 'shadow-dom-portal'; + shadowRoot.appendChild(portal); + + const onAction = jest.fn(); + const user = userEvent.setup({delay: null, pointerMap}); + + function ShadowApp() { + return ( + + + + + New… + Open… + Save + Save as… + Print… + + + + ); + } + render( + portal}> + + , + {container: appContainer} + ); + + let button = await screen.findByShadowRole('button'); + await user.click(button); + let menu = await screen.findByShadowRole('menu'); + expect(menu).toBeVisible(); + let items = await screen.findAllByShadowRole('menuitem'); + let openItem = items.find(item => item.textContent?.trim() === 'Open…'); + expect(openItem).toBeVisible(); + + await user.click(openItem); + expect(onAction).toHaveBeenCalledTimes(1); + cleanup(); + }); }); diff --git a/packages/react-aria/src/index.ts b/packages/react-aria/src/index.ts index 5a9339044d4..b51270b13c9 100644 --- a/packages/react-aria/src/index.ts +++ b/packages/react-aria/src/index.ts @@ -31,7 +31,7 @@ export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox'; export {useMenu, useMenuItem, useMenuSection, useMenuTrigger, useSubmenuTrigger} from '@react-aria/menu'; export {useMeter} from '@react-aria/meter'; export {useNumberField} from '@react-aria/numberfield'; -export {DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll, UNSAFE_PortalProvider, useUNSAFE_PortalContext} from '@react-aria/overlays'; +export {applyContainerBounds, DismissButton, ModalProvider, Overlay, OverlayContainer, OverlayProvider, useModal, useModalOverlay, useModalProvider, useOverlay, useOverlayPosition, useOverlayTrigger, usePopover, usePreventScroll, UNSAFE_PortalProvider, useUNSAFE_PortalContext} from '@react-aria/overlays'; export {useProgressBar} from '@react-aria/progress'; export {useRadio, useRadioGroup} from '@react-aria/radio'; export {useSearchField} from '@react-aria/searchfield'; diff --git a/yarn.lock b/yarn.lock index 30a6caec62d..caad28ca0f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5489,6 +5489,7 @@ __metadata: dependencies: "@react-aria/focus": "npm:^3.21.3" "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.25.6" "@react-aria/listbox": "npm:^3.15.1" "@react-aria/live-announcer": "npm:^3.4.4" "@react-aria/menu": "npm:^3.19.4" @@ -5686,7 +5687,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.26.0, @react-aria/interactions@workspace:packages/@react-aria/interactions": +"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.6, @react-aria/interactions@npm:^3.26.0, @react-aria/interactions@workspace:packages/@react-aria/interactions": version: 0.0.0-use.local resolution: "@react-aria/interactions@workspace:packages/@react-aria/interactions" dependencies: @@ -24897,6 +24898,7 @@ __metadata: regenerator-runtime: "npm:0.13.3" rehype-stringify: "npm:^9.0.4" rimraf: "npm:^6.0.1" + shadow-dom-testing-library: "npm:^1.13.1" sharp: "npm:^0.33.5" storybook: "npm:^8.6.14" storybook-dark-mode: "npm:^4.0.2" @@ -26161,6 +26163,15 @@ __metadata: languageName: node linkType: hard +"shadow-dom-testing-library@npm:^1.13.1": + version: 1.13.1 + resolution: "shadow-dom-testing-library@npm:1.13.1" + peerDependencies: + "@testing-library/dom": ">= 8" + checksum: 10c0/cd0a5e7799f868af665235d0812bdbcfbfe4461681ef35ce0fba4d460d395f3fa0e95df5c8fec4686ba30286a62c4e7ba48013e67646977726aa13363479d70f + languageName: node + linkType: hard + "shallow-clone@npm:^3.0.0": version: 3.0.1 resolution: "shallow-clone@npm:3.0.1"