From 7a6dfcc50cd7938c8ec9a28cc2dca24ed3ba33bd Mon Sep 17 00:00:00 2001 From: John Pangalos Date: Thu, 7 Aug 2025 11:46:47 +0200 Subject: [PATCH 01/12] test: adds ShadowDOM / UNSAFE_PortalProvider tests These tests specifically target issue #8675 where menu items in popovers close immediately instead when using ShadowDOM with UNSAFE_PortalProvider. New test suites added: - FocusScope: Shadow DOM boundary containment issues - usePopover: Shadow DOM popover interactions and focus management - useFocusWithin: Focus within detection across shadow boundaries - useInteractOutside: Interact outside detection with portals I generated these tests with AI then reviewed / updated them. --- .../@react-aria/focus/test/FocusScope.test.js | 411 +++++++++++++++++ .../interactions/test/useFocusWithin.test.js | 356 ++++++++++++++- .../test/useInteractOutside.test.js | 403 ++++++++++++++++- .../overlays/test/usePopover.test.tsx | 419 +++++++++++++++++- 4 files changed, 1585 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index ef0b104c084..f36876d2f37 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,416 @@ describe('FocusScope with Shadow DOM', function () { unmount(); document.body.removeChild(shadowHost); }); + + describe('Shadow DOM boundary containment issues (issue #8675)', function () { + it('should properly detect element containment across shadow DOM boundaries with UNSAFE_PortalProvider', async function () { + const {shadowRoot} = createShadowRoot(); + + // Create a menu-like structure that reproduces the issue with UNSAFE_PortalProvider + function MenuInPopoverWithPortalProvider() { + const [isOpen, setIsOpen] = React.useState(true); + + return ( + shadowRoot}> + +
+ {isOpen && ( + +
+ + +
+
+ )} +
+
+
+ ); + } + + const {unmount} = render(); + + const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); + const menuItem2 = shadowRoot.querySelector('[data-testid="menu-item-2"]'); + const menu = shadowRoot.querySelector('[data-testid="menu"]'); + + // Focus the first menu item + act(() => { menuItem1.focus(); }); + expect(shadowRoot.activeElement).toBe(menuItem1); + + // Tab to second menu item should work + await user.tab(); + expect(shadowRoot.activeElement).toBe(menuItem2); + + // Tab should wrap back to first item due to focus containment + await user.tab(); + expect(shadowRoot.activeElement).toBe(menuItem1); + + // Menu should still be visible (not closed unexpectedly) + expect(menu).toBeInTheDocument(); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle focus events correctly in shadow DOM with nested FocusScopes and UNSAFE_PortalProvider', async function () { + const {shadowRoot} = createShadowRoot(); + let menuItemClickHandled = false; + + function NestedScopeMenuWithPortalProvider() { + const handleMenuItemClick = () => { + menuItemClickHandled = true; + }; + + return ( + shadowRoot}> + +
+ + +
+ +
+
+
+
+
+ ); + } + + const {unmount} = render(); + + const trigger = shadowRoot.querySelector('[data-testid="trigger"]'); + const menuItem = shadowRoot.querySelector('[data-testid="menu-item"]'); + + // Focus the trigger first + act(() => { trigger.focus(); }); + expect(shadowRoot.activeElement).toBe(trigger); + + // Tab to menu item + await user.tab(); + expect(shadowRoot.activeElement).toBe(menuItem); + + // Click the menu item - this should fire the onClick handler + await user.click(menuItem); + expect(menuItemClickHandled).toBe(true); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + + it('should handle focus manager operations across shadow DOM boundaries', async function () { + const {shadowRoot} = createShadowRoot(); + + function FocusManagerTest() { + const focusManager = useFocusManager(); + + return ReactDOM.createPortal( + +
+ + + +
+
, + shadowRoot + ); + } + + const {unmount} = render(); + + const firstButton = shadowRoot.querySelector('[data-testid="first"]'); + const secondButton = shadowRoot.querySelector('[data-testid="second"]'); + const thirdButton = shadowRoot.querySelector('[data-testid="third"]'); + + // Focus first button + act(() => { firstButton.focus(); }); + expect(shadowRoot.activeElement).toBe(firstButton); + + // Click first button to trigger focusNext + await user.click(firstButton); + expect(shadowRoot.activeElement).toBe(secondButton); + + // Click second button to trigger focusPrevious + await user.click(secondButton); + expect(shadowRoot.activeElement).toBe(firstButton); + + // Move to third button and test focusFirst + act(() => { thirdButton.focus(); }); + await user.click(thirdButton); + expect(shadowRoot.activeElement).toBe(firstButton); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should correctly handle portaled elements within shadow DOM scopes', async function () { + const {shadowRoot} = createShadowRoot(); + const portalTarget = document.createElement('div'); + shadowRoot.appendChild(portalTarget); + + function PortalInShadowDOM() { + return ReactDOM.createPortal( + +
+ + {ReactDOM.createPortal( + , + portalTarget + )} +
+
, + shadowRoot + ); + } + + const {unmount} = render(); + + const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); + const portaledButton = shadowRoot.querySelector('[data-testid="portaled-button"]'); + + // Focus main button + act(() => { mainButton.focus(); }); + expect(shadowRoot.activeElement).toBe(mainButton); + + // Focus portaled button + act(() => { portaledButton.focus(); }); + expect(shadowRoot.activeElement).toBe(portaledButton); + + // Tab navigation should work between main and portaled elements + await user.tab(); + // The exact behavior may vary, but focus should remain within the shadow DOM + expect(shadowRoot.activeElement).toBeTruthy(); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should reproduce the specific issue #8675: Menu items in popover close immediately with UNSAFE_PortalProvider', async function () { + const {shadowRoot} = createShadowRoot(); + let actionExecuted = false; + let menuClosed = false; + + // 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}> +
+ {isPopoverOpen && ( + +
+ +
+ + +
+
+
+
+ )} + +
+
+ ); + } + + const {unmount} = render(); + + 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"]'); + + // Verify the menu is initially visible + expect(menuContainer).toBeInTheDocument(); + expect(popoverOverlay).toBeInTheDocument(); + + // 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(menuContainer).toBeInTheDocument(); + + // 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(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { + const {shadowRoot} = 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, setShowTooltip] = 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(); }); + // But due to modal containment, focus should be restored back to modal + act(() => { jest.runAllTimers(); }); + expect(shadowRoot.activeElement).toBe(modalButton1); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + }); }); describe('Unmounting cleanup', () => { diff --git a/packages/@react-aria/interactions/test/useFocusWithin.test.js b/packages/@react-aria/interactions/test/useFocusWithin.test.js index a5cd33a45b0..fd5cef60f4a 100644 --- a/packages/@react-aria/interactions/test/useFocusWithin.test.js +++ b/packages/@react-aria/interactions/test/useFocusWithin.test.js @@ -10,9 +10,13 @@ * governing permissions and limitations under the License. */ -import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import React, {useState} from 'react'; +import ReactDOM from 'react-dom'; +import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useFocusWithin} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props) { let {focusWithinProps} = useFocusWithin(props); @@ -195,3 +199,353 @@ describe('useFocusWithin', function () { ]); }); }); + +describe('useFocusWithin 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 focus within events in shadow DOM with UNSAFE_PortalProvider', async () => { + const {shadowRoot} = createShadowRoot(); + let focusWithinTriggered = false; + let blurWithinTriggered = false; + let focusChangeEvents = []; + + function ShadowFocusWithinExample() { + const handleFocusWithin = () => { + focusWithinTriggered = true; + }; + + const handleBlurWithin = () => { + blurWithinTriggered = true; + }; + + const handleFocusWithinChange = (isFocused) => { + focusChangeEvents.push(isFocused); + }; + + return ( + shadowRoot}> +
+ + + + + +
+
+ ); + } + + const {unmount} = render(); + + const innerButton = shadowRoot.querySelector('[data-testid="inner-button"]'); + const innerInput = shadowRoot.querySelector('[data-testid="inner-input"]'); + const outerButton = shadowRoot.querySelector('[data-testid="outer-button"]'); + + // Focus within the example container + act(() => { innerButton.focus(); }); + expect(shadowRoot.activeElement).toBe(innerButton); + expect(focusWithinTriggered).toBe(true); + expect(focusChangeEvents).toContain(true); + + // Move focus within the container (should not trigger blur) + act(() => { innerInput.focus(); }); + expect(shadowRoot.activeElement).toBe(innerInput); + expect(blurWithinTriggered).toBe(false); + + // Move focus outside the container + act(() => { outerButton.focus(); }); + expect(shadowRoot.activeElement).toBe(outerButton); + expect(blurWithinTriggered).toBe(true); + expect(focusChangeEvents).toContain(false); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle focus within detection across shadow DOM boundaries (issue #8675)', async () => { + const {shadowRoot} = createShadowRoot(); + let focusWithinEvents = []; + + function MenuWithFocusWithinExample() { + const handleFocusWithinChange = (isFocused) => { + focusWithinEvents.push({type: 'focusWithinChange', isFocused}); + }; + + return ( + shadowRoot}> +
+ + +
+ + +
+
+
+
+ ); + } + + const {unmount} = render(); + + const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); + const menuItem2 = shadowRoot.querySelector('[data-testid="menu-item-2"]'); + const menuTrigger = shadowRoot.querySelector('[data-testid="menu-trigger"]'); + + // Focus enters the menu + act(() => { menuItem1.focus(); }); + expect(shadowRoot.activeElement).toBe(menuItem1); + expect(focusWithinEvents).toContainEqual({type: 'focusWithinChange', isFocused: true}); + + // Click menu item (this should not cause focus within to be lost) + await user.click(menuItem1); + + // Focus should remain within the menu area + expect(focusWithinEvents.filter(e => e.isFocused === false)).toHaveLength(0); + + // Move focus within menu + act(() => { menuItem2.focus(); }); + expect(shadowRoot.activeElement).toBe(menuItem2); + + // Only when focus moves completely outside should focus within be false + act(() => { menuTrigger.focus(); }); + expect(shadowRoot.activeElement).toBe(menuTrigger); + expect(focusWithinEvents).toContainEqual({type: 'focusWithinChange', isFocused: false}); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle nested focus within containers in shadow DOM with portals', async () => { + const {shadowRoot} = createShadowRoot(); + let outerFocusEvents = []; + let innerFocusEvents = []; + + function NestedFocusWithinExample() { + return ( + shadowRoot}> + outerFocusEvents.push(isFocused)} + data-testid="outer-container" + > + + innerFocusEvents.push(isFocused)} + data-testid="inner-container" + > + + + + + + + ); + } + + const {unmount} = render(); + + const outerButton = shadowRoot.querySelector('[data-testid="outer-button"]'); + const innerButton1 = shadowRoot.querySelector('[data-testid="inner-button-1"]'); + const innerButton2 = shadowRoot.querySelector('[data-testid="inner-button-2"]'); + const outerButton2 = shadowRoot.querySelector('[data-testid="outer-button-2"]'); + + // Focus enters outer container + act(() => { outerButton.focus(); }); + expect(outerFocusEvents).toContain(true); + expect(innerFocusEvents).toHaveLength(0); + + // Focus enters inner container + act(() => { innerButton1.focus(); }); + expect(innerFocusEvents).toContain(true); + expect(outerFocusEvents.filter(e => e === false)).toHaveLength(0); // Outer should still be focused + + // Move within inner container + act(() => { innerButton2.focus(); }); + expect(innerFocusEvents.filter(e => e === false)).toHaveLength(0); + + // Move to outer container (leaves inner) + act(() => { outerButton2.focus(); }); + expect(innerFocusEvents).toContain(false); + expect(outerFocusEvents.filter(e => e === false)).toHaveLength(0); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle focus within with complex portal hierarchies in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + const modalPortal = document.createElement('div'); + modalPortal.setAttribute('data-testid', 'modal-portal'); + shadowRoot.appendChild(modalPortal); + + let modalFocusEvents = []; + let popoverFocusEvents = []; + + function ComplexPortalExample() { + return ( + shadowRoot}> +
+ + + {/* Modal with focus within */} + {ReactDOM.createPortal( + modalFocusEvents.push(isFocused)} + data-testid="modal" + > +
+ + + + {/* Nested popover within modal */} + popoverFocusEvents.push(isFocused)} + data-testid="popover" + > +
+ + +
+
+
+
, + modalPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const modalButton1 = shadowRoot.querySelector('[data-testid="modal-button-1"]'); + const popoverItem1 = shadowRoot.querySelector('[data-testid="popover-item-1"]'); + const popoverItem2 = shadowRoot.querySelector('[data-testid="popover-item-2"]'); + const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); + + // Focus enters modal + act(() => { modalButton1.focus(); }); + expect(modalFocusEvents).toContain(true); + + // Focus enters popover within modal + act(() => { popoverItem1.focus(); }); + expect(popoverFocusEvents).toContain(true); + expect(modalFocusEvents.filter(e => e === false)).toHaveLength(0); // Modal should still have focus within + + // Move within popover + act(() => { popoverItem2.focus(); }); + expect(popoverFocusEvents.filter(e => e === false)).toHaveLength(0); + + // Move back to modal (leaves popover) + act(() => { modalButton1.focus(); }); + expect(popoverFocusEvents).toContain(false); + expect(modalFocusEvents.filter(e => e === false)).toHaveLength(0); + + // Move completely outside (leaves modal) + act(() => { mainButton.focus(); }); + expect(modalFocusEvents).toContain(false); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should correctly handle focus within when elements are dynamically added/removed in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + let focusWithinEvents = []; + + function DynamicFocusWithinExample() { + const [showItems, setShowItems] = React.useState(true); + + return ( + shadowRoot}> + focusWithinEvents.push(isFocused)} + data-testid="dynamic-container" + > + + {showItems && ( +
+ + +
+ )} +
+
+ ); + } + + const {unmount} = render(); + + const toggleButton = shadowRoot.querySelector('[data-testid="toggle-button"]'); + const dynamicItem1 = shadowRoot.querySelector('[data-testid="dynamic-item-1"]'); + + // Focus within the container + act(() => { dynamicItem1.focus(); }); + expect(focusWithinEvents).toContain(true); + + // Click toggle to remove items while focused on one + await user.click(toggleButton); + + // Focus should now be on the toggle button, still within container + expect(shadowRoot.activeElement).toBe(toggleButton); + expect(focusWithinEvents.filter(e => e === false)).toHaveLength(0); + + // Toggle back to show items + await user.click(toggleButton); + + // Focus should still be within the container + const newDynamicItem1 = shadowRoot.querySelector('[data-testid="dynamic-item-1"]'); + act(() => { newDynamicItem1.focus(); }); + expect(focusWithinEvents.filter(e => e === false)).toHaveLength(0); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); +}); diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index cdc2aa07a40..2a671aea2c7 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,401 @@ 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} = createShadowRoot(); + let interactOutsideTriggered = false; + + function ShadowInteractOutsideExample() { + const ref = useRef(); + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideTriggered = true; + } + }); + + return ( + shadowRoot}> +
+
+ + +
+ +
+
+ ); + } + + 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(); + document.body.removeChild(shadowRoot.host); + }); + + it('should correctly identify interactions across shadow DOM boundaries (issue #8675)', async () => { + const {shadowRoot} = createShadowRoot(); + let popoverClosed = false; + + function MenuPopoverExample() { + const popoverRef = useRef(); + useInteractOutside({ + ref: popoverRef, + onInteractOutside: () => { + popoverClosed = true; + } + }); + + return ( + shadowRoot}> +
+ +
+
+ + +
+
+
+
+ ); + } + + const {unmount} = render(); + + const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); + const menuTrigger = shadowRoot.querySelector('[data-testid="menu-trigger"]'); + const menuPopover = shadowRoot.querySelector('[data-testid="menu-popover"]'); + + // Click menu item - should NOT close popover (this is the bug being tested) + await user.click(menuItem1); + expect(popoverClosed).toBe(false); + + // Click on the popover itself - should NOT close popover + await user.click(menuPopover); + expect(popoverClosed).toBe(false); + + // Click outside the popover - SHOULD close popover + await user.click(menuTrigger); + expect(popoverClosed).toBe(true); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle nested portal scenarios with interact outside in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + const modalPortal = document.createElement('div'); + modalPortal.setAttribute('data-testid', 'modal-portal'); + shadowRoot.appendChild(modalPortal); + + let modalInteractOutside = false; + let popoverInteractOutside = false; + + function NestedPortalsExample() { + const modalRef = useRef(); + const popoverRef = useRef(); + + useInteractOutside({ + ref: modalRef, + onInteractOutside: () => { + modalInteractOutside = true; + } + }); + + useInteractOutside({ + ref: popoverRef, + onInteractOutside: () => { + popoverInteractOutside = true; + } + }); + + return ( + shadowRoot}> +
+ + + {/* Modal */} + {ReactDOM.createPortal( +
+
+ + + {/* Popover within modal */} +
+ +
+
+
, + modalPortal + )} +
+
+ ); + } + + const {unmount} = render(); + + const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); + const modalButton = shadowRoot.querySelector('[data-testid="modal-button"]'); + const popoverButton = shadowRoot.querySelector('[data-testid="popover-button"]'); + + // Click popover button - should NOT trigger either interact outside + await user.click(popoverButton); + expect(popoverInteractOutside).toBe(false); + expect(modalInteractOutside).toBe(false); + + // Click modal button - should trigger popover interact outside but NOT modal + await user.click(modalButton); + expect(popoverInteractOutside).toBe(true); + expect(modalInteractOutside).toBe(false); + + // Reset and click completely outside + popoverInteractOutside = false; + modalInteractOutside = false; + + await user.click(mainButton); + expect(modalInteractOutside).toBe(true); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle pointer events correctly in shadow DOM with portal provider', async () => { + installPointerEvent(); + + const {shadowRoot} = createShadowRoot(); + let interactOutsideCount = 0; + + function PointerEventsExample() { + const ref = useRef(); + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideCount++; + } + }); + + return ( + shadowRoot}> +
+
+ +
+ +
+
+ ); + } + + const {unmount} = render(); + + const targetButton = shadowRoot.querySelector('[data-testid="target-button"]'); + const outsideButton = shadowRoot.querySelector('[data-testid="outside-button"]'); + + // Simulate pointer events on target - should NOT trigger interact outside + fireEvent(targetButton, pointerEvent('pointerdown')); + fireEvent(targetButton, pointerEvent('pointerup')); + fireEvent.click(targetButton); + expect(interactOutsideCount).toBe(0); + + // Simulate pointer events outside - should trigger interact outside + fireEvent(outsideButton, pointerEvent('pointerdown')); + fireEvent(outsideButton, pointerEvent('pointerup')); + fireEvent.click(outsideButton); + expect(interactOutsideCount).toBe(1); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle interact outside with dynamic content in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + let interactOutsideCount = 0; + + function DynamicContentExample() { + const ref = useRef(); + const [showContent, setShowContent] = React.useState(true); + + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideCount++; + } + }); + + return ( + shadowRoot}> +
+
+ + {showContent && ( +
+ +
+ )} +
+ +
+
+ ); + } + + const {unmount} = render(); + + const toggleButton = shadowRoot.querySelector('[data-testid="toggle-button"]'); + const dynamicButton = shadowRoot.querySelector('[data-testid="dynamic-button"]'); + const outsideButton = shadowRoot.querySelector('[data-testid="outside-button"]'); + + // Click dynamic content - should NOT trigger interact outside + await user.click(dynamicButton); + expect(interactOutsideCount).toBe(0); + + // Toggle to remove content, then click outside - should trigger interact outside + await user.click(toggleButton); + await user.click(outsideButton); + expect(interactOutsideCount).toBe(1); + + // Toggle content back and click it - should still NOT trigger interact outside + await user.click(toggleButton); + const newDynamicButton = shadowRoot.querySelector('[data-testid="dynamic-button"]'); + await user.click(newDynamicButton); + expect(interactOutsideCount).toBe(1); // Should remain 1 + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle interact outside across mixed shadow DOM and regular DOM boundaries', async () => { + const {shadowRoot} = createShadowRoot(); + let interactOutsideTriggered = false; + + // Create a regular DOM button outside the shadow DOM + const regularDOMButton = document.createElement('button'); + regularDOMButton.textContent = 'Regular DOM Button'; + regularDOMButton.setAttribute('data-testid', 'regular-dom-button'); + document.body.appendChild(regularDOMButton); + + function MixedDOMExample() { + const ref = useRef(); + useInteractOutside({ + ref, + onInteractOutside: () => { + interactOutsideTriggered = true; + } + }); + + return ( + shadowRoot}> +
+
+ +
+ +
+
+ ); + } + + const {unmount} = render(); + + const shadowButton = shadowRoot.querySelector('[data-testid="shadow-button"]'); + const shadowOutside = shadowRoot.querySelector('[data-testid="shadow-outside"]'); + + // Click inside shadow target - should NOT trigger + await user.click(shadowButton); + expect(interactOutsideTriggered).toBe(false); + + // Click outside in shadow DOM - should trigger + await user.click(shadowOutside); + expect(interactOutsideTriggered).toBe(true); + + // Reset and test regular DOM interaction + interactOutsideTriggered = false; + await user.click(regularDOMButton); + expect(interactOutsideTriggered).toBe(true); + + // Cleanup + document.body.removeChild(regularDOMButton); + unmount(); + document.body.removeChild(shadowRoot.host); + }); +}); + +function pointerEvent(type, opts) { + let evt = new Event(type, {bubbles: true, cancelable: true}); + Object.assign(evt, opts); + return evt; +} diff --git a/packages/@react-aria/overlays/test/usePopover.test.tsx b/packages/@react-aria/overlays/test/usePopover.test.tsx index 1b65f9edf23..a47a3c2f20e 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,415 @@ 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 within shadow DOM with UNSAFE_PortalProvider', async () => { + const {shadowRoot} = createShadowRoot(); + let triggerClicked = false; + let popoverInteracted = false; + + 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}> +
+ + {state.isOpen && ( +
+ + +
+ )} +
+
+ ); + } + + 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); + }); + + it('should handle focus management in shadow DOM popover with nested interactive elements', async () => { + const {shadowRoot} = createShadowRoot(); + + function FocusTestPopover() { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const state = useOverlayTriggerState({defaultOpen: true}); + + useOverlayTrigger({type: 'dialog'}, state, triggerRef); + const {popoverProps} = usePopover({ + triggerRef, + popoverRef + }, state); + + return ( + shadowRoot}> +
+ + {state.isOpen && ( +
+
+ + + +
+
+ )} +
+
+ ); + } + + const {unmount} = render(); + + const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); + const menuItem2 = shadowRoot.querySelector('[data-testid="menu-item-2"]'); + const menuItem3 = shadowRoot.querySelector('[data-testid="menu-item-3"]'); + + // Focus first menu item + act(() => { menuItem1.focus(); }); + expect(shadowRoot.activeElement).toBe(menuItem1); + + // Tab through menu items + await user.tab(); + expect(shadowRoot.activeElement).toBe(menuItem2); + + await user.tab(); + expect(shadowRoot.activeElement).toBe(menuItem3); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should properly handle click events on popover content within shadow DOM (issue #8675)', async () => { + const {shadowRoot} = createShadowRoot(); + let menuActionExecuted = false; + let popoverClosedUnexpectedly = false; + + function MenuPopoverExample() { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const [isOpen, setIsOpen] = React.useState(true); + + const state = useOverlayTriggerState({ + isOpen, + onOpenChange: (open) => { + setIsOpen(open); + if (!open) { + popoverClosedUnexpectedly = true; + } + } + }); + + useOverlayTrigger({type: 'listbox'}, state, triggerRef); + const {popoverProps} = usePopover({ + triggerRef, + popoverRef + }, state); + + const handleMenuAction = (action) => { + menuActionExecuted = true; + // In the buggy version, this wouldn't execute because popover closes first + console.log('Menu action:', action); + }; + + return ( + shadowRoot}> +
+ + {state.isOpen && ( +
+
+ + +
+
+ )} +
+
+ ); + } + + const {unmount} = render(); + + const saveItem = shadowRoot.querySelector('[data-testid="save-item"]'); + const menuPopover = shadowRoot.querySelector('[data-testid="menu-popover"]'); + + // Verify popover is initially open + expect(menuPopover).toBeInTheDocument(); + + // Focus the menu item + act(() => { saveItem.focus(); }); + expect(shadowRoot.activeElement).toBe(saveItem); + + // Click the menu item - this should execute the action, NOT close the popover + await user.click(saveItem); + + // The action should have been executed (this fails in the buggy version) + expect(menuActionExecuted).toBe(true); + + // The popover should NOT have closed unexpectedly (this fails in the buggy version) + expect(popoverClosedUnexpectedly).toBe(false); + + // Menu should still be visible + expect(shadowRoot.querySelector('[data-testid="menu-popover"]')).toBeInTheDocument(); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle multiple overlapping popovers in shadow DOM with portal provider', async () => { + const {shadowRoot} = createShadowRoot(); + + function MultiplePopoversExample() { + const trigger1Ref = useRef(null); + const popover1Ref = useRef(null); + const trigger2Ref = useRef(null); + const popover2Ref = useRef(null); + + const state1 = useOverlayTriggerState({defaultOpen: true}); + const state2 = useOverlayTriggerState({defaultOpen: true}); + + useOverlayTrigger({type: 'dialog'}, state1, trigger1Ref); + useOverlayTrigger({type: 'dialog'}, state2, trigger2Ref); + + const {popoverProps: popover1Props} = usePopover({ + triggerRef: trigger1Ref, + popoverRef: popover1Ref + }, state1); + + const {popoverProps: popover2Props} = usePopover({ + triggerRef: trigger2Ref, + popoverRef: popover2Ref + }, state2); + + return ( + shadowRoot}> +
+ + + + {state1.isOpen && ( +
+ +
+ )} + + {state2.isOpen && ( +
+ +
+ )} +
+
+ ); + } + + const {unmount} = render(); + + const popover1 = shadowRoot.querySelector('[data-testid="popover-1"]'); + const popover2 = shadowRoot.querySelector('[data-testid="popover-2"]'); + const popover1Action = shadowRoot.querySelector('[data-testid="popover-1-action"]'); + const popover2Action = shadowRoot.querySelector('[data-testid="popover-2-action"]'); + + // Both popovers should be present + expect(popover1).toBeInTheDocument(); + expect(popover2).toBeInTheDocument(); + + // Should be able to interact with both popovers + await user.click(popover1Action); + await user.click(popover2Action); + + // Both should still be present after interactions + expect(shadowRoot.querySelector('[data-testid="popover-1"]')).toBeInTheDocument(); + expect(shadowRoot.querySelector('[data-testid="popover-2"]')).toBeInTheDocument(); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should handle popover positioning and containment in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); + + function PositionedPopoverExample() { + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const state = useOverlayTriggerState({defaultOpen: true}); + + useOverlayTrigger({type: 'listbox'}, state, triggerRef); + const {popoverProps} = usePopover({ + triggerRef, + popoverRef, + placement: 'bottom start', + containerPadding: 12 + }, state); + + return ( + shadowRoot}> +
+ + {state.isOpen && ( +
+
+

This is a positioned popover

+ +
+
+ )} +
+
+ ); + } + + const {unmount} = render(); + + const trigger = shadowRoot.querySelector('[data-testid="positioned-trigger"]'); + const popover = shadowRoot.querySelector('[data-testid="positioned-popover"]'); + const actionButton = shadowRoot.querySelector('[data-testid="action-button"]'); + + // Verify popover exists and is positioned + expect(popover).toBeInTheDocument(); + expect(trigger).toBeInTheDocument(); + + // Verify we can interact with popover content + await user.click(actionButton); + + // Popover should still be present after interaction + expect(shadowRoot.querySelector('[data-testid="positioned-popover"]')).toBeInTheDocument(); + + // Cleanup + unmount(); + document.body.removeChild(shadowRoot.host); + }); +}); From 322c7cf53fa9ac773f049020c4a8fb919ea59e15 Mon Sep 17 00:00:00 2001 From: John Pangalos Date: Fri, 15 Aug 2025 20:30:25 +0200 Subject: [PATCH 02/12] Add patch and fixing some tests. After applying the patches I mentioned in my issue I've noticed that many of the AI generated tests were either broken or just not testing anything interesting. Luckily there are some that are. I'll have to update the rest of the tests as they aren't passing at the moment. --- packages/@react-aria/focus/src/FocusScope.tsx | 9 +- .../@react-aria/focus/test/FocusScope.test.js | 1835 +++++++++-------- .../interactions/src/useFocusWithin.ts | 6 +- .../interactions/src/useInteractOutside.ts | 4 +- .../test/useInteractOutside.test.js | 634 +++--- .../overlays/src/ariaHideOutside.ts | 13 +- .../utils/src/shadowdom/DOMFunctions.ts | 2 +- .../react-aria-components/src/Popover.tsx | 4 +- 8 files changed, 1308 insertions(+), 1199 deletions(-) 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 f36876d2f37..5e91ef2b3a2 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -10,25 +10,32 @@ * governing permissions and limitations under the License. */ -import {act, createShadowRoot, fireEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; -import {defaultTheme} from '@adobe/react-spectrum'; -import {DialogContainer} from '@react-spectrum/dialog'; -import {enableShadowDOM} from '@react-stately/flags'; -import {FocusScope, useFocusManager} from '../'; -import {focusScopeTree} from '../src/FocusScope'; -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'; - -describe('FocusScope', function () { +import { + act, + createShadowRoot, + fireEvent, + pointerMap, + render, + waitFor, +} from "@react-spectrum/test-utils-internal"; +import { defaultTheme } from "@adobe/react-spectrum"; +import { DialogContainer } from "@react-spectrum/dialog"; +import { enableShadowDOM } from "@react-stately/flags"; +import { FocusScope, useFocusManager } from "../"; +import { focusScopeTree } from "../src/FocusScope"; +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"; + +describe("FocusScope", function () { let user; beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); + user = userEvent.setup({ delay: null, pointerMap }); }); beforeEach(() => { @@ -36,24 +43,28 @@ describe('FocusScope', function () { }); afterEach(() => { // make sure to clean up any raf's that may be running to restore focus on unmount - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); }); - describe('focus containment', function () { - it('should contain focus within the scope', async function () { - let {getByTestId} = render( + describe("focus containment", function () { + it("should contain focus within the scope", async function () { + let { getByTestId } = render( - + , ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); - let input3 = getByTestId('input3'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); + let input3 = getByTestId("input3"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(input1); await user.tab(); @@ -65,18 +76,18 @@ describe('FocusScope', function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input3); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input2); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input1); }); - it('should work with nested elements', async function () { - let {getByTestId} = render( + it("should work with nested elements", async function () { + let { getByTestId } = render(
@@ -85,14 +96,16 @@ describe('FocusScope', function () {
-
+ , ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); - let input3 = getByTestId('input3'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); + let input3 = getByTestId("input3"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(input1); await user.tab(); @@ -104,37 +117,39 @@ describe('FocusScope', function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input3); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input2); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input1); }); - it('should skip non-tabbable elements', async function () { - let {getByTestId} = render( + it("should skip non-tabbable elements", async function () { + let { getByTestId } = render(
- - - + + +
- + , ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); - let input3 = getByTestId('input3'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); + let input3 = getByTestId("input3"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(input1); await user.tab(); @@ -146,18 +161,18 @@ describe('FocusScope', function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input3); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input2); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input1); }); - it('should only skip content editable which are false', async function () { - let {getByTestId} = render( + it("should only skip content editable which are false", async function () { + let { getByTestId } = render( @@ -165,15 +180,17 @@ describe('FocusScope', function () { - + , ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); - let input3 = getByTestId('input3'); - let input4 = getByTestId('input4'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); + let input3 = getByTestId("input3"); + let input4 = getByTestId("input4"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(input1); await user.tab(); @@ -185,62 +202,66 @@ describe('FocusScope', function () { await user.tab(); expect(document.activeElement).toBe(input4); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input3); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input2); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input1); }); - it('should do nothing if a modifier key is pressed', function () { - let {getByTestId} = render( + it("should do nothing if a modifier key is pressed", function () { + let { getByTestId } = render( - + , ); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(input1); - fireEvent.keyDown(document.activeElement, {key: 'Tab', altKey: true}); + fireEvent.keyDown(document.activeElement, { key: "Tab", altKey: true }); expect(document.activeElement).toBe(input1); }); - it('should work with multiple focus scopes', async function () { - let {getByTestId} = render( + it("should work with multiple focus scopes", async function () { + let { getByTestId } = render(
- - - + + + - - - + + + -
+
, ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); - let input3 = getByTestId('input3'); - let input4 = getByTestId('input4'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); + let input3 = getByTestId("input3"); + let input4 = getByTestId("input4"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(input1); await user.tab(); @@ -252,21 +273,23 @@ describe('FocusScope', function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input3); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input2); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(input1); - act(() => {input4.focus();}); + act(() => { + input4.focus(); + }); expect(document.activeElement).toBe(input1); }); - it('should restore focus to the last focused element in the scope when re-entering the browser', async function () { - let {getByTestId} = render( + it("should restore focus to the last focused element in the scope when re-entering the browser", async function () { + let { getByTestId } = render(
@@ -274,75 +297,95 @@ describe('FocusScope', function () { -
+
, ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); - let outside = getByTestId('outside'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); + let outside = getByTestId("outside"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); fireEvent.focusIn(input1); // jsdom doesn't fire this automatically expect(document.activeElement).toBe(input1); await user.tab(); fireEvent.focusIn(input2); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(input2); - act(() => {input2.blur();}); - act(() => {jest.runAllTimers();}); + act(() => { + input2.blur(); + }); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(input2); - act(() => {outside.focus();}); + act(() => { + outside.focus(); + }); fireEvent.focusIn(outside); expect(document.activeElement).toBe(input2); }); - it('should restore focus to the last focused element in the scope on focus out', async function () { - let {getByTestId} = render( + it("should restore focus to the last focused element in the scope on focus out", async function () { + let { getByTestId } = render(
-
+ , ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); fireEvent.focusIn(input1); // jsdom doesn't fire this automatically expect(document.activeElement).toBe(input1); await user.tab(); fireEvent.focusIn(input2); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(input2); - act(() => {input2.blur();}); - act(() => {jest.runAllTimers();}); + act(() => { + input2.blur(); + }); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(input2); fireEvent.focusOut(input2); expect(document.activeElement).toBe(input2); }); // This test setup is a bit contrived to just purely simulate the blur/focus events that would happen in a case like this - it('focus properly moves into child iframe on click', function () { - let {getByTestId} = render( + it("focus properly moves into child iframe on click", function () { + let { getByTestId } = render(
-
+ , ); - let input1 = getByTestId('input1'); - let input2 = getByTestId('input2'); + let input1 = getByTestId("input1"); + let input2 = getByTestId("input2"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); fireEvent.focusIn(input1); // jsdom doesn't fire this automatically expect(document.activeElement).toBe(input1); @@ -350,311 +393,343 @@ describe('FocusScope', function () { // set document.activeElement to input2 input2.focus(); // if onBlur didn't fallback to checking document.activeElement, this would reset focus to input1 - fireEvent.blur(input1, {relatedTarget: null}); + fireEvent.blur(input1, { relatedTarget: null }); }); expect(document.activeElement).toBe(input2); }); }); - describe('focus restoration', function () { - it('should restore focus to the previously focused node on unmount', function () { - function Test({show}) { + describe("focus restoration", function () { + it("should restore focus to the previously focused node on unmount", function () { + function Test({ show }) { return (
- {show && + {show && ( - } + )}
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let outside = getByTestId('outside'); - act(() => {outside.focus();}); + let outside = getByTestId("outside"); + act(() => { + outside.focus(); + }); rerender(); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); rerender(); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(outside); }); - it('should restore focus to the previously focused node after a child with autoFocus unmounts', function () { - function Test({show}) { + it("should restore focus to the previously focused node after a child with autoFocus unmounts", function () { + function Test({ show }) { return (
- {show && + {show && ( - } + )}
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let outside = getByTestId('outside'); - act(() => {outside.focus();}); + let outside = getByTestId("outside"); + act(() => { + outside.focus(); + }); rerender(); - let input2 = getByTestId('input2'); + let input2 = getByTestId("input2"); expect(document.activeElement).toBe(input2); rerender(); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(outside); }); - it('should move focus after the previously focused node when tabbing away from a scope with autoFocus', async function () { - function Test({show}) { + it("should move focus after the previously focused node when tabbing away from a scope with autoFocus", async function () { + function Test({ show }) { return (
- {show && + {show && ( - } + )}
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let outside = getByTestId('outside'); - act(() => {outside.focus();}); + let outside = getByTestId("outside"); + act(() => { + outside.focus(); + }); rerender(); - let input3 = getByTestId('input3'); + let input3 = getByTestId("input3"); expect(document.activeElement).toBe(input3); await user.tab(); - expect(document.activeElement).toBe(getByTestId('after')); + expect(document.activeElement).toBe(getByTestId("after")); }); - it('should move focus before the previously focused node when tabbing away from a scope with Shift+Tab', async function () { - function Test({show}) { + it("should move focus before the previously focused node when tabbing away from a scope with Shift+Tab", async function () { + function Test({ show }) { return (
- {show && + {show && ( - } + )}
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let outside = getByTestId('outside'); - act(() => {outside.focus();}); + let outside = getByTestId("outside"); + act(() => { + outside.focus(); + }); rerender(); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('before')); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("before")); }); - it('should restore focus to the previously focused node after children change', function () { - function Test({show, showChild}) { + it("should restore focus to the previously focused node after children change", function () { + function Test({ show, showChild }) { return (
- {show && + {show && ( {showChild && } - } + )}
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let outside = getByTestId('outside'); - act(() => {outside.focus();}); + let outside = getByTestId("outside"); + act(() => { + outside.focus(); + }); rerender(); rerender(); - let dynamic = getByTestId('dynamic'); - act(() => {dynamic.focus();}); + let dynamic = getByTestId("dynamic"); + act(() => { + dynamic.focus(); + }); expect(document.activeElement).toBe(dynamic); rerender(); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(outside); }); - it('should move focus to the element after the previously focused node on Tab', async function () { - function Test({show}) { + it("should move focus to the element after the previously focused node on Tab", async function () { + function Test({ show }) { return (
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let trigger = getByTestId('trigger'); - act(() => {trigger.focus();}); + let trigger = getByTestId("trigger"); + act(() => { + trigger.focus(); + }); rerender(); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); - let input3 = getByTestId('input3'); - act(() => {input3.focus();}); + let input3 = getByTestId("input3"); + act(() => { + input3.focus(); + }); await user.tab(); - expect(document.activeElement).toBe(getByTestId('after')); + expect(document.activeElement).toBe(getByTestId("after")); }); - it('should move focus to the previous element after the previously focused node on Shift+Tab', async function () { - function Test({show}) { + it("should move focus to the previous element after the previously focused node on Shift+Tab", async function () { + function Test({ show }) { return (
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let trigger = getByTestId('trigger'); - act(() => {trigger.focus();}); + let trigger = getByTestId("trigger"); + act(() => { + trigger.focus(); + }); rerender(); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('before')); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("before")); }); - it('should skip over elements within the scope when moving focus to the next element', async function () { - function Test({show}) { + it("should skip over elements within the scope when moving focus to the next element", async function () { + function Test({ show }) { return (
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let trigger = getByTestId('trigger'); - act(() => {trigger.focus();}); + let trigger = getByTestId("trigger"); + act(() => { + trigger.focus(); + }); rerender(); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); - let input3 = getByTestId('input3'); - act(() => {input3.focus();}); + let input3 = getByTestId("input3"); + act(() => { + input3.focus(); + }); await user.tab(); - expect(document.activeElement).toBe(getByTestId('after')); + expect(document.activeElement).toBe(getByTestId("after")); }); - it('should not handle tabbing if the focus scope does not restore focus', async function () { - function Test({show}) { + it("should not handle tabbing if the focus scope does not restore focus", async function () { + function Test({ show }) { return (
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); - let trigger = getByTestId('trigger'); - act(() => {trigger.focus();}); + let trigger = getByTestId("trigger"); + act(() => { + trigger.focus(); + }); rerender(); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); - let input3 = getByTestId('input3'); - act(() => {input3.focus();}); + let input3 = getByTestId("input3"); + act(() => { + input3.focus(); + }); await user.tab(); - expect(document.activeElement).toBe(getByTestId('after')); + expect(document.activeElement).toBe(getByTestId("after")); }); it.each` @@ -663,58 +738,86 @@ describe('FocusScope', function () { ${true} | ${false} ${false} | ${true} ${true} | ${true} - `('contain=$contain, isPortaled=$isPortaled should restore focus to previous nodeToRestore when the nodeToRestore for the unmounting scope in no longer in the DOM', - async function ({contain, isPortaled}) { - expect(focusScopeTree.size).toBe(1); - let {getAllByText, getAllByRole} = render(); - expect(focusScopeTree.size).toBe(1); - act(() => {getAllByText('Open dialog')[0].focus();}); - await user.click(document.activeElement); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(getAllByRole('textbox')[2]); - act(() => {getAllByText('Open dialog')[1].focus();}); - await user.click(document.activeElement); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(getAllByRole('textbox')[5]); - act(() => {getAllByText('Open dialog')[2].focus();}); - await user.click(document.activeElement); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(getAllByRole('textbox')[8]); - expect(focusScopeTree.size).toBe(4); - if (!contain) { + `( + "contain=$contain, isPortaled=$isPortaled should restore focus to previous nodeToRestore when the nodeToRestore for the unmounting scope in no longer in the DOM", + async function ({ contain, isPortaled }) { + expect(focusScopeTree.size).toBe(1); + let { getAllByText, getAllByRole } = render( + , + ); + expect(focusScopeTree.size).toBe(1); act(() => { - getAllByText('close')[1].focus(); + getAllByText("Open dialog")[0].focus(); }); await user.click(document.activeElement); - } else { - fireEvent.click(getAllByText('close')[1]); - } - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(getAllByText('Open dialog')[1]); - act(() => {getAllByText('close')[0].focus();}); - await user.click(document.activeElement); - act(() => {jest.runAllTimers();}); - expect(document.activeElement).toBe(getAllByText('Open dialog')[0]); - expect(focusScopeTree.size).toBe(1); - }); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getAllByRole("textbox")[2]); + act(() => { + getAllByText("Open dialog")[1].focus(); + }); + await user.click(document.activeElement); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getAllByRole("textbox")[5]); + act(() => { + getAllByText("Open dialog")[2].focus(); + }); + await user.click(document.activeElement); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getAllByRole("textbox")[8]); + expect(focusScopeTree.size).toBe(4); + if (!contain) { + act(() => { + getAllByText("close")[1].focus(); + }); + await user.click(document.activeElement); + } else { + fireEvent.click(getAllByText("close")[1]); + } + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getAllByText("Open dialog")[1]); + act(() => { + getAllByText("close")[0].focus(); + }); + await user.click(document.activeElement); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(getAllByText("Open dialog")[0]); + expect(focusScopeTree.size).toBe(1); + }, + ); - describe('focusable first in scope', function () { - it('should restore focus to the first focusable or tabbable element within the scope when focus is lost within the scope', async function () { - let {getByTestId} = render( + describe("focusable first in scope", function () { + it("should restore focus to the first focusable or tabbable element within the scope when focus is lost within the scope", async function () { + let { getByTestId } = render(
- Remove me! - Remove me, too! - Remove me, three! + + Remove me! + + + Remove me, too! + + + Remove me, three! +
-
+ , ); function Item(props) { let focusManager = useFocusManager(); - let onClick = e => { + let onClick = (e) => { focusManager.focusNext(); act(() => { // remove fails to fire blur event in jest-dom @@ -725,10 +828,10 @@ describe('FocusScope', function () { }; return {' '} + onClick={() => setDisplay((state) => !state)} + > + {display ? "Close dialog" : "Open dialog"} + {" "} {display && ( @@ -780,68 +885,78 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let button1 = getByTestId('button1'); - let button2 = getByTestId('button2'); + let { getByTestId } = render(); + let button1 = getByTestId("button1"); + let button2 = getByTestId("button2"); await user.click(button1); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(button1); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(input1).toBeVisible(); await user.click(button2); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(button2); expect(input1).not.toBeInTheDocument(); await user.click(button1); - act(() => {jest.runAllTimers();}); - input1 = getByTestId('input1'); + act(() => { + jest.runAllTimers(); + }); + input1 = getByTestId("input1"); expect(input1).toBeVisible(); await user.tab(); - fireEvent.keyDown(document.activeElement, {key: 'Escape'}); - fireEvent.keyUp(document.activeElement, {key: 'Escape'}); - act(() => {jest.runAllTimers();}); + fireEvent.keyDown(document.activeElement, { key: "Escape" }); + fireEvent.keyUp(document.activeElement, { key: "Escape" }); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(button2); expect(input1).not.toBeInTheDocument(); }); - it('should allow restoration to be overridden with a custom event', async function () { + it("should allow restoration to be overridden with a custom event", async function () { function Test() { let [show, setShow] = React.useState(false); let ref = React.useRef(null); - useEvent(ref, 'react-aria-focus-scope-restore', e => { + useEvent(ref, "react-aria-focus-scope-restore", (e) => { e.preventDefault(); }); return (
- {show && - setShow(false)} /> - } + {show && ( + + setShow(false)} /> + + )}
); } - let {getByRole} = render(); - let button = getByRole('button'); + let { getByRole } = render(); + let button = getByRole("button"); await user.click(button); - let input = getByRole('textbox'); + let input = getByRole("textbox"); expect(document.activeElement).toBe(input); - await user.keyboard('{Escape}'); + await user.keyboard("{Escape}"); act(() => jest.runAllTimers()); expect(input).not.toBeInTheDocument(); expect(document.activeElement).toBe(document.body); }); - it('should not bubble focus scope restoration event out of nested focus scopes', async function () { + it("should not bubble focus scope restoration event out of nested focus scopes", async function () { function Test() { let [show, setShow] = React.useState(false); let ref = React.useRef(null); - useEvent(ref, 'react-aria-focus-scope-restore', e => { + useEvent(ref, "react-aria-focus-scope-restore", (e) => { e.preventDefault(); }); @@ -849,62 +964,66 @@ describe('FocusScope', function () {
- {show && - setShow(false)} /> - } + {show && ( + + setShow(false)} /> + + )}
); } - let {getByRole} = render(); - let button = getByRole('button'); + let { getByRole } = render(); + let button = getByRole("button"); await user.click(button); - let input = getByRole('textbox'); + let input = getByRole("textbox"); expect(document.activeElement).toBe(input); - await user.keyboard('{Escape}'); + await user.keyboard("{Escape}"); act(() => jest.runAllTimers()); expect(input).not.toBeInTheDocument(); expect(document.activeElement).toBe(button); }); }); - describe('auto focus', function () { - it('should auto focus the first tabbable element in the scope on mount', function () { - let {getByTestId} = render( + describe("auto focus", function () { + it("should auto focus the first tabbable element in the scope on mount", function () { + let { getByTestId } = render(
- + , ); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); - let input1 = getByTestId('input1'); + let input1 = getByTestId("input1"); expect(document.activeElement).toBe(input1); }); - it('should do nothing if something is already focused in the scope', function () { - let {getByTestId} = render( + it("should do nothing if something is already focused in the scope", function () { + let { getByTestId } = render(
- + , ); - let input2 = getByTestId('input2'); + let input2 = getByTestId("input2"); expect(document.activeElement).toBe(input2); }); }); - describe('focus manager', function () { - it('should move focus forward', async function () { + describe("focus manager", function () { + it("should move focus forward", async function () { function Test() { return ( @@ -924,12 +1043,14 @@ describe('FocusScope', function () { return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item2 = getByTestId('item2'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item2 = getByTestId("item2"); + let item3 = getByTestId("item3"); - act(() => {item1.focus();}); + act(() => { + item1.focus(); + }); await user.click(item1); expect(document.activeElement).toBe(item2); @@ -941,7 +1062,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item3); }); - it('should move focus forward and wrap around', async function () { + it("should move focus forward and wrap around", async function () { function Test() { return ( @@ -955,18 +1076,20 @@ describe('FocusScope', function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusNext({wrap: true}); + focusManager.focusNext({ wrap: true }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item2 = getByTestId('item2'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item2 = getByTestId("item2"); + let item3 = getByTestId("item3"); - act(() => {item1.focus();}); + act(() => { + item1.focus(); + }); await user.click(item1); expect(document.activeElement).toBe(item2); @@ -978,15 +1101,15 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item1); }); - it('should move focus forward but only to tabbable elements', async function () { + it("should move focus forward but only to tabbable elements", async function () { function Test() { return ( - - - + + + ); @@ -995,34 +1118,36 @@ describe('FocusScope', function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusNext({tabbable: true}); + focusManager.focusNext({ tabbable: true }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item3 = getByTestId("item3"); - act(() => {item1.focus();}); + act(() => { + item1.focus(); + }); await user.click(item1); expect(document.activeElement).toBe(item3); }); - it('should move focus forward but only to tabbable elements while accounting for container elements within the scope', function () { + it("should move focus forward but only to tabbable elements while accounting for container elements within the scope", function () { function Test() { return ( - + - - + + @@ -1035,18 +1160,18 @@ describe('FocusScope', function () { function Group(props) { let focusManager = useFocusManager(); - let onMouseDown = e => { - focusManager.focusNext({from: e.target, tabbable: true}); + let onMouseDown = (e) => { + focusManager.focusNext({ from: e.target, tabbable: true }); }; // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions return
; } - let {getByTestId} = render(); - let group1 = getByTestId('group1'); - let group2 = getByTestId('group2'); - let item2 = getByTestId('item2'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let group1 = getByTestId("group1"); + let group2 = getByTestId("group2"); + let item2 = getByTestId("item2"); + let item3 = getByTestId("item3"); fireEvent.mouseDown(group2); expect(document.activeElement).toBe(item3); @@ -1055,7 +1180,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item2); }); - it('should move focus forward and allow users to skip certain elements', async function () { + it("should move focus forward and allow users to skip certain elements", async function () { function Test() { return ( @@ -1071,18 +1196,20 @@ describe('FocusScope', function () { let onClick = () => { focusManager.focusNext({ wrap: true, - accept: (e) => !e.getAttribute('data-skip') + accept: (e) => !e.getAttribute("data-skip"), }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item3 = getByTestId("item3"); - act(() => {item1.focus();}); + act(() => { + item1.focus(); + }); await user.click(item1); expect(document.activeElement).toBe(item3); @@ -1091,7 +1218,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item1); }); - it('should move focus backward', async function () { + it("should move focus backward", async function () { function Test() { return ( @@ -1111,12 +1238,14 @@ describe('FocusScope', function () { return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item2 = getByTestId('item2'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item2 = getByTestId("item2"); + let item3 = getByTestId("item3"); - act(() => {item3.focus();}); + act(() => { + item3.focus(); + }); await user.click(item3); expect(document.activeElement).toBe(item2); @@ -1128,7 +1257,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item1); }); - it('should move focus backward and wrap around', async function () { + it("should move focus backward and wrap around", async function () { function Test() { return ( @@ -1142,18 +1271,20 @@ describe('FocusScope', function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusPrevious({wrap: true}); + focusManager.focusPrevious({ wrap: true }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item2 = getByTestId('item2'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item2 = getByTestId("item2"); + let item3 = getByTestId("item3"); - act(() => {item3.focus();}); + act(() => { + item3.focus(); + }); await user.click(item3); expect(document.activeElement).toBe(item2); @@ -1165,15 +1296,15 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item3); }); - it('should move focus backward but only to tabbable elements', async function () { + it("should move focus backward but only to tabbable elements", async function () { function Test() { return ( - - - + + + ); @@ -1182,34 +1313,36 @@ describe('FocusScope', function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusPrevious({tabbable: true}); + focusManager.focusPrevious({ tabbable: true }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item3 = getByTestId("item3"); - act(() => {item3.focus();}); + act(() => { + item3.focus(); + }); await user.click(item3); expect(document.activeElement).toBe(item1); }); - it('should move focus backward but only to tabbable elements while accounting for container elements within the scope', function () { + it("should move focus backward but only to tabbable elements while accounting for container elements within the scope", function () { function Test() { return ( - + - - + + @@ -1222,17 +1355,17 @@ describe('FocusScope', function () { function Group(props) { let focusManager = useFocusManager(); - let onMouseDown = e => { - focusManager.focusPrevious({from: e.target, tabbable: true}); + let onMouseDown = (e) => { + focusManager.focusPrevious({ from: e.target, tabbable: true }); }; // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions return
; } - let {getByTestId} = render(); - let group1 = getByTestId('group1'); - let group2 = getByTestId('group2'); - let item1 = getByTestId('item1'); + let { getByTestId } = render(); + let group1 = getByTestId("group1"); + let group2 = getByTestId("group2"); + let item1 = getByTestId("item1"); fireEvent.mouseDown(group2); expect(document.activeElement).toBe(item1); @@ -1244,7 +1377,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(item1); }); - it('should move focus backward and allow users to skip certain elements', async function () { + it("should move focus backward and allow users to skip certain elements", async function () { function Test() { return ( @@ -1260,18 +1393,20 @@ describe('FocusScope', function () { let onClick = () => { focusManager.focusPrevious({ wrap: true, - accept: (e) => !e.getAttribute('data-skip') + accept: (e) => !e.getAttribute("data-skip"), }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let {getByTestId} = render(); - let item1 = getByTestId('item1'); - let item3 = getByTestId('item3'); + let { getByTestId } = render(); + let item1 = getByTestId("item1"); + let item3 = getByTestId("item3"); - act(() => {item1.focus();}); + act(() => { + item1.focus(); + }); await user.click(item1); expect(document.activeElement).toBe(item3); @@ -1281,7 +1416,7 @@ describe('FocusScope', function () { }); }); - it('skips radio buttons that are in the same group and are not the selectable one forwards', async function () { + it("skips radio buttons that are in the same group and are not the selectable one forwards", async function () { function Test() { return ( @@ -1291,7 +1426,13 @@ describe('FocusScope', function () { Select a maintenance drone:
- +
@@ -1329,25 +1470,25 @@ describe('FocusScope', function () { ); } - let {getByTestId, getAllByRole} = render(); - let radios = getAllByRole('radio'); + let { getByTestId, getAllByRole } = render(); + let radios = getAllByRole("radio"); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button1')); + expect(document.activeElement).toBe(getByTestId("button1")); await user.tab(); expect(document.activeElement).toBe(radios[0]); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button2')); + expect(document.activeElement).toBe(getByTestId("button2")); await user.tab(); expect(document.activeElement).toBe(radios[3]); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button3')); + expect(document.activeElement).toBe(getByTestId("button3")); await user.tab(); expect(document.activeElement).toBe(radios[5]); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button4')); + expect(document.activeElement).toBe(getByTestId("button4")); }); - it('skips radio buttons that are in the same group and are not the selectable one forwards outside of a form', async function () { + it("skips radio buttons that are in the same group and are not the selectable one forwards outside of a form", async function () { function Test() { return ( @@ -1356,7 +1497,13 @@ describe('FocusScope', function () { Select a maintenance drone:
- +
@@ -1393,25 +1540,25 @@ describe('FocusScope', function () { ); } - let {getByTestId, getAllByRole} = render(); - let radios = getAllByRole('radio'); + let { getByTestId, getAllByRole } = render(); + let radios = getAllByRole("radio"); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button1')); + expect(document.activeElement).toBe(getByTestId("button1")); await user.tab(); expect(document.activeElement).toBe(radios[0]); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button2')); + expect(document.activeElement).toBe(getByTestId("button2")); await user.tab(); expect(document.activeElement).toBe(radios[3]); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button3')); + expect(document.activeElement).toBe(getByTestId("button3")); await user.tab(); expect(document.activeElement).toBe(radios[5]); await user.tab(); - expect(document.activeElement).toBe(getByTestId('button4')); + expect(document.activeElement).toBe(getByTestId("button4")); }); - it('skips radio buttons that are in the same group and are not the selectable one backwards', async function () { + it("skips radio buttons that are in the same group and are not the selectable one backwards", async function () { function Test() { return ( @@ -1421,7 +1568,13 @@ describe('FocusScope', function () { Select a maintenance drone:
- +
@@ -1459,24 +1612,24 @@ describe('FocusScope', function () { ); } - let {getByTestId, getAllByRole} = render(); - let radios = getAllByRole('radio'); - await user.click(getByTestId('button4')); - await user.tab({shift: true}); + let { getByTestId, getAllByRole } = render(); + let radios = getAllByRole("radio"); + await user.click(getByTestId("button4")); + await user.tab({ shift: true }); expect(document.activeElement).toBe(radios[5]); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('button3')); - await user.tab({shift: true}); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("button3")); + await user.tab({ shift: true }); expect(document.activeElement).toBe(radios[4]); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('button2')); - await user.tab({shift: true}); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("button2")); + await user.tab({ shift: true }); expect(document.activeElement).toBe(radios[0]); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('button1')); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("button1")); }); - it('skips radio buttons that are in the same group and are not the selectable one backwards outside of a form', async function () { + it("skips radio buttons that are in the same group and are not the selectable one backwards outside of a form", async function () { function Test() { return ( @@ -1485,7 +1638,13 @@ describe('FocusScope', function () { Select a maintenance drone:
- +
@@ -1522,63 +1681,67 @@ describe('FocusScope', function () { ); } - let {getByTestId, getAllByRole} = render(); - let radios = getAllByRole('radio'); - await user.click(getByTestId('button4')); - await user.tab({shift: true}); + let { getByTestId, getAllByRole } = render(); + let radios = getAllByRole("radio"); + await user.click(getByTestId("button4")); + await user.tab({ shift: true }); expect(document.activeElement).toBe(radios[5]); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('button3')); - await user.tab({shift: true}); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("button3")); + await user.tab({ shift: true }); expect(document.activeElement).toBe(radios[4]); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('button2')); - await user.tab({shift: true}); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("button2")); + await user.tab({ shift: true }); expect(document.activeElement).toBe(radios[0]); - await user.tab({shift: true}); - expect(document.activeElement).toBe(getByTestId('button1')); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(getByTestId("button1")); }); - describe('nested focus scopes', function () { - it('should make child FocusScopes the active scope regardless of DOM structure', function () { + describe("nested focus scopes", function () { + it("should make child FocusScopes the active scope regardless of DOM structure", function () { function ChildComponent(props) { return ReactDOM.createPortal(props.children, document.body); } - function Test({show}) { + function Test({ show }) { return (
- {show && + {show && ( - } + )}
); } - let {getByTestId, rerender} = render(); + let { getByTestId, rerender } = render(); // Set a focused node and make first FocusScope the active scope - let input1 = getByTestId('input1'); - act(() => {input1.focus();}); + let input1 = getByTestId("input1"); + act(() => { + input1.focus(); + }); fireEvent.focusIn(input1); expect(document.activeElement).toBe(input1); rerender(); expect(document.activeElement).toBe(input1); - let input3 = getByTestId('input3'); - act(() => {input3.focus();}); + let input3 = getByTestId("input3"); + act(() => { + input3.focus(); + }); fireEvent.focusIn(input3); expect(document.activeElement).toBe(input3); }); - it('should lock tab navigation inside direct child focus scope', async function () { + it("should lock tab navigation inside direct child focus scope", async function () { function Test() { return (
@@ -1597,28 +1760,38 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let child1 = getByTestId('child1'); - let child2 = getByTestId('child2'); - let child3 = getByTestId('child3'); + let { getByTestId } = render(); + let child1 = getByTestId("child1"); + let child2 = getByTestId("child2"); + let child3 = getByTestId("child3"); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(child1); await user.tab(); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(child2); await user.tab(); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(child3); await user.tab(); - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(child1); - await user.tab({shift: true}); - act(() => {jest.runAllTimers();}); + await user.tab({ shift: true }); + act(() => { + jest.runAllTimers(); + }); expect(document.activeElement).toBe(child3); }); - it('should lock tab navigation inside nested child focus scope', async function () { + it("should lock tab navigation inside nested child focus scope", async function () { function Test() { return (
@@ -1641,10 +1814,10 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let child1 = getByTestId('child1'); - let child2 = getByTestId('child2'); - let child3 = getByTestId('child3'); + let { getByTestId } = render(); + let child1 = getByTestId("child1"); + let child2 = getByTestId("child2"); + let child3 = getByTestId("child3"); expect(document.activeElement).toBe(child1); await user.tab(); @@ -1653,11 +1826,11 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(child3); await user.tab(); expect(document.activeElement).toBe(child1); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(child3); }); - it('should not lock tab navigation inside a nested focus scope without contain', async function () { + it("should not lock tab navigation inside a nested focus scope without contain", async function () { function Test() { return (
@@ -1678,11 +1851,11 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let parent = getByTestId('parent'); - let child1 = getByTestId('child1'); - let child2 = getByTestId('child2'); - let child3 = getByTestId('child3'); + let { getByTestId } = render(); + let parent = getByTestId("parent"); + let child1 = getByTestId("child1"); + let child2 = getByTestId("child2"); + let child3 = getByTestId("child3"); expect(document.activeElement).toBe(parent); await user.tab(); @@ -1693,11 +1866,11 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(child3); await user.tab(); expect(document.activeElement).toBe(parent); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(child3); }); - it('should not lock tab navigation inside a nested focus scope with restore and not contain', async function () { + it("should not lock tab navigation inside a nested focus scope with restore and not contain", async function () { function Test() { return (
@@ -1718,11 +1891,11 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let parent = getByTestId('parent'); - let child1 = getByTestId('child1'); - let child2 = getByTestId('child2'); - let child3 = getByTestId('child3'); + let { getByTestId } = render(); + let parent = getByTestId("parent"); + let child1 = getByTestId("child1"); + let child2 = getByTestId("child2"); + let child3 = getByTestId("child3"); expect(document.activeElement).toBe(parent); await user.tab(); @@ -1733,39 +1906,39 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(child3); await user.tab(); expect(document.activeElement).toBe(parent); - await user.tab({shift: true}); + await user.tab({ shift: true }); expect(document.activeElement).toBe(child3); }); - it('should restore to the correct scope on unmount', async function () { - function Test({show1, show2, show3}) { + it("should restore to the correct scope on unmount", async function () { + function Test({ show1, show2, show3 }) { return (
- {show1 && + {show1 && ( - {show2 && + {show2 && ( - {show3 && + {show3 && ( - } + )} - } + )} - } + )}
); } - let {rerender, getByTestId} = render(); - let parent = getByTestId('parent'); + let { rerender, getByTestId } = render(); + let parent = getByTestId("parent"); expect(document.activeElement).toBe(parent); @@ -1773,7 +1946,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(parent); // Can move into a child, but not out. - let child1 = getByTestId('child1'); + let child1 = getByTestId("child1"); await user.tab(); expect(document.activeElement).toBe(child1); @@ -1783,7 +1956,7 @@ describe('FocusScope', function () { rerender(); expect(document.activeElement).toBe(child1); - let child2 = getByTestId('child2'); + let child2 = getByTestId("child2"); await user.tab(); expect(document.activeElement).toBe(child2); @@ -1795,7 +1968,7 @@ describe('FocusScope', function () { rerender(); - let child3 = getByTestId('child3'); + let child3 = getByTestId("child3"); await user.tab(); expect(document.activeElement).toBe(child3); @@ -1808,7 +1981,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(child1); }); - it('should not lock focus inside a focus scope with a child scope in a portal', function () { + it("should not lock focus inside a focus scope with a child scope in a portal", function () { function Portal(props) { return ReactDOM.createPortal(props.children, document.body); } @@ -1830,9 +2003,9 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let parent = getByTestId('parent'); - let child = getByTestId('child'); + let { getByTestId } = render(); + let parent = getByTestId("parent"); + let child = getByTestId("child"); expect(document.activeElement).toBe(parent); act(() => child.focus()); @@ -1841,7 +2014,7 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(parent); }); - it('should lock focus inside a child focus scope with contain in a portal', function () { + it("should lock focus inside a child focus scope with contain in a portal", function () { function Portal(props) { return ReactDOM.createPortal(props.children, document.body); } @@ -1863,9 +2036,9 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let parent = getByTestId('parent'); - let child = getByTestId('child'); + let { getByTestId } = render(); + let parent = getByTestId("parent"); + let child = getByTestId("child"); expect(document.activeElement).toBe(parent); act(() => child.focus()); @@ -1875,8 +2048,8 @@ describe('FocusScope', function () { }); }); - describe('scope child of document.body', function () { - it('should navigate in and out of scope in DOM order when the nodeToRestore is the document.body', async function () { + describe("scope child of document.body", function () { + it("should navigate in and out of scope in DOM order when the nodeToRestore is the document.body", async function () { function Test() { return (
@@ -1889,21 +2062,25 @@ describe('FocusScope', function () { ); } - let {getByTestId} = render(); - let beforeScope = getByTestId('beforeScope'); - let inScope = getByTestId('inScope'); - let afterScope = getByTestId('afterScope'); + let { getByTestId } = render(); + let beforeScope = getByTestId("beforeScope"); + let inScope = getByTestId("inScope"); + let afterScope = getByTestId("afterScope"); - act(() => {inScope.focus();}); + act(() => { + inScope.focus(); + }); await user.tab(); expect(document.activeElement).toBe(afterScope); - act(() => {inScope.focus();}); - await user.tab({shift: true}); + act(() => { + inScope.focus(); + }); + await user.tab({ shift: true }); expect(document.activeElement).toBe(beforeScope); }); }); - describe('node to restore edge cases', () => { - it('tracks node to restore if the node to restore was removed in another part of the tree', async () => { + describe("node to restore edge cases", () => { + it("tracks node to restore if the node to restore was removed in another part of the tree", async () => { function Test() { let [showMenu, setShowMenu] = useState(false); let [showDialog, setShowDialog] = useState(false); @@ -1917,7 +2094,7 @@ describe('FocusScope', function () { - {}}> + { }}> {showMenu && ( @@ -1925,7 +2102,7 @@ describe('FocusScope', function () { )} - {}}> + { }}> {showDialog && ( @@ -1943,16 +2120,16 @@ describe('FocusScope', function () { }); await user.tab(); await user.tab(); - expect(document.activeElement.textContent).toBe('Open Menu'); + expect(document.activeElement.textContent).toBe("Open Menu"); - await user.keyboard('[Enter]'); + await user.keyboard("[Enter]"); act(() => { jest.runAllTimers(); }); - expect(document.activeElement.textContent).toBe('Open Dialog'); + expect(document.activeElement.textContent).toBe("Open Dialog"); - await user.keyboard('[Enter]'); + await user.keyboard("[Enter]"); // Needed for onBlur raf in useFocusContainment act(() => { @@ -1963,9 +2140,9 @@ describe('FocusScope', function () { jest.runAllTimers(); }); - expect(document.activeElement.textContent).toBe('Close'); + expect(document.activeElement.textContent).toBe("Close"); - await user.keyboard('[Enter]'); + await user.keyboard("[Enter]"); act(() => { jest.runAllTimers(); }); @@ -1974,17 +2151,17 @@ describe('FocusScope', function () { }); expect(document.activeElement).not.toBe(document.body); - expect(document.activeElement.textContent).toBe('Open Menu'); + expect(document.activeElement.textContent).toBe("Open Menu"); }); }); }); -describe('FocusScope with Shadow DOM', function () { +describe("FocusScope with Shadow DOM", function () { let user; beforeAll(() => { enableShadowDOM(); - user = userEvent.setup({delay: null, pointerMap}); + user = userEvent.setup({ delay: null, pointerMap }); }); beforeEach(() => { @@ -1992,28 +2169,33 @@ describe('FocusScope with Shadow DOM', function () { }); afterEach(() => { // make sure to clean up any raf's that may be running to restore focus on unmount - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); }); - it('should contain focus within the shadow DOM scope', async function () { - const {shadowRoot} = createShadowRoot(); - const FocusableComponent = () => ReactDOM.createPortal( - - - - - , - shadowRoot - ); + it("should contain focus within the shadow DOM scope", async function () { + const { shadowRoot } = createShadowRoot(); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + + , + shadowRoot, + ); - const {unmount} = render(); + const { unmount } = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); const input3 = shadowRoot.querySelector('[data-testid="input3"]'); // Simulate focusing the first input - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(document.activeElement).toBe(shadowRoot.host); expect(shadowRoot.activeElement).toBe(input1); @@ -2033,23 +2215,29 @@ describe('FocusScope with Shadow DOM', function () { document.body.removeChild(shadowRoot.host); }); - it('should manage focus within nested shadow DOMs', async function () { - const {shadowRoot: parentShadowRoot} = createShadowRoot(); - const nestedDiv = document.createElement('div'); + it("should manage focus within nested shadow DOMs", async function () { + const { shadowRoot: parentShadowRoot } = createShadowRoot(); + const nestedDiv = document.createElement("div"); parentShadowRoot.appendChild(nestedDiv); - const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); + const childShadowRoot = nestedDiv.attachShadow({ mode: "open" }); - const FocusableComponent = () => ReactDOM.createPortal( - - - , childShadowRoot); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + , + childShadowRoot, + ); - const {unmount} = render(); + const { unmount } = render(); - const input1 = childShadowRoot.querySelector('[data-testid=input1]'); - const input2 = childShadowRoot.querySelector('[data-testid=input2]'); + const input1 = childShadowRoot.querySelector("[data-testid=input1]"); + const input2 = childShadowRoot.querySelector("[data-testid=input2]"); - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(childShadowRoot.activeElement).toBe(input1); await user.tab(); @@ -2068,7 +2256,7 @@ describe('FocusScope with Shadow DOM', function () { * │ └── Your custom elements and focusable elements here * └── Other elements */ - it('should restore focus to the element outside shadow DOM on unmount, with FocusScope outside as well', async () => { + it("should restore focus to the element outside shadow DOM on unmount, with FocusScope outside as well", async () => { const App = () => ( <> @@ -2078,27 +2266,32 @@ describe('FocusScope with Shadow DOM', function () { ); - const {getByTestId} = render(); - const shadowHost = document.getElementById('shadow-host'); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + const { getByTestId } = render(); + const shadowHost = document.getElementById("shadow-host"); + const shadowRoot = shadowHost.attachShadow({ mode: "open" }); - const FocusableComponent = () => ReactDOM.createPortal( - - - - - , - shadowRoot - ); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + + , + shadowRoot, + ); - const {unmount} = render(); + const { unmount } = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - act(() => { input1.focus(); }); + act(() => { + input1.focus(); + }); expect(shadowRoot.activeElement).toBe(input1); - const externalInput = getByTestId('outside'); - act(() => { externalInput.focus(); }); + const externalInput = getByTestId("outside"); + act(() => { + externalInput.focus(); + }); expect(document.activeElement).toBe(externalInput); act(() => { @@ -2113,26 +2306,29 @@ describe('FocusScope with Shadow DOM', function () { /** * Test case: https://github.com/adobe/react-spectrum/issues/1472 */ - it('should autofocus and lock tab navigation inside shadow DOM', async function () { - const {shadowRoot, shadowHost} = createShadowRoot(); - - const FocusableComponent = () => ReactDOM.createPortal( - - - - - , - shadowRoot - ); + it("should autofocus and lock tab navigation inside shadow DOM", async function () { + const { shadowRoot, shadowHost } = createShadowRoot(); - const {unmount} = render(); + const FocusableComponent = () => + ReactDOM.createPortal( + + + + + , + shadowRoot, + ); + + const { unmount } = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); const button = shadowRoot.querySelector('[data-testid="button"]'); // Simulate focusing the first input and tab through the elements - act(() => {input1.focus();}); + act(() => { + input1.focus(); + }); expect(shadowRoot.activeElement).toBe(input1); // Hit TAB key @@ -2152,252 +2348,42 @@ describe('FocusScope with Shadow DOM', function () { document.body.removeChild(shadowHost); }); - describe('Shadow DOM boundary containment issues (issue #8675)', function () { - it('should properly detect element containment across shadow DOM boundaries with UNSAFE_PortalProvider', async function () { - const {shadowRoot} = createShadowRoot(); - - // Create a menu-like structure that reproduces the issue with UNSAFE_PortalProvider - function MenuInPopoverWithPortalProvider() { - const [isOpen, setIsOpen] = React.useState(true); - - return ( - shadowRoot}> - -
- {isOpen && ( - -
- - -
-
- )} -
-
-
- ); - } - - const {unmount} = render(); - - const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); - const menuItem2 = shadowRoot.querySelector('[data-testid="menu-item-2"]'); - const menu = shadowRoot.querySelector('[data-testid="menu"]'); - - // Focus the first menu item - act(() => { menuItem1.focus(); }); - expect(shadowRoot.activeElement).toBe(menuItem1); - - // Tab to second menu item should work - await user.tab(); - expect(shadowRoot.activeElement).toBe(menuItem2); - - // Tab should wrap back to first item due to focus containment - await user.tab(); - expect(shadowRoot.activeElement).toBe(menuItem1); - - // Menu should still be visible (not closed unexpectedly) - expect(menu).toBeInTheDocument(); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle focus events correctly in shadow DOM with nested FocusScopes and UNSAFE_PortalProvider', async function () { - const {shadowRoot} = createShadowRoot(); - let menuItemClickHandled = false; - - function NestedScopeMenuWithPortalProvider() { - const handleMenuItemClick = () => { - menuItemClickHandled = true; - }; - - return ( - shadowRoot}> - -
- - -
- -
-
-
-
-
- ); - } - - const {unmount} = render(); - - const trigger = shadowRoot.querySelector('[data-testid="trigger"]'); - const menuItem = shadowRoot.querySelector('[data-testid="menu-item"]'); - - // Focus the trigger first - act(() => { trigger.focus(); }); - expect(shadowRoot.activeElement).toBe(trigger); - - // Tab to menu item - await user.tab(); - expect(shadowRoot.activeElement).toBe(menuItem); - - // Click the menu item - this should fire the onClick handler - await user.click(menuItem); - expect(menuItemClickHandled).toBe(true); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - - it('should handle focus manager operations across shadow DOM boundaries', async function () { - const {shadowRoot} = createShadowRoot(); - - function FocusManagerTest() { - const focusManager = useFocusManager(); - - return ReactDOM.createPortal( - -
- - - -
-
, - shadowRoot - ); - } - - const {unmount} = render(); - - const firstButton = shadowRoot.querySelector('[data-testid="first"]'); - const secondButton = shadowRoot.querySelector('[data-testid="second"]'); - const thirdButton = shadowRoot.querySelector('[data-testid="third"]'); + 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; - // Focus first button - act(() => { firstButton.focus(); }); - expect(shadowRoot.activeElement).toBe(firstButton); + // Create portal container within the shadow DOM for the popover + const popoverPortal = document.createElement("div"); + popoverPortal.setAttribute("data-testid", "popover-portal"); + shadowRoot.appendChild(popoverPortal); - // Click first button to trigger focusNext - await user.click(firstButton); - expect(shadowRoot.activeElement).toBe(secondButton); + // This reproduces the exact scenario described in the issue + function WebComponentWithReactApp() { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(true); - // Click second button to trigger focusPrevious - await user.click(secondButton); - expect(shadowRoot.activeElement).toBe(firstButton); + const handleMenuAction = (key) => { + actionExecuted = true; + // In the original issue, this never executes because the popover closes first + console.log("Menu action executed:", key); + }; - // Move to third button and test focusFirst - act(() => { thirdButton.focus(); }); - await user.click(thirdButton); - expect(shadowRoot.activeElement).toBe(firstButton); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should correctly handle portaled elements within shadow DOM scopes', async function () { - const {shadowRoot} = createShadowRoot(); - const portalTarget = document.createElement('div'); - shadowRoot.appendChild(portalTarget); - - function PortalInShadowDOM() { - return ReactDOM.createPortal( - -
- - {ReactDOM.createPortal( - , - portalTarget - )} -
-
, - shadowRoot - ); - } - - const {unmount} = render(); - - const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); - const portaledButton = shadowRoot.querySelector('[data-testid="portaled-button"]'); - - // Focus main button - act(() => { mainButton.focus(); }); - expect(shadowRoot.activeElement).toBe(mainButton); - - // Focus portaled button - act(() => { portaledButton.focus(); }); - expect(shadowRoot.activeElement).toBe(portaledButton); - - // Tab navigation should work between main and portaled elements - await user.tab(); - // The exact behavior may vary, but focus should remain within the shadow DOM - expect(shadowRoot.activeElement).toBeTruthy(); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should reproduce the specific issue #8675: Menu items in popover close immediately with UNSAFE_PortalProvider', async function () { - const {shadowRoot} = createShadowRoot(); - let actionExecuted = false; - let menuClosed = false; - - // 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}> -
- {isPopoverOpen && ( + return ( + shadowRoot}> +
+ + {/* Portal the popover overlay to simulate real-world usage */} + {isPopoverOpen && + ReactDOM.createPortal(
@@ -2405,104 +2391,121 @@ describe('FocusScope with Shadow DOM', function () {
- + , + popoverPortal, )} - -
-
- ); - } +
+ + ); + } - const {unmount} = render(); - - 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"]'); - - // Verify the menu is initially visible - expect(menuContainer).toBeInTheDocument(); - expect(popoverOverlay).toBeInTheDocument(); - - // 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(menuContainer).toBeInTheDocument(); - - // Test focus containment within the menu - act(() => { saveMenuItem.focus(); }); - await user.tab(); - expect(shadowRoot.activeElement).toBe(exportMenuItem); + const { unmount } = render(); - await user.tab(); - // Focus should wrap back to first item due to containment - expect(shadowRoot.activeElement).toBe(saveMenuItem); + // 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(); - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); + // Focus the first menu item + act(() => { + saveMenuItem.focus(); }); + expect(shadowRoot.activeElement).toBe(saveMenuItem); - it('should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider', async function () { - const {shadowRoot} = createShadowRoot(); - - // Create nested portal containers within the shadow DOM - const modalPortal = document.createElement('div'); - modalPortal.setAttribute('data-testid', 'modal-portal'); - shadowRoot.appendChild(modalPortal); + // Click the menu item - this should execute the onAction handler, NOT close the menu + await user.click(saveMenuItem); - const tooltipPortal = document.createElement('div'); - tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); - shadowRoot.appendChild(tooltipPortal); + // The action should have been executed (this would fail in the buggy version) + expect(actionExecuted).toBe(true); - function ComplexWebComponent() { - const [showModal, setShowModal] = React.useState(true); - const [showTooltip, setShowTooltip] = React.useState(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(); - return ( - shadowRoot}> -
- - - {/* Modal with its own focus scope */} - {showModal && ReactDOM.createPortal( + // 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, setShowTooltip] = React.useState(true); + + return ( + shadowRoot}> +
+ + + {/* Modal with its own focus scope */} + {showModal && + ReactDOM.createPortal(
-
, - modalPortal + modalPortal, )} - {/* Tooltip with nested focus scope */} - {showTooltip && ReactDOM.createPortal( + {/* Tooltip with nested focus scope */} + {showTooltip && + ReactDOM.createPortal(
, - tooltipPortal + tooltipPortal, )} -
-
- ); - } +
+
+ ); + } - const {unmount} = render(); + 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"]'); + 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); + // 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); + // 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'); + // 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(); }); - // But due to modal containment, focus should be restored back to modal - act(() => { jest.runAllTimers(); }); - expect(shadowRoot.activeElement).toBe(modalButton1); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); + 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', () => { +describe("Unmounting cleanup", () => { beforeAll(() => { jest.useFakeTimers(); }); @@ -2572,14 +2589,14 @@ describe('Unmounting cleanup', () => { }); // this test will fail in the 'afterAll' if there are any rafs left over - it('should not leak request animation frames', () => { + it("should not leak request animation frames", () => { let tree = render( - + , ); - let buttons = tree.getAllByRole('button'); + let buttons = tree.getAllByRole("button"); act(() => buttons[0].focus()); act(() => buttons[1].focus()); act(() => buttons[1].blur()); 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 2a671aea2c7..5bfb5ef6055 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -10,90 +10,94 @@ * governing permissions and limitations under the License. */ -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'; +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(); - useInteractOutside({ref, ...props}); - return
test
; + useInteractOutside({ ref, ...props }); + return ( +
+ test +
+ ); } function pointerEvent(type, opts) { - let evt = new Event(type, {bubbles: true, cancelable: true}); + let evt = new Event(type, { bubbles: true, cancelable: true }); Object.assign(evt, opts); return evt; } -describe('useInteractOutside', function () { +describe("useInteractOutside", function () { // TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests. // https://github.com/jsdom/jsdom/issues/2527 - describe('pointer events', function () { + describe("pointer events", function () { installPointerEvent(); - it('should fire interact outside events based on pointer events', function () { + it("should fire interact outside events based on pointer events", function () { let onInteractOutside = jest.fn(); - let res = render( - - ); + let res = render(); - let el = res.getByText('test'); - fireEvent(el, pointerEvent('pointerdown')); - fireEvent(el, pointerEvent('pointerup')); + let el = res.getByText("test"); + fireEvent(el, pointerEvent("pointerdown")); + fireEvent(el, pointerEvent("pointerup")); fireEvent.click(el); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(document.body, pointerEvent('pointerdown')); - fireEvent(document.body, pointerEvent('pointerup')); + fireEvent(document.body, pointerEvent("pointerdown")); + fireEvent(document.body, pointerEvent("pointerup")); fireEvent.click(document.body); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should only listen for the left mouse button', function () { + it("should only listen for the left mouse button", function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); - fireEvent(document.body, pointerEvent('pointerdown', {button: 1})); - fireEvent(document.body, pointerEvent('pointerup', {button: 1})); - fireEvent.click(document.body, {button: 1}); + fireEvent(document.body, pointerEvent("pointerdown", { button: 1 })); + fireEvent(document.body, pointerEvent("pointerup", { button: 1 })); + fireEvent.click(document.body, { button: 1 }); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(document.body, pointerEvent('pointerdown', {button: 0})); - fireEvent(document.body, pointerEvent('pointerup', {button: 0})); - fireEvent.click(document.body, {button: 0}); + fireEvent(document.body, pointerEvent("pointerdown", { button: 0 })); + fireEvent(document.body, pointerEvent("pointerup", { button: 0 })); + fireEvent.click(document.body, { button: 0 }); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should not fire interact outside if there is a pointer up event without a pointer down first', function () { + it("should not fire interact outside if there is a pointer up event without a pointer down first", function () { // Fire pointer down before component with useInteractOutside is mounted - fireEvent(document.body, pointerEvent('pointerdown')); + fireEvent(document.body, pointerEvent("pointerdown")); let onInteractOutside = jest.fn(); - render( - - ); + render(); - fireEvent(document.body, pointerEvent('pointerup')); + fireEvent(document.body, pointerEvent("pointerup")); fireEvent.click(document.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe('mouse events', function () { - it('should fire interact outside events based on mouse events', function () { + describe("mouse events", function () { + it("should fire interact outside events based on mouse events", function () { let onInteractOutside = jest.fn(); - let res = render( - - ); + let res = render(); - let el = res.getByText('test'); + let el = res.getByText("test"); fireEvent.mouseDown(el); fireEvent.mouseUp(el); expect(onInteractOutside).not.toHaveBeenCalled(); @@ -103,43 +107,37 @@ describe('useInteractOutside', function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should only listen for the left mouse button', function () { + it("should only listen for the left mouse button", function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); - fireEvent.mouseDown(document.body, {button: 1}); - fireEvent.mouseUp(document.body, {button: 1}); + fireEvent.mouseDown(document.body, { button: 1 }); + fireEvent.mouseUp(document.body, { button: 1 }); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent.mouseDown(document.body, {button: 0}); - fireEvent.mouseUp(document.body, {button: 0}); + fireEvent.mouseDown(document.body, { button: 0 }); + fireEvent.mouseUp(document.body, { button: 0 }); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should not fire interact outside if there is a mouse up event without a mouse down first', function () { + it("should not fire interact outside if there is a mouse up event without a mouse down first", function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.mouseDown(document.body); let onInteractOutside = jest.fn(); - render( - - ); + render(); fireEvent.mouseUp(document.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe('touch events', function () { - it('should fire interact outside events based on mouse events', function () { + describe("touch events", function () { + it("should fire interact outside events based on mouse events", function () { let onInteractOutside = jest.fn(); - let res = render( - - ); + let res = render(); - let el = res.getByText('test'); + let el = res.getByText("test"); fireEvent.touchStart(el); fireEvent.touchEnd(el); expect(onInteractOutside).not.toHaveBeenCalled(); @@ -149,13 +147,11 @@ describe('useInteractOutside', function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should ignore emulated mouse events', function () { + it("should ignore emulated mouse events", function () { let onInteractOutside = jest.fn(); - let res = render( - - ); + let res = render(); - let el = res.getByText('test'); + let el = res.getByText("test"); fireEvent.touchStart(el); fireEvent.touchEnd(el); fireEvent.mouseUp(el); @@ -167,47 +163,39 @@ describe('useInteractOutside', function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should not fire interact outside if there is a touch end event without a touch start first', function () { + it("should not fire interact outside if there is a touch end event without a touch start first", function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.touchStart(document.body); let onInteractOutside = jest.fn(); - render( - - ); + render(); fireEvent.touchEnd(document.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe('disable interact outside events', function () { - it('does not handle pointer events if disabled', function () { + describe("disable interact outside events", function () { + it("does not handle pointer events if disabled", function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); - fireEvent(document.body, pointerEvent('mousedown')); - fireEvent(document.body, pointerEvent('mouseup')); + fireEvent(document.body, pointerEvent("mousedown")); + fireEvent(document.body, pointerEvent("mouseup")); expect(onInteractOutside).not.toHaveBeenCalled(); }); - it('does not handle touch events if disabled', function () { + it("does not handle touch events if disabled", function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); fireEvent.touchStart(document.body); fireEvent.touchEnd(document.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); - it('does not handle mouse events if disabled', function () { + it("does not handle mouse events if disabled", function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); fireEvent.mouseDown(document.body); fireEvent.mouseUp(document.body); @@ -216,15 +204,15 @@ describe('useInteractOutside', function () { }); }); -describe('useInteractOutside (iframes)', function () { +describe("useInteractOutside (iframes)", function () { let iframe; let iframeRoot; let iframeDocument; beforeEach(() => { - iframe = document.createElement('iframe'); + iframe = document.createElement("iframe"); window.document.body.appendChild(iframe); iframeDocument = iframe.contentWindow.document; - iframeRoot = iframeDocument.createElement('div'); + iframeRoot = iframeDocument.createElement("div"); iframeDocument.body.appendChild(iframeRoot); }); @@ -238,82 +226,112 @@ describe('useInteractOutside (iframes)', function () { // TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests. // https://github.com/jsdom/jsdom/issues/2527 - describe('pointer events', function () { + describe("pointer events", function () { installPointerEvent(); - it('should fire interact outside events based on pointer events', async function () { + it("should fire interact outside events based on pointer events", async function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]'); - fireEvent(el, pointerEvent('pointerdown')); - fireEvent(el, pointerEvent('pointerup')); + const el = document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ); + fireEvent(el, pointerEvent("pointerdown")); + fireEvent(el, pointerEvent("pointerup")); fireEvent.click(el); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(iframeDocument.body, pointerEvent('pointerdown')); - fireEvent(iframeDocument.body, pointerEvent('pointerup')); + fireEvent(iframeDocument.body, pointerEvent("pointerdown")); + fireEvent(iframeDocument.body, pointerEvent("pointerup")); fireEvent.click(iframeDocument.body); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should only listen for the left mouse button', async function () { + it("should only listen for the left mouse button", async function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - fireEvent(iframeDocument.body, pointerEvent('pointerdown', {button: 1})); - fireEvent(iframeDocument.body, pointerEvent('pointerup', {button: 1})); - fireEvent.click(iframeDocument.body, {button: 0}); + fireEvent( + iframeDocument.body, + pointerEvent("pointerdown", { button: 1 }), + ); + fireEvent(iframeDocument.body, pointerEvent("pointerup", { button: 1 })); + fireEvent.click(iframeDocument.body, { button: 0 }); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(iframeDocument.body, pointerEvent('pointerdown', {button: 0})); - fireEvent(iframeDocument.body, pointerEvent('pointerup', {button: 0})); - fireEvent.click(iframeDocument.body, {button: 0}); + fireEvent( + iframeDocument.body, + pointerEvent("pointerdown", { button: 0 }), + ); + fireEvent(iframeDocument.body, pointerEvent("pointerup", { button: 0 })); + fireEvent.click(iframeDocument.body, { button: 0 }); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should not fire interact outside if there is a pointer up event without a pointer down first', async function () { + it("should not fire interact outside if there is a pointer up event without a pointer down first", async function () { // Fire pointer down before component with useInteractOutside is mounted - fireEvent(iframeDocument.body, pointerEvent('pointerdown')); + fireEvent(iframeDocument.body, pointerEvent("pointerdown")); let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - fireEvent(iframeDocument.body, pointerEvent('pointerup')); + fireEvent(iframeDocument.body, pointerEvent("pointerup")); fireEvent.click(iframeDocument.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe('mouse events', function () { - it('should fire interact outside events based on mouse events', async function () { + describe("mouse events", function () { + it("should fire interact outside events based on mouse events", async function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]'); + const el = document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ); fireEvent.mouseDown(el); fireEvent.mouseUp(el); expect(onInteractOutside).not.toHaveBeenCalled(); @@ -323,54 +341,70 @@ describe('useInteractOutside (iframes)', function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should only listen for the left mouse button', async function () { + it("should only listen for the left mouse button", async function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - fireEvent.mouseDown(iframeDocument.body, {button: 1}); - fireEvent.mouseUp(iframeDocument.body, {button: 1}); + fireEvent.mouseDown(iframeDocument.body, { button: 1 }); + fireEvent.mouseUp(iframeDocument.body, { button: 1 }); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent.mouseDown(iframeDocument.body, {button: 0}); - fireEvent.mouseUp(iframeDocument.body, {button: 0}); + fireEvent.mouseDown(iframeDocument.body, { button: 0 }); + fireEvent.mouseUp(iframeDocument.body, { button: 0 }); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should not fire interact outside if there is a mouse up event without a mouse down first', async function () { + it("should not fire interact outside if there is a mouse up event without a mouse down first", async function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.mouseDown(iframeDocument.body); let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); fireEvent.mouseUp(iframeDocument.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe('touch events', function () { - it('should fire interact outside events based on mouse events', async function () { + describe("touch events", function () { + it("should fire interact outside events based on mouse events", async function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]'); + const el = document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ); fireEvent.touchStart(el); fireEvent.touchEnd(el); expect(onInteractOutside).not.toHaveBeenCalled(); @@ -380,17 +414,25 @@ describe('useInteractOutside (iframes)', function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should ignore emulated mouse events', async function () { + it("should ignore emulated mouse events", async function () { let onInteractOutside = jest.fn(); - render( - - ); + render(); await waitFor(() => { - expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]')).toBeTruthy(); + expect( + document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ), + ).toBeTruthy(); }); - const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]'); + const el = document + .querySelector("iframe") + .contentWindow.document.body.querySelector( + 'div[data-testid="example"]', + ); fireEvent.touchStart(el); fireEvent.touchEnd(el); fireEvent.mouseUp(el); @@ -402,36 +444,34 @@ describe('useInteractOutside (iframes)', function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it('should not fire interact outside if there is a touch end event without a touch start first', function () { + it("should not fire interact outside if there is a touch end event without a touch start first", function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.touchStart(iframeDocument.body); let onInteractOutside = jest.fn(); - render( - - ); + render(); fireEvent.touchEnd(iframeDocument.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe('disable interact outside events', function () { - it('does not handle pointer events if disabled', function () { + describe("disable interact outside events", function () { + it("does not handle pointer events if disabled", function () { let onInteractOutside = jest.fn(); render( - + , ); - fireEvent(iframeDocument.body, pointerEvent('mousedown')); - fireEvent(iframeDocument.body, pointerEvent('mouseup')); + fireEvent(iframeDocument.body, pointerEvent("mousedown")); + fireEvent(iframeDocument.body, pointerEvent("mouseup")); expect(onInteractOutside).not.toHaveBeenCalled(); }); - it('does not handle touch events if disabled', function () { + it("does not handle touch events if disabled", function () { let onInteractOutside = jest.fn(); render( - + , ); fireEvent.touchStart(iframeDocument.body); @@ -439,10 +479,10 @@ describe('useInteractOutside (iframes)', function () { expect(onInteractOutside).not.toHaveBeenCalled(); }); - it('does not handle mouse events if disabled', function () { + it("does not handle mouse events if disabled", function () { let onInteractOutside = jest.fn(); render( - + , ); fireEvent.mouseDown(iframeDocument.body); @@ -452,24 +492,24 @@ describe('useInteractOutside (iframes)', function () { }); }); -describe('useInteractOutside shadow DOM', function () { +describe("useInteractOutside shadow DOM", function () { // Helper function to create a shadow root and render the component inside it function createShadowRootAndRender(ui) { - const shadowHost = document.createElement('div'); + const shadowHost = document.createElement("div"); document.body.appendChild(shadowHost); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + const shadowRoot = shadowHost.attachShadow({ mode: "open" }); function WrapperComponent() { return ReactDOM.createPortal(ui, shadowRoot); } render(); - return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; + return { shadowRoot, cleanup: () => document.body.removeChild(shadowHost) }; } - function App({onInteractOutside}) { + function App({ onInteractOutside }) { const ref = useRef(null); - useInteractOutside({ref, onInteractOutside}); + useInteractOutside({ ref, onInteractOutside }); return (
@@ -481,13 +521,13 @@ describe('useInteractOutside shadow DOM', function () { ); } - it('does not trigger when clicking inside popover', function () { + it("does not trigger when clicking inside popover", function () { const onInteractOutside = jest.fn(); - const {shadowRoot, cleanup} = createShadowRootAndRender( - + const { shadowRoot, cleanup } = createShadowRootAndRender( + , ); - const insidePopover = shadowRoot.getElementById('inside-popover'); + const insidePopover = shadowRoot.getElementById("inside-popover"); fireEvent.mouseDown(insidePopover); fireEvent.mouseUp(insidePopover); @@ -495,13 +535,13 @@ describe('useInteractOutside shadow DOM', function () { cleanup(); }); - it('does not trigger when clicking the popover', function () { + it("does not trigger when clicking the popover", function () { const onInteractOutside = jest.fn(); - const {shadowRoot, cleanup} = createShadowRootAndRender( - + const { shadowRoot, cleanup } = createShadowRootAndRender( + , ); - const popover = shadowRoot.getElementById('popover'); + const popover = shadowRoot.getElementById("popover"); fireEvent.mouseDown(popover); fireEvent.mouseUp(popover); @@ -509,10 +549,10 @@ describe('useInteractOutside shadow DOM', function () { cleanup(); }); - it('triggers when clicking outside the popover', function () { + it("triggers when clicking outside the popover", function () { const onInteractOutside = jest.fn(); - const {cleanup} = createShadowRootAndRender( - + const { cleanup } = createShadowRootAndRender( + , ); // Clicking on the document body outside the shadow DOM @@ -523,13 +563,13 @@ describe('useInteractOutside shadow DOM', function () { cleanup(); }); - it('triggers when clicking a button outside the shadow dom altogether', function () { + it("triggers when clicking a button outside the shadow dom altogether", function () { const onInteractOutside = jest.fn(); - const {cleanup} = createShadowRootAndRender( - + const { cleanup } = createShadowRootAndRender( + , ); // Button outside shadow DOM and component - const button = document.createElement('button'); + const button = document.createElement("button"); document.body.appendChild(button); fireEvent.mouseDown(button); @@ -541,29 +581,29 @@ describe('useInteractOutside shadow DOM', function () { }); }); -describe('useInteractOutside shadow DOM extended tests', function () { +describe("useInteractOutside shadow DOM extended tests", function () { // Setup function similar to previous tests, but includes a dynamic element scenario function createShadowRootAndRender(ui) { - const shadowHost = document.createElement('div'); + const shadowHost = document.createElement("div"); document.body.appendChild(shadowHost); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + const shadowRoot = shadowHost.attachShadow({ mode: "open" }); function WrapperComponent() { return ReactDOM.createPortal(ui, shadowRoot); } render(); - return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; + return { shadowRoot, cleanup: () => document.body.removeChild(shadowHost) }; } - function App({onInteractOutside, includeDynamicElement = false}) { + function App({ onInteractOutside, includeDynamicElement = false }) { const ref = useRef(null); - useInteractOutside({ref, onInteractOutside}); + useInteractOutside({ ref, onInteractOutside }); useEffect(() => { if (includeDynamicElement) { - const dynamicEl = document.createElement('div'); - dynamicEl.id = 'dynamic-outside'; + const dynamicEl = document.createElement("div"); + dynamicEl.id = "dynamic-outside"; document.body.appendChild(dynamicEl); return () => document.body.removeChild(dynamicEl); @@ -580,14 +620,14 @@ describe('useInteractOutside shadow DOM extended tests', function () { ); } - it('correctly identifies interaction with dynamically added external elements', function () { + it("correctly identifies interaction with dynamically added external elements", function () { jest.useFakeTimers(); const onInteractOutside = jest.fn(); - const {cleanup} = createShadowRootAndRender( - + const { cleanup } = createShadowRootAndRender( + , ); - const dynamicEl = document.getElementById('dynamic-outside'); + const dynamicEl = document.getElementById("dynamic-outside"); fireEvent.mouseDown(dynamicEl); fireEvent.mouseUp(dynamicEl); @@ -597,12 +637,12 @@ describe('useInteractOutside shadow DOM extended tests', function () { }); }); -describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { +describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { let user; beforeAll(() => { enableShadowDOM(); - user = userEvent.setup({delay: null, pointerMap}); + user = userEvent.setup({ delay: null, pointerMap }); }); beforeEach(() => { @@ -610,46 +650,66 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { }); afterEach(() => { - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); }); - it('should handle interact outside events with UNSAFE_PortalProvider in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); + it("should handle interact outside events with UNSAFE_PortalProvider in shadow DOM", async () => { + const { shadowRoot } = 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 { 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"]'); + 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 + // Click the target itself - should NOT trigger interact outside await user.click(target); expect(interactOutsideTriggered).toBe(false); @@ -662,8 +722,8 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { document.body.removeChild(shadowRoot.host); }); - it('should correctly identify interactions across shadow DOM boundaries (issue #8675)', async () => { - const {shadowRoot} = createShadowRoot(); + it("should correctly identify interactions across shadow DOM boundaries (issue #8675)", async () => { + const { shadowRoot } = createShadowRoot(); let popoverClosed = false; function MenuPopoverExample() { @@ -672,34 +732,34 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { ref: popoverRef, onInteractOutside: () => { popoverClosed = true; - } + }, }); return ( shadowRoot}>
-
- - - + {/* Modal */} {ReactDOM.createPortal( -
- + {/* Popover within modal */} -
, - modalPortal + modalPortal, )}
); } - const {unmount} = render(); + const { unmount } = render(); const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); - const modalButton = shadowRoot.querySelector('[data-testid="modal-button"]'); - const popoverButton = shadowRoot.querySelector('[data-testid="popover-button"]'); + const modalButton = shadowRoot.querySelector( + '[data-testid="modal-button"]', + ); + const popoverButton = shadowRoot.querySelector( + '[data-testid="popover-button"]', + ); // Click popover button - should NOT trigger either interact outside await user.click(popoverButton); @@ -821,10 +893,10 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { document.body.removeChild(shadowRoot.host); }); - it('should handle pointer events correctly in shadow DOM with portal provider', async () => { + it("should handle pointer events correctly in shadow DOM with portal provider", async () => { installPointerEvent(); - const {shadowRoot} = createShadowRoot(); + const { shadowRoot } = createShadowRoot(); let interactOutsideCount = 0; function PointerEventsExample() { @@ -833,7 +905,7 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { ref, onInteractOutside: () => { interactOutsideCount++; - } + }, }); return ( @@ -848,20 +920,24 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { ); } - const {unmount} = render(); + const { unmount } = render(); - const targetButton = shadowRoot.querySelector('[data-testid="target-button"]'); - const outsideButton = shadowRoot.querySelector('[data-testid="outside-button"]'); + const targetButton = shadowRoot.querySelector( + '[data-testid="target-button"]', + ); + const outsideButton = shadowRoot.querySelector( + '[data-testid="outside-button"]', + ); // Simulate pointer events on target - should NOT trigger interact outside - fireEvent(targetButton, pointerEvent('pointerdown')); - fireEvent(targetButton, pointerEvent('pointerup')); + fireEvent(targetButton, pointerEvent("pointerdown")); + fireEvent(targetButton, pointerEvent("pointerup")); fireEvent.click(targetButton); expect(interactOutsideCount).toBe(0); // Simulate pointer events outside - should trigger interact outside - fireEvent(outsideButton, pointerEvent('pointerdown')); - fireEvent(outsideButton, pointerEvent('pointerup')); + fireEvent(outsideButton, pointerEvent("pointerdown")); + fireEvent(outsideButton, pointerEvent("pointerup")); fireEvent.click(outsideButton); expect(interactOutsideCount).toBe(1); @@ -870,26 +946,26 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { document.body.removeChild(shadowRoot.host); }); - it('should handle interact outside with dynamic content in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); + it("should handle interact outside with dynamic content in shadow DOM", async () => { + const { shadowRoot } = createShadowRoot(); let interactOutsideCount = 0; function DynamicContentExample() { const ref = useRef(); const [showContent, setShowContent] = React.useState(true); - + useInteractOutside({ ref, onInteractOutside: () => { interactOutsideCount++; - } + }, }); return ( shadowRoot}>
-
- , + ); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); - let input3 = getByTestId("input3"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); + let input3 = getByTestId('input3'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); expect(document.activeElement).toBe(input1); await user.tab(); @@ -117,39 +104,37 @@ describe("FocusScope", function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input3); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input2); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input1); }); - it("should skip non-tabbable elements", async function () { - let { getByTestId } = render( + it('should skip non-tabbable elements', async function () { + let {getByTestId} = render(
- - - + + +
- , + ); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); - let input3 = getByTestId("input3"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); + let input3 = getByTestId('input3'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); expect(document.activeElement).toBe(input1); await user.tab(); @@ -161,18 +146,18 @@ describe("FocusScope", function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input3); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input2); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input1); }); - it("should only skip content editable which are false", async function () { - let { getByTestId } = render( + it('should only skip content editable which are false', async function () { + let {getByTestId} = render( @@ -180,17 +165,15 @@ describe("FocusScope", function () { - , + ); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); - let input3 = getByTestId("input3"); - let input4 = getByTestId("input4"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); + let input3 = getByTestId('input3'); + let input4 = getByTestId('input4'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); expect(document.activeElement).toBe(input1); await user.tab(); @@ -202,66 +185,62 @@ describe("FocusScope", function () { await user.tab(); expect(document.activeElement).toBe(input4); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input3); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input2); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input1); }); - it("should do nothing if a modifier key is pressed", function () { - let { getByTestId } = render( + it('should do nothing if a modifier key is pressed', function () { + let {getByTestId} = render( - , + ); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); expect(document.activeElement).toBe(input1); - fireEvent.keyDown(document.activeElement, { key: "Tab", altKey: true }); + fireEvent.keyDown(document.activeElement, {key: 'Tab', altKey: true}); expect(document.activeElement).toBe(input1); }); - it("should work with multiple focus scopes", async function () { - let { getByTestId } = render( + it('should work with multiple focus scopes', async function () { + let {getByTestId} = render(
- - - + + + - - - + + + -
, +
); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); - let input3 = getByTestId("input3"); - let input4 = getByTestId("input4"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); + let input3 = getByTestId('input3'); + let input4 = getByTestId('input4'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); expect(document.activeElement).toBe(input1); await user.tab(); @@ -273,23 +252,21 @@ describe("FocusScope", function () { await user.tab(); expect(document.activeElement).toBe(input1); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input3); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input2); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(input1); - act(() => { - input4.focus(); - }); + act(() => {input4.focus();}); expect(document.activeElement).toBe(input1); }); - it("should restore focus to the last focused element in the scope when re-entering the browser", async function () { - let { getByTestId } = render( + it('should restore focus to the last focused element in the scope when re-entering the browser', async function () { + let {getByTestId} = render(
@@ -297,95 +274,75 @@ describe("FocusScope", function () { -
, +
); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); - let outside = getByTestId("outside"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); + let outside = getByTestId('outside'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); fireEvent.focusIn(input1); // jsdom doesn't fire this automatically expect(document.activeElement).toBe(input1); await user.tab(); fireEvent.focusIn(input2); - act(() => { - jest.runAllTimers(); - }); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); - act(() => { - input2.blur(); - }); - act(() => { - jest.runAllTimers(); - }); + act(() => {input2.blur();}); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); - act(() => { - outside.focus(); - }); + act(() => {outside.focus();}); fireEvent.focusIn(outside); expect(document.activeElement).toBe(input2); }); - it("should restore focus to the last focused element in the scope on focus out", async function () { - let { getByTestId } = render( + it('should restore focus to the last focused element in the scope on focus out', async function () { + let {getByTestId} = render(
-
, +
); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); fireEvent.focusIn(input1); // jsdom doesn't fire this automatically expect(document.activeElement).toBe(input1); await user.tab(); fireEvent.focusIn(input2); - act(() => { - jest.runAllTimers(); - }); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); - act(() => { - input2.blur(); - }); - act(() => { - jest.runAllTimers(); - }); + act(() => {input2.blur();}); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(input2); fireEvent.focusOut(input2); expect(document.activeElement).toBe(input2); }); // This test setup is a bit contrived to just purely simulate the blur/focus events that would happen in a case like this - it("focus properly moves into child iframe on click", function () { - let { getByTestId } = render( + it('focus properly moves into child iframe on click', function () { + let {getByTestId} = render(
-
, +
); - let input1 = getByTestId("input1"); - let input2 = getByTestId("input2"); + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); - act(() => { - input1.focus(); - }); + act(() => {input1.focus();}); fireEvent.focusIn(input1); // jsdom doesn't fire this automatically expect(document.activeElement).toBe(input1); @@ -393,16 +350,16 @@ describe("FocusScope", function () { // set document.activeElement to input2 input2.focus(); // if onBlur didn't fallback to checking document.activeElement, this would reset focus to input1 - fireEvent.blur(input1, { relatedTarget: null }); + fireEvent.blur(input1, {relatedTarget: null}); }); expect(document.activeElement).toBe(input2); }); }); - describe("focus restoration", function () { - it("should restore focus to the previously focused node on unmount", function () { - function Test({ show }) { + describe('focus restoration', function () { + it('should restore focus to the previously focused node on unmount', function () { + function Test({show}) { return (
@@ -417,52 +374,46 @@ describe("FocusScope", function () { ); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let outside = getByTestId("outside"); - act(() => { - outside.focus(); - }); + let outside = getByTestId('outside'); + act(() => {outside.focus();}); rerender(); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); rerender(); - act(() => { - jest.runAllTimers(); - }); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(outside); }); - it("should restore focus to the previously focused node after a child with autoFocus unmounts", function () { - function Test({ show }) { + it('should restore focus to the previously focused node after a child with autoFocus unmounts', function () { + function Test({show}) { return (
- {show && ( + {show && - )} + }
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let outside = getByTestId("outside"); - act(() => { - outside.focus(); - }); + let outside = getByTestId('outside'); + act(() => {outside.focus();}); rerender(); - let input2 = getByTestId("input2"); + let input2 = getByTestId('input2'); expect(document.activeElement).toBe(input2); rerender(); @@ -473,115 +424,105 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(outside); }); - it("should move focus after the previously focused node when tabbing away from a scope with autoFocus", async function () { - function Test({ show }) { + it('should move focus after the previously focused node when tabbing away from a scope with autoFocus', async function () { + function Test({show}) { return (
- {show && ( + {show && - )} + }
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let outside = getByTestId("outside"); - act(() => { - outside.focus(); - }); + let outside = getByTestId('outside'); + act(() => {outside.focus();}); rerender(); - let input3 = getByTestId("input3"); + let input3 = getByTestId('input3'); expect(document.activeElement).toBe(input3); await user.tab(); - expect(document.activeElement).toBe(getByTestId("after")); + expect(document.activeElement).toBe(getByTestId('after')); }); - it("should move focus before the previously focused node when tabbing away from a scope with Shift+Tab", async function () { - function Test({ show }) { + it('should move focus before the previously focused node when tabbing away from a scope with Shift+Tab', async function () { + function Test({show}) { return (
- {show && ( + {show && - )} + }
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let outside = getByTestId("outside"); - act(() => { - outside.focus(); - }); + let outside = getByTestId('outside'); + act(() => {outside.focus();}); rerender(); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("before")); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('before')); }); - it("should restore focus to the previously focused node after children change", function () { - function Test({ show, showChild }) { + it('should restore focus to the previously focused node after children change', function () { + function Test({show, showChild}) { return (
- {show && ( + {show && {showChild && } - )} + }
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let outside = getByTestId("outside"); - act(() => { - outside.focus(); - }); + let outside = getByTestId('outside'); + act(() => {outside.focus();}); rerender(); rerender(); - let dynamic = getByTestId("dynamic"); - act(() => { - dynamic.focus(); - }); + let dynamic = getByTestId('dynamic'); + act(() => {dynamic.focus();}); expect(document.activeElement).toBe(dynamic); rerender(); - act(() => { - jest.runAllTimers(); - }); + act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(outside); }); - it("should move focus to the element after the previously focused node on Tab", async function () { - function Test({ show }) { + it('should move focus to the element after the previously focused node on Tab', async function () { + function Test({show}) { return (
@@ -598,138 +539,124 @@ describe("FocusScope", function () { ); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let trigger = getByTestId("trigger"); - act(() => { - trigger.focus(); - }); + let trigger = getByTestId('trigger'); + act(() => {trigger.focus();}); rerender(); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); - let input3 = getByTestId("input3"); - act(() => { - input3.focus(); - }); + let input3 = getByTestId('input3'); + act(() => {input3.focus();}); await user.tab(); - expect(document.activeElement).toBe(getByTestId("after")); + expect(document.activeElement).toBe(getByTestId('after')); }); - it("should move focus to the previous element after the previously focused node on Shift+Tab", async function () { - function Test({ show }) { + it('should move focus to the previous element after the previously focused node on Shift+Tab', async function () { + function Test({show}) { return (
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let trigger = getByTestId("trigger"); - act(() => { - trigger.focus(); - }); + let trigger = getByTestId('trigger'); + act(() => {trigger.focus();}); rerender(); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("before")); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('before')); }); - it("should skip over elements within the scope when moving focus to the next element", async function () { - function Test({ show }) { + it('should skip over elements within the scope when moving focus to the next element', async function () { + function Test({show}) { return (
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let trigger = getByTestId("trigger"); - act(() => { - trigger.focus(); - }); + let trigger = getByTestId('trigger'); + act(() => {trigger.focus();}); rerender(); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); - let input3 = getByTestId("input3"); - act(() => { - input3.focus(); - }); + let input3 = getByTestId('input3'); + act(() => {input3.focus();}); await user.tab(); - expect(document.activeElement).toBe(getByTestId("after")); + expect(document.activeElement).toBe(getByTestId('after')); }); - it("should not handle tabbing if the focus scope does not restore focus", async function () { - function Test({ show }) { + it('should not handle tabbing if the focus scope does not restore focus', async function () { + function Test({show}) { return (
); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); - let trigger = getByTestId("trigger"); - act(() => { - trigger.focus(); - }); + let trigger = getByTestId('trigger'); + act(() => {trigger.focus();}); rerender(); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); - let input3 = getByTestId("input3"); - act(() => { - input3.focus(); - }); + let input3 = getByTestId('input3'); + act(() => {input3.focus();}); await user.tab(); - expect(document.activeElement).toBe(getByTestId("after")); + expect(document.activeElement).toBe(getByTestId('after')); }); it.each` @@ -739,65 +666,63 @@ describe("FocusScope", function () { ${false} | ${true} ${true} | ${true} `( - "contain=$contain, isPortaled=$isPortaled should restore focus to previous nodeToRestore when the nodeToRestore for the unmounting scope in no longer in the DOM", - async function ({ contain, isPortaled }) { + 'contain=$contain, isPortaled=$isPortaled should restore focus to previous nodeToRestore when the nodeToRestore for the unmounting scope in no longer in the DOM', + async function ({contain, isPortaled}) { expect(focusScopeTree.size).toBe(1); - let { getAllByText, getAllByRole } = render( - , - ); + let {getAllByText, getAllByRole} = render(); expect(focusScopeTree.size).toBe(1); act(() => { - getAllByText("Open dialog")[0].focus(); + getAllByText('Open dialog')[0].focus(); }); await user.click(document.activeElement); act(() => { jest.runAllTimers(); }); - expect(document.activeElement).toBe(getAllByRole("textbox")[2]); + expect(document.activeElement).toBe(getAllByRole('textbox')[2]); act(() => { - getAllByText("Open dialog")[1].focus(); + getAllByText('Open dialog')[1].focus(); }); await user.click(document.activeElement); act(() => { jest.runAllTimers(); }); - expect(document.activeElement).toBe(getAllByRole("textbox")[5]); + expect(document.activeElement).toBe(getAllByRole('textbox')[5]); act(() => { - getAllByText("Open dialog")[2].focus(); + getAllByText('Open dialog')[2].focus(); }); await user.click(document.activeElement); act(() => { jest.runAllTimers(); }); - expect(document.activeElement).toBe(getAllByRole("textbox")[8]); + expect(document.activeElement).toBe(getAllByRole('textbox')[8]); expect(focusScopeTree.size).toBe(4); if (!contain) { act(() => { - getAllByText("close")[1].focus(); + getAllByText('close')[1].focus(); }); await user.click(document.activeElement); } else { - fireEvent.click(getAllByText("close")[1]); + fireEvent.click(getAllByText('close')[1]); } act(() => { jest.runAllTimers(); }); - expect(document.activeElement).toBe(getAllByText("Open dialog")[1]); + expect(document.activeElement).toBe(getAllByText('Open dialog')[1]); act(() => { - getAllByText("close")[0].focus(); + getAllByText('close')[0].focus(); }); await user.click(document.activeElement); act(() => { jest.runAllTimers(); }); - expect(document.activeElement).toBe(getAllByText("Open dialog")[0]); + expect(document.activeElement).toBe(getAllByText('Open dialog')[0]); expect(focusScopeTree.size).toBe(1); - }, + } ); - describe("focusable first in scope", function () { - it("should restore focus to the first focusable or tabbable element within the scope when focus is lost within the scope", async function () { - let { getByTestId } = render( + describe('focusable first in scope', function () { + it('should restore focus to the first focusable or tabbable element within the scope when focus is lost within the scope', async function () { + let {getByTestId} = render(
@@ -812,12 +737,12 @@ describe("FocusScope", function () {
-
, +
); function Item(props) { let focusManager = useFocusManager(); - let onClick = (e) => { + let onClick = e => { focusManager.focusNext(); act(() => { // remove fails to fire blur event in jest-dom @@ -828,10 +753,10 @@ describe("FocusScope", function () { }; return - {" "} + {' '} {display && ( @@ -885,15 +802,15 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let button1 = getByTestId("button1"); - let button2 = getByTestId("button2"); + let {getByTestId} = render(); + let button1 = getByTestId('button1'); + let button2 = getByTestId('button2'); await user.click(button1); act(() => { jest.runAllTimers(); }); expect(document.activeElement).toBe(button1); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(input1).toBeVisible(); await user.click(button2); @@ -907,11 +824,11 @@ describe("FocusScope", function () { act(() => { jest.runAllTimers(); }); - input1 = getByTestId("input1"); + input1 = getByTestId('input1'); expect(input1).toBeVisible(); await user.tab(); - fireEvent.keyDown(document.activeElement, { key: "Escape" }); - fireEvent.keyUp(document.activeElement, { key: "Escape" }); + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); act(() => { jest.runAllTimers(); }); @@ -919,11 +836,11 @@ describe("FocusScope", function () { expect(input1).not.toBeInTheDocument(); }); - it("should allow restoration to be overridden with a custom event", async function () { + it('should allow restoration to be overridden with a custom event', async function () { function Test() { let [show, setShow] = React.useState(false); let ref = React.useRef(null); - useEvent(ref, "react-aria-focus-scope-restore", (e) => { + useEvent(ref, 'react-aria-focus-scope-restore', e => { e.preventDefault(); }); @@ -939,24 +856,24 @@ describe("FocusScope", function () { ); } - let { getByRole } = render(); - let button = getByRole("button"); + let {getByRole} = render(); + let button = getByRole('button'); await user.click(button); - let input = getByRole("textbox"); + let input = getByRole('textbox'); expect(document.activeElement).toBe(input); - await user.keyboard("{Escape}"); + await user.keyboard('{Escape}'); act(() => jest.runAllTimers()); expect(input).not.toBeInTheDocument(); expect(document.activeElement).toBe(document.body); }); - it("should not bubble focus scope restoration event out of nested focus scopes", async function () { + it('should not bubble focus scope restoration event out of nested focus scopes', async function () { function Test() { let [show, setShow] = React.useState(false); let ref = React.useRef(null); - useEvent(ref, "react-aria-focus-scope-restore", (e) => { + useEvent(ref, 'react-aria-focus-scope-restore', e => { e.preventDefault(); }); @@ -974,56 +891,56 @@ describe("FocusScope", function () { ); } - let { getByRole } = render(); - let button = getByRole("button"); + let {getByRole} = render(); + let button = getByRole('button'); await user.click(button); - let input = getByRole("textbox"); + let input = getByRole('textbox'); expect(document.activeElement).toBe(input); - await user.keyboard("{Escape}"); + await user.keyboard('{Escape}'); act(() => jest.runAllTimers()); expect(input).not.toBeInTheDocument(); expect(document.activeElement).toBe(button); }); }); - describe("auto focus", function () { - it("should auto focus the first tabbable element in the scope on mount", function () { - let { getByTestId } = render( + describe('auto focus', function () { + it('should auto focus the first tabbable element in the scope on mount', function () { + let {getByTestId} = render(
- , + ); act(() => { jest.runAllTimers(); }); - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); expect(document.activeElement).toBe(input1); }); - it("should do nothing if something is already focused in the scope", function () { - let { getByTestId } = render( + it('should do nothing if something is already focused in the scope', function () { + let {getByTestId} = render(
- , + ); - let input2 = getByTestId("input2"); + let input2 = getByTestId('input2'); expect(document.activeElement).toBe(input2); }); }); - describe("focus manager", function () { - it("should move focus forward", async function () { + describe('focus manager', function () { + it('should move focus forward', async function () { function Test() { return ( @@ -1043,10 +960,10 @@ describe("FocusScope", function () { return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item2 = getByTestId("item2"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item2 = getByTestId('item2'); + let item3 = getByTestId('item3'); act(() => { item1.focus(); @@ -1062,7 +979,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item3); }); - it("should move focus forward and wrap around", async function () { + it('should move focus forward and wrap around', async function () { function Test() { return ( @@ -1076,16 +993,16 @@ describe("FocusScope", function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusNext({ wrap: true }); + focusManager.focusNext({wrap: true}); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item2 = getByTestId("item2"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item2 = getByTestId('item2'); + let item3 = getByTestId('item3'); act(() => { item1.focus(); @@ -1101,15 +1018,15 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item1); }); - it("should move focus forward but only to tabbable elements", async function () { + it('should move focus forward but only to tabbable elements', async function () { function Test() { return ( - - - + + + ); @@ -1118,15 +1035,15 @@ describe("FocusScope", function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusNext({ tabbable: true }); + focusManager.focusNext({tabbable: true}); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item3 = getByTestId('item3'); act(() => { item1.focus(); @@ -1136,18 +1053,18 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item3); }); - it("should move focus forward but only to tabbable elements while accounting for container elements within the scope", function () { + it('should move focus forward but only to tabbable elements while accounting for container elements within the scope', function () { function Test() { return ( - + - - + + @@ -1160,18 +1077,18 @@ describe("FocusScope", function () { function Group(props) { let focusManager = useFocusManager(); - let onMouseDown = (e) => { - focusManager.focusNext({ from: e.target, tabbable: true }); + let onMouseDown = e => { + focusManager.focusNext({from: e.target, tabbable: true}); }; // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions return
; } - let { getByTestId } = render(); - let group1 = getByTestId("group1"); - let group2 = getByTestId("group2"); - let item2 = getByTestId("item2"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let group1 = getByTestId('group1'); + let group2 = getByTestId('group2'); + let item2 = getByTestId('item2'); + let item3 = getByTestId('item3'); fireEvent.mouseDown(group2); expect(document.activeElement).toBe(item3); @@ -1180,7 +1097,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item2); }); - it("should move focus forward and allow users to skip certain elements", async function () { + it('should move focus forward and allow users to skip certain elements', async function () { function Test() { return ( @@ -1196,16 +1113,16 @@ describe("FocusScope", function () { let onClick = () => { focusManager.focusNext({ wrap: true, - accept: (e) => !e.getAttribute("data-skip"), + accept: e => !e.getAttribute('data-skip') }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item3 = getByTestId('item3'); act(() => { item1.focus(); @@ -1218,7 +1135,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item1); }); - it("should move focus backward", async function () { + it('should move focus backward', async function () { function Test() { return ( @@ -1238,10 +1155,10 @@ describe("FocusScope", function () { return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item2 = getByTestId("item2"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item2 = getByTestId('item2'); + let item3 = getByTestId('item3'); act(() => { item3.focus(); @@ -1257,7 +1174,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item1); }); - it("should move focus backward and wrap around", async function () { + it('should move focus backward and wrap around', async function () { function Test() { return ( @@ -1271,16 +1188,16 @@ describe("FocusScope", function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusPrevious({ wrap: true }); + focusManager.focusPrevious({wrap: true}); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item2 = getByTestId("item2"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item2 = getByTestId('item2'); + let item3 = getByTestId('item3'); act(() => { item3.focus(); @@ -1296,15 +1213,15 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item3); }); - it("should move focus backward but only to tabbable elements", async function () { + it('should move focus backward but only to tabbable elements', async function () { function Test() { return ( - - - + + + ); @@ -1313,15 +1230,15 @@ describe("FocusScope", function () { function Item(props) { let focusManager = useFocusManager(); let onClick = () => { - focusManager.focusPrevious({ tabbable: true }); + focusManager.focusPrevious({tabbable: true}); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item3 = getByTestId('item3'); act(() => { item3.focus(); @@ -1331,18 +1248,18 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item1); }); - it("should move focus backward but only to tabbable elements while accounting for container elements within the scope", function () { + it('should move focus backward but only to tabbable elements while accounting for container elements within the scope', function () { function Test() { return ( - + - - + + @@ -1355,17 +1272,17 @@ describe("FocusScope", function () { function Group(props) { let focusManager = useFocusManager(); - let onMouseDown = (e) => { - focusManager.focusPrevious({ from: e.target, tabbable: true }); + let onMouseDown = e => { + focusManager.focusPrevious({from: e.target, tabbable: true}); }; // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions return
; } - let { getByTestId } = render(); - let group1 = getByTestId("group1"); - let group2 = getByTestId("group2"); - let item1 = getByTestId("item1"); + let {getByTestId} = render(); + let group1 = getByTestId('group1'); + let group2 = getByTestId('group2'); + let item1 = getByTestId('item1'); fireEvent.mouseDown(group2); expect(document.activeElement).toBe(item1); @@ -1377,7 +1294,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(item1); }); - it("should move focus backward and allow users to skip certain elements", async function () { + it('should move focus backward and allow users to skip certain elements', async function () { function Test() { return ( @@ -1393,16 +1310,16 @@ describe("FocusScope", function () { let onClick = () => { focusManager.focusPrevious({ wrap: true, - accept: (e) => !e.getAttribute("data-skip"), + accept: e => !e.getAttribute('data-skip') }); }; // eslint-disable-next-line jsx-a11y/click-events-have-key-events return
; } - let { getByTestId } = render(); - let item1 = getByTestId("item1"); - let item3 = getByTestId("item3"); + let {getByTestId} = render(); + let item1 = getByTestId('item1'); + let item3 = getByTestId('item3'); act(() => { item1.focus(); @@ -1416,7 +1333,7 @@ describe("FocusScope", function () { }); }); - it("skips radio buttons that are in the same group and are not the selectable one forwards", async function () { + it('skips radio buttons that are in the same group and are not the selectable one forwards', async function () { function Test() { return ( @@ -1426,13 +1343,7 @@ describe("FocusScope", function () { Select a maintenance drone:
- +
@@ -1470,25 +1381,25 @@ describe("FocusScope", function () { ); } - let { getByTestId, getAllByRole } = render(); - let radios = getAllByRole("radio"); + let {getByTestId, getAllByRole} = render(); + let radios = getAllByRole('radio'); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button1")); + expect(document.activeElement).toBe(getByTestId('button1')); await user.tab(); expect(document.activeElement).toBe(radios[0]); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button2")); + expect(document.activeElement).toBe(getByTestId('button2')); await user.tab(); expect(document.activeElement).toBe(radios[3]); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button3")); + expect(document.activeElement).toBe(getByTestId('button3')); await user.tab(); expect(document.activeElement).toBe(radios[5]); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button4")); + expect(document.activeElement).toBe(getByTestId('button4')); }); - it("skips radio buttons that are in the same group and are not the selectable one forwards outside of a form", async function () { + it('skips radio buttons that are in the same group and are not the selectable one forwards outside of a form', async function () { function Test() { return ( @@ -1497,13 +1408,7 @@ describe("FocusScope", function () { Select a maintenance drone:
- +
@@ -1540,25 +1445,25 @@ describe("FocusScope", function () { ); } - let { getByTestId, getAllByRole } = render(); - let radios = getAllByRole("radio"); + let {getByTestId, getAllByRole} = render(); + let radios = getAllByRole('radio'); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button1")); + expect(document.activeElement).toBe(getByTestId('button1')); await user.tab(); expect(document.activeElement).toBe(radios[0]); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button2")); + expect(document.activeElement).toBe(getByTestId('button2')); await user.tab(); expect(document.activeElement).toBe(radios[3]); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button3")); + expect(document.activeElement).toBe(getByTestId('button3')); await user.tab(); expect(document.activeElement).toBe(radios[5]); await user.tab(); - expect(document.activeElement).toBe(getByTestId("button4")); + expect(document.activeElement).toBe(getByTestId('button4')); }); - it("skips radio buttons that are in the same group and are not the selectable one backwards", async function () { + it('skips radio buttons that are in the same group and are not the selectable one backwards', async function () { function Test() { return ( @@ -1568,13 +1473,7 @@ describe("FocusScope", function () { Select a maintenance drone:
- +
@@ -1612,24 +1511,24 @@ describe("FocusScope", function () { ); } - let { getByTestId, getAllByRole } = render(); - let radios = getAllByRole("radio"); - await user.click(getByTestId("button4")); - await user.tab({ shift: true }); + let {getByTestId, getAllByRole} = render(); + let radios = getAllByRole('radio'); + await user.click(getByTestId('button4')); + await user.tab({shift: true}); expect(document.activeElement).toBe(radios[5]); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("button3")); - await user.tab({ shift: true }); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('button3')); + await user.tab({shift: true}); expect(document.activeElement).toBe(radios[4]); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("button2")); - await user.tab({ shift: true }); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('button2')); + await user.tab({shift: true}); expect(document.activeElement).toBe(radios[0]); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("button1")); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('button1')); }); - it("skips radio buttons that are in the same group and are not the selectable one backwards outside of a form", async function () { + it('skips radio buttons that are in the same group and are not the selectable one backwards outside of a form', async function () { function Test() { return ( @@ -1638,13 +1537,7 @@ describe("FocusScope", function () { Select a maintenance drone:
- +
@@ -1681,30 +1574,30 @@ describe("FocusScope", function () { ); } - let { getByTestId, getAllByRole } = render(); - let radios = getAllByRole("radio"); - await user.click(getByTestId("button4")); - await user.tab({ shift: true }); + let {getByTestId, getAllByRole} = render(); + let radios = getAllByRole('radio'); + await user.click(getByTestId('button4')); + await user.tab({shift: true}); expect(document.activeElement).toBe(radios[5]); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("button3")); - await user.tab({ shift: true }); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('button3')); + await user.tab({shift: true}); expect(document.activeElement).toBe(radios[4]); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("button2")); - await user.tab({ shift: true }); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('button2')); + await user.tab({shift: true}); expect(document.activeElement).toBe(radios[0]); - await user.tab({ shift: true }); - expect(document.activeElement).toBe(getByTestId("button1")); + await user.tab({shift: true}); + expect(document.activeElement).toBe(getByTestId('button1')); }); - describe("nested focus scopes", function () { - it("should make child FocusScopes the active scope regardless of DOM structure", function () { + describe('nested focus scopes', function () { + it('should make child FocusScopes the active scope regardless of DOM structure', function () { function ChildComponent(props) { return ReactDOM.createPortal(props.children, document.body); } - function Test({ show }) { + function Test({show}) { return (
@@ -1722,9 +1615,9 @@ describe("FocusScope", function () { ); } - let { getByTestId, rerender } = render(); + let {getByTestId, rerender} = render(); // Set a focused node and make first FocusScope the active scope - let input1 = getByTestId("input1"); + let input1 = getByTestId('input1'); act(() => { input1.focus(); }); @@ -1733,7 +1626,7 @@ describe("FocusScope", function () { rerender(); expect(document.activeElement).toBe(input1); - let input3 = getByTestId("input3"); + let input3 = getByTestId('input3'); act(() => { input3.focus(); }); @@ -1741,7 +1634,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(input3); }); - it("should lock tab navigation inside direct child focus scope", async function () { + it('should lock tab navigation inside direct child focus scope', async function () { function Test() { return (
@@ -1760,10 +1653,10 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let child1 = getByTestId("child1"); - let child2 = getByTestId("child2"); - let child3 = getByTestId("child3"); + let {getByTestId} = render(); + let child1 = getByTestId('child1'); + let child2 = getByTestId('child2'); + let child3 = getByTestId('child3'); act(() => { jest.runAllTimers(); @@ -1784,14 +1677,14 @@ describe("FocusScope", function () { jest.runAllTimers(); }); expect(document.activeElement).toBe(child1); - await user.tab({ shift: true }); + await user.tab({shift: true}); act(() => { jest.runAllTimers(); }); expect(document.activeElement).toBe(child3); }); - it("should lock tab navigation inside nested child focus scope", async function () { + it('should lock tab navigation inside nested child focus scope', async function () { function Test() { return (
@@ -1814,10 +1707,10 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let child1 = getByTestId("child1"); - let child2 = getByTestId("child2"); - let child3 = getByTestId("child3"); + let {getByTestId} = render(); + let child1 = getByTestId('child1'); + let child2 = getByTestId('child2'); + let child3 = getByTestId('child3'); expect(document.activeElement).toBe(child1); await user.tab(); @@ -1826,11 +1719,11 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(child3); await user.tab(); expect(document.activeElement).toBe(child1); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(child3); }); - it("should not lock tab navigation inside a nested focus scope without contain", async function () { + it('should not lock tab navigation inside a nested focus scope without contain', async function () { function Test() { return (
@@ -1851,11 +1744,11 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let parent = getByTestId("parent"); - let child1 = getByTestId("child1"); - let child2 = getByTestId("child2"); - let child3 = getByTestId("child3"); + let {getByTestId} = render(); + let parent = getByTestId('parent'); + let child1 = getByTestId('child1'); + let child2 = getByTestId('child2'); + let child3 = getByTestId('child3'); expect(document.activeElement).toBe(parent); await user.tab(); @@ -1866,11 +1759,11 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(child3); await user.tab(); expect(document.activeElement).toBe(parent); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(child3); }); - it("should not lock tab navigation inside a nested focus scope with restore and not contain", async function () { + it('should not lock tab navigation inside a nested focus scope with restore and not contain', async function () { function Test() { return (
@@ -1891,11 +1784,11 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let parent = getByTestId("parent"); - let child1 = getByTestId("child1"); - let child2 = getByTestId("child2"); - let child3 = getByTestId("child3"); + let {getByTestId} = render(); + let parent = getByTestId('parent'); + let child1 = getByTestId('child1'); + let child2 = getByTestId('child2'); + let child3 = getByTestId('child3'); expect(document.activeElement).toBe(parent); await user.tab(); @@ -1906,12 +1799,12 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(child3); await user.tab(); expect(document.activeElement).toBe(parent); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(child3); }); - it("should restore to the correct scope on unmount", async function () { - function Test({ show1, show2, show3 }) { + it('should restore to the correct scope on unmount', async function () { + function Test({show1, show2, show3}) { return (
@@ -1937,8 +1830,8 @@ describe("FocusScope", function () { ); } - let { rerender, getByTestId } = render(); - let parent = getByTestId("parent"); + let {rerender, getByTestId} = render(); + let parent = getByTestId('parent'); expect(document.activeElement).toBe(parent); @@ -1946,7 +1839,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(parent); // Can move into a child, but not out. - let child1 = getByTestId("child1"); + let child1 = getByTestId('child1'); await user.tab(); expect(document.activeElement).toBe(child1); @@ -1956,7 +1849,7 @@ describe("FocusScope", function () { rerender(); expect(document.activeElement).toBe(child1); - let child2 = getByTestId("child2"); + let child2 = getByTestId('child2'); await user.tab(); expect(document.activeElement).toBe(child2); @@ -1968,7 +1861,7 @@ describe("FocusScope", function () { rerender(); - let child3 = getByTestId("child3"); + let child3 = getByTestId('child3'); await user.tab(); expect(document.activeElement).toBe(child3); @@ -1981,7 +1874,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(child1); }); - it("should not lock focus inside a focus scope with a child scope in a portal", function () { + it('should not lock focus inside a focus scope with a child scope in a portal', function () { function Portal(props) { return ReactDOM.createPortal(props.children, document.body); } @@ -2003,9 +1896,9 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let parent = getByTestId("parent"); - let child = getByTestId("child"); + let {getByTestId} = render(); + let parent = getByTestId('parent'); + let child = getByTestId('child'); expect(document.activeElement).toBe(parent); act(() => child.focus()); @@ -2014,7 +1907,7 @@ describe("FocusScope", function () { expect(document.activeElement).toBe(parent); }); - it("should lock focus inside a child focus scope with contain in a portal", function () { + it('should lock focus inside a child focus scope with contain in a portal', function () { function Portal(props) { return ReactDOM.createPortal(props.children, document.body); } @@ -2036,9 +1929,9 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let parent = getByTestId("parent"); - let child = getByTestId("child"); + let {getByTestId} = render(); + let parent = getByTestId('parent'); + let child = getByTestId('child'); expect(document.activeElement).toBe(parent); act(() => child.focus()); @@ -2048,8 +1941,8 @@ describe("FocusScope", function () { }); }); - describe("scope child of document.body", function () { - it("should navigate in and out of scope in DOM order when the nodeToRestore is the document.body", async function () { + describe('scope child of document.body', function () { + it('should navigate in and out of scope in DOM order when the nodeToRestore is the document.body', async function () { function Test() { return (
@@ -2062,10 +1955,10 @@ describe("FocusScope", function () { ); } - let { getByTestId } = render(); - let beforeScope = getByTestId("beforeScope"); - let inScope = getByTestId("inScope"); - let afterScope = getByTestId("afterScope"); + let {getByTestId} = render(); + let beforeScope = getByTestId('beforeScope'); + let inScope = getByTestId('inScope'); + let afterScope = getByTestId('afterScope'); act(() => { inScope.focus(); @@ -2075,12 +1968,12 @@ describe("FocusScope", function () { act(() => { inScope.focus(); }); - await user.tab({ shift: true }); + await user.tab({shift: true}); expect(document.activeElement).toBe(beforeScope); }); }); - describe("node to restore edge cases", () => { - it("tracks node to restore if the node to restore was removed in another part of the tree", async () => { + describe('node to restore edge cases', () => { + it('tracks node to restore if the node to restore was removed in another part of the tree', async () => { function Test() { let [showMenu, setShowMenu] = useState(false); let [showDialog, setShowDialog] = useState(false); @@ -2094,7 +1987,7 @@ describe("FocusScope", function () { - { }}> + {}}> {showMenu && ( @@ -2102,7 +1995,7 @@ describe("FocusScope", function () { )} - { }}> + {}}> {showDialog && ( @@ -2120,16 +2013,16 @@ describe("FocusScope", function () { }); await user.tab(); await user.tab(); - expect(document.activeElement.textContent).toBe("Open Menu"); + expect(document.activeElement.textContent).toBe('Open Menu'); - await user.keyboard("[Enter]"); + await user.keyboard('[Enter]'); act(() => { jest.runAllTimers(); }); - expect(document.activeElement.textContent).toBe("Open Dialog"); + expect(document.activeElement.textContent).toBe('Open Dialog'); - await user.keyboard("[Enter]"); + await user.keyboard('[Enter]'); // Needed for onBlur raf in useFocusContainment act(() => { @@ -2140,9 +2033,9 @@ describe("FocusScope", function () { jest.runAllTimers(); }); - expect(document.activeElement.textContent).toBe("Close"); + expect(document.activeElement.textContent).toBe('Close'); - await user.keyboard("[Enter]"); + await user.keyboard('[Enter]'); act(() => { jest.runAllTimers(); }); @@ -2151,17 +2044,17 @@ describe("FocusScope", function () { }); expect(document.activeElement).not.toBe(document.body); - expect(document.activeElement.textContent).toBe("Open Menu"); + expect(document.activeElement.textContent).toBe('Open Menu'); }); }); }); -describe("FocusScope with Shadow DOM", function () { +describe('FocusScope with Shadow DOM', function () { let user; beforeAll(() => { enableShadowDOM(); - user = userEvent.setup({ delay: null, pointerMap }); + user = userEvent.setup({delay: null, pointerMap}); }); beforeEach(() => { @@ -2174,8 +2067,8 @@ describe("FocusScope with Shadow DOM", function () { }); }); - it("should contain focus within the shadow DOM scope", async function () { - const { shadowRoot } = createShadowRoot(); + it('should contain focus within the shadow DOM scope', async function () { + const {shadowRoot} = createShadowRoot(); const FocusableComponent = () => ReactDOM.createPortal( @@ -2183,10 +2076,10 @@ describe("FocusScope with Shadow DOM", function () { , - shadowRoot, + shadowRoot ); - const { unmount } = render(); + const {unmount} = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); @@ -2215,11 +2108,11 @@ describe("FocusScope with Shadow DOM", function () { document.body.removeChild(shadowRoot.host); }); - it("should manage focus within nested shadow DOMs", async function () { - const { shadowRoot: parentShadowRoot } = createShadowRoot(); - const nestedDiv = document.createElement("div"); + it('should manage focus within nested shadow DOMs', async function () { + const {shadowRoot: parentShadowRoot} = createShadowRoot(); + const nestedDiv = document.createElement('div'); parentShadowRoot.appendChild(nestedDiv); - const childShadowRoot = nestedDiv.attachShadow({ mode: "open" }); + const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); const FocusableComponent = () => ReactDOM.createPortal( @@ -2227,13 +2120,13 @@ describe("FocusScope with Shadow DOM", function () { , - childShadowRoot, + childShadowRoot ); - const { unmount } = render(); + const {unmount} = render(); - const input1 = childShadowRoot.querySelector("[data-testid=input1]"); - const input2 = childShadowRoot.querySelector("[data-testid=input2]"); + const input1 = childShadowRoot.querySelector('[data-testid=input1]'); + const input2 = childShadowRoot.querySelector('[data-testid=input2]'); act(() => { input1.focus(); @@ -2256,7 +2149,7 @@ describe("FocusScope with Shadow DOM", function () { * │ └── Your custom elements and focusable elements here * └── Other elements */ - it("should restore focus to the element outside shadow DOM on unmount, with FocusScope outside as well", async () => { + it('should restore focus to the element outside shadow DOM on unmount, with FocusScope outside as well', async () => { const App = () => ( <> @@ -2266,9 +2159,9 @@ describe("FocusScope with Shadow DOM", function () { ); - const { getByTestId } = render(); - const shadowHost = document.getElementById("shadow-host"); - const shadowRoot = shadowHost.attachShadow({ mode: "open" }); + const {getByTestId} = render(); + const shadowHost = document.getElementById('shadow-host'); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); const FocusableComponent = () => ReactDOM.createPortal( @@ -2277,10 +2170,10 @@ describe("FocusScope with Shadow DOM", function () { , - shadowRoot, + shadowRoot ); - const { unmount } = render(); + const {unmount} = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); act(() => { @@ -2288,7 +2181,7 @@ describe("FocusScope with Shadow DOM", function () { }); expect(shadowRoot.activeElement).toBe(input1); - const externalInput = getByTestId("outside"); + const externalInput = getByTestId('outside'); act(() => { externalInput.focus(); }); @@ -2306,8 +2199,8 @@ describe("FocusScope with Shadow DOM", function () { /** * Test case: https://github.com/adobe/react-spectrum/issues/1472 */ - it("should autofocus and lock tab navigation inside shadow DOM", async function () { - const { shadowRoot, shadowHost } = createShadowRoot(); + it('should autofocus and lock tab navigation inside shadow DOM', async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); const FocusableComponent = () => ReactDOM.createPortal( @@ -2316,10 +2209,10 @@ describe("FocusScope with Shadow DOM", function () { , - shadowRoot, + shadowRoot ); - const { unmount } = render(); + const {unmount} = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); @@ -2348,24 +2241,24 @@ describe("FocusScope with Shadow DOM", function () { 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(); + 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"); + 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) => { + const handleMenuAction = key => { actionExecuted = true; // In the original issue, this never executes because the popover closes first - console.log("Menu action executed:", key); + console.log('Menu action executed:', key); }; return ( @@ -2377,7 +2270,7 @@ describe("FocusScope with Shadow DOM", function () { setIsPopoverOpen(false); menuClosed = true; }} - style={{ position: "absolute", top: 0, right: 0 }} + style={{position: 'absolute', top: 0, right: 0}} > Close @@ -2388,17 +2281,13 @@ describe("FocusScope with Shadow DOM", function () {
- @@ -2406,14 +2295,14 @@ describe("FocusScope with Shadow DOM", function () {
, - popoverPortal, + popoverPortal )}
); } - const { unmount } = render(); + const {unmount} = render(); // Wait for rendering act(() => { @@ -2421,21 +2310,11 @@ describe("FocusScope with Shadow DOM", function () { }); // 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"]', - ); + 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(); @@ -2457,9 +2336,7 @@ describe("FocusScope with Shadow DOM", function () { // 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(); + expect(shadowRoot.querySelector('[data-testid="menu-container"]')).not.toBeNull(); // Test focus containment within the menu act(() => { @@ -2477,16 +2354,16 @@ describe("FocusScope with Shadow DOM", function () { cleanup(); }); - it("should handle web component scenario with multiple nested portals and UNSAFE_PortalProvider", async function () { - const { shadowRoot, cleanup } = createShadowRoot(); + 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"); + 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"); + const tooltipPortal = document.createElement('div'); + tooltipPortal.setAttribute('data-testid', 'tooltip-portal'); shadowRoot.appendChild(tooltipPortal); function ComplexWebComponent() { @@ -2505,15 +2382,12 @@ describe("FocusScope with Shadow DOM", function () {
-
, - modalPortal, + modalPortal )} {/* Tooltip with nested focus scope */} @@ -2524,24 +2398,18 @@ describe("FocusScope with Shadow DOM", function () {
, - tooltipPortal, + tooltipPortal )}
); } - const { unmount } = render(); + 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"]', - ); + 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(() => { @@ -2556,9 +2424,7 @@ describe("FocusScope with Shadow DOM", function () { // 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", - ); + expect(shadowRoot.activeElement.getAttribute('data-testid')).toBe('close-modal'); await user.tab(); // Should wrap back to first modal button @@ -2580,7 +2446,7 @@ describe("FocusScope with Shadow DOM", function () { }); }); -describe("Unmounting cleanup", () => { +describe('Unmounting cleanup', () => { beforeAll(() => { jest.useFakeTimers(); }); @@ -2589,14 +2455,14 @@ describe("Unmounting cleanup", () => { }); // this test will fail in the 'afterAll' if there are any rafs left over - it("should not leak request animation frames", () => { + it('should not leak request animation frames', () => { let tree = render( - , + ); - let buttons = tree.getAllByRole("button"); + let buttons = tree.getAllByRole('button'); act(() => buttons[0].focus()); act(() => buttons[1].focus()); act(() => buttons[1].blur()); diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index 5bfb5ef6055..b602d8728fb 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -17,18 +17,18 @@ import { 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"; + 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(); - useInteractOutside({ ref, ...props }); + useInteractOutside({ref, ...props}); return (
test @@ -37,67 +37,67 @@ function Example(props) { } function pointerEvent(type, opts) { - let evt = new Event(type, { bubbles: true, cancelable: true }); + let evt = new Event(type, {bubbles: true, cancelable: true}); Object.assign(evt, opts); return evt; } -describe("useInteractOutside", function () { +describe('useInteractOutside', function () { // TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests. // https://github.com/jsdom/jsdom/issues/2527 - describe("pointer events", function () { + describe('pointer events', function () { installPointerEvent(); - it("should fire interact outside events based on pointer events", function () { + it('should fire interact outside events based on pointer events', function () { let onInteractOutside = jest.fn(); let res = render(); - let el = res.getByText("test"); - fireEvent(el, pointerEvent("pointerdown")); - fireEvent(el, pointerEvent("pointerup")); + let el = res.getByText('test'); + fireEvent(el, pointerEvent('pointerdown')); + fireEvent(el, pointerEvent('pointerup')); fireEvent.click(el); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(document.body, pointerEvent("pointerdown")); - fireEvent(document.body, pointerEvent("pointerup")); + fireEvent(document.body, pointerEvent('pointerdown')); + fireEvent(document.body, pointerEvent('pointerup')); fireEvent.click(document.body); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should only listen for the left mouse button", function () { + it('should only listen for the left mouse button', function () { let onInteractOutside = jest.fn(); render(); - fireEvent(document.body, pointerEvent("pointerdown", { button: 1 })); - fireEvent(document.body, pointerEvent("pointerup", { button: 1 })); - fireEvent.click(document.body, { button: 1 }); + fireEvent(document.body, pointerEvent('pointerdown', {button: 1})); + fireEvent(document.body, pointerEvent('pointerup', {button: 1})); + fireEvent.click(document.body, {button: 1}); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(document.body, pointerEvent("pointerdown", { button: 0 })); - fireEvent(document.body, pointerEvent("pointerup", { button: 0 })); - fireEvent.click(document.body, { button: 0 }); + fireEvent(document.body, pointerEvent('pointerdown', {button: 0})); + fireEvent(document.body, pointerEvent('pointerup', {button: 0})); + fireEvent.click(document.body, {button: 0}); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should not fire interact outside if there is a pointer up event without a pointer down first", function () { + it('should not fire interact outside if there is a pointer up event without a pointer down first', function () { // Fire pointer down before component with useInteractOutside is mounted - fireEvent(document.body, pointerEvent("pointerdown")); + fireEvent(document.body, pointerEvent('pointerdown')); let onInteractOutside = jest.fn(); render(); - fireEvent(document.body, pointerEvent("pointerup")); + fireEvent(document.body, pointerEvent('pointerup')); fireEvent.click(document.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe("mouse events", function () { - it("should fire interact outside events based on mouse events", function () { + describe('mouse events', function () { + it('should fire interact outside events based on mouse events', function () { let onInteractOutside = jest.fn(); let res = render(); - let el = res.getByText("test"); + let el = res.getByText('test'); fireEvent.mouseDown(el); fireEvent.mouseUp(el); expect(onInteractOutside).not.toHaveBeenCalled(); @@ -107,20 +107,20 @@ describe("useInteractOutside", function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should only listen for the left mouse button", function () { + it('should only listen for the left mouse button', function () { let onInteractOutside = jest.fn(); render(); - fireEvent.mouseDown(document.body, { button: 1 }); - fireEvent.mouseUp(document.body, { button: 1 }); + fireEvent.mouseDown(document.body, {button: 1}); + fireEvent.mouseUp(document.body, {button: 1}); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent.mouseDown(document.body, { button: 0 }); - fireEvent.mouseUp(document.body, { button: 0 }); + fireEvent.mouseDown(document.body, {button: 0}); + fireEvent.mouseUp(document.body, {button: 0}); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should not fire interact outside if there is a mouse up event without a mouse down first", function () { + it('should not fire interact outside if there is a mouse up event without a mouse down first', function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.mouseDown(document.body); @@ -132,12 +132,12 @@ describe("useInteractOutside", function () { }); }); - describe("touch events", function () { - it("should fire interact outside events based on mouse events", function () { + describe('touch events', function () { + it('should fire interact outside events based on mouse events', function () { let onInteractOutside = jest.fn(); let res = render(); - let el = res.getByText("test"); + let el = res.getByText('test'); fireEvent.touchStart(el); fireEvent.touchEnd(el); expect(onInteractOutside).not.toHaveBeenCalled(); @@ -147,11 +147,11 @@ describe("useInteractOutside", function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should ignore emulated mouse events", function () { + it('should ignore emulated mouse events', function () { let onInteractOutside = jest.fn(); let res = render(); - let el = res.getByText("test"); + let el = res.getByText('test'); fireEvent.touchStart(el); fireEvent.touchEnd(el); fireEvent.mouseUp(el); @@ -163,7 +163,7 @@ describe("useInteractOutside", function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should not fire interact outside if there is a touch end event without a touch start first", function () { + it('should not fire interact outside if there is a touch end event without a touch start first', function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.touchStart(document.body); @@ -174,17 +174,17 @@ describe("useInteractOutside", function () { expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe("disable interact outside events", function () { - it("does not handle pointer events if disabled", function () { + describe('disable interact outside events', function () { + it('does not handle pointer events if disabled', function () { let onInteractOutside = jest.fn(); render(); - fireEvent(document.body, pointerEvent("mousedown")); - fireEvent(document.body, pointerEvent("mouseup")); + fireEvent(document.body, pointerEvent('mousedown')); + fireEvent(document.body, pointerEvent('mouseup')); expect(onInteractOutside).not.toHaveBeenCalled(); }); - it("does not handle touch events if disabled", function () { + it('does not handle touch events if disabled', function () { let onInteractOutside = jest.fn(); render(); @@ -193,7 +193,7 @@ describe("useInteractOutside", function () { expect(onInteractOutside).not.toHaveBeenCalled(); }); - it("does not handle mouse events if disabled", function () { + it('does not handle mouse events if disabled', function () { let onInteractOutside = jest.fn(); render(); @@ -204,15 +204,15 @@ describe("useInteractOutside", function () { }); }); -describe("useInteractOutside (iframes)", function () { +describe('useInteractOutside (iframes)', function () { let iframe; let iframeRoot; let iframeDocument; beforeEach(() => { - iframe = document.createElement("iframe"); + iframe = document.createElement('iframe'); window.document.body.appendChild(iframe); iframeDocument = iframe.contentWindow.document; - iframeRoot = iframeDocument.createElement("div"); + iframeRoot = iframeDocument.createElement('div'); iframeDocument.body.appendChild(iframeRoot); }); @@ -220,79 +220,73 @@ describe("useInteractOutside (iframes)", function () { iframe.remove(); }); - const IframeExample = (props) => { + const IframeExample = props => { return createPortal(, iframeRoot); }; // TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests. // https://github.com/jsdom/jsdom/issues/2527 - describe("pointer events", function () { + describe('pointer events', function () { installPointerEvent(); - it("should fire interact outside events based on pointer events", async function () { + it('should fire interact outside events based on pointer events', async function () { let onInteractOutside = jest.fn(); render(); await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); const el = document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', + 'div[data-testid="example"]' ); - fireEvent(el, pointerEvent("pointerdown")); - fireEvent(el, pointerEvent("pointerup")); + fireEvent(el, pointerEvent('pointerdown')); + fireEvent(el, pointerEvent('pointerup')); fireEvent.click(el); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent(iframeDocument.body, pointerEvent("pointerdown")); - fireEvent(iframeDocument.body, pointerEvent("pointerup")); + fireEvent(iframeDocument.body, pointerEvent('pointerdown')); + fireEvent(iframeDocument.body, pointerEvent('pointerup')); fireEvent.click(iframeDocument.body); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should only listen for the left mouse button", async function () { + it('should only listen for the left mouse button', async function () { let onInteractOutside = jest.fn(); render(); await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); - fireEvent( - iframeDocument.body, - pointerEvent("pointerdown", { button: 1 }), - ); - fireEvent(iframeDocument.body, pointerEvent("pointerup", { button: 1 })); - fireEvent.click(iframeDocument.body, { button: 0 }); + fireEvent(iframeDocument.body, pointerEvent('pointerdown', {button: 1})); + fireEvent(iframeDocument.body, pointerEvent('pointerup', {button: 1})); + fireEvent.click(iframeDocument.body, {button: 0}); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent( - iframeDocument.body, - pointerEvent("pointerdown", { button: 0 }), - ); - fireEvent(iframeDocument.body, pointerEvent("pointerup", { button: 0 })); - fireEvent.click(iframeDocument.body, { button: 0 }); + fireEvent(iframeDocument.body, pointerEvent('pointerdown', {button: 0})); + fireEvent(iframeDocument.body, pointerEvent('pointerup', {button: 0})); + fireEvent.click(iframeDocument.body, {button: 0}); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should not fire interact outside if there is a pointer up event without a pointer down first", async function () { + it('should not fire interact outside if there is a pointer up event without a pointer down first', async function () { // Fire pointer down before component with useInteractOutside is mounted - fireEvent(iframeDocument.body, pointerEvent("pointerdown")); + fireEvent(iframeDocument.body, pointerEvent('pointerdown')); let onInteractOutside = jest.fn(); render(); @@ -300,37 +294,37 @@ describe("useInteractOutside (iframes)", function () { await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); - fireEvent(iframeDocument.body, pointerEvent("pointerup")); + fireEvent(iframeDocument.body, pointerEvent('pointerup')); fireEvent.click(iframeDocument.body); expect(onInteractOutside).not.toHaveBeenCalled(); }); }); - describe("mouse events", function () { - it("should fire interact outside events based on mouse events", async function () { + describe('mouse events', function () { + it('should fire interact outside events based on mouse events', async function () { let onInteractOutside = jest.fn(); render(); await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); const el = document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', + 'div[data-testid="example"]' ); fireEvent.mouseDown(el); fireEvent.mouseUp(el); @@ -341,30 +335,30 @@ describe("useInteractOutside (iframes)", function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should only listen for the left mouse button", async function () { + it('should only listen for the left mouse button', async function () { let onInteractOutside = jest.fn(); render(); await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); - fireEvent.mouseDown(iframeDocument.body, { button: 1 }); - fireEvent.mouseUp(iframeDocument.body, { button: 1 }); + fireEvent.mouseDown(iframeDocument.body, {button: 1}); + fireEvent.mouseUp(iframeDocument.body, {button: 1}); expect(onInteractOutside).not.toHaveBeenCalled(); - fireEvent.mouseDown(iframeDocument.body, { button: 0 }); - fireEvent.mouseUp(iframeDocument.body, { button: 0 }); + fireEvent.mouseDown(iframeDocument.body, {button: 0}); + fireEvent.mouseUp(iframeDocument.body, {button: 0}); expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should not fire interact outside if there is a mouse up event without a mouse down first", async function () { + it('should not fire interact outside if there is a mouse up event without a mouse down first', async function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.mouseDown(iframeDocument.body); @@ -374,10 +368,10 @@ describe("useInteractOutside (iframes)", function () { await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); fireEvent.mouseUp(iframeDocument.body); @@ -385,25 +379,25 @@ describe("useInteractOutside (iframes)", function () { }); }); - describe("touch events", function () { - it("should fire interact outside events based on mouse events", async function () { + describe('touch events', function () { + it('should fire interact outside events based on mouse events', async function () { let onInteractOutside = jest.fn(); render(); await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); const el = document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', + 'div[data-testid="example"]' ); fireEvent.touchStart(el); fireEvent.touchEnd(el); @@ -414,24 +408,24 @@ describe("useInteractOutside (iframes)", function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should ignore emulated mouse events", async function () { + it('should ignore emulated mouse events', async function () { let onInteractOutside = jest.fn(); render(); await waitFor(() => { expect( document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', - ), + 'div[data-testid="example"]' + ) ).toBeTruthy(); }); const el = document - .querySelector("iframe") + .querySelector('iframe') .contentWindow.document.body.querySelector( - 'div[data-testid="example"]', + 'div[data-testid="example"]' ); fireEvent.touchStart(el); fireEvent.touchEnd(el); @@ -444,7 +438,7 @@ describe("useInteractOutside (iframes)", function () { expect(onInteractOutside).toHaveBeenCalledTimes(1); }); - it("should not fire interact outside if there is a touch end event without a touch start first", function () { + it('should not fire interact outside if there is a touch end event without a touch start first', function () { // Fire mouse down before component with useInteractOutside is mounted fireEvent.touchStart(iframeDocument.body); @@ -456,22 +450,22 @@ describe("useInteractOutside (iframes)", function () { }); }); - describe("disable interact outside events", function () { - it("does not handle pointer events if disabled", function () { + describe('disable interact outside events', function () { + it('does not handle pointer events if disabled', function () { let onInteractOutside = jest.fn(); render( - , + ); - fireEvent(iframeDocument.body, pointerEvent("mousedown")); - fireEvent(iframeDocument.body, pointerEvent("mouseup")); + fireEvent(iframeDocument.body, pointerEvent('mousedown')); + fireEvent(iframeDocument.body, pointerEvent('mouseup')); expect(onInteractOutside).not.toHaveBeenCalled(); }); - it("does not handle touch events if disabled", function () { + it('does not handle touch events if disabled', function () { let onInteractOutside = jest.fn(); render( - , + ); fireEvent.touchStart(iframeDocument.body); @@ -479,10 +473,10 @@ describe("useInteractOutside (iframes)", function () { expect(onInteractOutside).not.toHaveBeenCalled(); }); - it("does not handle mouse events if disabled", function () { + it('does not handle mouse events if disabled', function () { let onInteractOutside = jest.fn(); render( - , + ); fireEvent.mouseDown(iframeDocument.body); @@ -492,24 +486,24 @@ describe("useInteractOutside (iframes)", function () { }); }); -describe("useInteractOutside shadow DOM", function () { +describe('useInteractOutside shadow DOM', function () { // Helper function to create a shadow root and render the component inside it function createShadowRootAndRender(ui) { - const shadowHost = document.createElement("div"); + const shadowHost = document.createElement('div'); document.body.appendChild(shadowHost); - const shadowRoot = shadowHost.attachShadow({ mode: "open" }); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); function WrapperComponent() { return ReactDOM.createPortal(ui, shadowRoot); } render(); - return { shadowRoot, cleanup: () => document.body.removeChild(shadowHost) }; + return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; } - function App({ onInteractOutside }) { + function App({onInteractOutside}) { const ref = useRef(null); - useInteractOutside({ ref, onInteractOutside }); + useInteractOutside({ref, onInteractOutside}); return (
@@ -521,13 +515,13 @@ describe("useInteractOutside shadow DOM", function () { ); } - it("does not trigger when clicking inside popover", function () { + it('does not trigger when clicking inside popover', function () { const onInteractOutside = jest.fn(); - const { shadowRoot, cleanup } = createShadowRootAndRender( - , + const {shadowRoot, cleanup} = createShadowRootAndRender( + ); - const insidePopover = shadowRoot.getElementById("inside-popover"); + const insidePopover = shadowRoot.getElementById('inside-popover'); fireEvent.mouseDown(insidePopover); fireEvent.mouseUp(insidePopover); @@ -535,13 +529,13 @@ describe("useInteractOutside shadow DOM", function () { cleanup(); }); - it("does not trigger when clicking the popover", function () { + it('does not trigger when clicking the popover', function () { const onInteractOutside = jest.fn(); - const { shadowRoot, cleanup } = createShadowRootAndRender( - , + const {shadowRoot, cleanup} = createShadowRootAndRender( + ); - const popover = shadowRoot.getElementById("popover"); + const popover = shadowRoot.getElementById('popover'); fireEvent.mouseDown(popover); fireEvent.mouseUp(popover); @@ -549,10 +543,10 @@ describe("useInteractOutside shadow DOM", function () { cleanup(); }); - it("triggers when clicking outside the popover", function () { + it('triggers when clicking outside the popover', function () { const onInteractOutside = jest.fn(); - const { cleanup } = createShadowRootAndRender( - , + const {cleanup} = createShadowRootAndRender( + ); // Clicking on the document body outside the shadow DOM @@ -563,13 +557,13 @@ describe("useInteractOutside shadow DOM", function () { cleanup(); }); - it("triggers when clicking a button outside the shadow dom altogether", function () { + it('triggers when clicking a button outside the shadow dom altogether', function () { const onInteractOutside = jest.fn(); - const { cleanup } = createShadowRootAndRender( - , + const {cleanup} = createShadowRootAndRender( + ); // Button outside shadow DOM and component - const button = document.createElement("button"); + const button = document.createElement('button'); document.body.appendChild(button); fireEvent.mouseDown(button); @@ -581,29 +575,29 @@ describe("useInteractOutside shadow DOM", function () { }); }); -describe("useInteractOutside shadow DOM extended tests", function () { +describe('useInteractOutside shadow DOM extended tests', function () { // Setup function similar to previous tests, but includes a dynamic element scenario function createShadowRootAndRender(ui) { - const shadowHost = document.createElement("div"); + const shadowHost = document.createElement('div'); document.body.appendChild(shadowHost); - const shadowRoot = shadowHost.attachShadow({ mode: "open" }); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); function WrapperComponent() { return ReactDOM.createPortal(ui, shadowRoot); } render(); - return { shadowRoot, cleanup: () => document.body.removeChild(shadowHost) }; + return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; } - function App({ onInteractOutside, includeDynamicElement = false }) { + function App({onInteractOutside, includeDynamicElement = false}) { const ref = useRef(null); - useInteractOutside({ ref, onInteractOutside }); + useInteractOutside({ref, onInteractOutside}); useEffect(() => { if (includeDynamicElement) { - const dynamicEl = document.createElement("div"); - dynamicEl.id = "dynamic-outside"; + const dynamicEl = document.createElement('div'); + dynamicEl.id = 'dynamic-outside'; document.body.appendChild(dynamicEl); return () => document.body.removeChild(dynamicEl); @@ -620,14 +614,14 @@ describe("useInteractOutside shadow DOM extended tests", function () { ); } - it("correctly identifies interaction with dynamically added external elements", function () { + it('correctly identifies interaction with dynamically added external elements', function () { jest.useFakeTimers(); const onInteractOutside = jest.fn(); - const { cleanup } = createShadowRootAndRender( - , + const {cleanup} = createShadowRootAndRender( + ); - const dynamicEl = document.getElementById("dynamic-outside"); + const dynamicEl = document.getElementById('dynamic-outside'); fireEvent.mouseDown(dynamicEl); fireEvent.mouseUp(dynamicEl); @@ -637,12 +631,12 @@ describe("useInteractOutside shadow DOM extended tests", function () { }); }); -describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { +describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { let user; beforeAll(() => { enableShadowDOM(); - user = userEvent.setup({ delay: null, pointerMap }); + user = userEvent.setup({delay: null, pointerMap}); }); beforeEach(() => { @@ -655,13 +649,13 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { }); }); - it("should handle interact outside events with UNSAFE_PortalProvider in shadow DOM", async () => { - const { shadowRoot } = createShadowRoot(); + it('should handle interact outside events with UNSAFE_PortalProvider in shadow DOM', async () => { + const {shadowRoot} = 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"); + const popoverPortal = document.createElement('div'); + popoverPortal.setAttribute('data-testid', 'popover-portal'); shadowRoot.appendChild(popoverPortal); function ShadowInteractOutsideExample() { @@ -670,7 +664,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ref, onInteractOutside: () => { interactOutsideTriggered = true; - }, + } }); return ( @@ -681,28 +675,28 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => {
, - popoverPortal, + popoverPortal )}
); } - const { unmount } = render(); + const {unmount} = render(); const target = shadowRoot.querySelector('[data-testid="target"]'); const innerButton = shadowRoot.querySelector( - '[data-testid="inner-button"]', + '[data-testid="inner-button"]' ); const outsideButton = shadowRoot.querySelector( - '[data-testid="outside-button"]', + '[data-testid="outside-button"]' ); // Click inside the target - should NOT trigger interact outside @@ -722,8 +716,8 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { document.body.removeChild(shadowRoot.host); }); - it("should correctly identify interactions across shadow DOM boundaries (issue #8675)", async () => { - const { shadowRoot } = createShadowRoot(); + it('should correctly identify interactions across shadow DOM boundaries (issue #8675)', async () => { + const {shadowRoot} = createShadowRoot(); let popoverClosed = false; function MenuPopoverExample() { @@ -732,7 +726,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ref: popoverRef, onInteractOutside: () => { popoverClosed = true; - }, + } }); return ( @@ -742,7 +736,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => {
@@ -845,30 +839,30 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ref={popoverRef} data-testid="popover-in-modal" style={{ - background: "white", - border: "1px solid gray", - padding: "10px", + background: 'white', + border: '1px solid gray', + padding: '10px' }} >
, - modalPortal, + modalPortal )}
); } - const { unmount } = render(); + const {unmount} = render(); const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); const modalButton = shadowRoot.querySelector( - '[data-testid="modal-button"]', + '[data-testid="modal-button"]' ); const popoverButton = shadowRoot.querySelector( - '[data-testid="popover-button"]', + '[data-testid="popover-button"]' ); // Click popover button - should NOT trigger either interact outside @@ -893,10 +887,10 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { document.body.removeChild(shadowRoot.host); }); - it("should handle pointer events correctly in shadow DOM with portal provider", async () => { + it('should handle pointer events correctly in shadow DOM with portal provider', async () => { installPointerEvent(); - const { shadowRoot } = createShadowRoot(); + const {shadowRoot} = createShadowRoot(); let interactOutsideCount = 0; function PointerEventsExample() { @@ -905,7 +899,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ref, onInteractOutside: () => { interactOutsideCount++; - }, + } }); return ( @@ -920,24 +914,24 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ); } - const { unmount } = render(); + const {unmount} = render(); const targetButton = shadowRoot.querySelector( - '[data-testid="target-button"]', + '[data-testid="target-button"]' ); const outsideButton = shadowRoot.querySelector( - '[data-testid="outside-button"]', + '[data-testid="outside-button"]' ); // Simulate pointer events on target - should NOT trigger interact outside - fireEvent(targetButton, pointerEvent("pointerdown")); - fireEvent(targetButton, pointerEvent("pointerup")); + fireEvent(targetButton, pointerEvent('pointerdown')); + fireEvent(targetButton, pointerEvent('pointerup')); fireEvent.click(targetButton); expect(interactOutsideCount).toBe(0); // Simulate pointer events outside - should trigger interact outside - fireEvent(outsideButton, pointerEvent("pointerdown")); - fireEvent(outsideButton, pointerEvent("pointerup")); + fireEvent(outsideButton, pointerEvent('pointerdown')); + fireEvent(outsideButton, pointerEvent('pointerup')); fireEvent.click(outsideButton); expect(interactOutsideCount).toBe(1); @@ -946,8 +940,8 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { document.body.removeChild(shadowRoot.host); }); - it("should handle interact outside with dynamic content in shadow DOM", async () => { - const { shadowRoot } = createShadowRoot(); + it('should handle interact outside with dynamic content in shadow DOM', async () => { + const {shadowRoot} = createShadowRoot(); let interactOutsideCount = 0; function DynamicContentExample() { @@ -958,7 +952,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ref, onInteractOutside: () => { interactOutsideCount++; - }, + } }); return ( @@ -983,16 +977,16 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ); } - const { unmount } = render(); + const {unmount} = render(); const toggleButton = shadowRoot.querySelector( - '[data-testid="toggle-button"]', + '[data-testid="toggle-button"]' ); const dynamicButton = shadowRoot.querySelector( - '[data-testid="dynamic-button"]', + '[data-testid="dynamic-button"]' ); const outsideButton = shadowRoot.querySelector( - '[data-testid="outside-button"]', + '[data-testid="outside-button"]' ); // Click dynamic content - should NOT trigger interact outside @@ -1007,7 +1001,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { // Toggle content back and click it - should still NOT trigger interact outside await user.click(toggleButton); const newDynamicButton = shadowRoot.querySelector( - '[data-testid="dynamic-button"]', + '[data-testid="dynamic-button"]' ); await user.click(newDynamicButton); expect(interactOutsideCount).toBe(1); // Should remain 1 @@ -1017,14 +1011,14 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { document.body.removeChild(shadowRoot.host); }); - it("should handle interact outside across mixed shadow DOM and regular DOM boundaries", async () => { - const { shadowRoot } = createShadowRoot(); + it('should handle interact outside across mixed shadow DOM and regular DOM boundaries', async () => { + const {shadowRoot} = createShadowRoot(); let interactOutsideTriggered = false; // Create a regular DOM button outside the shadow DOM - const regularDOMButton = document.createElement("button"); - regularDOMButton.textContent = "Regular DOM Button"; - regularDOMButton.setAttribute("data-testid", "regular-dom-button"); + const regularDOMButton = document.createElement('button'); + regularDOMButton.textContent = 'Regular DOM Button'; + regularDOMButton.setAttribute('data-testid', 'regular-dom-button'); document.body.appendChild(regularDOMButton); function MixedDOMExample() { @@ -1033,7 +1027,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ref, onInteractOutside: () => { interactOutsideTriggered = true; - }, + } }); return ( @@ -1048,13 +1042,13 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { ); } - const { unmount } = render(); + const {unmount} = render(); const shadowButton = shadowRoot.querySelector( - '[data-testid="shadow-button"]', + '[data-testid="shadow-button"]' ); const shadowOutside = shadowRoot.querySelector( - '[data-testid="shadow-outside"]', + '[data-testid="shadow-outside"]' ); // Click inside shadow target - should NOT trigger @@ -1078,7 +1072,7 @@ describe("useInteractOutside with Shadow DOM and UNSAFE_PortalProvider", () => { }); function pointerEvent(type, opts) { - let evt = new Event(type, { bubbles: true, cancelable: true }); + let evt = new Event(type, {bubbles: true, cancelable: true}); Object.assign(evt, opts); return evt; } From 2e0e86e1e3b147a8b9cbe6efe0f458cb64eac13d Mon Sep 17 00:00:00 2001 From: John Pangalos Date: Tue, 2 Sep 2025 11:10:28 +0200 Subject: [PATCH 04/12] Deleting AI gen tests that weren't working There are a bunch of redundant tests. Getting rid of them for now. --- .../interactions/test/useFocusWithin.test.js | 356 +--------------- .../test/useInteractOutside.test.js | 348 ---------------- .../overlays/test/usePopover.test.tsx | 385 +++--------------- 3 files changed, 53 insertions(+), 1036 deletions(-) diff --git a/packages/@react-aria/interactions/test/useFocusWithin.test.js b/packages/@react-aria/interactions/test/useFocusWithin.test.js index fd5cef60f4a..a5cd33a45b0 100644 --- a/packages/@react-aria/interactions/test/useFocusWithin.test.js +++ b/packages/@react-aria/interactions/test/useFocusWithin.test.js @@ -10,13 +10,9 @@ * governing permissions and limitations under the License. */ -import {act, createShadowRoot, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; -import {enableShadowDOM} from '@react-stately/flags'; +import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; import React, {useState} from 'react'; -import ReactDOM from 'react-dom'; -import {UNSAFE_PortalProvider} from '@react-aria/overlays'; import {useFocusWithin} from '../'; -import userEvent from '@testing-library/user-event'; function Example(props) { let {focusWithinProps} = useFocusWithin(props); @@ -199,353 +195,3 @@ describe('useFocusWithin', function () { ]); }); }); - -describe('useFocusWithin 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 focus within events in shadow DOM with UNSAFE_PortalProvider', async () => { - const {shadowRoot} = createShadowRoot(); - let focusWithinTriggered = false; - let blurWithinTriggered = false; - let focusChangeEvents = []; - - function ShadowFocusWithinExample() { - const handleFocusWithin = () => { - focusWithinTriggered = true; - }; - - const handleBlurWithin = () => { - blurWithinTriggered = true; - }; - - const handleFocusWithinChange = (isFocused) => { - focusChangeEvents.push(isFocused); - }; - - return ( - shadowRoot}> -
- - - - - -
-
- ); - } - - const {unmount} = render(); - - const innerButton = shadowRoot.querySelector('[data-testid="inner-button"]'); - const innerInput = shadowRoot.querySelector('[data-testid="inner-input"]'); - const outerButton = shadowRoot.querySelector('[data-testid="outer-button"]'); - - // Focus within the example container - act(() => { innerButton.focus(); }); - expect(shadowRoot.activeElement).toBe(innerButton); - expect(focusWithinTriggered).toBe(true); - expect(focusChangeEvents).toContain(true); - - // Move focus within the container (should not trigger blur) - act(() => { innerInput.focus(); }); - expect(shadowRoot.activeElement).toBe(innerInput); - expect(blurWithinTriggered).toBe(false); - - // Move focus outside the container - act(() => { outerButton.focus(); }); - expect(shadowRoot.activeElement).toBe(outerButton); - expect(blurWithinTriggered).toBe(true); - expect(focusChangeEvents).toContain(false); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle focus within detection across shadow DOM boundaries (issue #8675)', async () => { - const {shadowRoot} = createShadowRoot(); - let focusWithinEvents = []; - - function MenuWithFocusWithinExample() { - const handleFocusWithinChange = (isFocused) => { - focusWithinEvents.push({type: 'focusWithinChange', isFocused}); - }; - - return ( - shadowRoot}> -
- - -
- - -
-
-
-
- ); - } - - const {unmount} = render(); - - const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); - const menuItem2 = shadowRoot.querySelector('[data-testid="menu-item-2"]'); - const menuTrigger = shadowRoot.querySelector('[data-testid="menu-trigger"]'); - - // Focus enters the menu - act(() => { menuItem1.focus(); }); - expect(shadowRoot.activeElement).toBe(menuItem1); - expect(focusWithinEvents).toContainEqual({type: 'focusWithinChange', isFocused: true}); - - // Click menu item (this should not cause focus within to be lost) - await user.click(menuItem1); - - // Focus should remain within the menu area - expect(focusWithinEvents.filter(e => e.isFocused === false)).toHaveLength(0); - - // Move focus within menu - act(() => { menuItem2.focus(); }); - expect(shadowRoot.activeElement).toBe(menuItem2); - - // Only when focus moves completely outside should focus within be false - act(() => { menuTrigger.focus(); }); - expect(shadowRoot.activeElement).toBe(menuTrigger); - expect(focusWithinEvents).toContainEqual({type: 'focusWithinChange', isFocused: false}); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle nested focus within containers in shadow DOM with portals', async () => { - const {shadowRoot} = createShadowRoot(); - let outerFocusEvents = []; - let innerFocusEvents = []; - - function NestedFocusWithinExample() { - return ( - shadowRoot}> - outerFocusEvents.push(isFocused)} - data-testid="outer-container" - > - - innerFocusEvents.push(isFocused)} - data-testid="inner-container" - > - - - - - - - ); - } - - const {unmount} = render(); - - const outerButton = shadowRoot.querySelector('[data-testid="outer-button"]'); - const innerButton1 = shadowRoot.querySelector('[data-testid="inner-button-1"]'); - const innerButton2 = shadowRoot.querySelector('[data-testid="inner-button-2"]'); - const outerButton2 = shadowRoot.querySelector('[data-testid="outer-button-2"]'); - - // Focus enters outer container - act(() => { outerButton.focus(); }); - expect(outerFocusEvents).toContain(true); - expect(innerFocusEvents).toHaveLength(0); - - // Focus enters inner container - act(() => { innerButton1.focus(); }); - expect(innerFocusEvents).toContain(true); - expect(outerFocusEvents.filter(e => e === false)).toHaveLength(0); // Outer should still be focused - - // Move within inner container - act(() => { innerButton2.focus(); }); - expect(innerFocusEvents.filter(e => e === false)).toHaveLength(0); - - // Move to outer container (leaves inner) - act(() => { outerButton2.focus(); }); - expect(innerFocusEvents).toContain(false); - expect(outerFocusEvents.filter(e => e === false)).toHaveLength(0); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle focus within with complex portal hierarchies in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); - const modalPortal = document.createElement('div'); - modalPortal.setAttribute('data-testid', 'modal-portal'); - shadowRoot.appendChild(modalPortal); - - let modalFocusEvents = []; - let popoverFocusEvents = []; - - function ComplexPortalExample() { - return ( - shadowRoot}> -
- - - {/* Modal with focus within */} - {ReactDOM.createPortal( - modalFocusEvents.push(isFocused)} - data-testid="modal" - > -
- - - - {/* Nested popover within modal */} - popoverFocusEvents.push(isFocused)} - data-testid="popover" - > -
- - -
-
-
-
, - modalPortal - )} -
-
- ); - } - - const {unmount} = render(); - - const modalButton1 = shadowRoot.querySelector('[data-testid="modal-button-1"]'); - const popoverItem1 = shadowRoot.querySelector('[data-testid="popover-item-1"]'); - const popoverItem2 = shadowRoot.querySelector('[data-testid="popover-item-2"]'); - const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); - - // Focus enters modal - act(() => { modalButton1.focus(); }); - expect(modalFocusEvents).toContain(true); - - // Focus enters popover within modal - act(() => { popoverItem1.focus(); }); - expect(popoverFocusEvents).toContain(true); - expect(modalFocusEvents.filter(e => e === false)).toHaveLength(0); // Modal should still have focus within - - // Move within popover - act(() => { popoverItem2.focus(); }); - expect(popoverFocusEvents.filter(e => e === false)).toHaveLength(0); - - // Move back to modal (leaves popover) - act(() => { modalButton1.focus(); }); - expect(popoverFocusEvents).toContain(false); - expect(modalFocusEvents.filter(e => e === false)).toHaveLength(0); - - // Move completely outside (leaves modal) - act(() => { mainButton.focus(); }); - expect(modalFocusEvents).toContain(false); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should correctly handle focus within when elements are dynamically added/removed in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); - let focusWithinEvents = []; - - function DynamicFocusWithinExample() { - const [showItems, setShowItems] = React.useState(true); - - return ( - shadowRoot}> - focusWithinEvents.push(isFocused)} - data-testid="dynamic-container" - > - - {showItems && ( -
- - -
- )} -
-
- ); - } - - const {unmount} = render(); - - const toggleButton = shadowRoot.querySelector('[data-testid="toggle-button"]'); - const dynamicItem1 = shadowRoot.querySelector('[data-testid="dynamic-item-1"]'); - - // Focus within the container - act(() => { dynamicItem1.focus(); }); - expect(focusWithinEvents).toContain(true); - - // Click toggle to remove items while focused on one - await user.click(toggleButton); - - // Focus should now be on the toggle button, still within container - expect(shadowRoot.activeElement).toBe(toggleButton); - expect(focusWithinEvents.filter(e => e === false)).toHaveLength(0); - - // Toggle back to show items - await user.click(toggleButton); - - // Focus should still be within the container - const newDynamicItem1 = shadowRoot.querySelector('[data-testid="dynamic-item-1"]'); - act(() => { newDynamicItem1.focus(); }); - expect(focusWithinEvents.filter(e => e === false)).toHaveLength(0); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); -}); diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index 2d3438d4cd1..81a6f0efba7 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -681,352 +681,4 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { unmount(); document.body.removeChild(shadowRoot.host); }); - - it('should correctly identify interactions across shadow DOM boundaries (issue #8675)', async () => { - const {shadowRoot} = createShadowRoot(); - let popoverClosed = false; - - function MenuPopoverExample() { - const popoverRef = useRef(); - useInteractOutside({ - ref: popoverRef, - onInteractOutside: () => { - popoverClosed = true; - } - }); - - return ( - shadowRoot}> -
- -
-
- - -
-
-
-
- ); - } - - const {unmount} = render(); - - const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); - const menuTrigger = shadowRoot.querySelector( - '[data-testid="menu-trigger"]' - ); - const menuPopover = shadowRoot.querySelector( - '[data-testid="menu-popover"]' - ); - - // Click menu item - should NOT close popover (this is the bug being tested) - await user.click(menuItem1); - expect(popoverClosed).toBe(false); - - // Click on the popover itself - should NOT close popover - await user.click(menuPopover); - expect(popoverClosed).toBe(false); - - // Click outside the popover - SHOULD close popover - await user.click(menuTrigger); - expect(popoverClosed).toBe(true); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle nested portal scenarios with interact outside in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); - const modalPortal = document.createElement('div'); - modalPortal.setAttribute('data-testid', 'modal-portal'); - shadowRoot.appendChild(modalPortal); - - let modalInteractOutside = false; - let popoverInteractOutside = false; - - function NestedPortalsExample() { - const modalRef = useRef(); - const popoverRef = useRef(); - - useInteractOutside({ - ref: modalRef, - onInteractOutside: () => { - modalInteractOutside = true; - } - }); - - useInteractOutside({ - ref: popoverRef, - onInteractOutside: () => { - popoverInteractOutside = true; - } - }); - - return ( - shadowRoot}> -
- - - {/* Modal */} - {ReactDOM.createPortal( -
-
- - - {/* Popover within modal */} -
- -
-
-
, - modalPortal - )} -
-
- ); - } - - const {unmount} = render(); - - const mainButton = shadowRoot.querySelector('[data-testid="main-button"]'); - const modalButton = shadowRoot.querySelector( - '[data-testid="modal-button"]' - ); - const popoverButton = shadowRoot.querySelector( - '[data-testid="popover-button"]' - ); - - // Click popover button - should NOT trigger either interact outside - await user.click(popoverButton); - expect(popoverInteractOutside).toBe(false); - expect(modalInteractOutside).toBe(false); - - // Click modal button - should trigger popover interact outside but NOT modal - await user.click(modalButton); - expect(popoverInteractOutside).toBe(true); - expect(modalInteractOutside).toBe(false); - - // Reset and click completely outside - popoverInteractOutside = false; - modalInteractOutside = false; - - await user.click(mainButton); - expect(modalInteractOutside).toBe(true); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle pointer events correctly in shadow DOM with portal provider', async () => { - installPointerEvent(); - - const {shadowRoot} = createShadowRoot(); - let interactOutsideCount = 0; - - function PointerEventsExample() { - const ref = useRef(); - useInteractOutside({ - ref, - onInteractOutside: () => { - interactOutsideCount++; - } - }); - - return ( - shadowRoot}> -
-
- -
- -
-
- ); - } - - const {unmount} = render(); - - const targetButton = shadowRoot.querySelector( - '[data-testid="target-button"]' - ); - const outsideButton = shadowRoot.querySelector( - '[data-testid="outside-button"]' - ); - - // Simulate pointer events on target - should NOT trigger interact outside - fireEvent(targetButton, pointerEvent('pointerdown')); - fireEvent(targetButton, pointerEvent('pointerup')); - fireEvent.click(targetButton); - expect(interactOutsideCount).toBe(0); - - // Simulate pointer events outside - should trigger interact outside - fireEvent(outsideButton, pointerEvent('pointerdown')); - fireEvent(outsideButton, pointerEvent('pointerup')); - fireEvent.click(outsideButton); - expect(interactOutsideCount).toBe(1); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle interact outside with dynamic content in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); - let interactOutsideCount = 0; - - function DynamicContentExample() { - const ref = useRef(); - const [showContent, setShowContent] = React.useState(true); - - useInteractOutside({ - ref, - onInteractOutside: () => { - interactOutsideCount++; - } - }); - - return ( - shadowRoot}> -
-
- - {showContent && ( -
- -
- )} -
- -
-
- ); - } - - const {unmount} = render(); - - const toggleButton = shadowRoot.querySelector( - '[data-testid="toggle-button"]' - ); - const dynamicButton = shadowRoot.querySelector( - '[data-testid="dynamic-button"]' - ); - const outsideButton = shadowRoot.querySelector( - '[data-testid="outside-button"]' - ); - - // Click dynamic content - should NOT trigger interact outside - await user.click(dynamicButton); - expect(interactOutsideCount).toBe(0); - - // Toggle to remove content, then click outside - should trigger interact outside - await user.click(toggleButton); - await user.click(outsideButton); - expect(interactOutsideCount).toBe(1); - - // Toggle content back and click it - should still NOT trigger interact outside - await user.click(toggleButton); - const newDynamicButton = shadowRoot.querySelector( - '[data-testid="dynamic-button"]' - ); - await user.click(newDynamicButton); - expect(interactOutsideCount).toBe(1); // Should remain 1 - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle interact outside across mixed shadow DOM and regular DOM boundaries', async () => { - const {shadowRoot} = createShadowRoot(); - let interactOutsideTriggered = false; - - // Create a regular DOM button outside the shadow DOM - const regularDOMButton = document.createElement('button'); - regularDOMButton.textContent = 'Regular DOM Button'; - regularDOMButton.setAttribute('data-testid', 'regular-dom-button'); - document.body.appendChild(regularDOMButton); - - function MixedDOMExample() { - const ref = useRef(); - useInteractOutside({ - ref, - onInteractOutside: () => { - interactOutsideTriggered = true; - } - }); - - return ( - shadowRoot}> -
-
- -
- -
-
- ); - } - - const {unmount} = render(); - - const shadowButton = shadowRoot.querySelector( - '[data-testid="shadow-button"]' - ); - const shadowOutside = shadowRoot.querySelector( - '[data-testid="shadow-outside"]' - ); - - // Click inside shadow target - should NOT trigger - await user.click(shadowButton); - expect(interactOutsideTriggered).toBe(false); - - // Click outside in shadow DOM - should trigger - await user.click(shadowOutside); - expect(interactOutsideTriggered).toBe(true); - - // Reset and test regular DOM interaction - interactOutsideTriggered = false; - await user.click(regularDOMButton); - expect(interactOutsideTriggered).toBe(true); - - // Cleanup - document.body.removeChild(regularDOMButton); - unmount(); - document.body.removeChild(shadowRoot.host); - }); }); diff --git a/packages/@react-aria/overlays/test/usePopover.test.tsx b/packages/@react-aria/overlays/test/usePopover.test.tsx index a47a3c2f20e..aa57850a1d1 100644 --- a/packages/@react-aria/overlays/test/usePopover.test.tsx +++ b/packages/@react-aria/overlays/test/usePopover.test.tsx @@ -56,33 +56,42 @@ describe('usePopover with Shadow DOM and UNSAFE_PortalProvider', () => { }); afterEach(() => { - act(() => {jest.runAllTimers();}); + act(() => { + jest.runAllTimers(); + }); }); - it('should handle popover interactions within shadow DOM with UNSAFE_PortalProvider', async () => { + 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) => { + onOpenChange: isOpen => { // Track state changes } }); - + useOverlayTrigger({type: 'listbox'}, state, triggerRef); - const {popoverProps} = usePopover({ - triggerRef, - popoverRef, - placement: 'bottom start' - }, state); + const {popoverProps} = usePopover( + { + triggerRef, + popoverRef, + placement: 'bottom start' + }, + state + ); return ( - shadowRoot}> + shadowRoot as unknown as HTMLElement}>
+ {ReactDOM.createPortal( + <> + {state.isOpen && ( -
- - -
- )} + + +
+ )} + , + popoverPortal + )} +
); @@ -129,7 +142,7 @@ describe('usePopover with Shadow DOM and UNSAFE_PortalProvider', () => { const {unmount} = render(); const trigger = shadowRoot.querySelector('[data-testid="popover-trigger"]'); - + // Click trigger to open popover await user.click(trigger); expect(triggerClicked).toBe(true); @@ -149,305 +162,11 @@ describe('usePopover with Shadow DOM and UNSAFE_PortalProvider', () => { // 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); - }); - - it('should handle focus management in shadow DOM popover with nested interactive elements', async () => { - const {shadowRoot} = createShadowRoot(); - - function FocusTestPopover() { - const triggerRef = useRef(null); - const popoverRef = useRef(null); - const state = useOverlayTriggerState({defaultOpen: true}); - - useOverlayTrigger({type: 'dialog'}, state, triggerRef); - const {popoverProps} = usePopover({ - triggerRef, - popoverRef - }, state); - - return ( - shadowRoot}> -
- - {state.isOpen && ( -
-
- - - -
-
- )} -
-
- ); - } - - const {unmount} = render(); - - const menuItem1 = shadowRoot.querySelector('[data-testid="menu-item-1"]'); - const menuItem2 = shadowRoot.querySelector('[data-testid="menu-item-2"]'); - const menuItem3 = shadowRoot.querySelector('[data-testid="menu-item-3"]'); - - // Focus first menu item - act(() => { menuItem1.focus(); }); - expect(shadowRoot.activeElement).toBe(menuItem1); - - // Tab through menu items - await user.tab(); - expect(shadowRoot.activeElement).toBe(menuItem2); - - await user.tab(); - expect(shadowRoot.activeElement).toBe(menuItem3); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - it('should properly handle click events on popover content within shadow DOM (issue #8675)', async () => { - const {shadowRoot} = createShadowRoot(); - let menuActionExecuted = false; - let popoverClosedUnexpectedly = false; - - function MenuPopoverExample() { - const triggerRef = useRef(null); - const popoverRef = useRef(null); - const [isOpen, setIsOpen] = React.useState(true); - - const state = useOverlayTriggerState({ - isOpen, - onOpenChange: (open) => { - setIsOpen(open); - if (!open) { - popoverClosedUnexpectedly = true; - } - } - }); - - useOverlayTrigger({type: 'listbox'}, state, triggerRef); - const {popoverProps} = usePopover({ - triggerRef, - popoverRef - }, state); - - const handleMenuAction = (action) => { - menuActionExecuted = true; - // In the buggy version, this wouldn't execute because popover closes first - console.log('Menu action:', action); - }; - - return ( - shadowRoot}> -
- - {state.isOpen && ( -
-
- - -
-
- )} -
-
- ); - } - - const {unmount} = render(); - - const saveItem = shadowRoot.querySelector('[data-testid="save-item"]'); - const menuPopover = shadowRoot.querySelector('[data-testid="menu-popover"]'); - - // Verify popover is initially open - expect(menuPopover).toBeInTheDocument(); - - // Focus the menu item - act(() => { saveItem.focus(); }); - expect(shadowRoot.activeElement).toBe(saveItem); - - // Click the menu item - this should execute the action, NOT close the popover - await user.click(saveItem); - - // The action should have been executed (this fails in the buggy version) - expect(menuActionExecuted).toBe(true); - - // The popover should NOT have closed unexpectedly (this fails in the buggy version) - expect(popoverClosedUnexpectedly).toBe(false); - - // Menu should still be visible - expect(shadowRoot.querySelector('[data-testid="menu-popover"]')).toBeInTheDocument(); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle multiple overlapping popovers in shadow DOM with portal provider', async () => { - const {shadowRoot} = createShadowRoot(); - - function MultiplePopoversExample() { - const trigger1Ref = useRef(null); - const popover1Ref = useRef(null); - const trigger2Ref = useRef(null); - const popover2Ref = useRef(null); - - const state1 = useOverlayTriggerState({defaultOpen: true}); - const state2 = useOverlayTriggerState({defaultOpen: true}); - - useOverlayTrigger({type: 'dialog'}, state1, trigger1Ref); - useOverlayTrigger({type: 'dialog'}, state2, trigger2Ref); - - const {popoverProps: popover1Props} = usePopover({ - triggerRef: trigger1Ref, - popoverRef: popover1Ref - }, state1); - - const {popoverProps: popover2Props} = usePopover({ - triggerRef: trigger2Ref, - popoverRef: popover2Ref - }, state2); - - return ( - shadowRoot}> -
- - - - {state1.isOpen && ( -
- -
- )} - - {state2.isOpen && ( -
- -
- )} -
-
- ); - } - - const {unmount} = render(); - - const popover1 = shadowRoot.querySelector('[data-testid="popover-1"]'); - const popover2 = shadowRoot.querySelector('[data-testid="popover-2"]'); - const popover1Action = shadowRoot.querySelector('[data-testid="popover-1-action"]'); - const popover2Action = shadowRoot.querySelector('[data-testid="popover-2-action"]'); - - // Both popovers should be present - expect(popover1).toBeInTheDocument(); - expect(popover2).toBeInTheDocument(); - - // Should be able to interact with both popovers - await user.click(popover1Action); - await user.click(popover2Action); - - // Both should still be present after interactions - expect(shadowRoot.querySelector('[data-testid="popover-1"]')).toBeInTheDocument(); - expect(shadowRoot.querySelector('[data-testid="popover-2"]')).toBeInTheDocument(); - - // Cleanup - unmount(); - document.body.removeChild(shadowRoot.host); - }); - - it('should handle popover positioning and containment in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); - - function PositionedPopoverExample() { - const triggerRef = useRef(null); - const popoverRef = useRef(null); - const state = useOverlayTriggerState({defaultOpen: true}); - - useOverlayTrigger({type: 'listbox'}, state, triggerRef); - const {popoverProps} = usePopover({ - triggerRef, - popoverRef, - placement: 'bottom start', - containerPadding: 12 - }, state); - - return ( - shadowRoot}> -
- - {state.isOpen && ( -
-
-

This is a positioned popover

- -
-
- )} -
-
- ); - } - - const {unmount} = render(); - - const trigger = shadowRoot.querySelector('[data-testid="positioned-trigger"]'); - const popover = shadowRoot.querySelector('[data-testid="positioned-popover"]'); - const actionButton = shadowRoot.querySelector('[data-testid="action-button"]'); - - // Verify popover exists and is positioned - expect(popover).toBeInTheDocument(); - expect(trigger).toBeInTheDocument(); - - // Verify we can interact with popover content - await user.click(actionButton); - - // Popover should still be present after interaction - expect(shadowRoot.querySelector('[data-testid="positioned-popover"]')).toBeInTheDocument(); + // Wait for any cleanup + act(() => { + jest.runAllTimers(); + }); // Cleanup unmount(); From 06a538017d3a9973b6907505d72af1849d781d45 Mon Sep 17 00:00:00 2001 From: John Pangalos Date: Wed, 3 Sep 2025 09:06:22 +0200 Subject: [PATCH 05/12] Update packages/@react-aria/interactions/test/useInteractOutside.test.js Co-authored-by: Robert Snow --- .../@react-aria/interactions/test/useInteractOutside.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index 81a6f0efba7..c5f6aab8211 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -617,7 +617,7 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { }); it('should handle interact outside events with UNSAFE_PortalProvider in shadow DOM', async () => { - const {shadowRoot} = createShadowRoot(); + const {shadowRoot, cleanup} = createShadowRoot(); let interactOutsideTriggered = false; // Create portal container within the shadow DOM for the popover From a9b85aade2e21187a896cab7b3e678f5f2b8a582 Mon Sep 17 00:00:00 2001 From: John Pangalos Date: Wed, 3 Sep 2025 09:06:28 +0200 Subject: [PATCH 06/12] Update packages/@react-aria/interactions/test/useInteractOutside.test.js Co-authored-by: Robert Snow --- .../@react-aria/interactions/test/useInteractOutside.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index c5f6aab8211..844705874f7 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -679,6 +679,6 @@ describe('useInteractOutside with Shadow DOM and UNSAFE_PortalProvider', () => { // Cleanup unmount(); - document.body.removeChild(shadowRoot.host); + cleanup(); }); }); From bc794e114e6587734e00598f26c7d9f19d11ff3c Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Tue, 7 Oct 2025 13:10:37 -0400 Subject: [PATCH 07/12] Global find/replace for node.contains with nodeContains --- package.json | 4 ++++ .../@react-aria/calendar/src/useRangeCalendar.ts | 8 ++++---- packages/@react-aria/combobox/src/useComboBox.ts | 4 ++-- .../@react-aria/datepicker/src/useDatePicker.ts | 4 ++-- .../datepicker/src/useDatePickerGroup.ts | 4 ++-- .../datepicker/src/useDateRangePicker.ts | 4 ++-- .../@react-aria/datepicker/src/useDateSegment.ts | 4 ++-- packages/@react-aria/dialog/src/useDialog.ts | 4 ++-- packages/@react-aria/dnd/src/DragManager.ts | 16 ++++++++-------- packages/@react-aria/dnd/src/useDrop.ts | 4 ++-- packages/@react-aria/grid/src/useGrid.ts | 6 +++--- packages/@react-aria/grid/src/useGridCell.ts | 10 +++++----- .../@react-aria/gridlist/src/useGridListItem.ts | 10 +++++----- .../interactions/src/useFocusWithin.ts | 8 ++++---- .../@react-aria/interactions/src/useHover.ts | 6 +++--- .../@react-aria/interactions/src/usePress.ts | 2 +- packages/@react-aria/landmark/src/useLandmark.ts | 8 ++++---- .../menu/src/useSafelyMouseToSubmenu.ts | 4 ++-- .../@react-aria/menu/src/useSubmenuTrigger.ts | 12 ++++++------ .../@react-aria/overlays/src/ariaHideOutside.ts | 2 +- .../@react-aria/overlays/src/useCloseOnScroll.ts | 3 ++- .../overlays/src/useOverlayPosition.ts | 4 ++-- packages/@react-aria/select/src/useSelect.ts | 4 ++-- .../selection/src/useSelectableCollection.ts | 16 ++++++++-------- .../@react-aria/selection/src/useTypeSelect.ts | 3 ++- packages/@react-aria/toolbar/src/useToolbar.ts | 8 ++++---- packages/@react-aria/utils/src/scrollIntoView.ts | 5 +++-- .../utils/src/shadowdom/DOMFunctions.ts | 8 ++++---- packages/@react-aria/utils/src/useDrag1D.ts | 3 ++- packages/@react-spectrum/card/src/CardBase.tsx | 4 ++-- .../menu/src/ContextualHelpTrigger.tsx | 5 +++-- .../@react-spectrum/menu/src/SubmenuTrigger.tsx | 4 ++-- .../@react-spectrum/table/src/TableViewBase.tsx | 4 ++-- packages/dev/docs/src/client.js | 7 ++++--- packages/react-aria-components/src/DropZone.tsx | 4 ++-- 35 files changed, 108 insertions(+), 98 deletions(-) diff --git a/package.json b/package.json index b64ca456c7f..fcdfaac2e17 100644 --- a/package.json +++ b/package.json @@ -287,5 +287,9 @@ ] } ] + }, + "volta": { + "node": "22.20.0", + "yarn": "4.2.2" } } diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 87695d4268d..fd8d2f487c9 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -14,7 +14,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; import {RangeCalendarState} from '@react-stately/calendar'; -import {useEvent} from '@react-aria/utils'; +import {useEvent, nodeContains} from '@react-aria/utils'; import {useRef} from 'react'; /** @@ -52,8 +52,8 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = e.target as Element; if ( ref.current && - ref.current.contains(document.activeElement) && - (!ref.current.contains(target) || !target.closest('button, [role="button"]')) + nodeContains(ref.current, document.activeElement) && + (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); } @@ -66,7 +66,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr if (!ref.current) { return; } - if ((!e.relatedTarget || !ref.current.contains(e.relatedTarget)) && state.anchorDate) { + if ((!e.relatedTarget || !nodeContains(ref.current, e.relatedTarget)) && state.anchorDate) { state.selectFocusedDate(); } }; diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 6c7deae42ba..d21d8785eff 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -178,7 +178,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; - let blurIntoPopover = popoverRef.current?.contains(e.relatedTarget); + let blurIntoPopover = nodeContains(popoverRef.current, e.relatedTarget); // Ignore blur if focused moved to the button(if exists) or into the popover. if (blurFromButton || blurIntoPopover) { return; diff --git a/packages/@react-aria/datepicker/src/useDatePicker.ts b/packages/@react-aria/datepicker/src/useDatePicker.ts index f768b34df88..2677b39c020 100644 --- a/packages/@react-aria/datepicker/src/useDatePicker.ts +++ b/packages/@react-aria/datepicker/src/useDatePicker.ts @@ -17,7 +17,7 @@ import {CalendarProps} from '@react-types/calendar'; import {createFocusManager} from '@react-aria/focus'; import {DatePickerState} from '@react-stately/datepicker'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {privateValidationStateProp} from '@react-stately/form'; @@ -84,7 +84,7 @@ export function useDatePicker(props: AriaDatePickerProps onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!nodeContains(dialog, e.relatedTarget)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts index 862ba7e08a0..acf42a48816 100644 --- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts +++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts @@ -1,7 +1,7 @@ import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus'; import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker'; import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, nodeContains} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; import {useMemo} from 'react'; import {usePress} from '@react-aria/interactions'; @@ -12,7 +12,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState // Open the popover on alt + arrow down let onKeyDown = (e: KeyboardEvent) => { - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } diff --git a/packages/@react-aria/datepicker/src/useDateRangePicker.ts b/packages/@react-aria/datepicker/src/useDateRangePicker.ts index 6e9c748455b..5614487fced 100644 --- a/packages/@react-aria/datepicker/src/useDateRangePicker.ts +++ b/packages/@react-aria/datepicker/src/useDateRangePicker.ts @@ -18,7 +18,7 @@ import {DateRange, RangeCalendarProps} from '@react-types/calendar'; import {DateRangePickerState} from '@react-stately/datepicker'; import {DEFAULT_VALIDATION_RESULT, mergeValidation, privateValidationStateProp} from '@react-stately/form'; import {DOMAttributes, GroupDOMAttributes, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useDescription, useId} from '@react-aria/utils'; import {focusManagerSymbol, roleSymbol} from './useDateField'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -116,7 +116,7 @@ export function useDateRangePicker(props: AriaDateRangePick onBlurWithin: e => { // Ignore when focus moves into the popover. let dialog = document.getElementById(dialogId); - if (!dialog?.contains(e.relatedTarget)) { + if (!nodeContains(dialog, e.relatedTarget)) { isFocused.current = false; props.onBlur?.(e); props.onFocusChange?.(false); diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 2aad84bc0ea..328ff68c6e1 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -12,7 +12,7 @@ import {CalendarDate, toCalendar} from '@internationalized/date'; import {DateFieldState, DateSegment} from '@react-stately/datepicker'; -import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; +import {getScrollParent, isIOS, isMac, mergeProps, nodeContains, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils'; import {hookData} from './useDateField'; import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; @@ -281,7 +281,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: // Otherwise, when tapping on a segment in Android Chrome and then entering text, // composition events will be fired that break the DOM structure and crash the page. let selection = window.getSelection(); - if (selection?.anchorNode && ref.current?.contains(selection?.anchorNode)) { + if (selection?.anchorNode && nodeContains(ref.current, selection?.anchorNode)) { selection.collapse(ref.current); } }); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index 33c4a144b5a..eef23f9968c 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -12,7 +12,7 @@ import {AriaDialogProps} from '@react-types/dialog'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, nodeContains, useSlotId} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; import {useOverlayFocusContain} from '@react-aria/overlays'; @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { - if (ref.current && !ref.current.contains(document.activeElement)) { + if (ref.current && !nodeContains(ref.current, document.activeElement)) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 2128797ffb4..0a4be7afba1 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; import {getDragModality, getTypes} from './utils'; -import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils'; +import {isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -114,7 +114,7 @@ function endDragging() { export function isValidDropTarget(element: Element): boolean { for (let target of dropTargets.keys()) { - if (target.contains(element)) { + if (nodeContains(target, element)) { return true; } } @@ -243,7 +243,7 @@ class DragSession { this.cancelEvent(e); if (e.key === 'Enter') { - if (e.altKey || this.getCurrentActivateButton()?.contains(e.target as Node)) { + if (e.altKey || nodeContains(this.getCurrentActivateButton(), e.target as Node)) { this.activate(this.currentDropTarget, this.currentDropItem); } else { this.drop(); @@ -275,7 +275,7 @@ class DragSession { let dropTarget = this.validDropTargets.find(target => target.element === e.target as HTMLElement) || - this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); + this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); if (!dropTarget) { // if (e.target === activateButton) { @@ -321,10 +321,10 @@ class DragSession { this.cancelEvent(e); if (isVirtualClick(e) || this.isVirtualClick) { let dropElements = dropItems.values(); - let item = [...dropElements].find(item => item.element === e.target as HTMLElement || item.activateButtonRef?.current?.contains(e.target as HTMLElement)); - let dropTarget = this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); + let item = [...dropElements].find(item => item.element === e.target as HTMLElement || nodeContains(item.activateButtonRef?.current, e.target as HTMLElement)); + let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current; - if (activateButton?.contains(e.target as HTMLElement) && dropTarget) { + if (nodeContains(activateButton, e.target as HTMLElement) && dropTarget) { this.activate(dropTarget, item); return; } @@ -401,7 +401,7 @@ class DragSession { // Filter out drop targets that contain valid items. We don't want to stop hiding elements // other than the drop items that exist inside the collection. let visibleDropTargets = this.validDropTargets.filter(target => - !validDropItems.some(item => target.element.contains(item.element)) + !validDropItems.some(item => nodeContains(target.element, item.element)) ); this.restoreAriaHidden = ariaHideOutside([ diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index 09985b2d7a5..5d4a3b342c6 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -16,7 +16,7 @@ import {DragEvent, useRef, useState} from 'react'; import * as DragManager from './DragManager'; import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils'; import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants'; -import {isIPad, isMac, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {useVirtualDrop} from './useVirtualDrop'; export interface DropOptions { @@ -234,7 +234,7 @@ export function useDrop(options: DropOptions): DropResult { state.dragOverElements.delete(e.target as Element); for (let element of state.dragOverElements) { - if (!e.currentTarget.contains(element)) { + if (!nodeContains(e.currentTarget, element)) { state.dragOverElements.delete(element); } } diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 5a8c9935efd..5c6ba84eec0 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; @@ -136,7 +136,7 @@ export function useGrid(props: GridProps, state: GridState { if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { manager.setFocused(false); } @@ -144,7 +144,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (ref.current.contains(document.activeElement) && ref.current !== document.activeElement) { + if (nodeContains(ref.current, document.activeElement) && ref.current !== document.activeElement) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current.contains(document.activeElement) + !nodeContains(ref.current, document.activeElement) ) { focusSafely(ref.current); } @@ -109,7 +109,7 @@ export function useGridCell>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { return; } @@ -213,7 +213,7 @@ export function useGridCell>(props: GridCellProps // Prevent this event from reaching cell children, e.g. menu buttons. We want arrow keys to navigate // to the cell above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && ref.current.contains(e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, e.target as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 2ecfc4f8125..085b4f9033e 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -79,7 +79,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt if ( ref.current !== null && ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !ref.current?.contains(document.activeElement)) + !nodeContains(ref.current, document.activeElement)) ) { focusSafely(ref.current); } @@ -131,7 +131,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { return; } @@ -216,7 +216,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && ref.current.contains(e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, e.target as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -244,7 +244,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { return; } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 10f6254f69b..9d290998b52 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 (!nodeContains(e.currentTarget as Element, e.target as Element)) { + if (!nodeContains(e.currentTarget, e.target)) { 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 && !nodeContains(e.currentTarget as Element, e.relatedTarget as Element)) { + if (state.current.isFocusWithin && !nodeContains(e.currentTarget, e.relatedTarget)) { 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 (!nodeContains(e.currentTarget as Element, e.target as Element)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } @@ -103,7 +103,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { // can manually fire onBlur. let currentTarget = e.currentTarget; addGlobalListener(ownerDocument, 'focus', e => { - if (state.current.isFocusWithin && !nodeContains(currentTarget, e.target as Element)) { + if (state.current.isFocusWithin && !nodeContains(currentTarget, e.target)) { let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: e.target}); setEventTarget(nativeEvent, currentTarget); let event = createSyntheticEvent(nativeEvent); diff --git a/packages/@react-aria/interactions/src/useHover.ts b/packages/@react-aria/interactions/src/useHover.ts index 6c5c69ad0c1..cde3c286128 100644 --- a/packages/@react-aria/interactions/src/useHover.ts +++ b/packages/@react-aria/interactions/src/useHover.ts @@ -108,7 +108,7 @@ export function useHover(props: HoverProps): HoverResult { let {hoverProps, triggerHoverEnd} = useMemo(() => { let triggerHoverStart = (event, pointerType) => { state.pointerType = pointerType; - if (isDisabled || pointerType === 'touch' || state.isHovered || !event.currentTarget.contains(event.target)) { + if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, event.target)) { return; } @@ -180,7 +180,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onPointerLeave = (e) => { - if (!isDisabled && e.currentTarget.contains(e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { triggerHoverEnd(e, e.pointerType); } }; @@ -198,7 +198,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onMouseLeave = (e) => { - if (!isDisabled && e.currentTarget.contains(e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { triggerHoverEnd(e, 'mouse'); } }; diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 049ed1b288d..d5cbbe0b5ba 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -669,7 +669,7 @@ export function usePress(props: PressHookProps): PressResult { return; } - if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { + if (state.target && nodeContains(state.target, e.target as Element) && state.pointerType != null) { // Wait for onClick to fire onPress. This avoids browser issues when the DOM // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. } else { diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index aea8768c8f0..30c765e3f18 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; import {useCallback, useEffect, useState} from 'react'; -import {useLayoutEffect} from '@react-aria/utils'; +import {useLayoutEffect, nodeContains} from '@react-aria/utils'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; @@ -325,7 +325,7 @@ class LandmarkManager implements LandmarkManagerApi { private focusMain() { let main = this.getLandmarkByRole('main'); - if (main && main.ref.current && document.contains(main.ref.current)) { + if (main && main.ref.current && nodeContains(document, main.ref.current)) { this.focusLandmark(main.ref.current, 'forward'); return true; } @@ -345,14 +345,14 @@ class LandmarkManager implements LandmarkManagerApi { // If something was previously focused in the next landmark, then return focus to it if (nextLandmark.lastFocused) { let lastFocused = nextLandmark.lastFocused; - if (document.body.contains(lastFocused)) { + if (nodeContains(document.body, lastFocused)) { lastFocused.focus(); return true; } } // Otherwise, focus the landmark itself - if (nextLandmark.ref.current && document.contains(nextLandmark.ref.current)) { + if (nextLandmark.ref.current && nodeContains(document, nextLandmark.ref.current)) { this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); return true; } diff --git a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts index 1a43971bbe4..9461715dff6 100644 --- a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts +++ b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts @@ -1,7 +1,7 @@ import {RefObject} from '@react-types/shared'; import {useEffect, useRef, useState} from 'react'; -import {useEffectEvent, useResizeObserver} from '@react-aria/utils'; +import {nodeContains, useEffectEvent, useResizeObserver} from '@react-aria/utils'; import {useInteractionModality} from '@react-aria/interactions'; interface SafelyMouseToSubmenuOptions { @@ -148,7 +148,7 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v // Fire a pointerover event to trigger the menu to close. // Wait until pointer-events:none is no longer applied let target = document.elementFromPoint(mouseX, mouseY); - if (target && menu.contains(target)) { + if (target && nodeContains(menu, target)) { target.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, cancelable: true})); } }, 100); diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 590ce43a213..af873997d06 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, nodeContains, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -100,13 +100,13 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm let submenuKeyDown = (e: KeyboardEvent) => { // If focus is not within the menu, assume virtual focus is being used. // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!e.currentTarget.contains(document.activeElement)) { + if (!nodeContains(e.currentTarget, document.activeElement)) { return; } switch (e.key) { case 'ArrowLeft': - if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) { + if (direction === 'ltr' && nodeContains(e.currentTarget, e.target as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -116,7 +116,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } break; case 'ArrowRight': - if (direction === 'rtl' && e.currentTarget.contains(e.target as Element)) { + if (direction === 'rtl' && nodeContains(e.currentTarget, e.target as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -127,7 +127,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm break; case 'Escape': // TODO: can remove this when we fix collection event leaks - if (submenuRef.current?.contains(e.target as Element)) { + if (nodeContains(submenuRef.current, e.target as Element)) { e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { @@ -226,7 +226,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm useEvent(parentMenuRef, 'focusin', (e) => { // If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in // then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open - if (state.isOpen && (parentMenuRef.current?.contains(e.target as HTMLElement) && e.target !== ref.current)) { + if (state.isOpen && nodeContains(parentMenuRef.current, e.target as HTMLElement) && e.target !== ref.current) { onSubmenuClose(); } }); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index e50d0945da2..2bd6368f3fa 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -150,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 => nodeContains(node, change.target as Element))) { + if (![...visibleNodes, ...hiddenNodes].some(node => nodeContains(node, change.target))) { for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && diff --git a/packages/@react-aria/overlays/src/useCloseOnScroll.ts b/packages/@react-aria/overlays/src/useCloseOnScroll.ts index 23899dccbf8..64f54860947 100644 --- a/packages/@react-aria/overlays/src/useCloseOnScroll.ts +++ b/packages/@react-aria/overlays/src/useCloseOnScroll.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -39,7 +40,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { // Ignore if scrolling an scrollable region outside the trigger's tree. let target = e.target; // window is not a Node and doesn't have contain, but window contains everything - if (!triggerRef.current || ((target instanceof Node) && !target.contains(triggerRef.current))) { + if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index 59c61a08075..7a40774b3e1 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -15,7 +15,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; -import {useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,7 +154,7 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && scrollRef.current.contains(document.activeElement)) { + if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) { let anchorRect = document.activeElement?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index daebc1d3910..11dc057176e 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -13,7 +13,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; import {AriaSelectProps, SelectionMode} from '@react-types/select'; -import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared'; import {FocusEvent, useMemo} from 'react'; import {HiddenSelectProps} from './HiddenSelect'; @@ -223,7 +223,7 @@ export function useSelect(props: AriaSele disallowEmptySelection: true, linkBehavior: 'selection', onBlur: (e) => { - if (e.currentTarget.contains(e.relatedTarget as Node)) { + if (nodeContains(e.currentTarget, e.relatedTarget as Node)) { return; } diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 825888ffea6..04d3fc81f62 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -134,7 +134,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Keyboard events bubble through portals. Don't handle keyboard events // for elements outside the collection (e.g. menus). - if (!ref.current?.contains(e.target as Element)) { + if (!nodeContains(ref.current, e.target as Element)) { return; } @@ -292,7 +292,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } break; case 'Tab': { - if (!allowsTabNavigation) { + if (!allowsTabNavigation && ref.current) { // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). // However, collections should be treated as a single tab stop, with arrow key navigation internally. // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. @@ -312,7 +312,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } } while (last); - if (next && !next.contains(document.activeElement)) { + if (next && !nodeContains(next, document.activeElement)) { focusWithoutScrolling(next); } } @@ -335,7 +335,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let onFocus = (e: FocusEvent) => { if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { manager.setFocused(false); } @@ -343,7 +343,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } // Focus events can bubble through portals. Ignore these events. - if (!e.currentTarget.contains(e.target)) { + if (!nodeContains(e.currentTarget, e.target)) { return; } @@ -377,7 +377,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = getItemElement(ref, manager.focusedKey); if (element instanceof HTMLElement) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!element.contains(document.activeElement) && !shouldUseVirtualFocus) { + if (!nodeContains(element, document.activeElement) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } @@ -391,7 +391,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let onBlur = (e) => { // Don't set blurred and then focused again if moving focus within the collection. - if (!e.currentTarget.contains(e.relatedTarget as HTMLElement)) { + if (!nodeContains(e.currentTarget, e.relatedTarget as HTMLElement)) { manager.setFocused(false); } }; diff --git a/packages/@react-aria/selection/src/useTypeSelect.ts b/packages/@react-aria/selection/src/useTypeSelect.ts index 6a3e7dd7031..66f8e02136d 100644 --- a/packages/@react-aria/selection/src/useTypeSelect.ts +++ b/packages/@react-aria/selection/src/useTypeSelect.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {nodeContains} from '@react-aria/utils'; import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; import {KeyboardEvent, useRef} from 'react'; import {MultipleSelectionManager} from '@react-stately/selection'; @@ -53,7 +54,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let onKeyDown = (e: KeyboardEvent) => { let character = getStringForKey(e.key); - if (!character || e.ctrlKey || e.metaKey || !e.currentTarget.contains(e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { + if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { return; } diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index b94bb988c57..7331973d8f9 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -56,7 +56,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { // don't handle portalled events - if (!e.currentTarget.contains(e.target as HTMLElement)) { + if (!nodeContains(e.currentTarget, e.target as HTMLElement)) { return; } if ( @@ -101,7 +101,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); const onBlur = (e) => { - if (!e.currentTarget.contains(e.relatedTarget) && !lastFocused.current) { + if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) { lastFocused.current = e.target; } }; @@ -110,7 +110,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { - if (lastFocused.current && !e.currentTarget.contains(e.relatedTarget) && ref.current?.contains(e.target)) { + if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, e.target)) { lastFocused.current?.focus(); lastFocused.current = null; } diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 17b573ef8e6..96a6eecd1ce 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -12,6 +12,7 @@ import {getScrollParents} from './getScrollParents'; import {isChrome} from './platform'; +import {nodeContains} from './shadowdom/DOMFunctions'; interface ScrollIntoViewportOpts { /** The optional containing element of the target to be centered in the viewport. */ @@ -113,7 +114,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| if (child.offsetParent === ancestor) { // Stop once we have found the ancestor we are interested in. break; - } else if (child.offsetParent.contains(ancestor)) { + } else if (nodeContains(child.offsetParent, ancestor)) { // If the ancestor is not `position:relative`, then we stop at // _its_ offset parent, and we subtract off _its_ offset, so that // we end up with the proper offset from child to ancestor. @@ -131,7 +132,7 @@ function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'| * the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself. */ export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts): void { - if (targetElement && document.contains(targetElement)) { + if (targetElement && nodeContains(document, targetElement)) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; // If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index bb849088fca..9f48ee01917 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -7,14 +7,14 @@ import {shadowDOM} from '@react-stately/flags'; * ShadowDOM safe version of Node.contains. */ export function nodeContains( - node: Node | null | undefined, - otherNode: Node | null | undefined + node: Node | EventTarget | null | undefined, + otherNode: Node | EventTarget | null | undefined ): boolean { if (!shadowDOM()) { - return otherNode && node ? node.contains(otherNode) : false; + return node instanceof Node && otherNode instanceof Node ? node.contains(otherNode) : false; } - if (!node || !otherNode) { + if (!(node instanceof Node) || !(otherNode instanceof Node)) { return false; } diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index e907128c9b3..41fe28abac8 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -13,6 +13,7 @@ /* eslint-disable rulesdir/pure-render */ import {getOffset} from './getOffset'; +import {nodeContains} from './shadowdom/DOMFunctions'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; @@ -99,7 +100,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { const target = e.currentTarget; // If we're already handling dragging on a descendant with useDrag1D, then // we don't want to handle the drag motion on this target as well. - if (draggingElements.some(elt => target.contains(elt))) { + if (draggingElements.some(elt => nodeContains(target, elt))) { return; } draggingElements.push(target); diff --git a/packages/@react-spectrum/card/src/CardBase.tsx b/packages/@react-spectrum/card/src/CardBase.tsx index bada5ea8d4e..bc641c5c9a3 100644 --- a/packages/@react-spectrum/card/src/CardBase.tsx +++ b/packages/@react-spectrum/card/src/CardBase.tsx @@ -15,7 +15,7 @@ import {AriaCardProps, SpectrumCardProps} from '@react-types/card'; import {Checkbox} from '@react-spectrum/checkbox'; import {classNames, SlotProvider, useDOMRef, useHasChild, useStyleProps} from '@react-spectrum/utils'; import {DOMRef, Node} from '@react-types/shared'; -import {filterDOMProps, mergeProps, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, nodeContains, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils'; import {FocusRing, getFocusableTreeWalker} from '@react-aria/focus'; import React, {HTMLAttributes, useCallback, useMemo, useRef, useState} from 'react'; import styles from '@adobe/spectrum-css-temp/components/card/vars.css'; @@ -104,7 +104,7 @@ export const CardBase = React.forwardRef(function CardBase(pro let walker = getFocusableTreeWalker(gridRef.current); let nextNode = walker.nextNode(); while (nextNode != null) { - if (checkboxRef.current && !checkboxRef.current.UNSAFE_getDOMNode().contains(nextNode)) { + if (checkboxRef.current && !nodeContains(checkboxRef.current.UNSAFE_getDOMNode(), nextNode)) { console.warn('Card does not support focusable elements, please contact the team regarding your use case.'); break; } diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index b51d3580b4d..e3e641e82af 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -22,6 +22,7 @@ import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {SubmenuTriggerContext, useMenuStateContext} from './context'; import {TrayHeaderWrapper} from './Menu'; import {useSubmenuTrigger} from '@react-aria/menu'; +import {nodeContains} from '@react-aria/utils'; import {useSubmenuTriggerState} from '@react-stately/menu'; interface MenuDialogTriggerProps { @@ -85,7 +86,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem let [, content] = props.children as [ReactElement, ReactElement]; let onBlurWithin = (e) => { - if (e.relatedTarget && popoverRef.current && (!popoverRef.current.UNSAFE_getDOMNode()?.contains(e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) { + if (e.relatedTarget && popoverRef.current && (!nodeContains(popoverRef.current.UNSAFE_getDOMNode(), e.relatedTarget) && !(e.relatedTarget === triggerRef.current && getInteractionModality() === 'pointer'))) { if (submenuTriggerState.isOpen) { submenuTriggerState.close(); } @@ -98,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { parentMenuRef.current.focus(); } }, 220); // Matches transition duration diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index 902870bc922..f6e1a1ce532 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -13,7 +13,7 @@ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps} from '@react-aria/utils'; +import {mergeProps, nodeContains} from '@react-aria/utils'; import {Popover} from '@react-spectrum/overlays'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) { let isMobile = useIsMobileDevice(); let onBackButtonPress = () => { submenuTriggerState.close(); - if (parentMenuRef.current && !parentMenuRef.current.contains(document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 5934d217e19..443303f5fb2 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isAndroid, mergeProps, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; +import {isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer'; @@ -606,7 +606,7 @@ function TableVirtualizer(props: TableVirtualizerProps) { // only that it changes in a resize, and when that happens, we want to sync the body to the // header scroll position useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current?.contains(document.activeElement) && bodyRef.current) { + if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, document.activeElement) && bodyRef.current) { scrollIntoView(headerRef.current, document.activeElement as HTMLElement); scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; diff --git a/packages/dev/docs/src/client.js b/packages/dev/docs/src/client.js index f92f63fa291..953da2dc208 100644 --- a/packages/dev/docs/src/client.js +++ b/packages/dev/docs/src/client.js @@ -13,6 +13,7 @@ import {ActionButton, Flex, Link} from '@adobe/react-spectrum'; import DocSearch from './DocSearch'; import docsStyle from './docs.css'; +import {nodeContains} from '@react-aria/utils'; import LinkOut from '@spectrum-icons/workflow/LinkOut'; import {listen} from 'quicklink'; import React, {useEffect, useRef, useState} from 'react'; @@ -82,7 +83,7 @@ function Hamburger() { nav.classList.toggle(docsStyle.visible); - if (nav.classList.contains(docsStyle.visible)) { + if (nodeContains(nav.classList, docsStyle.visible)) { setIsPressed(true); main.setAttribute('aria-hidden', 'true'); themeSwitcher.setAttribute('aria-hidden', 'true'); @@ -108,7 +109,7 @@ function Hamburger() { let removeVisible = (isNotResponsive = false) => { setIsPressed(false); - if (nav.contains(document.activeElement) && !isNotResponsive) { + if (nodeContains(nav, document.activeElement) && !isNotResponsive) { hamburgerButton.focus(); } @@ -131,7 +132,7 @@ function Hamburger() { /* trap keyboard focus within expanded nav */ let onKeydownTab = (event) => { - if (event.keyCode === 9 && nav.classList.contains(docsStyle.visible)) { + if (event.keyCode === 9 && nodeContains(nav.classList, docsStyle.visible)) { let tabbables = nav.querySelectorAll('button, a[href]'); let first = tabbables[0]; let last = tabbables[tabbables.length - 1]; diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index ebb32507584..6c8d7873a88 100644 --- a/packages/react-aria-components/src/DropZone.tsx +++ b/packages/react-aria-components/src/DropZone.tsx @@ -13,7 +13,7 @@ import {AriaLabelingProps, GlobalDOMAttributes, HoverEvents} from '@react-types/shared'; import {ContextValue, Provider, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils'; import {DropOptions, mergeProps, useButton, useClipboard, useDrop, useFocusRing, useHover, useLocalizedStringFormatter, VisuallyHidden} from 'react-aria'; -import {filterDOMProps, isFocusable, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react'; @@ -103,7 +103,7 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: ref={dropzoneRef} onClick={(e) => { let target = e.target as HTMLElement | null; - while (target && dropzoneRef.current?.contains(target)) { + while (target && nodeContains(dropzoneRef.current, target)) { if (isFocusable(target)) { break; } else if (target === dropzoneRef.current) { From c020021cc6c63da71a3859574d3dd5a8ed24a17f Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Tue, 7 Oct 2025 13:19:26 -0400 Subject: [PATCH 08/12] Update nodeContains calls post-merge --- packages/@react-aria/test-utils/src/combobox.ts | 5 +++-- packages/@react-aria/test-utils/src/gridlist.ts | 5 +++-- packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/test-utils/src/combobox.ts b/packages/@react-aria/test-utils/src/combobox.ts index d95ac6f5711..313f9dc820a 100644 --- a/packages/@react-aria/test-utils/src/combobox.ts +++ b/packages/@react-aria/test-utils/src/combobox.ts @@ -12,6 +12,7 @@ import {act, waitFor, within} from '@testing-library/react'; import {ComboBoxTesterOpts, UserOpts} from './types'; +import { nodeContains } from '../../utils'; interface ComboBoxOpenOpts { /** @@ -176,7 +177,7 @@ export class ComboBoxTester { if (option.getAttribute('href') == null) { await waitFor(() => { - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; @@ -198,7 +199,7 @@ export class ComboBoxTester { await this.user.keyboard('[Escape]'); await waitFor(() => { - if (document.contains(listbox)) { + if (nodeContains(document, listbox)) { throw new Error('Expected listbox element to not be in the document after selecting an option'); } else { return true; diff --git a/packages/@react-aria/test-utils/src/gridlist.ts b/packages/@react-aria/test-utils/src/gridlist.ts index d5d1c21e082..510e3f6292d 100644 --- a/packages/@react-aria/test-utils/src/gridlist.ts +++ b/packages/@react-aria/test-utils/src/gridlist.ts @@ -13,6 +13,7 @@ import {act, within} from '@testing-library/react'; import {getAltKey, getMetaKey, pressElement, triggerLongPress} from './events'; import {GridListTesterOpts, GridRowActionOpts, ToggleGridRowOpts, UserOpts} from './types'; +import {nodeContains} from '../../utils'; interface GridListToggleRowOpts extends ToggleGridRowOpts {} interface GridListRowActionOpts extends GridRowActionOpts {} @@ -66,13 +67,13 @@ export class GridListTester { throw new Error('Option provided is not in the gridlist'); } - if (document.activeElement !== this._gridlist && !this._gridlist.contains(document.activeElement)) { + if (document.activeElement !== this._gridlist && !nodeContains(this._gridlist, document.activeElement)) { act(() => this._gridlist.focus()); } if (document.activeElement === this._gridlist) { await this.user.keyboard(`${selectionOnNav === 'none' ? `[${altKey}>]` : ''}[ArrowDown]${selectionOnNav === 'none' ? `[/${altKey}]` : ''}`); - } else if (this._gridlist.contains(document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { + } else if (nodeContains(this._gridlist, document.activeElement) && document.activeElement!.getAttribute('role') !== 'row') { do { await this.user.keyboard('[ArrowLeft]'); } while (document.activeElement!.getAttribute('role') !== 'row'); diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index 9f48ee01917..063d71500a2 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 instanceof HTMLElement && event.target.shadowRoot) { if (event.composedPath) { return event.composedPath()[0] as Element; } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index c6441c762fd..480b2456c33 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -71,7 +71,7 @@ import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1197,7 +1197,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!popoverRef.current?.contains(document.activeElement)) { + if (!nodeContains(popoverRef.current, document.activeElement)) { return false; } formRef.current?.requestSubmit(); From b7b7c285bc8619315508a2fcccfd6bf40c262cb2 Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Tue, 7 Oct 2025 16:52:30 -0400 Subject: [PATCH 09/12] Change event.target to getEventTarget --- .../actiongroup/src/useActionGroup.ts | 8 +-- .../autocomplete/src/useAutocomplete.ts | 22 ++++--- .../calendar/src/useCalendarCell.ts | 7 ++- .../calendar/src/useRangeCalendar.ts | 4 +- .../@react-aria/combobox/src/useComboBox.ts | 4 +- .../datepicker/src/useDatePickerGroup.ts | 10 +-- packages/@react-aria/dnd/src/DragManager.ts | 32 +++++----- packages/@react-aria/dnd/src/useDrag.ts | 15 ++--- packages/@react-aria/dnd/src/useDrop.ts | 6 +- packages/@react-aria/grid/src/useGrid.ts | 8 +-- packages/@react-aria/grid/src/useGridCell.ts | 8 +-- .../gridlist/src/useGridListItem.ts | 10 +-- .../@react-aria/interactions/src/useFocus.ts | 10 +-- .../interactions/src/useFocusVisible.ts | 6 +- .../interactions/src/useFocusWithin.ts | 13 ++-- .../@react-aria/interactions/src/useHover.ts | 31 +++++----- .../interactions/src/useInteractOutside.ts | 13 ++-- .../@react-aria/interactions/src/usePress.ts | 61 ++++++++++--------- .../@react-aria/interactions/src/utils.ts | 20 +++--- .../@react-aria/landmark/src/useLandmark.ts | 12 ++-- packages/@react-aria/menu/src/useMenuItem.ts | 9 +-- .../@react-aria/menu/src/useSubmenuTrigger.ts | 10 +-- .../numberfield/src/useNumberField.ts | 7 ++- .../overlays/src/useCloseOnScroll.ts | 6 +- .../@react-aria/overlays/src/useOverlay.ts | 7 ++- .../overlays/src/usePreventScroll.ts | 6 +- .../@react-aria/radio/src/useRadioGroup.ts | 5 +- .../@react-aria/select/src/HiddenSelect.tsx | 7 ++- .../selection/src/useSelectableCollection.ts | 11 ++-- .../selection/src/useSelectableItem.ts | 4 +- .../selection/src/useTypeSelect.ts | 4 +- .../@react-aria/toast/src/useToastRegion.ts | 7 ++- .../@react-aria/toolbar/src/useToolbar.ts | 17 +++--- .../utils/src/runAfterTransition.ts | 20 +++--- .../utils/src/shadowdom/DOMFunctions.ts | 17 ++++-- packages/@react-aria/utils/src/useDrag1D.ts | 4 +- .../@react-aria/utils/src/useViewportSize.ts | 3 +- .../virtualizer/src/ScrollView.tsx | 4 +- packages/@react-spectrum/s2/src/Field.tsx | 6 +- packages/@react-spectrum/s2/src/Toast.tsx | 4 +- .../react-aria-components/src/DropZone.tsx | 4 +- 41 files changed, 251 insertions(+), 211 deletions(-) diff --git a/packages/@react-aria/actiongroup/src/useActionGroup.ts b/packages/@react-aria/actiongroup/src/useActionGroup.ts index 336e32ac79d..14b0f24b3f7 100644 --- a/packages/@react-aria/actiongroup/src/useActionGroup.ts +++ b/packages/@react-aria/actiongroup/src/useActionGroup.ts @@ -13,10 +13,10 @@ import {AriaActionGroupProps} from '@react-types/actiongroup'; import {createFocusManager} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, Orientation, RefObject} from '@react-types/shared'; -import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {ListState} from '@react-stately/list'; import {useLocale} from '@react-aria/i18n'; -import {useState} from 'react'; +import {useState, KeyboardEvent} from 'react'; const BUTTON_GROUP_ROLES = { 'none': 'toolbar', @@ -47,8 +47,8 @@ export function useActionGroup(props: AriaActionGroupProps, state: ListSta let {direction} = useLocale(); let focusManager = createFocusManager(ref); let flipDirection = direction === 'rtl' && orientation === 'horizontal'; - let onKeyDown = (e) => { - if (!e.currentTarget.contains(e.target)) { + let onKeyDown = (e: KeyboardEvent) => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 313615d5977..42ea24c500a 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getEventTarget, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore @@ -112,7 +112,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut inputRef.current.focus(); } - let target = e.target as Element | null; + let target = getEventTarget(e) as Element | null; if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) { return; } @@ -221,7 +221,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let keyDownTarget = useRef(null); // For textfield specific keydown operations let onKeyDown = (e: BaseEvent>) => { - keyDownTarget.current = e.target as Element; + keyDownTarget.current = getEventTarget(e) as Element; if (e.nativeEvent.isComposing) { return; } @@ -325,7 +325,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut // Dispatch simulated key up events for things like triggering links in listbox // Make sure to stop the propagation of the input keyup event so that the simulated keyup/down pair // is detected by usePress instead of the original keyup originating from the input - if (e.target === keyDownTarget.current) { + if (getEventTarget(e) === keyDownTarget.current) { e.stopImmediatePropagation(); let focusedNodeId = queuedActiveDescendant.current; if (focusedNodeId == null) { @@ -382,12 +382,14 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null; if (curFocusedNode) { - let target = e.target; - queueMicrotask(() => { - // instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item - dispatchVirtualBlur(target, collectionRef.current); - dispatchVirtualFocus(collectionRef.current!, target); - }); + let target = getEventTarget(e); + if (target instanceof Element) { + queueMicrotask(() => { + // instead of focusing the last focused node, just focus the collection instead and have the collection handle what item to focus via useSelectableCollection/Item + dispatchVirtualBlur(target, collectionRef.current); + dispatchVirtualFocus(collectionRef.current!, target); + }); + } } }; diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index aabeac2f9a5..f99898e06a8 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -13,7 +13,7 @@ import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {DOMAttributes, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality, usePress} from '@react-aria/interactions'; // @ts-ignore @@ -334,11 +334,12 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta } }, onPointerDown(e) { + const eventTarget = getEventTarget(e); // This is necessary on touch devices to allow dragging // outside the original pressed element. // (JSDOM does not support this) - if ('releasePointerCapture' in e.target) { - e.target.releasePointerCapture(e.pointerId); + if (eventTarget instanceof Element && 'releasePointerCapture' in eventTarget) { + eventTarget.releasePointerCapture(e.pointerId); } }, onContextMenu(e) { diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index fd8d2f487c9..3d178a19c30 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -14,7 +14,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; import {RangeCalendarState} from '@react-stately/calendar'; -import {useEvent, nodeContains} from '@react-aria/utils'; +import {useEvent, nodeContains, getEventTarget} from '@react-aria/utils'; import {useRef} from 'react'; /** @@ -49,7 +49,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr return; } - let target = e.target as Element; + let target = getEventTarget(e) as Element; if ( ref.current && nodeContains(ref.current, document.activeElement) && diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index a1f69e1d541..a74752974a8 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getEventTarget, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -262,7 +262,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta return; } - let rect = (e.target as Element).getBoundingClientRect(); + let rect = (getEventTarget(e) as Element).getBoundingClientRect(); let touch = e.changedTouches[0]; let centerX = Math.ceil(rect.left + .5 * rect.width); diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts index acf42a48816..8de9cae918e 100644 --- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts +++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts @@ -1,7 +1,7 @@ import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus'; import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker'; import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared'; -import {mergeProps, nodeContains} from '@react-aria/utils'; +import {getEventTarget, mergeProps, nodeContains} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; import {useMemo} from 'react'; import {usePress} from '@react-aria/interactions'; @@ -12,7 +12,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState // Open the popover on alt + arrow down let onKeyDown = (e: KeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -32,7 +32,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { - let target = e.target as FocusableElement; + let target = getEventTarget(e) as FocusableElement; let prev = findNextSegment(ref.current, target.getBoundingClientRect().left, -1); if (prev) { @@ -48,7 +48,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { - let target = e.target as FocusableElement; + let target = getEventTarget(e) as FocusableElement; let next = findNextSegment(ref.current, target.getBoundingClientRect().left, 1); if (next) { @@ -68,7 +68,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState return; } // Try to find the segment prior to the element that was clicked on. - let target = window.event?.target as FocusableElement; + let target = window.event ? getEventTarget(window.event) as FocusableElement : null; let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); if (target) { walker.currentNode = target; diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 0a4be7afba1..4749e9969ff 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; import {getDragModality, getTypes} from './utils'; -import {isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; +import {getEventTarget, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -243,7 +243,7 @@ class DragSession { this.cancelEvent(e); if (e.key === 'Enter') { - if (e.altKey || nodeContains(this.getCurrentActivateButton(), e.target as Node)) { + if (e.altKey || nodeContains(this.getCurrentActivateButton(), getEventTarget(e) as Node)) { this.activate(this.currentDropTarget, this.currentDropItem); } else { this.drop(); @@ -257,28 +257,29 @@ class DragSession { onFocus(e: FocusEvent): void { let activateButton = this.getCurrentActivateButton(); - if (e.target === activateButton) { + let eventTarget = getEventTarget(e); + if (eventTarget === activateButton) { // TODO: canceling this breaks the focus ring. Revisit when we support tabbing. this.cancelEvent(e); return; } // Prevent focus events, except to the original drag target. - if (e.target !== this.dragTarget.element) { + if (eventTarget !== this.dragTarget.element) { this.cancelEvent(e); } // Ignore focus events on the window/document (JSDOM). Will be handled in onBlur, below. - if (!(e.target instanceof HTMLElement) || e.target === this.dragTarget.element) { + if (!(eventTarget instanceof HTMLElement) || eventTarget === this.dragTarget.element) { return; } let dropTarget = - this.validDropTargets.find(target => target.element === e.target as HTMLElement) || - this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); + this.validDropTargets.find(target => target.element === eventTarget as HTMLElement) || + this.validDropTargets.find(target => nodeContains(target.element, eventTarget as HTMLElement)); if (!dropTarget) { - // if (e.target === activateButton) { + // if (eventTarget === activateButton) { // activateButton.focus(); // } if (this.currentDropTarget) { @@ -289,7 +290,7 @@ class DragSession { return; } - let item = dropItems.get(e.target as HTMLElement); + let item = dropItems.get(eventTarget as HTMLElement); if (dropTarget) { this.setCurrentDropTarget(dropTarget, item); } @@ -302,7 +303,7 @@ class DragSession { return; } - if (e.target !== this.dragTarget.element) { + if (getEventTarget(e) !== this.dragTarget.element) { this.cancelEvent(e); } @@ -321,15 +322,16 @@ class DragSession { this.cancelEvent(e); if (isVirtualClick(e) || this.isVirtualClick) { let dropElements = dropItems.values(); - let item = [...dropElements].find(item => item.element === e.target as HTMLElement || nodeContains(item.activateButtonRef?.current, e.target as HTMLElement)); - let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, e.target as HTMLElement)); + let eventTarget = getEventTarget(e); + let item = [...dropElements].find(item => item.element === eventTarget as HTMLElement || nodeContains(item.activateButtonRef?.current, eventTarget as HTMLElement)); + let dropTarget = this.validDropTargets.find(target => nodeContains(target.element, eventTarget as HTMLElement)); let activateButton = item?.activateButtonRef?.current ?? dropTarget?.activateButtonRef?.current; - if (nodeContains(activateButton, e.target as HTMLElement) && dropTarget) { + if (nodeContains(activateButton, eventTarget as HTMLElement) && dropTarget) { this.activate(dropTarget, item); return; } - if (e.target === this.dragTarget.element) { + if (eventTarget === this.dragTarget.element) { this.cancel(); return; } @@ -350,7 +352,7 @@ class DragSession { cancelEvent(e: Event): void { // Allow focusin and focusout on the drag target so focus ring works properly. - if ((e.type === 'focusin' || e.type === 'focusout') && (e.target === this.dragTarget?.element || e.target === this.getCurrentActivateButton())) { + if ((e.type === 'focusin' || e.type === 'focusout') && (getEventTarget(e) === this.dragTarget?.element || getEventTarget(e) === this.getCurrentActivateButton())) { return; } diff --git a/packages/@react-aria/dnd/src/useDrag.ts b/packages/@react-aria/dnd/src/useDrag.ts index 6cfbb9be7b1..f7e28d32801 100644 --- a/packages/@react-aria/dnd/src/useDrag.ts +++ b/packages/@react-aria/dnd/src/useDrag.ts @@ -18,7 +18,7 @@ import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './c import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, useDragModality, writeToDataTransfer} from './utils'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils'; +import {getEventTarget, isVirtualClick, isVirtualPointerEvent, useDescription, useGlobalListeners} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface DragOptions { @@ -102,7 +102,7 @@ export function useDrag(options: DragOptions): DragResult { // If this drag was initiated by a mobile screen reader (e.g. VoiceOver or TalkBack), enter virtual dragging mode. if (modalityOnPointerDown.current === 'virtual') { e.preventDefault(); - startDragging(e.target as HTMLElement); + startDragging(getEventTarget(e) as HTMLElement); modalityOnPointerDown.current = null; return; } @@ -188,7 +188,7 @@ export function useDrag(options: DragOptions): DragResult { // Wait a frame before we set dragging to true so that the browser has time to // render the preview image before we update the element that has been dragged. - let target = e.target; + let target = getEventTarget(e); requestAnimationFrame(() => { setDragging(target as Element); }); @@ -340,16 +340,17 @@ export function useDrag(options: DragOptions): DragResult { } }, onKeyDownCapture(e) { - if (e.target === e.currentTarget && e.key === 'Enter') { + if (getEventTarget(e) === e.currentTarget && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); } }, onKeyUpCapture(e) { - if (e.target === e.currentTarget && e.key === 'Enter') { + let eventTarget = getEventTarget(e); + if (eventTarget === e.currentTarget && e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); - startDragging(e.target as HTMLElement); + startDragging(eventTarget as HTMLElement); } }, onClick(e) { @@ -357,7 +358,7 @@ export function useDrag(options: DragOptions): DragResult { if (isVirtualClick(e.nativeEvent) || modalityOnPointerDown.current === 'virtual') { e.preventDefault(); e.stopPropagation(); - startDragging(e.target as HTMLElement); + startDragging(getEventTarget(e) as HTMLElement); } } }; diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index 5d4a3b342c6..1a33e2e1ebb 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -16,7 +16,7 @@ import {DragEvent, useRef, useState} from 'react'; import * as DragManager from './DragManager'; import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils'; import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants'; -import {isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {getEventTarget, isIPad, isMac, nodeContains, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {useVirtualDrop} from './useVirtualDrop'; export interface DropOptions { @@ -186,7 +186,7 @@ export function useDrop(options: DropOptions): DropResult { let onDragEnter = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - state.dragOverElements.add(e.target as Element); + state.dragOverElements.add(getEventTarget(e) as Element); if (state.dragOverElements.size > 1) { return; } @@ -232,7 +232,7 @@ export function useDrop(options: DropOptions): DropResult { // events will never be fired for these. This can happen, for example, with drop // indicators between items, which disappear when the drop target changes. - state.dragOverElements.delete(e.target as Element); + state.dragOverElements.delete(getEventTarget(e) as Element); for (let element of state.dragOverElements) { if (!nodeContains(e.currentTarget, element)) { state.dragOverElements.delete(element); diff --git a/packages/@react-aria/grid/src/useGrid.ts b/packages/@react-aria/grid/src/useGrid.ts index 5c6ba84eec0..43596517724 100644 --- a/packages/@react-aria/grid/src/useGrid.ts +++ b/packages/@react-aria/grid/src/useGrid.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, DOMProps, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; -import {filterDOMProps, mergeProps, nodeContains, useId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, mergeProps, nodeContains, useId} from '@react-aria/utils'; import {GridCollection} from '@react-types/grid'; import {GridKeyboardDelegate} from './GridKeyboardDelegate'; import {gridMap} from './utils'; @@ -133,10 +133,10 @@ export function useGrid(props: GridProps, state: GridState { + let onFocus = useCallback((e: FocusEvent) => { if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { manager.setFocused(false); } @@ -144,7 +144,7 @@ export function useGrid(props: GridProps, state: GridState>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { return; } @@ -213,7 +213,7 @@ export function useGridCell>(props: GridCellProps // Prevent this event from reaching cell children, e.g. menu buttons. We want arrow keys to navigate // to the cell above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && nodeContains(ref.current, e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -228,7 +228,7 @@ export function useGridCell>(props: GridCellProps // be marshalled to that element rather than focusing the cell itself. let onFocus = (e) => { keyWhenFocused.current = node.key; - if (e.target !== ref.current) { + if (getEventTarget(e) !== ref.current) { // useSelectableItem only handles setting the focused key when // the focused element is the gridcell itself. We also want to // set the focused key when a child element receives focus. diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 085b4f9033e..84223f7affe 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getEventTarget, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -131,7 +131,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !document.activeElement) { return; } @@ -216,7 +216,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate // to the row above/below instead. We need to re-dispatch the event from a higher parent so it still // bubbles and gets handled by useSelectableCollection. - if (!e.altKey && nodeContains(ref.current, e.target as Element)) { + if (!e.altKey && nodeContains(ref.current, getEventTarget(e) as Element)) { e.stopPropagation(); e.preventDefault(); ref.current.parentElement?.dispatchEvent( @@ -229,7 +229,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let onFocus = (e) => { keyWhenFocused.current = node.key; - if (e.target !== ref.current) { + if (getEventTarget(e) !== ref.current) { // useSelectableItem only handles setting the focused key when // the focused element is the row itself. We also want to // set the focused key when a child element receives focus. @@ -244,7 +244,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!nodeContains(e.currentTarget, e.target as Element) || !ref.current || !document.activeElement) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !document.activeElement) { return; } diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index d2c910ecedd..7ae3205bfeb 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -43,7 +43,7 @@ export function useFocus(pro } = props; const onBlur: FocusProps['onBlur'] = useCallback((e: FocusEvent) => { - if (e.target === e.currentTarget) { + if (getEventTarget(e) === e.currentTarget) { if (onBlurProp) { onBlurProp(e); } @@ -60,12 +60,14 @@ export function useFocus(pro const onSyntheticFocus = useSyntheticBlurEvent(onBlur); const onFocus: FocusProps['onFocus'] = useCallback((e: FocusEvent) => { - // Double check that document.activeElement actually matches e.target in case a previously chained + // Double check that document.activeElement actually matches getEventTarget(e) in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const eventTarget = getEventTarget(e); + const target = eventTarget instanceof Element ? eventTarget : null; + const ownerDocument = getOwnerDocument(target); const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement(); - if (e.target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) { + if (target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusProp) { onFocusProp(e); } diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 1ffd18463cb..cd3120d4888 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.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, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; +import {getEventTarget, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; import {ignoreFocusEvent} from './utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -90,10 +90,12 @@ function handleClickEvent(e: MouseEvent) { } function handleFocusEvent(e: FocusEvent) { + const eventTarget = getEventTarget(e); // Firefox fires two extra focus events when the user first clicks into an iframe: // first on the window, then on the document. We ignore these events so they don't // cause keyboard focus rings to appear. - if (e.target === window || e.target === document || ignoreFocusEvent || !e.isTrusted) { + if ( + eventTarget === window || eventTarget === document || ignoreFocusEvent || !e.isTrusted) { return; } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 9d290998b52..eae23479c22 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -54,7 +54,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onBlur = useCallback((e: FocusEvent) => { // Ignore events bubbling through portals. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { return; } @@ -77,14 +77,15 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onSyntheticFocus = useSyntheticBlurEvent(onBlur); let onFocus = useCallback((e: FocusEvent) => { + const eventTarget = getEventTarget(e); // Ignore events bubbling through portals. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, eventTarget)) { return; } - // Double check that document.activeElement actually matches e.target in case a previously chained + // Double check that document.activeElement actually matches eventTarget in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const ownerDocument = getOwnerDocument(eventTarget instanceof Element ? eventTarget : null); const activeElement = getActiveElement(ownerDocument); if (!state.current.isFocusWithin && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusWithin) { @@ -103,8 +104,8 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { // can manually fire onBlur. let currentTarget = e.currentTarget; addGlobalListener(ownerDocument, 'focus', e => { - if (state.current.isFocusWithin && !nodeContains(currentTarget, e.target)) { - let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: e.target}); + if (state.current.isFocusWithin && !nodeContains(currentTarget, eventTarget)) { + let nativeEvent = new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: eventTarget}); setEventTarget(nativeEvent, currentTarget); let event = createSyntheticEvent(nativeEvent); onBlur(event); diff --git a/packages/@react-aria/interactions/src/useHover.ts b/packages/@react-aria/interactions/src/useHover.ts index cde3c286128..b133f62e36f 100644 --- a/packages/@react-aria/interactions/src/useHover.ts +++ b/packages/@react-aria/interactions/src/useHover.ts @@ -15,9 +15,9 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {DOMAttributes, HoverEvents} from '@react-types/shared'; -import {getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils'; -import {useEffect, useMemo, useRef, useState} from 'react'; +import {DOMAttributes, FocusableElement, HoverEvent, HoverEvents} from '@react-types/shared'; +import {getEventTarget, getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils'; +import {SyntheticEvent, useEffect, useMemo, useRef, useState} from 'react'; export interface HoverProps extends HoverEvents { /** Whether the hover events should be disabled. */ @@ -99,16 +99,17 @@ export function useHover(props: HoverProps): HoverResult { isHovered: false, ignoreEmulatedMouseEvents: false, pointerType: '', - target: null + target: null as (EventTarget & FocusableElement) | null }).current; useEffect(setupGlobalTouchEvents, []); let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); let {hoverProps, triggerHoverEnd} = useMemo(() => { - let triggerHoverStart = (event, pointerType) => { + let triggerHoverStart = (event: SyntheticEvent, pointerType: string) => { state.pointerType = pointerType; - if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, event.target)) { + let eventTarget = getEventTarget(event); + if (isDisabled || pointerType === 'touch' || state.isHovered || !nodeContains(event.currentTarget, eventTarget)) { return; } @@ -120,8 +121,8 @@ export function useHover(props: HoverProps): HoverResult { // even though the originally hovered target may have shrunk in size so it is no longer hovered. // However, a pointerover event will be fired on the new target the mouse is over. // In Chrome this happens immediately. In Safari and Firefox, it happens upon moving the mouse one pixel. - addGlobalListener(getOwnerDocument(event.target), 'pointerover', e => { - if (state.isHovered && state.target && !nodeContains(state.target, e.target as Element)) { + addGlobalListener(getOwnerDocument(eventTarget instanceof Element ? eventTarget : null), 'pointerover', e => { + if (state.isHovered && state.target && !nodeContains(state.target, getEventTarget(e) as Element)) { triggerHoverEnd(e, e.pointerType); } }, {capture: true}); @@ -129,8 +130,8 @@ export function useHover(props: HoverProps): HoverResult { if (onHoverStart) { onHoverStart({ type: 'hoverstart', - target, - pointerType + target: target as HTMLElement, + pointerType: pointerType as HoverEvent['pointerType'] }); } @@ -141,7 +142,7 @@ export function useHover(props: HoverProps): HoverResult { setHovered(true); }; - let triggerHoverEnd = (event, pointerType) => { + let triggerHoverEnd = (_event, pointerType: string) => { let target = state.target; state.pointerType = ''; state.target = null; @@ -156,8 +157,8 @@ export function useHover(props: HoverProps): HoverResult { if (onHoverEnd) { onHoverEnd({ type: 'hoverend', - target, - pointerType + target: target as HTMLElement, + pointerType: pointerType as HoverEvent['pointerType'] }); } @@ -180,7 +181,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onPointerLeave = (e) => { - if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { triggerHoverEnd(e, e.pointerType); } }; @@ -198,7 +199,7 @@ export function useHover(props: HoverProps): HoverResult { }; hoverProps.onMouseLeave = (e) => { - if (!isDisabled && nodeContains(e.currentTarget, e.target as Element)) { + if (!isDisabled && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { triggerHoverEnd(e, 'mouse'); } }; diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 374d0f739d6..c7bda70170c 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, nodeContains, useEffectEvent} from '@react-aria/utils'; +import {getEventTarget, getOwnerDocument, nodeContains, useEffectEvent} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect, useRef} from 'react'; @@ -118,14 +118,15 @@ function isValidEvent(event, ref) { if (event.button > 0) { return false; } - if (event.target) { + let eventTarget = getEventTarget(event); + if (eventTarget instanceof Element) { // if the event target is no longer in the document, ignore - const ownerDocument = event.target.ownerDocument; - if (!ownerDocument || !nodeContains(ownerDocument.documentElement, event.target)) { + const ownerDocument = eventTarget.ownerDocument; + if (!ownerDocument || !nodeContains(ownerDocument.documentElement, eventTarget)) { return false; } // If the target is within a top layer element (e.g. toasts), ignore. - if (event.target.closest('[data-react-aria-top-layer]')) { + if (eventTarget.closest('[data-react-aria-top-layer]')) { return false; } } @@ -134,7 +135,7 @@ function isValidEvent(event, ref) { return false; } - // When the event source is inside a Shadow DOM, event.target is just the shadow root. + // When the event source is inside a Shadow DOM, getEventTarget(event) is just the shadow root. // Using event.composedPath instead means we can get the actual element inside the shadow root. // This only works if the shadow root is open, there is no way to detect if it is closed. // If the event composed path contains the ref, interaction is inside. diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 48fb9ab5447..82962534162 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -326,8 +326,9 @@ export function usePress(props: PressHookProps): PressResult { let state = ref.current; let pressProps: DOMAttributes = { onKeyDown(e) { - if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { - if (shouldPreventDefaultKeyboard(getEventTarget(e.nativeEvent), e.key)) { + let eventTarget = getEventTarget(e.nativeEvent); + if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, eventTarget) && eventTarget instanceof Element) { + if (shouldPreventDefaultKeyboard(eventTarget, e.key)) { e.preventDefault(); } @@ -345,7 +346,7 @@ export function usePress(props: PressHookProps): PressResult { // instead of the same element where the key down event occurred. Make it capturing so that it will trigger // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element. let originalTarget = e.currentTarget; - let pressUp = (e) => { + let pressUp = (e: KeyboardEvent) => { if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { triggerPressUp(createEvent(state.target, e), 'keyboard'); } @@ -411,30 +412,32 @@ export function usePress(props: PressHookProps): PressResult { let onKeyUp = (e: KeyboardEvent) => { if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { - if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { - e.preventDefault(); - } - let target = getEventTarget(e); - let wasPressed = nodeContains(state.target, getEventTarget(e)); - triggerPressEnd(createEvent(state.target, e), 'keyboard', wasPressed); - if (wasPressed) { - triggerSyntheticClick(e, state.target); - } - removeAllGlobalListeners(); + if(target instanceof Element) { + if (shouldPreventDefaultKeyboard(target, e.key)) { + e.preventDefault(); + } - // If a link was triggered with a key other than Enter, open the URL ourselves. - // This means the link has a role override, and the default browser behavior - // only applies when using the Enter key. - if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { - // Store a hidden property on the event so we only trigger link click once, - // even if there are multiple usePress instances attached to the element. - e[LINK_CLICKED] = true; - openLink(state.target, e, false); - } + let wasPressed = nodeContains(state.target, target); + triggerPressEnd(createEvent(state.target, e), 'keyboard', wasPressed); + if (wasPressed) { + triggerSyntheticClick(e, state.target); + } + removeAllGlobalListeners(); + + // If a link was triggered with a key other than Enter, open the URL ourselves. + // This means the link has a role override, and the default browser behavior + // only applies when using the Enter key. + if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { + // Store a hidden property on the event so we only trigger link click once, + // even if there are multiple usePress instances attached to the element. + e[LINK_CLICKED] = true; + openLink(state.target, e, false); + } - state.isPressed = false; - state.metaKeyEvents?.delete(e.key); + state.isPressed = false; + state.metaKeyEvents?.delete(e.key); + } } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { // If we recorded keydown events that occurred while the Meta key was pressed, // and those haven't received keyup events already, fire keyup events ourselves. @@ -481,7 +484,7 @@ export function usePress(props: PressHookProps): PressResult { // Release pointer capture so that touch interactions can leave the original target. // This enables onPointerLeave and onPointerEnter to fire. let target = getEventTarget(e.nativeEvent); - if ('releasePointerCapture' in target) { + if (target instanceof Element && 'releasePointerCapture' in target) { target.releasePointerCapture(e.pointerId); } @@ -501,7 +504,7 @@ export function usePress(props: PressHookProps): PressResult { if (e.button === 0) { if (preventFocusOnPress) { - let dispose = preventFocus(e.target as FocusableElement); + let dispose = preventFocus(getEventTarget(e) as FocusableElement); if (dispose) { state.disposables.push(dispose); } @@ -614,7 +617,7 @@ export function usePress(props: PressHookProps): PressResult { } if (preventFocusOnPress) { - let dispose = preventFocus(e.target as FocusableElement); + let dispose = preventFocus(getEventTarget(e) as FocusableElement); if (dispose) { state.disposables.push(dispose); } @@ -677,7 +680,7 @@ export function usePress(props: PressHookProps): PressResult { return; } - if (state.target && nodeContains(state.target, e.target as Element) && state.pointerType != null) { + if (state.target && nodeContains(state.target, getEventTarget(e) as Element) && state.pointerType != null) { // Wait for onClick to fire onPress. This avoids browser issues when the DOM // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. } else { @@ -872,7 +875,7 @@ export function usePress(props: PressHookProps): PressResult { } function isHTMLAnchorLink(target: Element): target is HTMLAnchorElement { - return target.tagName === 'A' && target.hasAttribute('href'); + return !!target && target.tagName === 'A' && target.hasAttribute('href'); } function isValidKeyboardEvent(event: KeyboardEvent, currentTarget: Element): boolean { diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index f31da638bc9..bff0a23a526 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -54,19 +54,19 @@ export function useSyntheticBlurEvent(onBlur: // This function is called during a React onFocus event. return useCallback((e: ReactFocusEvent) => { + let target = getEventTarget(e); // React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142 // Most browsers fire a native focusout event in this case, except for Firefox. In that case, we use a // MutationObserver to watch for the disabled attribute, and dispatch these events ourselves. // For browsers that do, focusout fires before the MutationObserver, so onBlur should not fire twice. if ( - e.target instanceof HTMLButtonElement || - e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLSelectElement + target instanceof HTMLButtonElement || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement ) { stateRef.current.isFocused = true; - let target = e.target; let onBlurHandler: EventListenerOrEventListenerObject | null = (e) => { stateRef.current.isFocused = false; @@ -121,13 +121,13 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un ignoreFocusEvent = true; let isRefocusing = false; let onBlur = (e: FocusEvent) => { - if (e.target === activeElement || isRefocusing) { + if (getEventTarget(e) === activeElement || isRefocusing) { e.stopImmediatePropagation(); } }; let onFocusOut = (e: FocusEvent) => { - if (e.target === activeElement || isRefocusing) { + if (getEventTarget(e) === activeElement || isRefocusing) { e.stopImmediatePropagation(); // If there was no focusable ancestor, we don't expect a focus event. @@ -141,13 +141,13 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un }; let onFocus = (e: FocusEvent) => { - if (e.target === target || isRefocusing) { + if (getEventTarget(e) === target || isRefocusing) { e.stopImmediatePropagation(); } }; let onFocusIn = (e: FocusEvent) => { - if (e.target === target || isRefocusing) { + if (getEventTarget(e) === target || isRefocusing) { e.stopImmediatePropagation(); if (!isRefocusing) { diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 30c765e3f18..e8b7513127a 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; import {useCallback, useEffect, useState} from 'react'; -import {useLayoutEffect, nodeContains} from '@react-aria/utils'; +import {getEventTarget, useLayoutEffect, nodeContains} from '@react-aria/utils'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; @@ -315,7 +315,7 @@ class LandmarkManager implements LandmarkManagerApi { private f6Handler(e: KeyboardEvent) { if (e.key === 'F6') { // If alt key pressed, focus main landmark, otherwise navigate forward or backward based on shift key. - let handled = e.altKey ? this.focusMain() : this.navigate(e.target as FocusableElement, e.shiftKey); + let handled = e.altKey ? this.focusMain() : this.navigate(getEventTarget(e) as FocusableElement, e.shiftKey); if (handled) { e.preventDefault(); e.stopPropagation(); @@ -365,9 +365,9 @@ class LandmarkManager implements LandmarkManagerApi { * Lets the last focused landmark know it was blurred if something else is focused. */ private focusinHandler(e: FocusEvent) { - let currentLandmark = this.closestLandmark(e.target as FocusableElement); - if (currentLandmark && currentLandmark.ref.current !== e.target) { - this.updateLandmark({ref: currentLandmark.ref, lastFocused: e.target as FocusableElement}); + let currentLandmark = this.closestLandmark(getEventTarget(e) as FocusableElement); + if (currentLandmark && currentLandmark.ref.current !== getEventTarget(e)) { + this.updateLandmark({ref: currentLandmark.ref, lastFocused: getEventTarget(e) as FocusableElement}); } let previousFocusedElement = e.relatedTarget as FocusableElement; if (previousFocusedElement) { @@ -382,7 +382,7 @@ class LandmarkManager implements LandmarkManagerApi { * Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had focus. */ private focusoutHandler(e: FocusEvent) { - let previousFocusedElement = e.target as FocusableElement; + let previousFocusedElement = getEventTarget(e) as FocusableElement; let nextFocusedElement = e.relatedTarget; // the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur(); // browsers appear to send focus instead to document.body and the relatedTarget is null when that happens diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 30440abd5d7..b2107c7e635 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject} from '@react-types/shared'; -import {filterDOMProps, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils'; import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; @@ -279,14 +279,15 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re switch (e.key) { case ' ': interaction.current = {pointerType: 'keyboard', key: ' '}; - (e.target as HTMLElement).click(); + (getEventTarget(e) as HTMLElement).click(); break; case 'Enter': interaction.current = {pointerType: 'keyboard', key: 'Enter'}; + let eventTarget = getEventTarget(e); // Trigger click unless this is a link. Links trigger click natively. - if ((e.target as HTMLElement).tagName !== 'A') { - (e.target as HTMLElement).click(); + if (eventTarget instanceof HTMLElement && eventTarget.tagName !== 'A') { + eventTarget.click(); } break; default: diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index af873997d06..9b8e52e4dc3 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, nodeContains, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, nodeContains, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -106,7 +106,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm switch (e.key) { case 'ArrowLeft': - if (direction === 'ltr' && nodeContains(e.currentTarget, e.target as Element)) { + if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -116,7 +116,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } break; case 'ArrowRight': - if (direction === 'rtl' && nodeContains(e.currentTarget, e.target as Element)) { + if (direction === 'rtl' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { e.preventDefault(); e.stopPropagation(); onSubmenuClose(); @@ -127,7 +127,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm break; case 'Escape': // TODO: can remove this when we fix collection event leaks - if (nodeContains(submenuRef.current, e.target as Element)) { + if (nodeContains(submenuRef.current, getEventTarget(e) as Element)) { e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { @@ -226,7 +226,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm useEvent(parentMenuRef, 'focusin', (e) => { // If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in // then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open - if (state.isOpen && nodeContains(parentMenuRef.current, e.target as HTMLElement) && e.target !== ref.current) { + if (state.isOpen && nodeContains(parentMenuRef.current, getEventTarget(e) as HTMLElement) && getEventTarget(e) !== ref.current) { onSubmenuClose(); } }); diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 08da12b97a5..7cc1637ffe5 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; -import {chain, filterDOMProps, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import { InputHTMLAttributes, @@ -264,7 +264,10 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt if (e.pointerType === 'mouse') { inputRef.current?.focus(); } else { - e.target.focus(); + const eventTarget = getEventTarget(e) as EventTarget; + if (eventTarget && 'focus' in eventTarget && typeof eventTarget.focus === 'function') { + eventTarget.focus(); + } } }; diff --git a/packages/@react-aria/overlays/src/useCloseOnScroll.ts b/packages/@react-aria/overlays/src/useCloseOnScroll.ts index 64f54860947..539df203212 100644 --- a/packages/@react-aria/overlays/src/useCloseOnScroll.ts +++ b/packages/@react-aria/overlays/src/useCloseOnScroll.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {nodeContains} from '@react-aria/utils'; +import {getEventTarget, nodeContains} from '@react-aria/utils'; import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; @@ -38,7 +38,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { let onScroll = (e: Event) => { // Ignore if scrolling an scrollable region outside the trigger's tree. - let target = e.target; + let target = getEventTarget(e); // window is not a Node and doesn't have contain, but window contains everything if (!triggerRef.current || ((target instanceof Node) && !nodeContains(target, triggerRef.current))) { return; @@ -47,7 +47,7 @@ export function useCloseOnScroll(opts: CloseOnScrollOptions): void { // Ignore scroll events on any input or textarea as the cursor position can cause it to scroll // such as in a combobox. Clicking the dropdown button places focus on the input, and if the // text inside the input extends beyond the 'end', then it will scroll so the cursor is visible at the end. - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 8fdcfa39410..06473b03769 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -14,6 +14,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {isElementInChildOfActiveScope} from '@react-aria/focus'; import {useEffect} from 'react'; import {useFocusWithin, useInteractOutside} from '@react-aria/interactions'; +import {getEventTarget} from '@react-aria/utils'; export interface AriaOverlayProps { /** Whether the overlay is currently open. */ @@ -91,7 +92,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -100,7 +101,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(getEventTarget(e) as Element)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -145,7 +146,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { // fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846 - if (e.target === e.currentTarget) { + if (getEventTarget(e) === e.currentTarget) { e.preventDefault(); } }; diff --git a/packages/@react-aria/overlays/src/usePreventScroll.ts b/packages/@react-aria/overlays/src/usePreventScroll.ts index 6c4b7ee5772..b66e66baf1d 100644 --- a/packages/@react-aria/overlays/src/usePreventScroll.ts +++ b/packages/@react-aria/overlays/src/usePreventScroll.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; +import {chain, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; interface PreventScrollOptions { /** Whether the scroll lock is disabled. */ @@ -96,7 +96,7 @@ function preventScrollMobileSafari() { let allowTouchMove = false; let onTouchStart = (e: TouchEvent) => { // Store the nearest scrollable parent element from the element that the user touched. - let target = e.target as Element; + let target = getEventTarget(e) as Element; scrollable = isScrollable(target) ? target : getScrollParent(target, true); allowTouchMove = false; @@ -154,7 +154,7 @@ function preventScrollMobileSafari() { }; let onBlur = (e: FocusEvent) => { - let target = e.target as HTMLElement; + let target = getEventTarget(e) as HTMLElement; let relatedTarget = e.relatedTarget as HTMLElement | null; if (relatedTarget && willOpenKeyboard(relatedTarget)) { // Focus without scrolling the whole page, and then scroll into view manually. diff --git a/packages/@react-aria/radio/src/useRadioGroup.ts b/packages/@react-aria/radio/src/useRadioGroup.ts index df09b13fe1d..dd0ac7f5d84 100644 --- a/packages/@react-aria/radio/src/useRadioGroup.ts +++ b/packages/@react-aria/radio/src/useRadioGroup.ts @@ -12,7 +12,7 @@ import {AriaRadioGroupProps} from '@react-types/radio'; import {DOMAttributes, ValidationResult} from '@react-types/shared'; -import {filterDOMProps, getOwnerWindow, mergeProps, useId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, getOwnerWindow, mergeProps, useId} from '@react-aria/utils'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {radioGroupData} from './utils'; import {RadioGroupState} from '@react-stately/radio'; @@ -102,8 +102,9 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState return; } e.preventDefault(); + const eventTarget = getEventTarget(e); let walker = getFocusableTreeWalker(e.currentTarget, { - from: e.target, + from: eventTarget instanceof Element ? eventTarget : undefined, accept: (node) => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio' }); let nextElem; diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 61e9455b571..cd4a5310808 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -15,7 +15,7 @@ import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'r import {selectData} from './useSelect'; import {SelectionMode} from '@react-types/select'; import {SelectState} from '@react-stately/select'; -import {useFormReset} from '@react-aria/utils'; +import {getEventTarget, useFormReset} from '@react-aria/utils'; import {useFormValidation} from '@react-aria/form'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -92,9 +92,10 @@ export function useHiddenSelect(props: Ar let setValue = state.setValue; let onChange = useCallback((e: React.ChangeEvent) => { - if (e.target.multiple) { + const eventTarget = getEventTarget(e); + if (eventTarget.multiple) { setValue(Array.from( - e.target.selectedOptions, + eventTarget.selectedOptions, (option) => option.value ) as any); } else { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 04d3fc81f62..085f0662fc9 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, getEventTarget, isCtrlKeyPressed, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -134,7 +134,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions // Keyboard events bubble through portals. Don't handle keyboard events // for elements outside the collection (e.g. menus). - if (!nodeContains(ref.current, e.target as Element)) { + if (!nodeContains(ref.current, getEventTarget(e) as Element)) { return; } @@ -333,9 +333,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions }); let onFocus = (e: FocusEvent) => { + let eventTarget = getEventTarget(e); if (manager.isFocused) { // If a focus event bubbled through a portal, reset focus state. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, eventTarget)) { manager.setFocused(false); } @@ -343,7 +344,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } // Focus events can bubble through portals. Ignore these events. - if (!nodeContains(e.currentTarget, e.target)) { + if (!nodeContains(e.currentTarget, eventTarget)) { return; } @@ -565,7 +566,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions onBlur, onMouseDown(e) { // Ignore events that bubbled through portals. - if (scrollRef.current === e.target) { + if (scrollRef.current === getEventTarget(e)) { // Prevent focus going to the collection when clicking on the scrollbar. e.preventDefault(); } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index cd6107a769b..6ec4f6a4ab6 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; +import {chain, getEventTarget, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared'; import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; @@ -188,7 +188,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte itemProps = { tabIndex: key === manager.focusedKey ? 0 : -1, onFocus(e) { - if (e.target === ref.current) { + if (getEventTarget(e) === ref.current) { manager.setFocusedKey(key); } } diff --git a/packages/@react-aria/selection/src/useTypeSelect.ts b/packages/@react-aria/selection/src/useTypeSelect.ts index 66f8e02136d..c8145a869ad 100644 --- a/packages/@react-aria/selection/src/useTypeSelect.ts +++ b/packages/@react-aria/selection/src/useTypeSelect.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {nodeContains} from '@react-aria/utils'; +import {nodeContains, getEventTarget} from '@react-aria/utils'; import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared'; import {KeyboardEvent, useRef} from 'react'; import {MultipleSelectionManager} from '@react-stately/selection'; @@ -54,7 +54,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { let onKeyDown = (e: KeyboardEvent) => { let character = getStringForKey(e.key); - if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, e.target as HTMLElement) || (state.search.length === 0 && character === ' ')) { + if (!character || e.ctrlKey || e.metaKey || !nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) || (state.search.length === 0 && character === ' ')) { return; } diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 04d6a8dceed..9ca985970fa 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -11,7 +11,7 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, mergeProps, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getEventTarget, mergeProps, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -189,8 +189,9 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState 'data-react-aria-top-layer': true, // listen to focus events separate from focuswithin because that will only fire once // and we need to follow all focus changes - onFocus: (e) => { - let target = e.target.closest('[role="alertdialog"]'); + onFocus: (e: FocusEvent) => { + let eventTarget = getEventTarget(e); + let target = eventTarget instanceof Element ? eventTarget.closest('[role="alertdialog"]') : null; focusedToast.current = toasts.current.findIndex(t => t === target); }, onBlur: () => { diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index 7331973d8f9..e2228f8c4f5 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,8 +12,8 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, nodeContains, useLayoutEffect} from '@react-aria/utils'; -import {HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; +import {filterDOMProps, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {FocusEvent, HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; export interface AriaToolbarProps extends AriaLabelingProps { @@ -56,7 +56,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { // don't handle portalled events - if (!nodeContains(e.currentTarget, e.target as HTMLElement)) { + if (!nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement)) { return; } if ( @@ -100,17 +100,20 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject(null); - const onBlur = (e) => { + const onBlur = (e: FocusEvent) => { if (!nodeContains(e.currentTarget, e.relatedTarget) && !lastFocused.current) { - lastFocused.current = e.target; + const eventTarget = getEventTarget(e); + if (eventTarget instanceof HTMLElement) { + lastFocused.current = eventTarget; + } } }; // Restore focus to the last focused child when focus returns into the toolbar. // If the element was removed, do nothing, either the first item in the first group, // or the last item in the last group will be focused, depending on direction. - const onFocus = (e) => { - if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, e.target)) { + const onFocus = (e: FocusEvent) => { + if (lastFocused.current && !nodeContains(e.currentTarget, e.relatedTarget) && nodeContains(ref.current, getEventTarget(e))) { lastFocused.current?.focus(); lastFocused.current = null; } diff --git a/packages/@react-aria/utils/src/runAfterTransition.ts b/packages/@react-aria/utils/src/runAfterTransition.ts index 3004d2313df..003e9a89ca1 100644 --- a/packages/@react-aria/utils/src/runAfterTransition.ts +++ b/packages/@react-aria/utils/src/runAfterTransition.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import { getEventTarget } from "./shadowdom/DOMFunctions"; + // We store a global list of elements that are currently transitioning, // mapped to a set of CSS properties that are transitioning for that element. // This is necessary rather than a simple count of transitions because of browser @@ -31,19 +33,20 @@ function setupGlobalEvents() { } let onTransitionStart = (e: Event) => { - if (!isTransitionEvent(e) || !e.target) { + const eventTarget = getEventTarget(e); + if (!isTransitionEvent(e) || !eventTarget) { return; } // Add the transitioning property to the list for this element. - let transitions = transitionsByElement.get(e.target); + let transitions = transitionsByElement.get(eventTarget); if (!transitions) { transitions = new Set(); - transitionsByElement.set(e.target, transitions); + transitionsByElement.set(eventTarget, transitions); // The transitioncancel event must be registered on the element itself, rather than as a global // event. This enables us to handle when the node is deleted from the document while it is transitioning. // In that case, the cancel event would have nowhere to bubble to so we need to handle it directly. - e.target.addEventListener('transitioncancel', onTransitionEnd, { + eventTarget.addEventListener('transitioncancel', onTransitionEnd, { once: true }); } @@ -52,11 +55,12 @@ function setupGlobalEvents() { }; let onTransitionEnd = (e: Event) => { - if (!isTransitionEvent(e) || !e.target) { + const eventTarget = getEventTarget(e); + if (!isTransitionEvent(e) || !eventTarget) { return; } // Remove property from list of transitioning properties. - let properties = transitionsByElement.get(e.target); + let properties = transitionsByElement.get(eventTarget); if (!properties) { return; } @@ -65,8 +69,8 @@ function setupGlobalEvents() { // If empty, remove transitioncancel event, and remove the element from the list of transitioning elements. if (properties.size === 0) { - e.target.removeEventListener('transitioncancel', onTransitionEnd); - transitionsByElement.delete(e.target); + eventTarget.removeEventListener('transitioncancel', onTransitionEnd); + transitionsByElement.delete(eventTarget); } // If no transitioning elements, call all of the queued callbacks. diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index 063d71500a2..02c7c1b8a56 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,5 +1,6 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 +import {SyntheticEvent} from 'react'; import {isShadowRoot} from '../domHelpers'; import {shadowDOM} from '@react-stately/flags'; @@ -57,14 +58,20 @@ export const getActiveElement = (doc: Document = document): Element | null => { return activeElement; }; +type EventTargetType = { + target: T; +}; + /** * ShadowDOM safe version of event.target. */ -export function getEventTarget(event: T): Element { - if (shadowDOM() && event.target instanceof HTMLElement && event.target.shadowRoot) { - if (event.composedPath) { - return event.composedPath()[0] as Element; +export function getEventTarget>(event: SE): SE extends EventTargetType ? Target : never; +export function getEventTarget(event: Event): EventTarget | null; +export function getEventTarget>(event: Event | SE): EventTarget | null { + if (shadowDOM() && (event.target instanceof Element) && event.target.shadowRoot) { + if ('composedPath' in event) { + return event.composedPath()[0]; } } - return event.target as Element; + return event.target; } diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index 41fe28abac8..1c0c3ddd82c 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -13,7 +13,7 @@ /* eslint-disable rulesdir/pure-render */ import {getOffset} from './getOffset'; -import {nodeContains} from './shadowdom/DOMFunctions'; +import {getEventTarget, nodeContains} from './shadowdom/DOMFunctions'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; @@ -81,7 +81,7 @@ export function useDrag1D(props: UseDrag1DProps): HTMLAttributes { }; let onMouseUp = (e: MouseEvent) => { - const target = e.target as HTMLElement; + const target = getEventTarget(e) as HTMLElement; dragging.current = false; let nextOffset = getNextOffset(e); if (handlers.current.onDrag) { diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts index 30fc9d26385..0ab2200863c 100644 --- a/packages/@react-aria/utils/src/useViewportSize.ts +++ b/packages/@react-aria/utils/src/useViewportSize.ts @@ -13,6 +13,7 @@ import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; import {willOpenKeyboard} from './keyboard'; +import {getEventTarget} from './shadowdom/DOMFunctions'; interface ViewportSize { width: number, @@ -50,7 +51,7 @@ export function useViewportSize(): ViewportSize { return; } - if (willOpenKeyboard(e.target as Element)) { + if (willOpenKeyboard(getEventTarget(e) as Element)) { // Wait one frame to see if a new element gets focused. frame = requestAnimationFrame(() => { if (!document.activeElement || !willOpenKeyboard(document.activeElement)) { diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 7c4988a5b3e..f88bad18ed4 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -25,7 +25,7 @@ import React, { useState } from 'react'; import {Rect, Size} from '@react-stately/virtualizer'; -import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {getEventTarget, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; interface ScrollViewProps extends HTMLAttributes { @@ -87,7 +87,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - if (e.target !== e.currentTarget) { + if (getEventTarget(e) !== e.currentTarget) { return; } diff --git a/packages/@react-spectrum/s2/src/Field.tsx b/packages/@react-spectrum/s2/src/Field.tsx index 22c73463a86..d8623939dbe 100644 --- a/packages/@react-spectrum/s2/src/Field.tsx +++ b/packages/@react-spectrum/s2/src/Field.tsx @@ -25,7 +25,7 @@ import intlMessages from '../intl/*.json'; import {mergeStyles} from '../style/runtime'; import {StyleString} from '../style/types'; import {useDOMRef} from '@react-spectrum/utils'; -import {useId} from '@react-aria/utils'; +import {getEventTarget, useId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; interface FieldLabelProps extends Omit, StyleProps { @@ -195,13 +195,13 @@ export const FieldGroup = forwardRef(function FieldGroup(props: FieldGroupProps, {...otherProps} onPointerDown={(e) => { // Forward focus to input element when clicking on a non-interactive child (e.g. icon or padding) - if (e.pointerType === 'mouse' && !(e.target as Element).closest('button,input,textarea')) { + if (e.pointerType === 'mouse' && !(getEventTarget(e) as Element).closest('button,input,textarea')) { e.preventDefault(); e.currentTarget.querySelector('input')?.focus(); } }} onPointerUp={e => { - if (e.pointerType !== 'mouse' && !(e.target as Element).closest('button,input,textarea')) { + if (e.pointerType !== 'mouse' && !(getEventTarget(e) as Element).closest('button,input,textarea')) { e.preventDefault(); e.currentTarget.querySelector('input')?.focus(); } diff --git a/packages/@react-spectrum/s2/src/Toast.tsx b/packages/@react-spectrum/s2/src/Toast.tsx index 0391cf2bc23..37631d98550 100644 --- a/packages/@react-spectrum/s2/src/Toast.tsx +++ b/packages/@react-spectrum/s2/src/Toast.tsx @@ -19,7 +19,7 @@ import Chevron from '../s2wf-icons/S2_Icon_ChevronDown_20_N.svg'; import {CloseButton} from './CloseButton'; import {createContext, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {DOMProps} from '@react-types/shared'; -import {filterDOMProps, useEvent} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, useEvent} from '@react-aria/utils'; import {flushSync} from 'react-dom'; import {focusRing, style} from '../style' with {type: 'macro'}; import {FocusScope, useModalOverlay} from 'react-aria'; @@ -427,7 +427,7 @@ function SpectrumToastList({placement, align}) { let toastListRef = useRef(null); useEvent(toastListRef, 'click', (e) => { // Have to check if this is a button because stopPropagation in react events doesn't affect native events. - if (!isExpanded && !(e.target as Element)?.closest('button')) { + if (!isExpanded && !(getEventTarget(e) as Element)?.closest('button')) { toggleExpanded(); } }); diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index 298179b6ef9..44083258dc4 100644 --- a/packages/react-aria-components/src/DropZone.tsx +++ b/packages/react-aria-components/src/DropZone.tsx @@ -21,7 +21,7 @@ import { useRenderProps } from './utils'; import {DropOptions, mergeProps, useButton, useClipboard, useDrop, useFocusRing, useHover, useLocalizedStringFormatter, VisuallyHidden} from 'react-aria'; -import {filterDOMProps, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getEventTarget, isFocusable, nodeContains, useLabels, useObjectRef, useSlotId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react'; @@ -116,7 +116,7 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: slot={props.slot || undefined} ref={dropzoneRef} onClick={(e) => { - let target = e.target as HTMLElement | null; + let target = getEventTarget(e) as HTMLElement | null; while (target && nodeContains(dropzoneRef.current, target)) { if (isFocusable(target)) { break; From a2c06727b831cd74ca906ea56d98b926847667ea Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Wed, 8 Oct 2025 11:16:14 -0400 Subject: [PATCH 10/12] repalce .activeElement with getActiveElement --- .../calendar/src/useCalendarCell.ts | 3 ++- .../calendar/src/useRangeCalendar.ts | 4 ++-- .../datepicker/src/useDateSegment.ts | 3 ++- packages/@react-aria/dialog/src/useDialog.ts | 6 +++--- packages/@react-aria/dnd/src/DragManager.ts | 8 ++++---- packages/@react-aria/focus/src/FocusScope.tsx | 6 +++--- packages/@react-aria/grid/src/useGridCell.ts | 13 ++++++------ .../gridlist/src/useGridListItem.ts | 16 ++++++++------- .../@react-aria/interactions/src/useFocus.ts | 2 +- .../interactions/src/useFocusVisible.ts | 20 ++++++++++--------- .../interactions/src/useFocusWithin.ts | 2 +- .../interactions/src/useLongPress.ts | 4 ++-- .../@react-aria/interactions/src/utils.ts | 7 ++++--- .../@react-aria/landmark/src/useLandmark.ts | 10 +++++----- .../@react-aria/menu/src/useSubmenuTrigger.ts | 8 ++++---- .../numberfield/src/useNumberField.ts | 4 ++-- .../overlays/src/useOverlayPosition.ts | 11 +++++----- .../overlays/src/usePreventScroll.ts | 7 ++++--- .../selection/src/useSelectableCollection.ts | 4 ++-- .../selection/src/useSelectableItem.ts | 4 ++-- .../table/src/useTableColumnResize.ts | 4 ++-- .../@react-aria/toolbar/src/useToolbar.ts | 4 ++-- .../utils/src/shadowdom/DOMFunctions.ts | 12 +++++------ .../@react-aria/utils/src/useViewportSize.ts | 5 +++-- .../src/MobileSearchAutocomplete.tsx | 4 ++-- .../combobox/src/MobileComboBox.tsx | 4 ++-- .../menu/src/ContextualHelpTrigger.tsx | 4 ++-- .../menu/src/SubmenuTrigger.tsx | 4 ++-- packages/@react-spectrum/s2/src/TableView.tsx | 4 ++-- .../table/src/TableViewBase.tsx | 9 +++++---- .../react-aria-components/src/Popover.tsx | 4 ++-- 31 files changed, 105 insertions(+), 95 deletions(-) diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index f99898e06a8..a63c6f0824c 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -16,6 +16,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {focusWithoutScrolling, getEventTarget, getScrollParent, mergeProps, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils'; import {getEraFormat, hookData} from './utils'; import {getInteractionModality, usePress} from '@react-aria/interactions'; +import {getActiveElement} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {useDateFormatter, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -291,7 +292,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta // Also only scroll into view if the cell actually got focused. // There are some cases where the cell might be disabled or inside, // an inert container and we don't want to scroll then. - if (getInteractionModality() !== 'pointer' && document.activeElement === ref.current) { + if (getInteractionModality() !== 'pointer' && getActiveElement(document) === ref.current) { scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } } diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 3d178a19c30..82abb66d235 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -14,7 +14,7 @@ import {AriaRangeCalendarProps, DateValue} from '@react-types/calendar'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {FocusableElement, RefObject} from '@react-types/shared'; import {RangeCalendarState} from '@react-stately/calendar'; -import {useEvent, nodeContains, getEventTarget} from '@react-aria/utils'; +import {getActiveElement, useEvent, nodeContains, getEventTarget} from '@react-aria/utils'; import {useRef} from 'react'; /** @@ -52,7 +52,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr let target = getEventTarget(e) as Element; if ( ref.current && - nodeContains(ref.current, document.activeElement) && + nodeContains(ref.current, getActiveElement(document)) && (!nodeContains(ref.current, target) || !target.closest('button, [role="button"]')) ) { state.selectFocusedDate(); diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 328ff68c6e1..41cf84e1700 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -18,6 +18,7 @@ import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; import {RefObject} from '@react-types/shared'; import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n'; +import {getActiveElement} from '@react-aria/utils'; import {useDisplayNames} from './useDisplayNames'; import {useSpinButton} from '@react-aria/spinbutton'; @@ -339,7 +340,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: let element = ref.current; return () => { // If the focused segment is removed, focus the previous one, or the next one if there was no previous one. - if (document.activeElement === element) { + if (getActiveElement(document) === element) { let prev = focusManager.focusPrevious(); if (!prev) { focusManager.focusNext(); diff --git a/packages/@react-aria/dialog/src/useDialog.ts b/packages/@react-aria/dialog/src/useDialog.ts index eef23f9968c..b2f64d1f001 100644 --- a/packages/@react-aria/dialog/src/useDialog.ts +++ b/packages/@react-aria/dialog/src/useDialog.ts @@ -12,7 +12,7 @@ import {AriaDialogProps} from '@react-types/dialog'; import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {filterDOMProps, nodeContains, useSlotId} from '@react-aria/utils'; +import {filterDOMProps, getActiveElement, nodeContains, useSlotId} from '@react-aria/utils'; import {focusSafely} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; import {useOverlayFocusContain} from '@react-aria/overlays'; @@ -40,7 +40,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { - if (ref.current && !nodeContains(ref.current, document.activeElement)) { + if (ref.current && !nodeContains(ref.current, getActiveElement(document))) { focusSafely(ref.current); // Safari on iOS does not move the VoiceOver cursor to the dialog @@ -48,7 +48,7 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { // Check that the dialog is still focused, or focused was lost to the body. - if (document.activeElement === ref.current || document.activeElement === document.body) { + if (getActiveElement(document) === ref.current || getActiveElement(document) === document.body) { isRefocusing.current = true; if (ref.current) { ref.current.blur(); diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 4749e9969ff..5188bd0364b 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; import {getDragModality, getTypes} from './utils'; -import {getEventTarget, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, isVirtualClick, isVirtualPointerEvent, nodeContains} from '@react-aria/utils'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {RefObject, useEffect, useState} from 'react'; @@ -420,7 +420,7 @@ class DragSession { // For now, the activate button is reachable by screen readers and ArrowLeft/ArrowRight // is usable specifically by Tree. Will need tabbing for other components. // let activateButton = this.getCurrentActivateButton(); - // if (activateButton && document.activeElement !== activateButton) { + // if (activateButton && getActiveElement(document) !== activateButton) { // activateButton.focus(); // return; // } @@ -452,7 +452,7 @@ class DragSession { previous(): void { // let activateButton = this.getCurrentActivateButton(); - // if (activateButton && document.activeElement === activateButton) { + // if (activateButton && getActiveElement(document) === activateButton) { // let target = this.currentDropItem ?? this.currentDropTarget; // if (target) { // target.element.focus(); @@ -572,7 +572,7 @@ class DragSession { // Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent). // This corrects state such as whether focus ring should appear. // useDroppableCollection handles this itself, so this is only for standalone drop zones. - document.activeElement?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + getActiveElement(document)?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); } this.setCurrentDropTarget(null); diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 0f3334993b4..f00df699059 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -392,7 +392,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo let modality = getInteractionModality(); let shouldSkipFocusRestore = (modality === 'virtual' || modality === null) && isAndroid() && isChrome(); - // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe + // Use getActiveElement(document) instead of e.relatedTarget so we can tell if user clicked into iframe let activeElement = getActiveElement(ownerDocument); if (!shouldSkipFocusRestore && activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef)) { activeScope = scopeRef; @@ -620,7 +620,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b return; } - let focusedElement = ownerDocument.activeElement as FocusableElement; + let focusedElement = getActiveElement(ownerDocument) as FocusableElement; if (!isElementInChildScope(focusedElement, scopeRef) || !shouldRestoreFocus(scopeRef)) { return; } @@ -713,7 +713,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b let clonedTree = focusScopeTree.clone(); requestAnimationFrame(() => { // Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere - if (ownerDocument.activeElement === ownerDocument.body) { + if (getActiveElement(ownerDocument) === ownerDocument.body) { // look up the tree starting with our scope to find a nodeToRestore still in the DOM let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { diff --git a/packages/@react-aria/grid/src/useGridCell.ts b/packages/@react-aria/grid/src/useGridCell.ts index d34b4d96b3f..5a329ccf253 100644 --- a/packages/@react-aria/grid/src/useGridCell.ts +++ b/packages/@react-aria/grid/src/useGridCell.ts @@ -13,7 +13,7 @@ import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared'; import {focusSafely, isFocusVisible} from '@react-aria/interactions'; import {getFocusableTreeWalker} from '@react-aria/focus'; -import {getEventTarget, getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, getScrollParent, mergeProps, nodeContains, scrollIntoViewport} from '@react-aria/utils'; import {GridCollection, GridNode} from '@react-types/grid'; import {gridMap} from './utils'; import {GridState} from '@react-stately/grid'; @@ -75,7 +75,7 @@ export function useGridCell>(props: GridCellProps let treeWalker = getFocusableTreeWalker(ref.current); if (focusMode === 'child') { // If focus is already on a focusable child within the cell, early return so we don't shift focus - if (nodeContains(ref.current, document.activeElement) && ref.current !== document.activeElement) { + if (nodeContains(ref.current, getActiveElement(document)) && ref.current !== getActiveElement(document)) { return; } @@ -90,7 +90,7 @@ export function useGridCell>(props: GridCellProps if ( (keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement) + !nodeContains(ref.current, getActiveElement(document)) ) { focusSafely(ref.current); } @@ -109,12 +109,13 @@ export function useGridCell>(props: GridCellProps }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(document); + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || state.isKeyboardNavigationDisabled || !ref.current || !activeElement) { return; } let walker = getFocusableTreeWalker(ref.current); - walker.currentNode = document.activeElement; + walker.currentNode = activeElement; switch (e.key) { case 'ArrowLeft': { @@ -244,7 +245,7 @@ export function useGridCell>(props: GridCellProps // If the cell itself is focused, wait a frame so that focus finishes propagatating // up to the tree, and move focus to a focusable child if possible. requestAnimationFrame(() => { - if (focusMode === 'child' && document.activeElement === ref.current) { + if (focusMode === 'child' && getActiveElement(document) === ref.current) { focus(); } }); diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 84223f7affe..f01eae49e36 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getEventTarget, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {chain, getActiveElement, getEventTarget, getScrollParent, mergeProps, nodeContains, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -79,7 +79,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt if ( ref.current !== null && ((keyWhenFocused.current != null && node.key !== keyWhenFocused.current) || - !nodeContains(ref.current, document.activeElement)) + !nodeContains(ref.current, getActiveElement(document))) ) { focusSafely(ref.current); } @@ -131,14 +131,15 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); let onKeyDownCapture = (e: ReactKeyboardEvent) => { - if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(document) + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !activeElement) { return; } let walker = getFocusableTreeWalker(ref.current); - walker.currentNode = document.activeElement; + walker.currentNode = activeElement; - if ('expandedKeys' in state && document.activeElement === ref.current) { + if ('expandedKeys' in state && getActiveElement(document) === ref.current) { if ((e.key === EXPANSION_KEYS['expand'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && !state.expandedKeys.has(node.key)) { state.toggleKey(node.key); e.stopPropagation(); @@ -244,7 +245,8 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }; let onKeyDown = (e) => { - if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !document.activeElement) { + let activeElement = getActiveElement(document) + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element) || !ref.current || !activeElement) { return; } @@ -254,7 +256,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // If there is another focusable element within this item, stop propagation so the tab key // is handled by the browser and not by useSelectableCollection (which would take us out of the list). let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); - walker.currentNode = document.activeElement; + walker.currentNode = activeElement; let next = e.shiftKey ? walker.previousNode() : walker.nextNode(); if (next) { diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 7ae3205bfeb..6ebd1551030 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -60,7 +60,7 @@ export function useFocus(pro const onSyntheticFocus = useSyntheticBlurEvent(onBlur); const onFocus: FocusProps['onFocus'] = useCallback((e: FocusEvent) => { - // Double check that document.activeElement actually matches getEventTarget(e) in case a previously chained + // Double check that getActiveElement(document) actually matches getEventTarget(e) in case a previously chained // focus handler already moved focus somewhere else. const eventTarget = getEventTarget(e); diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index cd3120d4888..0b56ead1c12 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.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 {getEventTarget, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; import {ignoreFocusEvent} from './utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -292,18 +292,20 @@ const nonTextInputTypes = new Set([ * focus visible style can be properly set. */ function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) { - let document = getOwnerDocument(e?.target as Element); - const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement; - const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement; - const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement; - const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent; + const eventTarget = e ? getEventTarget(e) as Element: null; + let document = getOwnerDocument(e ? getEventTarget(e) as Element: null); + const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLInputElement : HTMLInputElement; + const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLTextAreaElement : HTMLTextAreaElement; + const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).HTMLElement : HTMLElement; + const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(eventTarget).KeyboardEvent : KeyboardEvent; + const activeElement = getActiveElement(document); // 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); + (activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(activeElement.type)) || + activeElement instanceof IHTMLTextAreaElement || + (activeElement instanceof IHTMLElement && activeElement.isContentEditable); return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]); } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index eae23479c22..eeb63ee24f8 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -83,7 +83,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { return; } - // Double check that document.activeElement actually matches eventTarget in case a previously chained + // Double check that getActiveElement(document) actually matches eventTarget in case a previously chained // focus handler already moved focus somewhere else. const ownerDocument = getOwnerDocument(eventTarget instanceof Element ? eventTarget : null); const activeElement = getActiveElement(ownerDocument); diff --git a/packages/@react-aria/interactions/src/useLongPress.ts b/packages/@react-aria/interactions/src/useLongPress.ts index 1f910e487f5..9288b26f320 100644 --- a/packages/@react-aria/interactions/src/useLongPress.ts +++ b/packages/@react-aria/interactions/src/useLongPress.ts @@ -11,7 +11,7 @@ */ import {DOMAttributes, FocusableElement, LongPressEvent} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; import {usePress} from './usePress'; import {useRef} from 'react'; @@ -83,7 +83,7 @@ export function useLongPress(props: LongPressProps): LongPressResult { e.target.dispatchEvent(new PointerEvent('pointercancel', {bubbles: true})); // Ensure target is focused. On touch devices, browsers typically focus on pointer up. - if (getOwnerDocument(e.target).activeElement !== e.target) { + if (getActiveElement(getOwnerDocument(e.target)) !== e.target) { focusWithoutScrolling(e.target as FocusableElement); } diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index bff0a23a526..d7270c51ae6 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getEventTarget, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -88,7 +88,8 @@ export function useSyntheticBlurEvent(onBlur: stateRef.current.observer = new MutationObserver(() => { if (stateRef.current.isFocused && target.disabled) { stateRef.current.observer?.disconnect(); - let relatedTargetEl = target === document.activeElement ? null : document.activeElement; + let activeElement = getActiveElement(document); + let relatedTargetEl = target === activeElement ? null : activeElement; target.dispatchEvent(new FocusEvent('blur', {relatedTarget: relatedTargetEl})); target.dispatchEvent(new FocusEvent('focusout', {bubbles: true, relatedTarget: relatedTargetEl})); } @@ -113,7 +114,7 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un } let window = getOwnerWindow(target); - let activeElement = window.document.activeElement as FocusableElement | null; + let activeElement = getActiveElement(window.document) as FocusableElement | null; if (!activeElement || activeElement === target) { return; } diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index e8b7513127a..5a5a297adf4 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; import {useCallback, useEffect, useState} from 'react'; -import {getEventTarget, useLayoutEffect, nodeContains} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, useLayoutEffect, nodeContains} from '@react-aria/utils'; import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; @@ -55,7 +55,7 @@ interface Landmark { export interface LandmarkControllerOptions { /** * The element from which to start navigating. - * @default document.activeElement + * @default getActiveElement(document) */ from?: FocusableElement } @@ -400,15 +400,15 @@ class LandmarkManager implements LandmarkManagerApi { instance.setupIfNeeded(); return { navigate(direction, opts) { - let element = opts?.from || (document!.activeElement as FocusableElement); + let element = opts?.from || (getActiveElement(document) as FocusableElement); return instance!.navigate(element, direction === 'backward'); }, focusNext(opts) { - let element = opts?.from || (document!.activeElement as FocusableElement); + let element = opts?.from || (getActiveElement(document) as FocusableElement); return instance!.navigate(element, false); }, focusPrevious(opts) { - let element = opts?.from || (document!.activeElement as FocusableElement); + let element = opts?.from || (getActiveElement(document) as FocusableElement); return instance!.navigate(element, true); }, focusMain() { diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 9b8e52e4dc3..fe241f8b3bd 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, getEventTarget, nodeContains, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getActiveElement, getEventTarget, nodeContains, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -100,7 +100,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm let submenuKeyDown = (e: KeyboardEvent) => { // If focus is not within the menu, assume virtual focus is being used. // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!nodeContains(e.currentTarget, document.activeElement)) { + if (!nodeContains(e.currentTarget, getActiveElement(document))) { return; } @@ -159,7 +159,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm onSubmenuOpen('first'); } - if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) { + if (type === 'menu' && !!submenuRef?.current && getActiveElement(document) === ref?.current) { focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { @@ -178,7 +178,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm onSubmenuOpen('first'); } - if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) { + if (type === 'menu' && !!submenuRef?.current && getActiveElement(document) === ref?.current) { focusWithoutScrolling(submenuRef.current); } } else if (state.isOpen) { diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 7cc1637ffe5..ff43b8f48c6 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; -import {chain, filterDOMProps, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; import {DOMAttributes, GroupDOMAttributes, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import { InputHTMLAttributes, @@ -254,7 +254,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt let onButtonPressStart = (e) => { // If focus is already on the input, keep it there so we don't hide the // software keyboard when tapping the increment/decrement buttons. - if (document.activeElement === inputRef.current) { + if (getActiveElement(document) === inputRef.current) { return; } diff --git a/packages/@react-aria/overlays/src/useOverlayPosition.ts b/packages/@react-aria/overlays/src/useOverlayPosition.ts index 7a40774b3e1..efb180d4656 100644 --- a/packages/@react-aria/overlays/src/useOverlayPosition.ts +++ b/packages/@react-aria/overlays/src/useOverlayPosition.ts @@ -15,7 +15,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {Placement, PlacementAxis, PositionProps} from '@react-types/overlays'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; -import {nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; +import {getActiveElement, nodeContains, useLayoutEffect, useResizeObserver} from '@react-aria/utils'; import {useLocale} from '@react-aria/i18n'; export interface AriaPositionProps extends PositionProps { @@ -154,8 +154,8 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { // so it can be restored after repositioning. This way if the overlay height // changes, the focused element appears to stay in the same position. let anchor: ScrollAnchor | null = null; - if (scrollRef.current && nodeContains(scrollRef.current, document.activeElement)) { - let anchorRect = document.activeElement?.getBoundingClientRect(); + if (scrollRef.current && nodeContains(scrollRef.current, getActiveElement(document))) { + let anchorRect = getActiveElement(document)?.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); // Anchor from the top if the offset is in the top half of the scrollable element, // otherwise anchor from the bottom. @@ -207,9 +207,10 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { Object.keys(position.position).forEach(key => overlay.style[key] = (position.position!)[key] + 'px'); overlay.style.maxHeight = position.maxHeight != null ? position.maxHeight + 'px' : ''; + let activeElement = getActiveElement(document); // Restore scroll position relative to anchor element. - if (anchor && document.activeElement && scrollRef.current) { - let anchorRect = document.activeElement.getBoundingClientRect(); + if (anchor && activeElement && scrollRef.current) { + let anchorRect = activeElement.getBoundingClientRect(); let scrollRect = scrollRef.current.getBoundingClientRect(); let newOffset = anchorRect[anchor.type] - scrollRect[anchor.type]; scrollRef.current.scrollTop += newOffset - anchor.offset; diff --git a/packages/@react-aria/overlays/src/usePreventScroll.ts b/packages/@react-aria/overlays/src/usePreventScroll.ts index b66e66baf1d..c4074385ebc 100644 --- a/packages/@react-aria/overlays/src/usePreventScroll.ts +++ b/packages/@react-aria/overlays/src/usePreventScroll.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; +import {chain, getActiveElement, getEventTarget, getScrollParent, isIOS, isScrollable, useLayoutEffect, willOpenKeyboard} from '@react-aria/utils'; interface PreventScrollOptions { /** Whether the scroll lock is disabled. */ @@ -111,7 +111,7 @@ function preventScrollMobileSafari() { 'selectionStart' in target && 'selectionEnd' in target && (target.selectionStart as number) < (target.selectionEnd as number) && - target.ownerDocument.activeElement === target + getActiveElement(target.ownerDocument) === target ) { allowTouchMove = true; } @@ -174,8 +174,9 @@ function preventScrollMobileSafari() { // Override programmatic focus to scroll into view without scrolling the whole page. let focus = HTMLElement.prototype.focus; HTMLElement.prototype.focus = function (opts) { + let activeElement = getActiveElement(document) // Track whether the keyboard was already visible before. - let wasKeyboardVisible = document.activeElement != null && willOpenKeyboard(document.activeElement); + let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); // Focus the element without scrolling the page. focus.call(this, {...opts, preventScroll: true}); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 085f0662fc9..a42ef0362d9 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -312,7 +312,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } } while (last); - if (next && !nodeContains(next, document.activeElement)) { + if (next && !nodeContains(next, getActiveElement(document))) { focusWithoutScrolling(next); } } @@ -378,7 +378,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let element = getItemElement(ref, manager.focusedKey); if (element instanceof HTMLElement) { // This prevents a flash of focus on the first/last element in the collection, or the collection itself. - if (!nodeContains(element, document.activeElement) && !shouldUseVirtualFocus) { + if (!nodeContains(element, getActiveElement(document)) && !shouldUseVirtualFocus) { focusWithoutScrolling(element); } diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index 6ec4f6a4ab6..268c871f997 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {chain, getEventTarget, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; +import {chain, getActiveElement, getEventTarget, isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils'; import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared'; import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions'; import {getCollectionId, isNonContiguousSelectionModifier} from './utils'; @@ -169,7 +169,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte if (!shouldUseVirtualFocus) { if (focus) { focus(); - } else if (document.activeElement !== ref.current && ref.current) { + } else if (getActiveElement(document) !== ref.current && ref.current) { focusSafely(ref.current); } } else { diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 0a8c956ab13..2e6458335d3 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -18,7 +18,7 @@ import {getColumnHeaderId} from './utils'; import {GridNode} from '@react-types/grid'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; +import {getActiveElement, mergeProps, useDescription, useEffectEvent, useId} from '@react-aria/utils'; import {TableColumnResizeState} from '@react-stately/table'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -196,7 +196,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let prevResizingColumn = useRef(null); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) { - wasFocusedOnResizeStart.current = document.activeElement === ref.current; + wasFocusedOnResizeStart.current = getActiveElement(document) === ref.current; startResize(item); // Delay focusing input until Android Chrome's delayed click after touchend happens: https://bugs.chromium.org/p/chromium/issues/detail?id=1150073 let timeout = setTimeout(() => focusInput(), 0); diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index e2228f8c4f5..a3454b929ed 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -12,7 +12,7 @@ import {AriaLabelingProps, Orientation, RefObject} from '@react-types/shared'; import {createFocusManager} from '@react-aria/focus'; -import {filterDOMProps, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getActiveElement, getEventTarget, nodeContains, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent, HTMLAttributes, KeyboardEventHandler, useRef, useState} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -81,7 +81,7 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject = { /** * ShadowDOM safe version of event.target. */ -export function getEventTarget>(event: SE): SE extends EventTargetType ? Target : never; -export function getEventTarget(event: Event): EventTarget | null; -export function getEventTarget>(event: Event | SE): EventTarget | null { - if (shadowDOM() && (event.target instanceof Element) && event.target.shadowRoot) { - if ('composedPath' in event) { - return event.composedPath()[0]; - } +export function getEventTarget>(event: SE): SE extends EventTargetType ? Target : never; +export function getEventTarget(event: Event): Event['target']; +export function getEventTarget>(event: Event | SE): Event['target'] { + if (shadowDOM() && (event.target instanceof Element) && event.target.shadowRoot && 'composedPath' in event) { + return event.composedPath()[0] || null; } return event.target; } diff --git a/packages/@react-aria/utils/src/useViewportSize.ts b/packages/@react-aria/utils/src/useViewportSize.ts index 0ab2200863c..2209ce1018e 100644 --- a/packages/@react-aria/utils/src/useViewportSize.ts +++ b/packages/@react-aria/utils/src/useViewportSize.ts @@ -13,7 +13,7 @@ import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; import {willOpenKeyboard} from './keyboard'; -import {getEventTarget} from './shadowdom/DOMFunctions'; +import {getActiveElement, getEventTarget} from './shadowdom/DOMFunctions'; interface ViewportSize { width: number, @@ -54,7 +54,8 @@ export function useViewportSize(): ViewportSize { if (willOpenKeyboard(getEventTarget(e) as Element)) { // Wait one frame to see if a new element gets focused. frame = requestAnimationFrame(() => { - if (!document.activeElement || !willOpenKeyboard(document.activeElement)) { + let activeElement = getActiveElement(document); + if (!activeElement || !willOpenKeyboard(activeElement)) { setSize(size => { let newSize = {width: window.innerWidth, height: window.innerHeight}; if (newSize.width === size.width && newSize.height === size.height) { diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index 242a1e0c91b..01cfe05f441 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -25,7 +25,7 @@ import {FocusScope, useFocusRing} from '@react-aria/focus'; import intlMessages from '../intl/*.json'; import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox'; import Magnifier from '@spectrum-icons/ui/Magnifier'; -import {mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {getActiveElement, mergeProps, useFormReset, useId} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; import React, { HTMLAttributes, @@ -482,7 +482,7 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { }; let onScroll = useCallback(() => { - if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) { + if (!inputRef.current || getActiveElement(document) !== inputRef.current || !isTouchDown.current) { return; } diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 5954ab301a3..54aa4d7bc52 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -28,7 +28,7 @@ import {focusSafely, setInteractionModality, useHover} from '@react-aria/interac import intlMessages from '../intl/*.json'; import labelStyles from '@adobe/spectrum-css-temp/components/fieldlabel/vars.css'; import {ListBoxBase, useListBoxLayout} from '@react-spectrum/listbox'; -import {mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, mergeProps, useFormReset, useId, useObjectRef} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {ForwardedRef, HTMLAttributes, InputHTMLAttributes, ReactElement, ReactNode, useCallback, useEffect, useRef, useState} from 'react'; import searchStyles from '@adobe/spectrum-css-temp/components/search/vars.css'; @@ -436,7 +436,7 @@ function ComboBoxTray(props: ComboBoxTrayProps) { }; let onScroll = useCallback(() => { - if (!inputRef.current || document.activeElement !== inputRef.current || !isTouchDown.current) { + if (!inputRef.current || getActiveElement(document) !== inputRef.current || !isTouchDown.current) { return; } diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index e3e641e82af..7575a720bc4 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -22,7 +22,7 @@ import styles from '@adobe/spectrum-css-temp/components/menu/vars.css'; import {SubmenuTriggerContext, useMenuStateContext} from './context'; import {TrayHeaderWrapper} from './Menu'; import {useSubmenuTrigger} from '@react-aria/menu'; -import {nodeContains} from '@react-aria/utils'; +import {getActiveElement, nodeContains} from '@react-aria/utils'; import {useSubmenuTriggerState} from '@react-stately/menu'; interface MenuDialogTriggerProps { @@ -99,7 +99,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem setTraySubmenuAnimation('spectrum-TraySubmenu-exit'); setTimeout(() => { submenuTriggerState.close(); - if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, getActiveElement(document))) { parentMenuRef.current.focus(); } }, 220); // Matches transition duration diff --git a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx index f6e1a1ce532..9ec5bbe5348 100644 --- a/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx +++ b/packages/@react-spectrum/menu/src/SubmenuTrigger.tsx @@ -13,7 +13,7 @@ import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; import {Key} from '@react-types/shared'; import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context'; -import {mergeProps, nodeContains} from '@react-aria/utils'; +import {getActiveElement, mergeProps, nodeContains} from '@react-aria/utils'; import {Popover} from '@react-spectrum/overlays'; import React, {type JSX, ReactElement, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -49,7 +49,7 @@ function SubmenuTrigger(props: SubmenuTriggerProps) { let isMobile = useIsMobileDevice(); let onBackButtonPress = () => { submenuTriggerState.close(); - if (parentMenuRef.current && !nodeContains(parentMenuRef.current, document.activeElement)) { + if (parentMenuRef.current && !nodeContains(parentMenuRef.current, getActiveElement(document))) { parentMenuRef.current.focus(); } }; diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 480b2456c33..fca38acad88 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -71,7 +71,7 @@ import SortDownArrow from '../s2wf-icons/S2_Icon_SortDown_20_N.svg'; import SortUpArrow from '../s2wf-icons/S2_Icon_SortUp_20_N.svg'; import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from '@react-spectrum/utils'; -import {nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; +import {getActiveElement, nodeContains, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -1197,7 +1197,7 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, onOpenChange={setIsOpen} ref={popoverRef} shouldCloseOnInteractOutside={() => { - if (!nodeContains(popoverRef.current, document.activeElement)) { + if (!nodeContains(popoverRef.current, getActiveElement(document))) { return false; } formRef.current?.requestSubmit(); diff --git a/packages/@react-spectrum/table/src/TableViewBase.tsx b/packages/@react-spectrum/table/src/TableViewBase.tsx index 443303f5fb2..439905c25b4 100644 --- a/packages/@react-spectrum/table/src/TableViewBase.tsx +++ b/packages/@react-spectrum/table/src/TableViewBase.tsx @@ -33,7 +33,7 @@ import {GridNode} from '@react-types/grid'; import {InsertionIndicator} from './InsertionIndicator'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; +import {getActiveElement, isAndroid, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useLoadMore} from '@react-aria/utils'; import {Item, Menu, MenuTrigger} from '@react-spectrum/menu'; import {LayoutInfo, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer'; import {layoutInfoToStyle, ScrollView, setScrollLeft, VirtualizerItem} from '@react-aria/virtualizer'; @@ -606,9 +606,10 @@ function TableVirtualizer(props: TableVirtualizerProps) { // only that it changes in a resize, and when that happens, we want to sync the body to the // header scroll position useEffect(() => { - if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, document.activeElement) && bodyRef.current) { - scrollIntoView(headerRef.current, document.activeElement as HTMLElement); - scrollIntoViewport(document.activeElement, {containingElement: domRef.current}); + let activeElement = getActiveElement(document) as HTMLElement; + if (getInteractionModality() === 'keyboard' && headerRef.current && nodeContains(headerRef.current, activeElement) && bodyRef.current) { + scrollIntoView(headerRef.current, activeElement); + scrollIntoViewport(activeElement, {containingElement: domRef.current}); bodyRef.current.scrollLeft = headerRef.current.scrollLeft; } }, [state.contentSize, headerRef, bodyRef, domRef]); diff --git a/packages/react-aria-components/src/Popover.tsx b/packages/react-aria-components/src/Popover.tsx index 49eda1c0880..173d70349af 100644 --- a/packages/react-aria-components/src/Popover.tsx +++ b/packages/react-aria-components/src/Popover.tsx @@ -20,7 +20,7 @@ import { useContextProps, useRenderProps } from './utils'; -import {filterDOMProps, mergeProps, nodeContains, useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, getActiveElement, 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'; @@ -198,7 +198,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 && !nodeContains(ref.current, document.activeElement)) { + if (isDialog && props.trigger !== 'SubmenuTrigger' && ref.current && !nodeContains(ref.current, getActiveElement(document))) { focusSafely(ref.current); } }, [isDialog, ref, props.trigger]); From f6617e8af0bddb3ea032d3ad72f6288c08b663f5 Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Wed, 8 Oct 2025 11:20:09 -0400 Subject: [PATCH 11/12] Recommended node version in CONTRIBUTING.md in line with @types/node in package.json --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba8fa9452bf..75755330fba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -202,7 +202,7 @@ parcel build packages/@react-{spectrum,aria,stately}/*/ packages/@internationali make: *** [build] Segmentation fault: 11 ``` -It's likely that you are using a different version of Node.js. Please use Node.js 18. When changing the node version, delete `node_modules` and re-run `yarn install` +It's likely that you are using a different version of Node.js. Please use Node.js 22. When changing the node version, delete `node_modules` and re-run `yarn install` > `yarn start` fails. From 43a24384219985400c6f3c54ca8cae4f03b0b8e1 Mon Sep 17 00:00:00 2001 From: Paul Zaczkiewicz Date: Wed, 8 Oct 2025 13:24:12 -0400 Subject: [PATCH 12/12] Add composedPath support for SyntheticEvent --- .../utils/src/shadowdom/DOMFunctions.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index 72481bcb107..79f216a0402 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -68,8 +68,18 @@ type EventTargetType = { export function getEventTarget>(event: SE): SE extends EventTargetType ? Target : never; export function getEventTarget(event: Event): Event['target']; export function getEventTarget>(event: Event | SE): Event['target'] { - if (shadowDOM() && (event.target instanceof Element) && event.target.shadowRoot && 'composedPath' in event) { - return event.composedPath()[0] || null; + if (shadowDOM() && (event.target instanceof Element) && event.target.shadowRoot) { + if ('composedPath' in event) { + return event.composedPath()[0] || null; + } else if ('composedPath' in event.nativeEvent) { + /** If Typescript types are to be strictly trusted, there is a risk + * that the return type of this branch doesn't match the return type of the first overload. + * In practice, SyntheticEvents only seem to have `target: EventTarget & T` when the event + * doesn't bubble. In that case, .composedPath()[0] and .target should always + * be the same. + */ + return event.nativeEvent.composedPath()[0] || null + } } return event.target; }