diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6dad540400a..0f3334993b4 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -19,6 +19,7 @@ import { isChrome, isFocusable, isTabbable, + nodeContains, ShadowTreeWalker, useLayoutEffect } from '@react-aria/utils'; @@ -440,7 +441,7 @@ function isElementInScope(element?: Element | null, scope?: Element[] | null) { if (!scope) { return false; } - return scope.some(node => node.contains(element)); + return scope.some(node => nodeContains(node, element)); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { @@ -771,7 +772,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions { acceptNode(node) { // Skip nodes inside the starting node. - if (opts?.from?.contains(node)) { + if (opts?.from && nodeContains(opts.from, node)) { return NodeFilter.FILTER_REJECT; } @@ -822,7 +823,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } let nextNode = walker.nextNode() as FocusableElement; @@ -843,7 +844,7 @@ export function createFocusManager(ref: RefObject, defaultOption let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); - if (root.contains(node)) { + if (nodeContains(root, node)) { walker.currentNode = node!; } else { let next = last(walker); diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index ef0b104c084..c903616ddcd 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -20,6 +20,7 @@ import {Provider} from '@react-spectrum/provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useEvent} from '@react-aria/utils'; import userEvent from '@testing-library/user-event'; @@ -2150,6 +2151,208 @@ describe('FocusScope with Shadow DOM', function () { unmount(); document.body.removeChild(shadowHost); }); + + it('should reproduce the specific issue #8675: Menu items in popover close immediately with UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + let actionExecuted = false; + let menuClosed = false; + + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + // This reproduces the exact scenario described in the issue + function WebComponentWithReactApp() { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(true); + + const handleMenuAction = key => { + actionExecuted = true; + // In the original issue, this never executes because the popover closes first + console.log('Menu action executed:', key); + }; + + return ( + shadowRoot}> +
+ + {/* Portal the popover overlay to simulate real-world usage */} + {isPopoverOpen && + ReactDOM.createPortal( + +
+ +
+ + +
+
+
+
, + popoverPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + // Wait for rendering + act(() => { + jest.runAllTimers(); + }); + + // Query elements from shadow DOM + const saveMenuItem = shadowRoot.querySelector('[data-testid="menu-item-save"]'); + const exportMenuItem = shadowRoot.querySelector('[data-testid="menu-item-export"]'); + const menuContainer = shadowRoot.querySelector('[data-testid="menu-container"]'); + const popoverOverlay = shadowRoot.querySelector('[data-testid="popover-overlay"]'); + // const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); + + // Verify the menu is initially visible in shadow DOM + expect(popoverOverlay).not.toBeNull(); + expect(menuContainer).not.toBeNull(); + expect(saveMenuItem).not.toBeNull(); + expect(exportMenuItem).not.toBeNull(); + + // Focus the first menu item + act(() => { + saveMenuItem.focus(); + }); + expect(shadowRoot.activeElement).toBe(saveMenuItem); + + // Click the menu item - this should execute the onAction handler, NOT close the menu + await user.click(saveMenuItem); + + // The action should have been executed (this would fail in the buggy version) + expect(actionExecuted).toBe(true); + + // The menu should still be open (this would fail in the buggy version where it closes immediately) + expect(menuClosed).toBe(false); + expect(shadowRoot.querySelector('[data-testid="menu-container"]')).not.toBeNull(); + + // Test focus containment within the menu + act(() => { + saveMenuItem.focus(); + }); + await user.tab(); + expect(shadowRoot.activeElement).toBe(exportMenuItem); + + await user.tab(); + // Focus should wrap back to first item due to containment + expect(shadowRoot.activeElement).toBe(saveMenuItem); + + // Cleanup + unmount(); + cleanup(); + }); + + it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { + const {shadowRoot, cleanup} = createShadowRoot(); + + // Create nested portal containers within the shadow DOM + const modalPortal = document.createElement('div'); + modalPortal.setAttribute('data-testid', 'modal-portal'); + shadowRoot.appendChild(modalPortal); + + const tooltipPortal = document.createElement('div'); + tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); + shadowRoot.appendChild(tooltipPortal); + + function ComplexWebComponent() { + const [showModal, setShowModal] = React.useState(true); + const [showTooltip] = React.useState(true); + + return ( + shadowRoot}> +
+ + + {/* Modal with its own focus scope */} + {showModal && + ReactDOM.createPortal( + +
+ + + +
+
, + modalPortal + )} + + {/* Tooltip with nested focus scope */} + {showTooltip && + ReactDOM.createPortal( + +
+ +
+
, + tooltipPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const modalButton1 = shadowRoot.querySelector('[data-testid="modal-button-1"]'); + const modalButton2 = shadowRoot.querySelector('[data-testid="modal-button-2"]'); + const tooltipAction = shadowRoot.querySelector('[data-testid="tooltip-action"]'); + + // Due to autoFocus, the first modal button should be focused + act(() => { + jest.runAllTimers(); + }); + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Tab navigation should work within the modal + await user.tab(); + expect(shadowRoot.activeElement).toBe(modalButton2); + + // Focus should be contained within the modal due to the contain prop + await user.tab(); + // Should cycle to the close button + expect(shadowRoot.activeElement.getAttribute('data-testid')).toBe('close-modal'); + + await user.tab(); + // Should wrap back to first modal button + expect(shadowRoot.activeElement).toBe(modalButton1); + + // The tooltip button should be focusable when we explicitly focus it + act(() => { + tooltipAction.focus(); + }); + act(() => { + jest.runAllTimers(); + }); + // But due to modal containment, focus should be restored back to modal + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Cleanup + unmount(); + cleanup(); + }); }); describe('Unmounting cleanup', () => { diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 9e1c839b612..10f6254f69b 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -54,14 +54,14 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onBlur = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget as Element, e.target as Element)) { return; } // 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 && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) { + if (state.current.isFocusWithin && !nodeContains(e.currentTarget as Element, e.relatedTarget as Element)) { state.current.isFocusWithin = false; removeAllGlobalListeners(); @@ -78,7 +78,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget as Element, e.target as Element)) { return; } diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 94c1e65a46c..374d0f739d6 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -15,7 +15,7 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {getOwnerDocument, useEffectEvent} from '@react-aria/utils'; +import {getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect, useRef} from 'react'; @@ -121,7 +121,7 @@ function isValidEvent(event, ref) { if (event.target) { // if the event target is no longer in the document, ignore const ownerDocument = event.target.ownerDocument; - if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) { + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, event.target)) { return false; } // If the target is within a top layer element (e.g. toasts), ignore. diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index cdc2aa07a40..844705874f7 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -10,10 +10,13 @@ * governing permissions and limitations under the License. */ -import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, installPointerEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import React, {useEffect, useRef} from 'react'; import ReactDOM, {createPortal} from 'react-dom'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useInteractOutside} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props) { let ref = useRef(); @@ -593,3 +596,89 @@ describe('useInteractOutside shadow DOM extended tests', function () { cleanup(); }); }); + + +describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { + let user; + + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('should handle interact outside events with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot, cleanup} = createShadowRoot(); + let interactOutsideTriggered = false; + + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + function ShadowInteractOutsideExample() { + const ref = useRef(); + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideTriggered = true; + } + }); + + return ( + shadowRoot}> +
+ {ReactDOM.createPortal( + <> +
+ + +
+ + , + popoverPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const target = shadowRoot.querySelector('[data-testid="target"]'); + const innerButton = shadowRoot.querySelector( + '[data-testid="inner-button"]' + ); + const outsideButton = shadowRoot.querySelector( + '[data-testid="outside-button"]' + ); + + // Click inside the target - should NOT trigger interact outside + await user.click(innerButton); + expect(interactOutsideTriggered).toBe(false); + + // Click the target itself - should NOT trigger interact outside + await user.click(target); + expect(interactOutsideTriggered).toBe(false); + + // Click outside the target within shadow DOM - should trigger interact outside + await user.click(outsideButton); + expect(interactOutsideTriggered).toBe(true); + + // Cleanup + unmount(); + cleanup(); + }); +}); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 753c2a926a3..e50d0945da2 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} from '@react-aria/utils'; +import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils'; const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; interface AriaHideOutsideOptions { @@ -85,7 +85,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // Skip this node but continue to children if one of the targets is inside the node. for (let target of visibleNodes) { - if (node.contains(target)) { + if (nodeContains(node, target)) { return NodeFilter.FILTER_SKIP; } } @@ -93,8 +93,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} ); @@ -147,7 +150,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt // If the parent element of the added nodes is not within one of the targets, // and not already inside a hidden node, hide all of the new children. - if (![...visibleNodes, ...hiddenNodes].some(node => node.contains(change.target))) { + if (![...visibleNodes, ...hiddenNodes].some(node => nodeContains(node, change.target as Element))) { for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && diff --git a/packages/@react-aria/overlays/test/usePopover.test.tsx b/packages/@react-aria/overlays/test/usePopover.test.tsx index 1b65f9edf23..aa57850a1d1 100644 --- a/packages/@react-aria/overlays/test/usePopover.test.tsx +++ b/packages/@react-aria/overlays/test/usePopover.test.tsx @@ -10,10 +10,13 @@ * governing permissions and limitations under the License. */ -import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import {type OverlayTriggerProps, useOverlayTriggerState} from '@react-stately/overlays'; import React, {useRef} from 'react'; -import {useOverlayTrigger, usePopover} from '../'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider, useOverlayTrigger, usePopover} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props: OverlayTriggerProps) { const triggerRef = useRef(null); @@ -39,3 +42,134 @@ describe('usePopover', () => { expect(onOpenChange).not.toHaveBeenCalled(); }); }); + +describe('usePopover with Shadow DOM and UNSAFE_PortalProvider', () => { + let user; + + beforeAll(() => { + enableShadowDOM(); + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('should handle popover interactions with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + let triggerClicked = false; + let popoverInteracted = false; + + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); + shadowRoot.appendChild(popoverPortal); + + function ShadowPopoverExample() { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const state = useOverlayTriggerState({ + defaultOpen: false, + onOpenChange: isOpen => { + // Track state changes + } + }); + + useOverlayTrigger({type: 'listbox'}, state, triggerRef); + const {popoverProps} = usePopover( + { + triggerRef, + popoverRef, + placement: 'bottom start' + }, + state + ); + + return ( + shadowRoot as unknown as HTMLElement}> +
+ + {ReactDOM.createPortal( + <> + + {state.isOpen && ( +
+ + +
+ )} + , + popoverPortal + )} + +
+
+ ); + } + + const {unmount} = render(); + + const trigger = shadowRoot.querySelector('[data-testid="popover-trigger"]'); + + // Click trigger to open popover + await user.click(trigger); + expect(triggerClicked).toBe(true); + + // Verify popover opened in shadow DOM + const popoverContent = shadowRoot.querySelector('[data-testid="popover-content"]'); + expect(popoverContent).toBeInTheDocument(); + + // Interact with popover content + const popoverAction = shadowRoot.querySelector('[data-testid="popover-action"]'); + await user.click(popoverAction); + expect(popoverInteracted).toBe(true); + + // Popover should still be open after interaction + expect(shadowRoot.querySelector('[data-testid="popover-content"]')).toBeInTheDocument(); + + // Close popover + const closeButton = shadowRoot.querySelector('[data-testid="close-popover"]'); + await user.click(closeButton); + + // Wait for any cleanup + act(() => { + jest.runAllTimers(); + }); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); +}); diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index 1f822a0ef17..bb849088fca 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -61,7 +61,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-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 58651c7317b..a11b88adab4 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -13,7 +13,7 @@ import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; import {AriaPopoverProps, DismissButton, Overlay, PlacementAxis, PositionProps, useLocale, usePopover} from 'react-aria'; import {ContextValue, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; -import {filterDOMProps, mergeProps, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {OverlayArrowContext} from './OverlayArrow'; import {OverlayTriggerProps, OverlayTriggerState, useOverlayTriggerState} from 'react-stately'; @@ -186,7 +186,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, clearContexts // Focus the popover itself on mount, unless a child element is already focused. // Skip this for submenus since hovering a submenutrigger should keep focus on the trigger useEffect(() => { - if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !ref.current.contains(document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, document.activeElement)) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]);