From c026270f0365587907a75d64286ac34d08bff16a Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 11 Mar 2024 23:12:29 +0200 Subject: [PATCH 001/102] Add `getRootNode` utility. --- packages/@react-aria/utils/src/domHelpers.ts | 6 ++++++ packages/@react-aria/utils/src/index.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index b8be4d07ee2..0be7f5e4fe6 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -12,3 +12,9 @@ export const getOwnerWindow = ( const doc = getOwnerDocument(el as Element | null | undefined); return doc.defaultView || window; }; + +export const getRootNode = (el: Element | null | undefined): Document | (ShadowRoot & { + body: ShadowRoot +}) => { + return el?.getRootNode() ?? document; +}; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 0f9f03377df..ca7b7205547 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -11,7 +11,7 @@ */ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; -export {getOwnerDocument, getOwnerWindow} from './domHelpers'; +export {getOwnerDocument, getOwnerWindow, getRootNode} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; From 9299ceecbddb614aac558f9b978dd3efbdbeead0 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 11 Mar 2024 23:36:34 +0200 Subject: [PATCH 002/102] Update `getRootNode` util. Update domHelpers.test.js. --- packages/@react-aria/utils/src/domHelpers.ts | 20 ++++++++++--- .../@react-aria/utils/test/domHelpers.test.js | 30 +++++++++---------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 0be7f5e4fe6..c97a43ca717 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -13,8 +13,20 @@ export const getOwnerWindow = ( return doc.defaultView || window; }; -export const getRootNode = (el: Element | null | undefined): Document | (ShadowRoot & { - body: ShadowRoot -}) => { - return el?.getRootNode() ?? document; +export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot => { + // Fallback to document if the element is null or undefined + if (!el) { + return document; + } + + const rootNode = el.getRootNode ? el.getRootNode() : document; + + // Check if the rootNode is a Document, or if the element is disconnected from the DOM + // In such cases, rootNode could either be the actual Document or a ShadowRoot, + // but for disconnected nodes, we want to ensure consistency by returning the Document. + if (rootNode instanceof Document || !(el.isConnected)) { + return document; + } + + return rootNode; }; diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index 330e6e4954b..b53f42af6a1 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -11,36 +11,36 @@ */ -import {getOwnerDocument, getOwnerWindow} from '../'; +import {getOwnerWindow, getRootNode} from '../'; import React, {createRef} from 'react'; import {render} from '@react-spectrum/test-utils'; -describe('getOwnerDocument', () => { +describe('getRootNode', () => { test.each([null, undefined])('returns the document if the argument is %p', (value) => { - expect(getOwnerDocument(value)).toBe(document); + expect(getRootNode(value)).toBe(document); }); it('returns the document if the element is in the document', () => { const div = document.createElement('div'); window.document.body.appendChild(div); - expect(getOwnerDocument(div)).toBe(document); + expect(getRootNode(div)).toBe(document); }); it('returns the document if object passed in does not have an ownerdocument', () => { const div = document.createElement('div'); - expect(getOwnerDocument(div)).toBe(document); + expect(getRootNode(div)).toBe(document); }); it('returns the document if nothing is passed in', () => { - expect(getOwnerDocument()).toBe(document); - expect(getOwnerDocument(null)).toBe(document); - expect(getOwnerDocument(undefined)).toBe(document); + expect(getRootNode()).toBe(document); + expect(getRootNode(null)).toBe(document); + expect(getRootNode(undefined)).toBe(document); }); it('returns the document if ref exists, but is not associated with an element', () => { const ref = createRef(); - expect(getOwnerDocument(ref.current)).toBe(document); + expect(getRootNode(ref.current)).toBe(document); }); it("returns the iframe's document if the element is in an iframe", () => { @@ -49,9 +49,9 @@ describe('getOwnerDocument', () => { window.document.body.appendChild(iframe); iframe.contentWindow.document.body.appendChild(iframeDiv); - expect(getOwnerDocument(iframeDiv)).not.toBe(document); - expect(getOwnerDocument(iframeDiv)).toBe(iframe.contentWindow.document); - expect(getOwnerDocument(iframeDiv)).toBe(iframe.contentDocument); + expect(getRootNode(iframeDiv)).not.toBe(document); + expect(getRootNode(iframeDiv)).toBe(iframe.contentWindow.document); + expect(getRootNode(iframeDiv)).toBe(iframe.contentDocument); // Teardown iframe.remove(); @@ -68,9 +68,9 @@ describe('getOwnerDocument', () => { container: iframeDiv }); - expect(getOwnerDocument(ref.current)).not.toBe(document); - expect(getOwnerDocument(ref.current)).toBe(iframe.contentWindow.document); - expect(getOwnerDocument(ref.current)).toBe(iframe.contentDocument); + expect(getRootNode(ref.current)).not.toBe(document); + expect(getRootNode(ref.current)).toBe(iframe.contentWindow.document); + expect(getRootNode(ref.current)).toBe(iframe.contentDocument); }); }); From 71836e386520247d6f6b5e2110afc585f24f2fba Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 11 Mar 2024 23:39:34 +0200 Subject: [PATCH 003/102] Update `getOwnerWindow` util. --- packages/@react-aria/utils/src/domHelpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index c97a43ca717..3296443ee29 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -9,8 +9,8 @@ export const getOwnerWindow = ( return el; } - const doc = getOwnerDocument(el as Element | null | undefined); - return doc.defaultView || window; + const doc = getRootNode(el as Element | null | undefined); + return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc.defaultView || window; }; export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot => { From 10893b988fe93046a9ee0e45fdbb38b91788b197 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 11 Mar 2024 23:50:13 +0200 Subject: [PATCH 004/102] Add tests for Shadow DOM handling using `getRootNode`. --- .../@react-aria/utils/test/domHelpers.test.js | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index b53f42af6a1..441b8424ef3 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -73,6 +73,49 @@ describe('getRootNode', () => { expect(getRootNode(ref.current)).toBe(iframe.contentDocument); }); + it('returns the shadow root if the element is in a shadow DOM', () => { + // Setup shadow DOM + const hostDiv = document.createElement('div'); + const shadowRoot = hostDiv.attachShadow({mode: 'open'}); + const shadowDiv = document.createElement('div'); + shadowRoot.appendChild(shadowDiv); + document.body.appendChild(hostDiv); + + expect(getRootNode(shadowDiv)).toBe(shadowRoot); + + // Teardown + document.body.removeChild(hostDiv); + }); + + it('returns the correct shadow root for nested shadow DOMs', () => { + // Setup nested shadow DOM + const outerHostDiv = document.createElement('div'); + const outerShadowRoot = outerHostDiv.attachShadow({mode: 'open'}); + const innerHostDiv = document.createElement('div'); + outerShadowRoot.appendChild(innerHostDiv); + const innerShadowRoot = innerHostDiv.attachShadow({mode: 'open'}); + const shadowDiv = document.createElement('div'); + innerShadowRoot.appendChild(shadowDiv); + document.body.appendChild(outerHostDiv); + + expect(getRootNode(shadowDiv)).toBe(innerShadowRoot); + + document.body.removeChild(outerHostDiv); + }); + + it('returns the document for elements directly inside the shadow host', () => { + const hostDiv = document.createElement('div'); + document.body.appendChild(hostDiv); + hostDiv.attachShadow({mode: 'open'}); + const directChildDiv = document.createElement('div'); + hostDiv.appendChild(directChildDiv); + + expect(getRootNode(directChildDiv)).toBe(document); + + // Teardown + document.body.removeChild(hostDiv); + }); + }); describe('getOwnerWindow', () => { From 5f81d34a6d50dc5c80d4bde395692ccdb9ec22b7 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 11 Mar 2024 23:51:17 +0200 Subject: [PATCH 005/102] Update comment. --- packages/@react-aria/utils/test/domHelpers.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index 441b8424ef3..5f6fe7bb059 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -100,6 +100,7 @@ describe('getRootNode', () => { expect(getRootNode(shadowDiv)).toBe(innerShadowRoot); + // Teardown document.body.removeChild(outerHostDiv); }); From bfb942909f6b6ec6cf41984624b4f0851b727819 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 12 Mar 2024 05:41:38 +0200 Subject: [PATCH 006/102] Fix FocusScope.tsx in Shadow DOM. Add Tests for FocusScope.test.js. New helper util `getRootBody`. --- packages/@react-aria/focus/src/FocusScope.tsx | 47 +++++++++++-------- .../@react-aria/focus/test/FocusScope.test.js | 44 +++++++++++++++++ packages/@react-aria/utils/src/domHelpers.ts | 9 ++++ packages/@react-aria/utils/src/index.ts | 2 +- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 7029a0cfbf0..9446a17ff64 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,8 @@ import {FocusableElement} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils'; +import {getRootBody} from '@react-aria/utils/src/domHelpers'; +import {getRootNode, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react'; @@ -133,7 +134,7 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { - const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; + const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { @@ -197,7 +198,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject): Focus focusNext(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getOwnerDocument(scope[0]).activeElement!; + let node = from || getRootNode(scope[0]).activeElement!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -215,7 +216,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject): Focus focusPrevious(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getOwnerDocument(scope[0]).activeElement!; + let node = from || getRootNode(scope[0]).activeElement!; let sentinel = scope[scope.length - 1].nextElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -310,7 +311,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) return; } - const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined); // Handle the Tab key to contain focus within the scope let onKeyDown = (e) => { @@ -370,7 +371,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { activeScope = scopeRef; - if (ownerDocument.body.contains(e.target)) { + if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; focusedNode.current?.focus(); } else if (activeScope.current) { @@ -489,7 +490,7 @@ function useAutoFocus(scopeRef: RefObject, autoFocus?: boolean) { useEffect(() => { if (autoFocusRef.current) { activeScope = scopeRef; - const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined); if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) { focusFirstInScope(scopeRef.current); } @@ -507,7 +508,7 @@ function useActiveScopeTracker(scopeRef: RefObject, restore?: boolean } let scope = scopeRef.current; - const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined); let onFocus = (e) => { let target = e.target as Element; @@ -543,13 +544,13 @@ function shouldRestoreFocus(scopeRef: ScopeRef) { function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, contain?: boolean) { // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts. // eslint-disable-next-line no-restricted-globals - const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null); + const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null); // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus // restoring-non-containing scopes should only care if they become active so they can perform the restore useLayoutEffect(() => { let scope = scopeRef.current; - const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined); if (!restoreFocus || contain) { return; } @@ -574,7 +575,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, }, [scopeRef, contain]); useLayoutEffect(() => { - const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -599,14 +600,16 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, } let nodeToRestore = treeNode.nodeToRestore; + const rootBody = getRootBody(ownerDocument); + // Create a DOM tree walker that matches all tabbable elements - let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true}); + let walker = getFocusableTreeWalker(rootBody, {tabbable: true}); // Find the next tabbable element after the currently focused element walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; - if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) { + if (!nodeToRestore || !rootBody.contains(nodeToRestore) || nodeToRestore === rootBody) { nodeToRestore = undefined; treeNode.nodeToRestore = undefined; } @@ -651,7 +654,8 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously. useLayoutEffect(() => { - const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined); + const rootBody = getRootBody(ownerDocument); if (!restoreFocus) { return; @@ -676,14 +680,14 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, && ( // eslint-disable-next-line react-hooks/exhaustive-deps isElementInScope(ownerDocument.activeElement, scopeRef.current) - || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef)) + || (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef)) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it 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 (ownerDocument.activeElement === rootBody) { // look up the tree starting with our scope to find a nodeToRestore still in the DOM let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { @@ -717,8 +721,11 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, */ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; - let walker = getOwnerDocument(root).createTreeWalker( - root, + // Adjusted to directly handle root being a Document or ShadowRoot + let doc = root instanceof Document || root instanceof ShadowRoot ? root : getRootNode(root); + let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc; + let walker = effectiveDocument.createTreeWalker( + root || doc, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { @@ -758,7 +765,7 @@ export function createFocusManager(ref: RefObject, defaultOptions: Focu return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || getOwnerDocument(root).activeElement; + let node = from || getRootNode(root).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -779,7 +786,7 @@ export function createFocusManager(ref: RefObject, defaultOptions: Focu return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || getOwnerDocument(root).activeElement; + let node = from || getRootNode(root).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 2e8f3ff5a87..306eba2b2de 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -22,6 +22,13 @@ import {Example as StorybookExample} from '../stories/FocusScope.stories'; import userEvent from '@testing-library/user-event'; +function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; +} + describe('FocusScope', function () { let user; @@ -314,6 +321,43 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(input2); }); + + it('should contain focus within the shadow DOM scope', async function () { + const {shadowRoot} = createShadowRoot(); + const FocusableComponent = () => ( + + + + + + ); + + // Use ReactDOM to render directly into the shadow root + ReactDOM.render(, shadowRoot); + + 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();}); + expect(document.activeElement).toBe(shadowRoot.host); + expect(shadowRoot.activeElement).toBe(input1); + + // Simulate tabbing through inputs + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); + + await user.tab(); + expect(shadowRoot.activeElement).toBe(input3); + + // Simulate tabbing back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + document.body.removeChild(shadowRoot.host); + }); }); describe('focus restoration', function () { diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 3296443ee29..eb2784f73c9 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -30,3 +30,12 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo return rootNode; }; + +/** + * `getRootBody`: Retrieves a suitable "body" element for an element, accommodating both + * Shadow DOM and traditional DOM contexts. Returns `document.body` for elements in the + * light DOM or the root of the Shadow DOM for elements within a shadow DOM. + */ +export const getRootBody = (root: Document | ShadowRoot): HTMLElement | ShadowRoot => { + return root instanceof Document ? root.body : root; +}; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index ca7b7205547..adbc012daf6 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -11,7 +11,7 @@ */ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; -export {getOwnerDocument, getOwnerWindow, getRootNode} from './domHelpers'; +export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; From 3448ed77acee56c487d06d87d05c0a3915a1ae18 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 12 Mar 2024 22:15:24 +0200 Subject: [PATCH 007/102] Add more test for FocusScope.test.js. Fix `useRestoreFocus` issue. Add new DOM util `getDeepActiveElement`. --- packages/@react-aria/focus/src/FocusScope.tsx | 8 +- .../@react-aria/focus/test/FocusScope.test.js | 150 +++++++++++++----- packages/@react-aria/utils/src/domHelpers.ts | 8 + packages/@react-aria/utils/src/index.ts | 2 +- 4 files changed, 126 insertions(+), 42 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 9446a17ff64..27b0218074c 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,7 @@ import {FocusableElement} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getRootBody} from '@react-aria/utils/src/domHelpers'; +import {getDeepActiveElement, getRootBody} from '@react-aria/utils/src/domHelpers'; import {getRootNode, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react'; @@ -369,7 +369,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) } raf.current = requestAnimationFrame(() => { // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe - if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { + if (getDeepActiveElement && shouldContainFocus(scopeRef) && !isElementInChildScope(getDeepActiveElement(), scopeRef)) { activeScope = scopeRef; if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; @@ -544,7 +544,7 @@ function shouldRestoreFocus(scopeRef: ScopeRef) { function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, contain?: boolean) { // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts. // eslint-disable-next-line no-restricted-globals - const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null); + const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getDeepActiveElement() as FocusableElement : null); // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus // restoring-non-containing scopes should only care if they become active so they can perform the restore @@ -559,7 +559,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && - isElementInScope(ownerDocument.activeElement, scopeRef.current) + isElementInScope(getDeepActiveElement(), scopeRef.current) ) { activeScope = scopeRef; } diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 306eba2b2de..4e2bb6ccf46 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -321,43 +321,6 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(input2); }); - - it('should contain focus within the shadow DOM scope', async function () { - const {shadowRoot} = createShadowRoot(); - const FocusableComponent = () => ( - - - - - - ); - - // Use ReactDOM to render directly into the shadow root - ReactDOM.render(, shadowRoot); - - 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();}); - expect(document.activeElement).toBe(shadowRoot.host); - expect(shadowRoot.activeElement).toBe(input1); - - // Simulate tabbing through inputs - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); - - await user.tab(); - expect(shadowRoot.activeElement).toBe(input3); - - // Simulate tabbing back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); - - // Cleanup - document.body.removeChild(shadowRoot.host); - }); }); describe('focus restoration', function () { @@ -1661,6 +1624,119 @@ describe('FocusScope', function () { expect(document.activeElement.textContent).toBe('Open Menu'); }); }); + + describe('FocusScope with Shadow DOM', function () { + it('should contain focus within the shadow DOM scope', async function () { + const {shadowRoot} = createShadowRoot(); + const FocusableComponent = () => ( + + + + + + ); + + // Use ReactDOM to render directly into the shadow root + ReactDOM.render(, shadowRoot); + + 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();}); + expect(document.activeElement).toBe(shadowRoot.host); + expect(shadowRoot.activeElement).toBe(input1); + + // Simulate tabbing through inputs + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); + + await user.tab(); + expect(shadowRoot.activeElement).toBe(input3); + + // Simulate tabbing back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + document.body.removeChild(shadowRoot.host); + ReactDOM.unmountComponentAtNode(shadowRoot); + }); + + 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'}); + + // Use ReactDOM to render into the nested shadow DOM + ReactDOM.render(( + + + + + ), childShadowRoot); + + const input1 = childShadowRoot.querySelector('[data-testid=input1]'); + const input2 = childShadowRoot.querySelector('[data-testid=input2]'); + + act(() => {input1.focus();}); + expect(childShadowRoot.activeElement).toBe(input1); + + await user.tab(); + expect(childShadowRoot.activeElement).toBe(input2); + + // Cleanup + document.body.removeChild(parentShadowRoot.host); + }); + + /** + * document.body + * ├── div#outside-shadow (contains ) + * │ ├── input (focus can be restored here) + * │ └── shadow-root + * │ └── 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 () => { + const App = () => ( + <> + + + +
+ + ); + + const {getByTestId} = render(); + const shadowHost = document.getElementById('shadow-host'); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + + const FocusableComponent = () => ( + + + + + + ); + + ReactDOM.render(, shadowRoot); + + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + act(() => { input1.focus(); }); + expect(shadowRoot.activeElement).toBe(input1); + + const externalInput = getByTestId('outside'); + act(() => { externalInput.focus(); }); + expect(document.activeElement).toBe(externalInput); + + ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { jest.runAllTimers(); }); + + expect(document.activeElement).toBe(externalInput); + }); + }); }); describe('Unmounting cleanup', () => { diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index eb2784f73c9..e705a4f327f 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -39,3 +39,11 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo export const getRootBody = (root: Document | ShadowRoot): HTMLElement | ShadowRoot => { return root instanceof Document ? root.body : root; }; + +export const getDeepActiveElement = () => { + let activeElement = document.activeElement; + while (activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + return activeElement; +}; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index adbc012daf6..e9bcf667c9c 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -11,7 +11,7 @@ */ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; -export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody} from './domHelpers'; +export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, getDeepActiveElement} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; From 0c07404b4df1b1ca96f0fea32cc8e880c63fe82c Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 13 Mar 2024 04:16:51 +0200 Subject: [PATCH 008/102] Fix another `useRestoreFocus` issue with restoring focus in `Keyboard navigation example`. --- packages/@react-aria/focus/src/FocusScope.tsx | 31 +++++++++++++------ .../@react-aria/focus/test/FocusScope.test.js | 16 +++++----- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 27b0218074c..78b44b96ce2 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -319,7 +319,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) return; } - let focusedElement = ownerDocument.activeElement; + let focusedElement = getDeepActiveElement(); let scope = scopeRef.current; if (!scope || !isElementInScope(focusedElement, scope)) { return; @@ -368,8 +368,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) cancelAnimationFrame(raf.current); } raf.current = requestAnimationFrame(() => { - // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe - if (getDeepActiveElement && shouldContainFocus(scopeRef) && !isElementInChildScope(getDeepActiveElement(), scopeRef)) { + if (getDeepActiveElement() && shouldContainFocus(scopeRef) && !isElementInChildScope(getDeepActiveElement(), scopeRef)) { activeScope = scopeRef; if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; @@ -490,8 +489,7 @@ function useAutoFocus(scopeRef: RefObject, autoFocus?: boolean) { useEffect(() => { if (autoFocusRef.current) { activeScope = scopeRef; - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined); - if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) { + if (!isElementInScope(getDeepActiveElement(), activeScope.current) && scopeRef.current) { focusFirstInScope(scopeRef.current); } } @@ -590,7 +588,8 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, return; } - let focusedElement = ownerDocument.activeElement as FocusableElement; + let focusedElement = getDeepActiveElement() as FocusableElement; + if (!isElementInScope(focusedElement, scopeRef.current)) { return; } @@ -673,21 +672,33 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, } let nodeToRestore = treeNode.nodeToRestore; + const activeElement = getDeepActiveElement(); + // if we already lost focus to the body and this was the active scope, then we should attempt to restore if ( restoreFocus && nodeToRestore && ( // eslint-disable-next-line react-hooks/exhaustive-deps - isElementInScope(ownerDocument.activeElement, scopeRef.current) - || (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef)) + isElementInScope(activeElement, scopeRef.current) + || (activeElement === rootBody && shouldRestoreFocus(scopeRef)) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it 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 === rootBody) { + const activeElement = getDeepActiveElement(); + let focusOutsideScope; + + if (rootBody instanceof ShadowRoot) { + // In Shadow DOM, check if the active element is outside the shadow root. This includes the scenario where focus has moved to the shadow host. + focusOutsideScope = !rootBody.contains(activeElement) || activeElement === rootBody.host; + } else { + // In Light DOM, check if focus has moved to the body, indicating it's outside your component. + focusOutsideScope = activeElement === rootBody; + } + + if (focusOutsideScope) { // 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/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 4e2bb6ccf46..6e1f06d6c36 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -21,14 +21,6 @@ import ReactDOM from 'react-dom'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; import userEvent from '@testing-library/user-event'; - -function createShadowRoot() { - const div = document.createElement('div'); - document.body.appendChild(div); - const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; -} - describe('FocusScope', function () { let user; @@ -1626,6 +1618,14 @@ describe('FocusScope', function () { }); describe('FocusScope with Shadow DOM', function () { + + function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; + } + it('should contain focus within the shadow DOM scope', async function () { const {shadowRoot} = createShadowRoot(); const FocusableComponent = () => ( From 90906bf40e3b8da62c1ece2c14f38ede77262737 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 13 Mar 2024 04:30:05 +0200 Subject: [PATCH 009/102] Add tests for `getDeepActiveElement` --- .../@react-aria/utils/test/domHelpers.test.js | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index 5f6fe7bb059..7ed45f683ed 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -11,7 +11,8 @@ */ -import {getOwnerWindow, getRootNode} from '../'; +import {act} from 'react-dom/test-utils'; +import {getDeepActiveElement, getOwnerWindow, getRootNode} from '../'; import React, {createRef} from 'react'; import {render} from '@react-spectrum/test-utils'; @@ -146,3 +147,74 @@ describe('getOwnerWindow', () => { iframe.remove(); }); }); + +describe('getDeepActiveElement', () => { + it('returns the body as the active element by default', () => { + act(() => {document.body.focus();}); // Ensure the body is focused, clearing any specific active element + expect(getDeepActiveElement()).toBe(document.body); + }); + + it('returns the active element in the light DOM', () => { + const btn = document.createElement('button'); + document.body.appendChild(btn); + act(() => {btn.focus();}); + expect(getDeepActiveElement()).toBe(btn); + document.body.removeChild(btn); + }); + + it('returns the active element inside a shadow DOM', () => { + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({mode: 'open'}); + const btnInShadow = document.createElement('button'); + + shadowRoot.appendChild(btnInShadow); + document.body.appendChild(div); + + act(() => {btnInShadow.focus();}); + + expect(getDeepActiveElement()).toBe(btnInShadow); + + document.body.removeChild(div); + }); + + it('returns the active element from within nested shadow DOMs', () => { + const outerHost = document.createElement('div'); + const outerShadow = outerHost.attachShadow({mode: 'open'}); + const innerHost = document.createElement('div'); + + outerShadow.appendChild(innerHost); + + const innerShadow = innerHost.attachShadow({mode: 'open'}); + const input = document.createElement('input'); + + innerShadow.appendChild(input); + document.body.appendChild(outerHost); + + act(() => {input.focus();}); + + expect(getDeepActiveElement()).toBe(input); + + document.body.removeChild(outerHost); + }); + + it('returns the active element in document after focusing an element in shadow DOM and then in document', () => { + const hostDiv = document.createElement('div'); + + document.body.appendChild(hostDiv); + + const shadowRoot = hostDiv.attachShadow({mode: 'open'}); + const shadowInput = document.createElement('input'); + const bodyInput = document.createElement('input'); + + shadowRoot.appendChild(shadowInput); + document.body.appendChild(bodyInput); + + act(() => {shadowInput.focus();}); + act(() => {bodyInput.focus();}); + + expect(getDeepActiveElement()).toBe(bodyInput); + + document.body.removeChild(hostDiv); + document.body.removeChild(bodyInput); + }); +}); From 314fc44e52df688f9ee94347cfe4daf9fd9feb15 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 13 Mar 2024 05:21:11 +0200 Subject: [PATCH 010/102] Add `useFocus` shadow DOM tests. update `useFocus` - `useFocusWithin` - `usePress`. --- .../@react-aria/interactions/src/useFocus.ts | 4 +- .../interactions/src/useFocusWithin.ts | 3 +- .../@react-aria/interactions/src/usePress.ts | 12 +- .../interactions/test/useFocus.test.js | 126 ++++++++++++++++++ 4 files changed, 136 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index bf877c9988d..5aee99c3211 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,7 +17,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared'; import {FocusEvent, useCallback} from 'react'; -import {getOwnerDocument} from '@react-aria/utils'; +import {getRootNode} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { @@ -63,7 +63,7 @@ export function useFocus(pro // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getOwnerDocument(e.target); + const ownerDocument = getRootNode(e.target); if (e.target === e.currentTarget && ownerDocument.activeElement === e.target) { if (onFocusProp) { diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 0c0bf39ccf9..8ad74562304 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -17,6 +17,7 @@ import {DOMAttributes} from '@react-types/shared'; import {FocusEvent, useCallback, useRef} from 'react'; +import {getDeepActiveElement} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusWithinProps { @@ -70,7 +71,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onFocus = useCallback((e: FocusEvent) => { // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - if (!state.current.isFocusWithin && document.activeElement === e.target) { + if (!state.current.isFocusWithin && getDeepActiveElement() === e.target) { if (onFocusWithin) { onFocusWithin(e); } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 583a71bfb2f..e81991e7307 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.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 {chain, focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils'; +import {chain, focusWithoutScrolling, getOwnerWindow, getRootNode, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents} from '@react-types/shared'; import {PressResponderContext} from './context'; @@ -279,7 +279,7 @@ export function usePress(props: PressHookProps): PressResult { } }; - addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); + addGlobalListener(getRootNode(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); } if (shouldStopPropagation) { @@ -409,9 +409,9 @@ export function usePress(props: PressHookProps): PressResult { shouldStopPropagation = triggerPressStart(e, state.pointerType); - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointermove', onPointerMove, false); - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false); - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false); + addGlobalListener(getRootNode(e.currentTarget), 'pointermove', onPointerMove, false); + addGlobalListener(getRootNode(e.currentTarget), 'pointerup', onPointerUp, false); + addGlobalListener(getRootNode(e.currentTarget), 'pointercancel', onPointerCancel, false); } if (shouldStopPropagation) { @@ -533,7 +533,7 @@ export function usePress(props: PressHookProps): PressResult { e.stopPropagation(); } - addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false); + addGlobalListener(getRootNode(e.currentTarget), 'mouseup', onMouseUp, false); }; pressProps.onMouseEnter = (e) => { diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index c95ab03cc5d..d81d400488c 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -12,6 +12,7 @@ import {act, render, waitFor} from '@react-spectrum/test-utils'; import React from 'react'; +import ReactDOM from 'react-dom'; import {useFocus} from '../'; function Example(props) { @@ -153,4 +154,129 @@ describe('useFocus', function () { // MutationObserver is async await waitFor(() => expect(onBlur).toHaveBeenCalled()); }); + + describe('useFocus with Shadow DOM', function () { + function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; + } + + it('handles focus events within shadow DOM', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const events = []; + const ExampleComponent = () => ( + events.push({type: 'focus', target: e.target})} + onBlur={(e) => events.push({type: 'blur', target: e.target})} + onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> + ); + + act(() => ReactDOM.render(, shadowRoot)); + + const el = shadowRoot.querySelector('[data-testid="example"]'); + + act(() => {el.focus();}); + act(() => {el.blur();}); + + // Assertions similar to your original test, but ensure you're checking for events triggered within the shadow DOM + expect(events).toEqual([ + {type: 'focus', target: el}, + {type: 'focuschange', isFocused: true}, + {type: 'blur', target: el}, + {type: 'focuschange', isFocused: false} + ]); + + // Cleanup + ReactDOM.unmountComponentAtNode(shadowRoot); + document.body.removeChild(shadowHost); + }); + + it('does not handle focus events if disabled within shadow DOM', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const events = []; + const ExampleComponent = () => ( + events.push({type: 'focus', target: e.target})} + onBlur={(e) => events.push({type: 'blur', target: e.target})} + onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> + ); + + act(() => ReactDOM.render(, shadowRoot)); + const el = shadowRoot.querySelector('[data-testid="example"]'); + + act(() => {el.focus();}); + act(() => {el.blur();}); + + expect(events).toEqual([]); + + // Cleanup + ReactDOM.unmountComponentAtNode(shadowRoot); + document.body.removeChild(shadowHost); + }); + + it('events do not bubble when stopPropagation is called within shadow DOM', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const onWrapperFocus = jest.fn(); + const onWrapperBlur = jest.fn(); + const onInnerFocus = jest.fn(e => e.stopPropagation()); + const onInnerBlur = jest.fn(e => e.stopPropagation()); + + const WrapperComponent = () => ( +
+ +
+ ); + + act(() => ReactDOM.render(, shadowRoot)); + const el = shadowRoot.querySelector('[data-testid="example"]'); + + act(() => {el.focus();}); + act(() => {el.blur();}); + + expect(onInnerFocus).toHaveBeenCalledTimes(1); + expect(onInnerBlur).toHaveBeenCalledTimes(1); + expect(onWrapperFocus).not.toHaveBeenCalled(); + expect(onWrapperBlur).not.toHaveBeenCalled(); + + // Cleanup + ReactDOM.unmountComponentAtNode(shadowRoot); + document.body.removeChild(shadowHost); + }); + + it('events bubble by default within shadow DOM', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const onWrapperFocus = jest.fn(); + const onWrapperBlur = jest.fn(); + const onInnerFocus = jest.fn(); + const onInnerBlur = jest.fn(); + + const WrapperComponent = () => ( +
+ +
+ ); + + act(() => ReactDOM.render(, shadowRoot)); + const el = shadowRoot.querySelector('[data-testid="example"]'); + + act(() => {el.focus();}); + act(() => {el.blur();}); + + expect(onInnerFocus).toHaveBeenCalledTimes(1); + expect(onInnerBlur).toHaveBeenCalledTimes(1); + expect(onWrapperFocus).toHaveBeenCalledTimes(1); + expect(onWrapperBlur).toHaveBeenCalledTimes(1); + + // Cleanup + ReactDOM.unmountComponentAtNode(shadowRoot); + document.body.removeChild(shadowHost); + }); + }); }); From 9cb6c5a72e5d4dbf8cf69e714c9c864823f4b083 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 13 Mar 2024 05:29:18 +0200 Subject: [PATCH 011/102] Update `focusSafely`. Test for `focusSafely`. --- packages/@react-aria/focus/src/focusSafely.ts | 4 +- .../focus/test/focusSafely.test.js | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index e0bc52e9465..6a4dc1bfe3d 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerDocument, runAfterTransition} from '@react-aria/utils'; +import {focusWithoutScrolling, getRootNode, runAfterTransition} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; /** @@ -24,7 +24,7 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const ownerDocument = getOwnerDocument(element); + const ownerDocument = getRootNode(element); if (getInteractionModality() === 'virtual') { let lastFocusedElement = ownerDocument.activeElement; runAfterTransition(() => { diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 8b7534eb1ee..97fbee6561f 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -15,6 +15,7 @@ import {act, render} from '@react-spectrum/test-utils'; import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '../../utils/index'; +import ReactDOM from 'react-dom'; import {setInteractionModality} from '@react-aria/interactions'; jest.useFakeTimers(); @@ -60,4 +61,61 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); }); + + describe('focusSafely with Shadow DOM', function () { + function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; + } + + const focusWithoutScrollingSpy = jest.spyOn(ReactAriaUtils, 'focusWithoutScrolling').mockImplementation(() => {}); + + it("should not focus on the element if it's no longer connected within shadow DOM", async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + setInteractionModality('virtual'); + + const Example = () => ; + act(() => ReactDOM.render(, shadowRoot)); + + const button = shadowRoot.querySelector('button'); + + requestAnimationFrame(() => { + ReactDOM.unmountComponentAtNode(shadowRoot); + document.body.removeChild(shadowHost); + }); + expect(button).toBeTruthy(); + focusSafely(button); + + act(() => { + jest.runAllTimers(); + }); + + expect(focusWithoutScrollingSpy).toBeCalledTimes(0); + }); + + it("should focus on the element if it's connected within shadow DOM", async function () { + const {shadowRoot} = createShadowRoot(); + setInteractionModality('virtual'); + + const Example = () => ; + act(() => ReactDOM.render(, shadowRoot)); + + const button = shadowRoot.querySelector('button'); + + expect(button).toBeTruthy(); + focusSafely(button); + + act(() => { + jest.runAllTimers(); + }); + + expect(focusWithoutScrollingSpy).toBeCalledTimes(1); + + // Cleanup + ReactDOM.unmountComponentAtNode(shadowRoot); + shadowRoot.host.remove(); + }); + }); }); From 67399bb1383b4bfb70c66f9700db0ded7dbfcf35 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 13 Mar 2024 22:50:13 +0200 Subject: [PATCH 012/102] Update `useInteractionOutside` for Shadow DOM support. --- .../interactions/src/useInteractOutside.ts | 73 +++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 99625c3bb69..8b462d731b8 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 {getRootNode, useEffectEvent} from '@react-aria/utils'; import {RefObject, useEffect, useRef} from 'react'; export interface InteractOutsideProps { @@ -59,7 +59,8 @@ export function useInteractOutside(props: InteractOutsideProps) { } const element = ref.current; - const documentObject = getOwnerDocument(element); + const documentObject = getRootNode(element); + const isShadowRoot = documentObject instanceof ShadowRoot; // Use pointer events if available. Otherwise, fall back to mouse and touch events. if (typeof PointerEvent !== 'undefined') { @@ -74,9 +75,19 @@ export function useInteractOutside(props: InteractOutsideProps) { documentObject.addEventListener('pointerdown', onPointerDown, true); documentObject.addEventListener('pointerup', onPointerUp, true); + if (isShadowRoot) { + documentObject.ownerDocument.addEventListener('pointerdown', onPointerDown, true); + documentObject.ownerDocument.addEventListener('pointerup', onPointerUp, true); + } + return () => { documentObject.removeEventListener('pointerdown', onPointerDown, true); documentObject.removeEventListener('pointerup', onPointerUp, true); + + if (isShadowRoot) { + documentObject.ownerDocument.removeEventListener('pointerdown', onPointerDown, true); + documentObject.ownerDocument.removeEventListener('pointerup', onPointerUp, true); + } }; } else { let onMouseUp = (e) => { @@ -96,12 +107,27 @@ export function useInteractOutside(props: InteractOutsideProps) { state.isPointerDown = false; }; + if (isShadowRoot) { + documentObject.ownerDocument.addEventListener('mousedown', onPointerDown, true); + documentObject.ownerDocument.addEventListener('mouseup', onMouseUp, true); + documentObject.ownerDocument.addEventListener('touchstart', onPointerDown, true); + documentObject.ownerDocument.addEventListener('touchend', onTouchEnd, true); + } + documentObject.addEventListener('mousedown', onPointerDown, true); documentObject.addEventListener('mouseup', onMouseUp, true); documentObject.addEventListener('touchstart', onPointerDown, true); documentObject.addEventListener('touchend', onTouchEnd, true); return () => { + + if (isShadowRoot) { + documentObject.ownerDocument.removeEventListener('mousedown', onPointerDown, true); + documentObject.ownerDocument.removeEventListener('mouseup', onMouseUp, true); + documentObject.ownerDocument.removeEventListener('touchstart', onPointerDown, true); + documentObject.ownerDocument.removeEventListener('touchend', onTouchEnd, true); + } + documentObject.removeEventListener('mousedown', onPointerDown, true); documentObject.removeEventListener('mouseup', onMouseUp, true); documentObject.removeEventListener('touchstart', onPointerDown, true); @@ -111,23 +137,44 @@ export function useInteractOutside(props: InteractOutsideProps) { }, [ref, isDisabled, onPointerDown, triggerInteractOutside]); } +/** + * Checks if an event is valid for triggering interact outside logic. + * + * This function determines the validity of an event based on several conditions: + * - The event must be triggered by a left mouse click (button 0). + * - The target of the event must be within the document. + * - The target should not be within any element designated as a "top layer" (e.g., modals, overlays). + * - The event target must not be contained within the specified reference element. + * + * For shadow DOM support, it uses event.composedPath() to accurately determine the event's target, ensuring + * compatibility with events that originate from within shadow roots. + * + * @param {Event} event - The event to check. + * @param {RefObject} ref - A React ref object pointing to the component's root element. + * @returns {boolean} True if the event is valid and should trigger interact outside logic, false otherwise. + */ function isValidEvent(event, ref) { if (event.button > 0) { return false; } - 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)) { - return false; - } + // Use composedPath to accurately get the event path, including shadow DOMs + const path = event.composedPath(); + const target = path[0]; - // If the target is within a top layer element (e.g. toasts), ignore. - if (event.target.closest('[data-react-aria-top-layer]')) { - return false; - } + // Determine the root node for the event target and the component's ref + const refRoot = getRootNode(ref.current); + const targetRoot = getRootNode(target); + + // Check for top layer elements (e.g., modals or overlays) and ignore events within them + if (target.closest('[data-react-aria-top-layer]')) { + return false; } - return ref.current && !ref.current.contains(event.target); + // The event is considered outside if: + // 1. The target and ref are not in the same root (document or shadow root). + // 2. The ref does not contain the target. + return refRoot !== targetRoot || !ref.current.contains(target); } + + From 59c37056eda847d81147537ae278aee93c8ce710 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 14 Mar 2024 04:41:12 +0200 Subject: [PATCH 013/102] Update `useFocusVisible` for Shadow DOM support. --- packages/@react-aria/interactions/src/useFocusVisible.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index d0637c913dd..f7d54477121 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 {getOwnerWindow, getRootNode, isMac, isVirtualClick} from '@react-aria/utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -123,7 +123,7 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { } const windowObject = getOwnerWindow(element); - const documentObject = getOwnerDocument(element); + const documentObject = getRootNode(element); // Programmatic focus() calls shouldn't affect the current input modality. // However, we need to detect other cases when a focus event occurs without @@ -164,7 +164,7 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { const windowObject = getOwnerWindow(element); - const documentObject = getOwnerDocument(element); + const documentObject = getRootNode(element); if (loadListener) { documentObject.removeEventListener('DOMContentLoaded', loadListener); } @@ -210,7 +210,7 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { * @returns A function to remove the event listeners and cleanup the state. */ export function addWindowFocusTracking(element?: HTMLElement | null): () => void { - const documentObject = getOwnerDocument(element); + const documentObject = getRootNode(element); let loadListener; if (documentObject.readyState !== 'loading') { setupGlobalFocusEvents(element); From 3c854088094e9160bcfcfb65a56427d7fa30928b Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 14 Mar 2024 21:56:51 +0200 Subject: [PATCH 014/102] Add `useInteractOutside` tests. --- .../test/useInteractOutside.test.js | 151 +++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index b4e13f3f216..dada8077be6 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -11,8 +11,8 @@ */ import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils'; -import React, {useRef} from 'react'; -import {render as ReactDOMRender} from 'react-dom'; +import React, {useEffect, useRef} from 'react'; +import ReactDOM, {render as ReactDOMRender} from 'react-dom'; import {useInteractOutside} from '../'; function Example(props) { @@ -442,3 +442,150 @@ describe('useInteractOutside (iframes)', 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'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + + function WrapperComponent() { + return ReactDOM.createPortal(ui, shadowRoot); + } + + render(); + return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; + } + + function App({onInteractOutside}) { + const ref = useRef(null); + useInteractOutside({ref, onInteractOutside}); + + return ( +
+
+
+
+
+
+ ); + } + + it('does not trigger when clicking inside popover', function () { + const onInteractOutside = jest.fn(); + const {shadowRoot, cleanup} = createShadowRootAndRender( + + ); + + const insidePopover = shadowRoot.getElementById('inside-popover'); + fireEvent.mouseDown(insidePopover); + fireEvent.mouseUp(insidePopover); + + expect(onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it('does not trigger when clicking the popover', function () { + const onInteractOutside = jest.fn(); + const {shadowRoot, cleanup} = createShadowRootAndRender( + + ); + + const popover = shadowRoot.getElementById('popover'); + fireEvent.mouseDown(popover); + fireEvent.mouseUp(popover); + + expect(onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it('triggers when clicking outside the popover', function () { + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender( + + ); + + // Clicking on the document body outside the shadow DOM + fireEvent.mouseDown(document.body); + fireEvent.mouseUp(document.body); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); + cleanup(); + }); + + it('triggers when clicking a button outside the shadow dom altogether', function () { + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender( + + ); + // Button outside shadow DOM and component + const button = document.createElement('button'); + document.body.appendChild(button); + + fireEvent.mouseDown(button); + fireEvent.mouseUp(button); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); + document.body.removeChild(button); + cleanup(); + }); +}); + +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'); + document.body.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + + function WrapperComponent() { + return ReactDOM.createPortal(ui, shadowRoot); + } + + render(); + return {shadowRoot, cleanup: () => document.body.removeChild(shadowHost)}; + } + + function App({onInteractOutside, includeDynamicElement = false}) { + const ref = useRef(null); + useInteractOutside({ref, onInteractOutside}); + + useEffect(() => { + if (includeDynamicElement) { + const dynamicEl = document.createElement('div'); + dynamicEl.id = 'dynamic-outside'; + document.body.appendChild(dynamicEl); + + return () => document.body.removeChild(dynamicEl); + } + }, [includeDynamicElement]); + + return ( +
+
+
+
+
+
+ ); + } + + it('correctly identifies interaction with dynamically added external elements', function () { + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender( + + ); + + // Wait for dynamic element to be added + setTimeout(() => { + const dynamicEl = document.getElementById('dynamic-outside'); + fireEvent.mouseDown(dynamicEl); + fireEvent.mouseUp(dynamicEl); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); + }, 0); + + cleanup(); + }); +}); From 61ba1b0ab65df4eb07460e9d33af0b3ebb083b42 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 14 Mar 2024 22:41:32 +0200 Subject: [PATCH 015/102] Add test for use case mentioned in issue #1472. --- .../@react-aria/focus/test/FocusScope.test.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 6e1f06d6c36..27eaa558339 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1736,6 +1736,52 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(externalInput); }); + + /** + * Test case: https://github.com/adobe/react-spectrum/issues/1472 + * sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 + */ + it('should autofocus and lock tab navigation inside shadow DOM', async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + let user; + + act(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + + const FocusableComponent = () => ( + + + + + + ); + + await act(() => ReactDOM.render(, shadowRoot)); + + 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 + await act(() => input1.focus()); + expect(shadowRoot.activeElement).toBe(input1); + + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); + + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(button); + + // Simulate tab again to check if focus loops back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + document.body.removeChild(shadowHost); + }); }); }); From 75d7fa500ad3b7701908928f9ec9401e5d24490b Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 15 Mar 2024 05:06:33 +0200 Subject: [PATCH 016/102] Add tests for `usePress` hook. --- .../interactions/test/usePress.test.js | 640 +++++++++++++++++- 1 file changed, 638 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 05730eb2534..1adab68dde1 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -16,14 +16,14 @@ import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; -import {render as ReactDOMRender} from 'react-dom'; +import ReactDOM, {render as ReactDOMRender} from 'react-dom'; import {theme} from '@react-spectrum/theme-default'; import {usePress} from '../'; function Example(props) { let {elementType: ElementType = 'div', style, draggable, ...otherProps} = props; let {pressProps} = usePress(otherProps); - return {ElementType !== 'input' ? 'test' : undefined}; + return {ElementType !== 'input' ? 'test' : undefined}; } function pointerEvent(type, opts) { @@ -3301,4 +3301,640 @@ describe('usePress', function () { ]); }); }); + + describe('FocusScope with Shadow DOM', function () { + installPointerEvent(); + let cleanupShadowRoot; + let events = []; + let addEvent = (e) => events.push(e); + + function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; + } + + function setupShadowDOMTest(extraProps = {}) { + const {shadowRoot} = createShadowRoot(); + cleanupShadowRoot = shadowRoot; + events = []; + addEvent = (e) => events.push(e); + const ExampleComponent = () => ( + addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent} + {...extraProps} /> + ); + + // Use ReactDOM to render directly into the shadow root + ReactDOM.render(, shadowRoot); + + return shadowRoot; + } + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + document.body.removeChild(cleanupShadowRoot.host); + ReactDOM.unmountComponentAtNode(cleanupShadowRoot); + }); + + it('should fire press events based on pointer events', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'press', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + }); + + it('should fire press change events when moving pointer outside target', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + } + ]); + + events = []; + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'press', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + }); + + it('should handle pointer cancel events', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointercancel', {pointerId: 1, pointerType: 'mouse'})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + } + ]); + }); + + it('should cancel press on dragstart', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, new MouseEvent('dragstart', {bubbles: true, cancelable: true, composed: true})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + } + ]); + }); + + it('should cancel press when moving outside and the shouldCancelOnPointerExit option is set', function () { + const shadowRoot = setupShadowDOMTest({shouldCancelOnPointerExit: true}); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + } + ]); + }); + + it('should handle modifier keys', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', shiftKey: true})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', ctrlKey: true, clientX: 0, clientY: 0})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: true, + altKey: false + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'press', + target: el, + pointerType: 'mouse', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + }); + + it('should only handle left clicks', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', button: 1})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', button: 1, clientX: 0, clientY: 0})); + expect(events).toEqual([]); + }); + + it('should not focus the target on click if preventFocusOnPress is true', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + expect(document.activeElement).not.toBe(el); + }); + + it('should focus the target on click by default', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + expect(shadowRoot.activeElement).toBe(el); + }); + + it('should prevent default on pointerdown and mousedown by default', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + expect(allowDefault).toBe(false); + + allowDefault = fireEvent.mouseDown(el); + expect(allowDefault).toBe(false); + }); + + it('should still prevent default when pressing on a non draggable + pressable item in a draggable container', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + expect(allowDefault).toBe(false); + + allowDefault = fireEvent.mouseDown(el); + expect(allowDefault).toBe(false); + }); + + it('should not prevent default when pressing on a draggable item', function () { + const shadowRoot = setupShadowDOMTest({draggable: true}); + + const el = shadowRoot.getElementById('testElement'); + let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + expect(allowDefault).toBe(true); + + allowDefault = fireEvent.mouseDown(el); + expect(allowDefault).toBe(true); + }); + + it('should ignore virtual pointer events', function () { + const shadowRoot = setupShadowDOMTest({onPressChange: null}); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0, clientX: 0, clientY: 0})); + + expect(events).toEqual([]); + + fireEvent.click(el); + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressup', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'press', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + }); + + it('should not ignore virtual pointer events on android ', function () { + let uaMock = jest.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => 'Android'); + + const shadowRoot = setupShadowDOMTest({onPressChange: null}); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0, clientX: 0, clientY: 0})); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'press', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + + uaMock.mockRestore(); + }); + + it('should detect Android TalkBack double tap', function () { + const shadowRoot = setupShadowDOMTest({onPressChange: null}); + + const el = shadowRoot.getElementById('testElement'); + // Android TalkBack will occasionally fire a pointer down event with "width: 1, height: 1" instead of "width: 0, height: 0". + // Make sure we can still determine that this is a virtual event by checking the pressure, detail, and height/width. + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); + expect(events).toEqual([]); + + // Virtual pointer event sets pointerType and onClick handles the rest + fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressup', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'pressend', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }, + { + type: 'press', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + } + ]); + }); + + it('should not fire press events for disabled elements', function () { + const shadowRoot = setupShadowDOMTest({isDisabled: true}); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + expect(events).toEqual([]); + }); + + it('should fire press event when pointerup close to the target', function () { + let spy = jest.fn(); + const shadowRoot = setupShadowDOMTest({onPress: spy}); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0, width: 20, height: 20})); + fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 10, clientY: 10, width: 20, height: 20})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 10, clientY: 10, width: 20, height: 20})); + + expect(spy).toHaveBeenCalled(); + }); + + it('should add/remove user-select: none to the element on pointer down/up', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + expect(el).toHaveStyle('user-select: none'); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse'})); + expect(el).not.toHaveStyle('user-select: none'); + }); + }); }); From db45ef48d2279b258aa945f1b00e383f515ee8de Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 15 Mar 2024 05:35:42 +0200 Subject: [PATCH 017/102] Update the fix for `useInteractOutside` to use simpler one. --- .../interactions/src/useInteractOutside.ts | 79 +++++-------------- 1 file changed, 19 insertions(+), 60 deletions(-) diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 8b462d731b8..1c8918cd823 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 {getRootNode, useEffectEvent} from '@react-aria/utils'; +import {getOwnerDocument, useEffectEvent} from '@react-aria/utils'; import {RefObject, useEffect, useRef} from 'react'; export interface InteractOutsideProps { @@ -59,8 +59,7 @@ export function useInteractOutside(props: InteractOutsideProps) { } const element = ref.current; - const documentObject = getRootNode(element); - const isShadowRoot = documentObject instanceof ShadowRoot; + const documentObject = getOwnerDocument(element); // Use pointer events if available. Otherwise, fall back to mouse and touch events. if (typeof PointerEvent !== 'undefined') { @@ -75,19 +74,9 @@ export function useInteractOutside(props: InteractOutsideProps) { documentObject.addEventListener('pointerdown', onPointerDown, true); documentObject.addEventListener('pointerup', onPointerUp, true); - if (isShadowRoot) { - documentObject.ownerDocument.addEventListener('pointerdown', onPointerDown, true); - documentObject.ownerDocument.addEventListener('pointerup', onPointerUp, true); - } - return () => { documentObject.removeEventListener('pointerdown', onPointerDown, true); documentObject.removeEventListener('pointerup', onPointerUp, true); - - if (isShadowRoot) { - documentObject.ownerDocument.removeEventListener('pointerdown', onPointerDown, true); - documentObject.ownerDocument.removeEventListener('pointerup', onPointerUp, true); - } }; } else { let onMouseUp = (e) => { @@ -107,27 +96,12 @@ export function useInteractOutside(props: InteractOutsideProps) { state.isPointerDown = false; }; - if (isShadowRoot) { - documentObject.ownerDocument.addEventListener('mousedown', onPointerDown, true); - documentObject.ownerDocument.addEventListener('mouseup', onMouseUp, true); - documentObject.ownerDocument.addEventListener('touchstart', onPointerDown, true); - documentObject.ownerDocument.addEventListener('touchend', onTouchEnd, true); - } - documentObject.addEventListener('mousedown', onPointerDown, true); documentObject.addEventListener('mouseup', onMouseUp, true); documentObject.addEventListener('touchstart', onPointerDown, true); documentObject.addEventListener('touchend', onTouchEnd, true); return () => { - - if (isShadowRoot) { - documentObject.ownerDocument.removeEventListener('mousedown', onPointerDown, true); - documentObject.ownerDocument.removeEventListener('mouseup', onMouseUp, true); - documentObject.ownerDocument.removeEventListener('touchstart', onPointerDown, true); - documentObject.ownerDocument.removeEventListener('touchend', onTouchEnd, true); - } - documentObject.removeEventListener('mousedown', onPointerDown, true); documentObject.removeEventListener('mouseup', onMouseUp, true); documentObject.removeEventListener('touchstart', onPointerDown, true); @@ -137,44 +111,29 @@ export function useInteractOutside(props: InteractOutsideProps) { }, [ref, isDisabled, onPointerDown, triggerInteractOutside]); } -/** - * Checks if an event is valid for triggering interact outside logic. - * - * This function determines the validity of an event based on several conditions: - * - The event must be triggered by a left mouse click (button 0). - * - The target of the event must be within the document. - * - The target should not be within any element designated as a "top layer" (e.g., modals, overlays). - * - The event target must not be contained within the specified reference element. - * - * For shadow DOM support, it uses event.composedPath() to accurately determine the event's target, ensuring - * compatibility with events that originate from within shadow roots. - * - * @param {Event} event - The event to check. - * @param {RefObject} ref - A React ref object pointing to the component's root element. - * @returns {boolean} True if the event is valid and should trigger interact outside logic, false otherwise. - */ function isValidEvent(event, ref) { if (event.button > 0) { return false; } + 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)) { + return false; + } + // If the target is within a top layer element (e.g. toasts), ignore. + if (event.target.closest('[data-react-aria-top-layer]')) { + return false; + } + } - // Use composedPath to accurately get the event path, including shadow DOMs - const path = event.composedPath(); - const target = path[0]; - - // Determine the root node for the event target and the component's ref - const refRoot = getRootNode(ref.current); - const targetRoot = getRootNode(target); - - // Check for top layer elements (e.g., modals or overlays) and ignore events within them - if (target.closest('[data-react-aria-top-layer]')) { + if (!ref.current) { return false; } - // The event is considered outside if: - // 1. The target and ref are not in the same root (document or shadow root). - // 2. The ref does not contain the target. - return refRoot !== targetRoot || !ref.current.contains(target); + // When the event source is inside a Shadow DOM, event.target 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. + return !event.composedPath().includes(ref.current); } - - From f9b94a36e6da5f3cc799d96a8d1bfc7b6a1c7b3d Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 15 Mar 2024 05:41:08 +0200 Subject: [PATCH 018/102] Update `useOverlay` to use composedPath. --- packages/@react-aria/overlays/src/useOverlay.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index 2e4dc24cac4..e75463ee79f 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -92,7 +92,8 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject): Ov }; let onInteractOutsideStart = (e: PointerEvent) => { - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { + const actualTarget = e.composedPath()[0] as Element; + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(actualTarget)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -101,7 +102,8 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject): Ov }; let onInteractOutside = (e: PointerEvent) => { - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { + const actualTarget = e.composedPath()[0] as Element; + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(actualTarget)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -145,8 +147,9 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject): Ov }); let onPointerDownUnderlay = e => { + const actualTarget = e.composedPath()[0]; // fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846 - if (e.target === e.currentTarget) { + if (actualTarget === e.currentTarget) { e.preventDefault(); } }; From 758575c6662b9f1d617ab3bf79c31bc18a937bc7 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 15 Mar 2024 20:59:26 +0200 Subject: [PATCH 019/102] Tests refactor. --- packages/@react-aria/focus/test/FocusScope.test.js | 9 +-------- packages/@react-aria/focus/test/focusSafely.test.js | 8 +------- packages/@react-aria/interactions/test/useFocus.test.js | 8 +------- packages/@react-aria/interactions/test/usePress.test.js | 8 +------- packages/dev/test-utils/src/shadowDOM.js | 6 ++++++ 5 files changed, 10 insertions(+), 29 deletions(-) create mode 100644 packages/dev/test-utils/src/shadowDOM.js diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 27eaa558339..d649b60bd21 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -11,6 +11,7 @@ */ import {act, fireEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils'; +import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; import {defaultTheme} from '@adobe/react-spectrum'; import {DialogContainer} from '@react-spectrum/dialog'; import {FocusScope, useFocusManager} from '../'; @@ -1618,14 +1619,6 @@ describe('FocusScope', function () { }); describe('FocusScope with Shadow DOM', function () { - - function createShadowRoot() { - const div = document.createElement('div'); - document.body.appendChild(div); - const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; - } - it('should contain focus within the shadow DOM scope', async function () { const {shadowRoot} = createShadowRoot(); const FocusableComponent = () => ( diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 97fbee6561f..0907fdd36bf 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -12,6 +12,7 @@ import {act, render} from '@react-spectrum/test-utils'; +import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '../../utils/index'; @@ -63,13 +64,6 @@ describe('focusSafely', () => { }); describe('focusSafely with Shadow DOM', function () { - function createShadowRoot() { - const div = document.createElement('div'); - document.body.appendChild(div); - const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; - } - const focusWithoutScrollingSpy = jest.spyOn(ReactAriaUtils, 'focusWithoutScrolling').mockImplementation(() => {}); it("should not focus on the element if it's no longer connected within shadow DOM", async function () { diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index d81d400488c..da885ff738a 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -11,6 +11,7 @@ */ import {act, render, waitFor} from '@react-spectrum/test-utils'; +import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; import React from 'react'; import ReactDOM from 'react-dom'; import {useFocus} from '../'; @@ -156,13 +157,6 @@ describe('useFocus', function () { }); describe('useFocus with Shadow DOM', function () { - function createShadowRoot() { - const div = document.createElement('div'); - document.body.appendChild(div); - const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; - } - it('handles focus events within shadow DOM', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const events = []; diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 1adab68dde1..8fa3935ae97 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -12,6 +12,7 @@ import {act, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils'; import {ActionButton} from '@react-spectrum/button'; +import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; @@ -3308,13 +3309,6 @@ describe('usePress', function () { let events = []; let addEvent = (e) => events.push(e); - function createShadowRoot() { - const div = document.createElement('div'); - document.body.appendChild(div); - const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; - } - function setupShadowDOMTest(extraProps = {}) { const {shadowRoot} = createShadowRoot(); cleanupShadowRoot = shadowRoot; diff --git a/packages/dev/test-utils/src/shadowDOM.js b/packages/dev/test-utils/src/shadowDOM.js new file mode 100644 index 00000000000..aad9b296dfe --- /dev/null +++ b/packages/dev/test-utils/src/shadowDOM.js @@ -0,0 +1,6 @@ +export function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; +} From 25ef5c5a42fea69c8a726ddb92f41129729d5db5 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Sun, 17 Mar 2024 23:22:04 +0200 Subject: [PATCH 020/102] Revert `useOverlay` changes as it works correctly without these changes. --- packages/@react-aria/overlays/src/useOverlay.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/overlays/src/useOverlay.ts b/packages/@react-aria/overlays/src/useOverlay.ts index e75463ee79f..2e4dc24cac4 100644 --- a/packages/@react-aria/overlays/src/useOverlay.ts +++ b/packages/@react-aria/overlays/src/useOverlay.ts @@ -92,8 +92,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject): Ov }; let onInteractOutsideStart = (e: PointerEvent) => { - const actualTarget = e.composedPath()[0] as Element; - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(actualTarget)) { + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -102,8 +101,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject): Ov }; let onInteractOutside = (e: PointerEvent) => { - const actualTarget = e.composedPath()[0] as Element; - if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(actualTarget)) { + if (!shouldCloseOnInteractOutside || shouldCloseOnInteractOutside(e.target as Element)) { if (visibleOverlays[visibleOverlays.length - 1] === ref) { e.stopPropagation(); e.preventDefault(); @@ -147,9 +145,8 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject): Ov }); let onPointerDownUnderlay = e => { - const actualTarget = e.composedPath()[0]; // fixes a firefox issue that starts text selection https://bugzilla.mozilla.org/show_bug.cgi?id=1675846 - if (actualTarget === e.currentTarget) { + if (e.target === e.currentTarget) { e.preventDefault(); } }; From 0d4f70efc985fcc085b0d4296e62713d8232496a Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 18 Mar 2024 04:52:30 +0200 Subject: [PATCH 021/102] Fix types. --- packages/@react-aria/focus/src/FocusScope.tsx | 4 ++-- packages/@react-aria/interactions/src/useFocusVisible.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 78b44b96ce2..ccf0e540409 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -730,10 +730,10 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ -export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) { +export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; // Adjusted to directly handle root being a Document or ShadowRoot - let doc = root instanceof Document || root instanceof ShadowRoot ? root : getRootNode(root); + let doc = root instanceof ShadowRoot ? root : getRootNode(root); let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc; let walker = effectiveDocument.createTreeWalker( root || doc, diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index f7d54477121..f8a7204d7a7 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -210,15 +210,17 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { * @returns A function to remove the event listeners and cleanup the state. */ export function addWindowFocusTracking(element?: HTMLElement | null): () => void { - const documentObject = getRootNode(element); + const rootNode = getRootNode(element); let loadListener; - if (documentObject.readyState !== 'loading') { + + // Shadow root doesn't have a readyState, so we can assume it's ready in case of there is a shadow root. + if (rootNode instanceof ShadowRoot || (rootNode.readyState !== 'loading')) { setupGlobalFocusEvents(element); } else { loadListener = () => { setupGlobalFocusEvents(element); }; - documentObject.addEventListener('DOMContentLoaded', loadListener); + rootNode.addEventListener('DOMContentLoaded', loadListener); } return () => tearDownWindowFocusTracking(element, loadListener); From 1d8c4391790afea6633b7d124d685989d22bd5a9 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 18 Mar 2024 05:09:31 +0200 Subject: [PATCH 022/102] Fix types. --- packages/@react-aria/utils/src/domHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index e705a4f327f..09b60b77de7 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -28,7 +28,7 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo return document; } - return rootNode; + return rootNode as ShadowRoot; }; /** From e5dbda1764108cf644e9c8e3b90c7d5fb4f19157 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 18 Mar 2024 05:34:05 +0200 Subject: [PATCH 023/102] lint. --- packages/@react-aria/focus/src/FocusScope.tsx | 6 +-- .../interactions/src/useFocusVisible.ts | 37 ++++++++++--------- packages/@react-aria/utils/src/domHelpers.ts | 4 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index ccf0e540409..c4488edde45 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -368,7 +368,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) cancelAnimationFrame(raf.current); } raf.current = requestAnimationFrame(() => { - if (getDeepActiveElement() && shouldContainFocus(scopeRef) && !isElementInChildScope(getDeepActiveElement(), scopeRef)) { + if (getDeepActiveElement() && (shouldContainFocus(scopeRef) && !isElementInChildScope(getDeepActiveElement(), scopeRef))) { activeScope = scopeRef; if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; @@ -641,12 +641,12 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, }; if (!contain) { - ownerDocument.addEventListener('keydown', onKeyDown, true); + ownerDocument.addEventListener('keydown', onKeyDown as EventListener, true); } return () => { if (!contain) { - ownerDocument.removeEventListener('keydown', onKeyDown, true); + ownerDocument.removeEventListener('keydown', onKeyDown as EventListener, true); } }; }, [scopeRef, restoreFocus, contain]); diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index f8a7204d7a7..f528d833b84 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -135,9 +135,9 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); }; - documentObject.addEventListener('keydown', handleKeyboardEvent, true); - documentObject.addEventListener('keyup', handleKeyboardEvent, true); - documentObject.addEventListener('click', handleClickEvent, true); + documentObject.addEventListener('keydown', handleKeyboardEvent as EventListener, true); + documentObject.addEventListener('keyup', handleKeyboardEvent as EventListener, true); + documentObject.addEventListener('click', handleClickEvent as EventListener, true); // Register focus events on the window so they are sure to happen // before React's event listeners (registered on the document). @@ -145,13 +145,13 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { windowObject.addEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject.addEventListener('pointerdown', handlePointerEvent, true); - documentObject.addEventListener('pointermove', handlePointerEvent, true); - documentObject.addEventListener('pointerup', handlePointerEvent, true); + documentObject.addEventListener('pointerdown', handlePointerEvent as EventListener, true); + documentObject.addEventListener('pointermove', handlePointerEvent as EventListener, true); + documentObject.addEventListener('pointerup', handlePointerEvent as EventListener, true); } else { - documentObject.addEventListener('mousedown', handlePointerEvent, true); - documentObject.addEventListener('mousemove', handlePointerEvent, true); - documentObject.addEventListener('mouseup', handlePointerEvent, true); + documentObject.addEventListener('mousedown', handlePointerEvent as EventListener, true); + documentObject.addEventListener('mousemove', handlePointerEvent as EventListener, true); + documentObject.addEventListener('mouseup', handlePointerEvent as EventListener, true); } // Add unmount handler @@ -173,20 +173,21 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { } windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus; - documentObject.removeEventListener('keydown', handleKeyboardEvent, true); - documentObject.removeEventListener('keyup', handleKeyboardEvent, true); - documentObject.removeEventListener('click', handleClickEvent, true); + documentObject.removeEventListener('keydown', handleKeyboardEvent as EventListener, true); + documentObject.removeEventListener('keyup', handleKeyboardEvent as EventListener, true); + documentObject.removeEventListener('click', handleClickEvent as EventListener, true); + windowObject.removeEventListener('focus', handleFocusEvent, true); windowObject.removeEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject.removeEventListener('pointerdown', handlePointerEvent, true); - documentObject.removeEventListener('pointermove', handlePointerEvent, true); - documentObject.removeEventListener('pointerup', handlePointerEvent, true); + documentObject.removeEventListener('pointerdown', handlePointerEvent as EventListener, true); + documentObject.removeEventListener('pointermove', handlePointerEvent as EventListener, true); + documentObject.removeEventListener('pointerup', handlePointerEvent as EventListener, true); } else { - documentObject.removeEventListener('mousedown', handlePointerEvent, true); - documentObject.removeEventListener('mousemove', handlePointerEvent, true); - documentObject.removeEventListener('mouseup', handlePointerEvent, true); + documentObject.removeEventListener('mousedown', handlePointerEvent as EventListener, true); + documentObject.removeEventListener('mousemove', handlePointerEvent as EventListener, true); + documentObject.removeEventListener('mouseup', handlePointerEvent as EventListener, true); } hasSetupGlobalListeners.delete(windowObject); diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 09b60b77de7..015097d8354 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -42,8 +42,8 @@ export const getRootBody = (root: Document | ShadowRoot): HTMLElement | ShadowRo export const getDeepActiveElement = () => { let activeElement = document.activeElement; - while (activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; + while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement?.shadowRoot?.activeElement; } return activeElement; }; From ae7b76ef5bf7237b33aab4d6b54426f0cbd6b0ca Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 18 Mar 2024 05:43:54 +0200 Subject: [PATCH 024/102] lint. --- packages/@react-aria/focus/src/FocusScope.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index c4488edde45..d813f26fc16 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -368,7 +368,8 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) cancelAnimationFrame(raf.current); } raf.current = requestAnimationFrame(() => { - if (getDeepActiveElement() && (shouldContainFocus(scopeRef) && !isElementInChildScope(getDeepActiveElement(), scopeRef))) { + const activeElement = getDeepActiveElement() || ownerDocument.activeElement; + if (activeElement && (shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef))) { activeScope = scopeRef; if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; From aea5d8dd5f97d400b49d67be268b14e6559ff683 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 19 Mar 2024 19:26:25 +0200 Subject: [PATCH 025/102] Fix failing tests. --- packages/@react-aria/focus/src/FocusScope.tsx | 50 +++++++------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index d813f26fc16..7a1546d7941 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,8 +12,7 @@ import {FocusableElement} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getDeepActiveElement, getRootBody} from '@react-aria/utils/src/domHelpers'; -import {getRootNode, useLayoutEffect} from '@react-aria/utils'; +import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react'; @@ -55,7 +54,7 @@ export interface FocusManager { focusPrevious(opts?: FocusManagerOptions): FocusableElement | null, /** Moves focus to the first focusable or tabbable element in the focus scope. */ focusFirst(opts?: FocusManagerOptions): FocusableElement | null, - /** Moves focus to the last focusable or tabbable element in the focus scope. */ + /** Moves focus to the last focusable or tabbable element in the focus scope. */ focusLast(opts?: FocusManagerOptions): FocusableElement | null } @@ -134,6 +133,7 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { + // eslint-disable-next-line no-undef const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; let scope: TreeNode | null = null; @@ -319,7 +319,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) return; } - let focusedElement = getDeepActiveElement(); + let focusedElement = ownerDocument.activeElement; let scope = scopeRef.current; if (!scope || !isElementInScope(focusedElement, scope)) { return; @@ -368,8 +368,8 @@ function useFocusContainment(scopeRef: RefObject, contain?: boolean) cancelAnimationFrame(raf.current); } raf.current = requestAnimationFrame(() => { - const activeElement = getDeepActiveElement() || ownerDocument.activeElement; - if (activeElement && (shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef))) { + // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe + if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { activeScope = scopeRef; if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; @@ -490,7 +490,8 @@ function useAutoFocus(scopeRef: RefObject, autoFocus?: boolean) { useEffect(() => { if (autoFocusRef.current) { activeScope = scopeRef; - if (!isElementInScope(getDeepActiveElement(), activeScope.current) && scopeRef.current) { + const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) { focusFirstInScope(scopeRef.current); } } @@ -543,7 +544,7 @@ function shouldRestoreFocus(scopeRef: ScopeRef) { function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, contain?: boolean) { // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts. // eslint-disable-next-line no-restricted-globals - const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getDeepActiveElement() as FocusableElement : null); + const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null); // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus // restoring-non-containing scopes should only care if they become active so they can perform the restore @@ -558,7 +559,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && - isElementInScope(getDeepActiveElement(), scopeRef.current) + isElementInScope(ownerDocument.activeElement, scopeRef.current) ) { activeScope = scopeRef; } @@ -570,7 +571,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, ownerDocument.removeEventListener('focusin', onFocus, false); scope?.forEach(element => element.removeEventListener('focusin', onFocus, false)); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeRef, contain]); useLayoutEffect(() => { @@ -589,8 +590,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, return; } - let focusedElement = getDeepActiveElement() as FocusableElement; - + let focusedElement = ownerDocument.activeElement as FocusableElement; if (!isElementInScope(focusedElement, scopeRef.current)) { return; } @@ -629,9 +629,9 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, if (nextElement) { focusElement(nextElement, true); } else { - // If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope) - // then move focus to the body. - // Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger) + // If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope) + // then move focus to the body. + // Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger) if (!isElementInAnyScope(nodeToRestore)) { focusedElement.blur(); } else { @@ -673,33 +673,21 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, } let nodeToRestore = treeNode.nodeToRestore; - const activeElement = getDeepActiveElement(); - // if we already lost focus to the body and this was the active scope, then we should attempt to restore if ( restoreFocus && nodeToRestore && ( // eslint-disable-next-line react-hooks/exhaustive-deps - isElementInScope(activeElement, scopeRef.current) - || (activeElement === rootBody && shouldRestoreFocus(scopeRef)) + isElementInScope(ownerDocument.activeElement, scopeRef.current) + || (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef)) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it let clonedTree = focusScopeTree.clone(); requestAnimationFrame(() => { - const activeElement = getDeepActiveElement(); - let focusOutsideScope; - - if (rootBody instanceof ShadowRoot) { - // In Shadow DOM, check if the active element is outside the shadow root. This includes the scenario where focus has moved to the shadow host. - focusOutsideScope = !rootBody.contains(activeElement) || activeElement === rootBody.host; - } else { - // In Light DOM, check if focus has moved to the body, indicating it's outside your component. - focusOutsideScope = activeElement === rootBody; - } - - if (focusOutsideScope) { + // 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) { // look up the tree starting with our scope to find a nodeToRestore still in the DOM let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { From f2ca9b794617760cba7930f95fb053a578ac2b76 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 19 Mar 2024 23:23:39 +0200 Subject: [PATCH 026/102] Fix failing tests. --- packages/@react-aria/focus/src/FocusScope.tsx | 2 +- packages/@react-aria/utils/src/domHelpers.ts | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 7a1546d7941..b0f554de1b4 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -687,7 +687,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, 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 (ownerDocument.activeElement === rootBody) { // 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/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 015097d8354..935ac909c83 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -32,14 +32,23 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo }; /** - * `getRootBody`: Retrieves a suitable "body" element for an element, accommodating both - * Shadow DOM and traditional DOM contexts. Returns `document.body` for elements in the - * light DOM or the root of the Shadow DOM for elements within a shadow DOM. + * Retrieves a reference to the most appropriate "body" element for a given DOM context, + * accommodating both traditional DOM and Shadow DOM environments. When used with a Shadow DOM, + * it returns the body of the document to which the shadow root belongs, as shadow root is a document fragment, + * meaning that it doesn't have a body. When used with a regular document, it simply returns the document's body. + * + * @param {Document | ShadowRoot} root - The root document or shadow root from which to find the body. + * @returns {HTMLElement} - The "body" element of the document, or the document's body associated with the shadow root. */ -export const getRootBody = (root: Document | ShadowRoot): HTMLElement | ShadowRoot => { - return root instanceof Document ? root.body : root; +export const getRootBody = (root: Document | ShadowRoot): HTMLElement => { + if (root instanceof ShadowRoot) { + return root.ownerDocument?.body; + } else { + return root.body; + } }; + export const getDeepActiveElement = () => { let activeElement = document.activeElement; while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { From 4d6ff95e78184cc527257ff40a7b401798eb151e Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 00:22:51 +0200 Subject: [PATCH 027/102] Fix failing tests. --- packages/@react-aria/interactions/src/useFocus.ts | 6 +++--- packages/@react-aria/interactions/test/useFocus.test.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 5aee99c3211..961d9a5d781 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,7 +17,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared'; import {FocusEvent, useCallback} from 'react'; -import {getRootNode} from '@react-aria/utils'; +import {getDeepActiveElement, getRootNode} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { @@ -64,8 +64,8 @@ export function useFocus(pro // focus handler already moved focus somewhere else. const ownerDocument = getRootNode(e.target); - - if (e.target === e.currentTarget && ownerDocument.activeElement === e.target) { + const activeElement = ownerDocument instanceof ShadowRoot ? getDeepActiveElement() : ownerDocument.activeElement; + if (e.target === e.currentTarget && activeElement === e.target) { if (onFocusProp) { onFocusProp(e); } diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index da885ff738a..552391ab51f 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -157,7 +157,7 @@ describe('useFocus', function () { }); describe('useFocus with Shadow DOM', function () { - it('handles focus events within shadow DOM', function () { + it('handles focus events', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const events = []; const ExampleComponent = () => ( @@ -187,7 +187,7 @@ describe('useFocus', function () { document.body.removeChild(shadowHost); }); - it('does not handle focus events if disabled within shadow DOM', function () { + it('does not handle focus events if disabled', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const events = []; const ExampleComponent = () => ( @@ -211,7 +211,7 @@ describe('useFocus', function () { document.body.removeChild(shadowHost); }); - it('events do not bubble when stopPropagation is called within shadow DOM', function () { + it('events do not bubble when stopPropagation is called', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const onWrapperFocus = jest.fn(); const onWrapperBlur = jest.fn(); @@ -242,7 +242,7 @@ describe('useFocus', function () { document.body.removeChild(shadowHost); }); - it('events bubble by default within shadow DOM', function () { + it('events bubble by default', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const onWrapperFocus = jest.fn(); const onWrapperBlur = jest.fn(); From 4c53797a36a05ac81cb889b0d84ac73546670a28 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 00:31:56 +0200 Subject: [PATCH 028/102] Test CI --- .../interactions/test/useFocus.test.js | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 552391ab51f..1b10639a11c 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -242,35 +242,35 @@ describe('useFocus', function () { document.body.removeChild(shadowHost); }); - it('events bubble by default', function () { - const {shadowRoot, shadowHost} = createShadowRoot(); - const onWrapperFocus = jest.fn(); - const onWrapperBlur = jest.fn(); - const onInnerFocus = jest.fn(); - const onInnerBlur = jest.fn(); - - const WrapperComponent = () => ( -
- -
- ); - - act(() => ReactDOM.render(, shadowRoot)); - const el = shadowRoot.querySelector('[data-testid="example"]'); - - act(() => {el.focus();}); - act(() => {el.blur();}); - - expect(onInnerFocus).toHaveBeenCalledTimes(1); - expect(onInnerBlur).toHaveBeenCalledTimes(1); - expect(onWrapperFocus).toHaveBeenCalledTimes(1); - expect(onWrapperBlur).toHaveBeenCalledTimes(1); - - // Cleanup - ReactDOM.unmountComponentAtNode(shadowRoot); - document.body.removeChild(shadowHost); - }); + // it('events bubble by default', function () { + // const {shadowRoot, shadowHost} = createShadowRoot(); + // const onWrapperFocus = jest.fn(); + // const onWrapperBlur = jest.fn(); + // const onInnerFocus = jest.fn(); + // const onInnerBlur = jest.fn(); + // + // const WrapperComponent = () => ( + //
+ // + //
+ // ); + // + // act(() => ReactDOM.render(, shadowRoot)); + // const el = shadowRoot.querySelector('[data-testid="example"]'); + // + // act(() => {el.focus();}); + // act(() => {el.blur();}); + // + // expect(onInnerFocus).toHaveBeenCalledTimes(1); + // expect(onInnerBlur).toHaveBeenCalledTimes(1); + // expect(onWrapperFocus).toHaveBeenCalledTimes(1); + // expect(onWrapperBlur).toHaveBeenCalledTimes(1); + // + // // Cleanup + // ReactDOM.unmountComponentAtNode(shadowRoot); + // document.body.removeChild(shadowHost); + // }); }); }); From 85b7146d24f45f9d9471551e8ef8d3ff16223c6c Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 00:45:29 +0200 Subject: [PATCH 029/102] Test CI --- .../interactions/test/useFocus.test.js | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 1b10639a11c..6e80a6579df 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -167,7 +167,7 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - act(() => ReactDOM.render(, shadowRoot)); + ReactDOM.render(, shadowRoot); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -198,7 +198,7 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - act(() => ReactDOM.render(, shadowRoot)); + ReactDOM.render(, shadowRoot); const el = shadowRoot.querySelector('[data-testid="example"]'); act(() => {el.focus();}); @@ -226,7 +226,7 @@ describe('useFocus', function () {
); - act(() => ReactDOM.render(, shadowRoot)); + ReactDOM.render(, shadowRoot); const el = shadowRoot.querySelector('[data-testid="example"]'); act(() => {el.focus();}); @@ -242,35 +242,35 @@ describe('useFocus', function () { document.body.removeChild(shadowHost); }); - // it('events bubble by default', function () { - // const {shadowRoot, shadowHost} = createShadowRoot(); - // const onWrapperFocus = jest.fn(); - // const onWrapperBlur = jest.fn(); - // const onInnerFocus = jest.fn(); - // const onInnerBlur = jest.fn(); - // - // const WrapperComponent = () => ( - //
- // - //
- // ); - // - // act(() => ReactDOM.render(, shadowRoot)); - // const el = shadowRoot.querySelector('[data-testid="example"]'); - // - // act(() => {el.focus();}); - // act(() => {el.blur();}); - // - // expect(onInnerFocus).toHaveBeenCalledTimes(1); - // expect(onInnerBlur).toHaveBeenCalledTimes(1); - // expect(onWrapperFocus).toHaveBeenCalledTimes(1); - // expect(onWrapperBlur).toHaveBeenCalledTimes(1); - // - // // Cleanup - // ReactDOM.unmountComponentAtNode(shadowRoot); - // document.body.removeChild(shadowHost); - // }); + it('events bubble by default', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const onWrapperFocus = jest.fn(); + const onWrapperBlur = jest.fn(); + const onInnerFocus = jest.fn(); + const onInnerBlur = jest.fn(); + + const WrapperComponent = () => ( +
+ +
+ ); + + ReactDOM.render(, shadowRoot); + const el = shadowRoot.querySelector('[data-testid="example"]'); + + act(() => {el.focus();}); + act(() => {el.blur();}); + + expect(onInnerFocus).toHaveBeenCalledTimes(1); + expect(onInnerBlur).toHaveBeenCalledTimes(1); + expect(onWrapperFocus).toHaveBeenCalledTimes(1); + expect(onWrapperBlur).toHaveBeenCalledTimes(1); + + // Cleanup + ReactDOM.unmountComponentAtNode(shadowRoot); + document.body.removeChild(shadowHost); + }); }); }); From a001f2a3a0f364cc59097ee55f5fc3ae58491331 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 00:54:05 +0200 Subject: [PATCH 030/102] Fix shadow DOM tests --- packages/@react-aria/focus/test/FocusScope.test.js | 2 +- packages/@react-aria/focus/test/focusSafely.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index d649b60bd21..2e6916ff871 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1750,7 +1750,7 @@ describe('FocusScope', function () { ); - await act(() => ReactDOM.render(, shadowRoot)); + ReactDOM.render(, shadowRoot); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 0907fdd36bf..6447ca5671a 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -71,7 +71,7 @@ describe('focusSafely', () => { setInteractionModality('virtual'); const Example = () => ; - act(() => ReactDOM.render(, shadowRoot)); + ReactDOM.render(, shadowRoot); const button = shadowRoot.querySelector('button'); @@ -94,7 +94,7 @@ describe('focusSafely', () => { setInteractionModality('virtual'); const Example = () => ; - act(() => ReactDOM.render(, shadowRoot)); + ReactDOM.render(, shadowRoot); const button = shadowRoot.querySelector('button'); From d00f0b2afaa277f900504936582162c0c31a9bda Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 04:36:51 +0200 Subject: [PATCH 031/102] Fix shadow DOM tests. --- .../@react-aria/focus/test/FocusScope.test.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 2e6916ff871..376ae88765f 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1619,6 +1619,20 @@ describe('FocusScope', function () { }); describe('FocusScope with Shadow DOM', function () { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + // make sure to clean up any raf's that may be running to restore focus on unmount + act(() => {jest.runAllTimers();}); + }); + it('should contain focus within the shadow DOM scope', async function () { const {shadowRoot} = createShadowRoot(); const FocusableComponent = () => ( @@ -1736,11 +1750,6 @@ describe('FocusScope', function () { */ it('should autofocus and lock tab navigation inside shadow DOM', async function () { const {shadowRoot, shadowHost} = createShadowRoot(); - let user; - - act(() => { - user = userEvent.setup({delay: null, pointerMap}); - }); const FocusableComponent = () => ( From e875828b8a6b311f4ed9d101350c5c73441e7a40 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 04:48:25 +0200 Subject: [PATCH 032/102] Fix CI? --- .../@react-aria/focus/test/FocusScope.test.js | 268 +++++++++--------- 1 file changed, 134 insertions(+), 134 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 376ae88765f..f1f49cf1edd 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1617,173 +1617,173 @@ describe('FocusScope', function () { expect(document.activeElement.textContent).toBe('Open Menu'); }); }); +}); - describe('FocusScope with Shadow DOM', function () { - let user; - - beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); - }); - - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - // make sure to clean up any raf's that may be running to restore focus on unmount - act(() => {jest.runAllTimers();}); - }); +describe('FocusScope with Shadow DOM', function () { + let user; - it('should contain focus within the shadow DOM scope', async function () { - const {shadowRoot} = createShadowRoot(); - const FocusableComponent = () => ( - - - - - - ); + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); - // Use ReactDOM to render directly into the shadow root - ReactDOM.render(, shadowRoot); + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + // make sure to clean up any raf's that may be running to restore focus on unmount + act(() => {jest.runAllTimers();}); + }); - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - const input2 = shadowRoot.querySelector('[data-testid="input2"]'); - const input3 = shadowRoot.querySelector('[data-testid="input3"]'); + it('should contain focus within the shadow DOM scope', async function () { + const {shadowRoot} = createShadowRoot(); + const FocusableComponent = () => ( + + + + + + ); - // Simulate focusing the first input - act(() => {input1.focus();}); - expect(document.activeElement).toBe(shadowRoot.host); - expect(shadowRoot.activeElement).toBe(input1); + // Use ReactDOM to render directly into the shadow root + ReactDOM.render(, shadowRoot); - // Simulate tabbing through inputs - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const input3 = shadowRoot.querySelector('[data-testid="input3"]'); - await user.tab(); - expect(shadowRoot.activeElement).toBe(input3); + // Simulate focusing the first input + act(() => {input1.focus();}); + expect(document.activeElement).toBe(shadowRoot.host); + expect(shadowRoot.activeElement).toBe(input1); - // Simulate tabbing back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); + // Simulate tabbing through inputs + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); - // Cleanup - document.body.removeChild(shadowRoot.host); - ReactDOM.unmountComponentAtNode(shadowRoot); - }); + await user.tab(); + expect(shadowRoot.activeElement).toBe(input3); - 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'}); + // Simulate tabbing back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); - // Use ReactDOM to render into the nested shadow DOM - ReactDOM.render(( - - - - - ), childShadowRoot); + // Cleanup + document.body.removeChild(shadowRoot.host); + ReactDOM.unmountComponentAtNode(shadowRoot); + }); - const input1 = childShadowRoot.querySelector('[data-testid=input1]'); - const input2 = childShadowRoot.querySelector('[data-testid=input2]'); + 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'}); + + // Use ReactDOM to render into the nested shadow DOM + ReactDOM.render(( + + + + + ), childShadowRoot); - act(() => {input1.focus();}); - expect(childShadowRoot.activeElement).toBe(input1); + const input1 = childShadowRoot.querySelector('[data-testid=input1]'); + const input2 = childShadowRoot.querySelector('[data-testid=input2]'); - await user.tab(); - expect(childShadowRoot.activeElement).toBe(input2); + act(() => {input1.focus();}); + expect(childShadowRoot.activeElement).toBe(input1); - // Cleanup - document.body.removeChild(parentShadowRoot.host); - }); + await user.tab(); + expect(childShadowRoot.activeElement).toBe(input2); - /** - * document.body - * ├── div#outside-shadow (contains ) - * │ ├── input (focus can be restored here) - * │ └── shadow-root - * │ └── 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 () => { - const App = () => ( - <> - - - -
- - ); - - const {getByTestId} = render(); - const shadowHost = document.getElementById('shadow-host'); - const shadowRoot = shadowHost.attachShadow({mode: 'open'}); + // Cleanup + document.body.removeChild(parentShadowRoot.host); + }); - const FocusableComponent = () => ( + /** + * document.body + * ├── div#outside-shadow (contains ) + * │ ├── input (focus can be restored here) + * │ └── shadow-root + * │ └── 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 () => { + const App = () => ( + <> - - - + - ); +
+ + ); - ReactDOM.render(, shadowRoot); + const {getByTestId} = render(); + const shadowHost = document.getElementById('shadow-host'); + const shadowRoot = shadowHost.attachShadow({mode: 'open'}); - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - act(() => { input1.focus(); }); - expect(shadowRoot.activeElement).toBe(input1); + const FocusableComponent = () => ( + + + + + + ); - const externalInput = getByTestId('outside'); - act(() => { externalInput.focus(); }); - expect(document.activeElement).toBe(externalInput); + ReactDOM.render(, shadowRoot); - ReactDOM.unmountComponentAtNode(shadowRoot); - act(() => { jest.runAllTimers(); }); + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + act(() => { input1.focus(); }); + expect(shadowRoot.activeElement).toBe(input1); - expect(document.activeElement).toBe(externalInput); - }); + const externalInput = getByTestId('outside'); + act(() => { externalInput.focus(); }); + expect(document.activeElement).toBe(externalInput); - /** - * Test case: https://github.com/adobe/react-spectrum/issues/1472 - * sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 - */ - it('should autofocus and lock tab navigation inside shadow DOM', async function () { - const {shadowRoot, shadowHost} = createShadowRoot(); + ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { jest.runAllTimers(); }); - const FocusableComponent = () => ( - - - - - - ); + expect(document.activeElement).toBe(externalInput); + }); - ReactDOM.render(, shadowRoot); + /** + * Test case: https://github.com/adobe/react-spectrum/issues/1472 + * sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 + */ + it('should autofocus and lock tab navigation inside shadow DOM', async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + + const FocusableComponent = () => ( + + + + + + ); - const input1 = shadowRoot.querySelector('[data-testid="input1"]'); - const input2 = shadowRoot.querySelector('[data-testid="input2"]'); - const button = shadowRoot.querySelector('[data-testid="button"]'); + ReactDOM.render(, shadowRoot); - // Simulate focusing the first input and tab through the elements - await act(() => input1.focus()); - expect(shadowRoot.activeElement).toBe(input1); + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const button = shadowRoot.querySelector('[data-testid="button"]'); - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); + // Simulate focusing the first input and tab through the elements + await act(() => input1.focus()); + expect(shadowRoot.activeElement).toBe(input1); - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(button); + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); - // Simulate tab again to check if focus loops back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(button); - // Cleanup - document.body.removeChild(shadowHost); - }); + // Simulate tab again to check if focus loops back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + document.body.removeChild(shadowHost); }); }); From b483e61d8994e0c5b747d2fe67265998936887a5 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 04:56:56 +0200 Subject: [PATCH 033/102] Fix CI? --- packages/@react-aria/focus/test/FocusScope.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index f1f49cf1edd..73140c499f6 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1697,6 +1697,8 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(parentShadowRoot.host); + ReactDOM.unmountComponentAtNode(childShadowRoot); + ReactDOM.unmountComponentAtNode(parentShadowRoot); }); /** @@ -1784,6 +1786,7 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowHost); + ReactDOM.unmountComponentAtNode(shadowRoot); }); }); From b8ef0550c55fe35a76f003c03c92355fcb5af208 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 05:03:58 +0200 Subject: [PATCH 034/102] Fix CI? --- .../@react-aria/focus/test/FocusScope.test.js | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 73140c499f6..04f1483175a 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1751,43 +1751,43 @@ describe('FocusScope with Shadow DOM', function () { * Test case: https://github.com/adobe/react-spectrum/issues/1472 * sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 */ - it('should autofocus and lock tab navigation inside shadow DOM', async function () { - const {shadowRoot, shadowHost} = createShadowRoot(); - - const FocusableComponent = () => ( - - - - - - ); - - ReactDOM.render(, shadowRoot); - - 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 - await act(() => input1.focus()); - expect(shadowRoot.activeElement).toBe(input1); - - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(input2); - - // Hit TAB key - await user.tab(); - expect(shadowRoot.activeElement).toBe(button); - - // Simulate tab again to check if focus loops back to the first input - await user.tab(); - expect(shadowRoot.activeElement).toBe(input1); - - // Cleanup - document.body.removeChild(shadowHost); - ReactDOM.unmountComponentAtNode(shadowRoot); - }); + // it('should autofocus and lock tab navigation inside shadow DOM', async function () { + // const {shadowRoot, shadowHost} = createShadowRoot(); + // + // const FocusableComponent = () => ( + // + // + // + // + // + // ); + // + // ReactDOM.render(, shadowRoot); + // + // 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 + // await act(() => input1.focus()); + // expect(shadowRoot.activeElement).toBe(input1); + // + // // Hit TAB key + // await user.tab(); + // expect(shadowRoot.activeElement).toBe(input2); + // + // // Hit TAB key + // await user.tab(); + // expect(shadowRoot.activeElement).toBe(button); + // + // // Simulate tab again to check if focus loops back to the first input + // await user.tab(); + // expect(shadowRoot.activeElement).toBe(input1); + // + // // Cleanup + // document.body.removeChild(shadowHost); + // ReactDOM.unmountComponentAtNode(shadowRoot); + // }); }); describe('Unmounting cleanup', () => { From cc2a7c4d033592525f290e8af644245c710041ba Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 20 Mar 2024 23:14:54 +0200 Subject: [PATCH 035/102] Re-add commented test. --- .../@react-aria/focus/test/FocusScope.test.js | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 04f1483175a..d9a5ec5f7ef 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1751,43 +1751,43 @@ describe('FocusScope with Shadow DOM', function () { * Test case: https://github.com/adobe/react-spectrum/issues/1472 * sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 */ - // it('should autofocus and lock tab navigation inside shadow DOM', async function () { - // const {shadowRoot, shadowHost} = createShadowRoot(); - // - // const FocusableComponent = () => ( - // - // - // - // - // - // ); - // - // ReactDOM.render(, shadowRoot); - // - // 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 - // await act(() => input1.focus()); - // expect(shadowRoot.activeElement).toBe(input1); - // - // // Hit TAB key - // await user.tab(); - // expect(shadowRoot.activeElement).toBe(input2); - // - // // Hit TAB key - // await user.tab(); - // expect(shadowRoot.activeElement).toBe(button); - // - // // Simulate tab again to check if focus loops back to the first input - // await user.tab(); - // expect(shadowRoot.activeElement).toBe(input1); - // - // // Cleanup - // document.body.removeChild(shadowHost); - // ReactDOM.unmountComponentAtNode(shadowRoot); - // }); + it('should autofocus and lock tab navigation inside shadow DOM', async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + + const FocusableComponent = () => ( + + + + + + ); + + ReactDOM.render(, shadowRoot); + + 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();}); + expect(shadowRoot.activeElement).toBe(input1); + + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(input2); + + // Hit TAB key + await user.tab(); + expect(shadowRoot.activeElement).toBe(button); + + // Simulate tab again to check if focus loops back to the first input + await user.tab(); + expect(shadowRoot.activeElement).toBe(input1); + + // Cleanup + document.body.removeChild(shadowHost); + ReactDOM.unmountComponentAtNode(shadowRoot); + }); }); describe('Unmounting cleanup', () => { From 492d83f7acd201a5019499853932b4b0322024f6 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 28 Mar 2024 23:52:44 +0200 Subject: [PATCH 036/102] Update `getRootNode` to handle iframes as well, and everything that `getOwnerDocument` used to handle. --- packages/@react-aria/utils/src/domHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 935ac909c83..d1e7abc717d 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -25,7 +25,7 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo // In such cases, rootNode could either be the actual Document or a ShadowRoot, // but for disconnected nodes, we want to ensure consistency by returning the Document. if (rootNode instanceof Document || !(el.isConnected)) { - return document; + return el?.ownerDocument ?? document; } return rootNode as ShadowRoot; From dc2231d6e8a06801d89ff857fb150010f2248138 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 7 May 2024 05:17:13 +0300 Subject: [PATCH 037/102] Fix tests. --- packages/dev/test-utils/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dev/test-utils/src/index.ts b/packages/dev/test-utils/src/index.ts index 17ae72a7621..5e6609fdf7c 100644 --- a/packages/dev/test-utils/src/index.ts +++ b/packages/dev/test-utils/src/index.ts @@ -17,4 +17,5 @@ export * from './renderOverride'; export * from './StrictModeWrapper'; export * from './mockImplementation'; export * from './events'; +export * from './shadowDOM'; export * from '@react-spectrum/test-utils'; From bd536b6a248db0ce1c441448ec46cff3bbf9e708 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 7 May 2024 05:26:46 +0300 Subject: [PATCH 038/102] Fix tests? --- packages/@react-aria/focus/test/focusSafely.test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 880280894bf..3efc429ccd5 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -12,13 +12,19 @@ import {act, render} from '@react-spectrum/test-utils-internal'; -import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '@react-aria/utils'; import ReactDOM from 'react-dom'; import {setInteractionModality} from '@react-aria/interactions'; +function createShadowRoot() { + const div = document.createElement('div'); + document.body.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot}; +} + jest.mock('@react-aria/utils', () => { let original = jest.requireActual('@react-aria/utils'); return { From f1aa64ae7de996e7cc5237b881be320610ccfacb Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 7 May 2024 05:35:38 +0300 Subject: [PATCH 039/102] Fix tests? --- packages/@react-aria/focus/test/FocusScope.test.js | 3 +-- packages/@react-aria/interactions/test/useFocus.test.js | 3 +-- packages/@react-aria/interactions/test/usePress.test.js | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index ea699315f95..16c18830776 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -10,8 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; -import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; +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 {FocusScope, useFocusManager} from '../'; diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index ad3998f99d9..9a925a1e0e8 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -10,8 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; -import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; +import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; import React from 'react'; import ReactDOM from 'react-dom'; import {useFocus} from '../'; diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index bb3a2b5c655..3814f6075f8 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -10,9 +10,8 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; -import {createShadowRoot} from '@react-spectrum/test-utils/src/shadowDOM'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; From 1a3e0689528c10fa8189ec1a5ea87afb4fb8a162 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 22 May 2024 01:27:30 +0300 Subject: [PATCH 040/102] Fix tests. --- .../@react-aria/focus/test/FocusScope.test.js | 53 +++++++++++++------ .../focus/test/focusSafely.test.js | 26 ++++++--- .../interactions/test/useFocus.test.js | 49 +++++++++++++---- .../interactions/test/usePress.test.js | 15 ++++-- 4 files changed, 108 insertions(+), 35 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 16c18830776..f5807283262 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -11,6 +11,7 @@ */ import {act, createShadowRoot, fireEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {createRoot} from 'react-dom/client'; import {defaultTheme} from '@adobe/react-spectrum'; import {DialogContainer} from '@react-spectrum/dialog'; import {FocusScope, useFocusManager} from '../'; @@ -1640,8 +1641,12 @@ describe('FocusScope with Shadow DOM', function () { ); - // Use ReactDOM to render directly into the shadow root - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); @@ -1665,7 +1670,9 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowRoot.host); - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); }); it('should manage focus within nested shadow DOMs', async function () { @@ -1674,13 +1681,15 @@ describe('FocusScope with Shadow DOM', function () { parentShadowRoot.appendChild(nestedDiv); const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); - // Use ReactDOM to render into the nested shadow DOM - ReactDOM.render(( - + // Using createRoot to mount the component + const root = createRoot(childShadowRoot); + + act(() => { + root.render( - - ), childShadowRoot); + ); + }); const input1 = childShadowRoot.querySelector('[data-testid=input1]'); const input2 = childShadowRoot.querySelector('[data-testid=input2]'); @@ -1693,8 +1702,9 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(parentShadowRoot.host); - ReactDOM.unmountComponentAtNode(childShadowRoot); - ReactDOM.unmountComponentAtNode(parentShadowRoot); + act(() => { + root.unmount(); + }); }); /** @@ -1727,7 +1737,12 @@ describe('FocusScope with Shadow DOM', function () { ); - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); act(() => { input1.focus(); }); @@ -1737,8 +1752,10 @@ describe('FocusScope with Shadow DOM', function () { act(() => { externalInput.focus(); }); expect(document.activeElement).toBe(externalInput); - ReactDOM.unmountComponentAtNode(shadowRoot); - act(() => { jest.runAllTimers(); }); + act(() => { + jest.runAllTimers(); + root.unmount(); + }); expect(document.activeElement).toBe(externalInput); }); @@ -1758,7 +1775,11 @@ describe('FocusScope with Shadow DOM', function () { ); - ReactDOM.render(, shadowRoot); + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); @@ -1782,7 +1803,9 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowHost); - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); }); }); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 3efc429ccd5..2e8575a7bb1 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -12,10 +12,10 @@ import {act, render} from '@react-spectrum/test-utils-internal'; +import {createRoot} from 'react-dom/client'; import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '@react-aria/utils'; -import ReactDOM from 'react-dom'; import {setInteractionModality} from '@react-aria/interactions'; function createShadowRoot() { @@ -83,12 +83,19 @@ describe('focusSafely', () => { setInteractionModality('virtual'); const Example = () => ; - ReactDOM.render(, shadowRoot); + + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); const button = shadowRoot.querySelector('button'); requestAnimationFrame(() => { - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); document.body.removeChild(shadowHost); }); expect(button).toBeTruthy(); @@ -106,7 +113,12 @@ describe('focusSafely', () => { setInteractionModality('virtual'); const Example = () => ; - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); const button = shadowRoot.querySelector('button'); @@ -119,8 +131,10 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); - // Cleanup - ReactDOM.unmountComponentAtNode(shadowRoot); + // Cleanup using the new API + act(() => { + root.unmount(); + }); shadowRoot.host.remove(); }); }); diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 9a925a1e0e8..a0e618e0f8f 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -11,8 +11,8 @@ */ import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {createRoot} from 'react-dom/client'; import React from 'react'; -import ReactDOM from 'react-dom'; import {useFocus} from '../'; function Example(props) { @@ -166,7 +166,12 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -182,7 +187,9 @@ describe('useFocus', function () { ]); // Cleanup - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); document.body.removeChild(shadowHost); }); @@ -197,7 +204,13 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); + const el = shadowRoot.querySelector('[data-testid="example"]'); act(() => {el.focus();}); @@ -206,7 +219,9 @@ describe('useFocus', function () { expect(events).toEqual([]); // Cleanup - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); document.body.removeChild(shadowHost); }); @@ -225,7 +240,13 @@ describe('useFocus', function () {
); - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); + const el = shadowRoot.querySelector('[data-testid="example"]'); act(() => {el.focus();}); @@ -237,7 +258,9 @@ describe('useFocus', function () { expect(onWrapperBlur).not.toHaveBeenCalled(); // Cleanup - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); document.body.removeChild(shadowHost); }); @@ -256,7 +279,13 @@ describe('useFocus', function () {
); - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + const root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); + const el = shadowRoot.querySelector('[data-testid="example"]'); act(() => {el.focus();}); @@ -268,7 +297,9 @@ describe('useFocus', function () { expect(onWrapperBlur).toHaveBeenCalledTimes(1); // Cleanup - ReactDOM.unmountComponentAtNode(shadowRoot); + act(() => { + root.unmount(); + }); document.body.removeChild(shadowHost); }); }); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 3814f6075f8..f0f5353daf5 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -12,11 +12,12 @@ import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; +import {createRoot} from 'react-dom/client'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; -import ReactDOM, {render as ReactDOMRender} from 'react-dom'; +import {render as ReactDOMRender} from 'react-dom'; import {theme} from '@react-spectrum/theme-default'; import {usePress} from '../'; @@ -3304,7 +3305,7 @@ describe('usePress', function () { describe('FocusScope with Shadow DOM', function () { installPointerEvent(); - let cleanupShadowRoot; + let cleanupShadowRoot, root; let events = []; let addEvent = (e) => events.push(e); @@ -3323,8 +3324,12 @@ describe('usePress', function () { {...extraProps} /> ); - // Use ReactDOM to render directly into the shadow root - ReactDOM.render(, shadowRoot); + // Using createRoot to mount the component + root = createRoot(shadowRoot); + + act(() => { + root.render(); + }); return shadowRoot; } @@ -3336,7 +3341,7 @@ describe('usePress', function () { afterEach(() => { act(() => {jest.runAllTimers();}); document.body.removeChild(cleanupShadowRoot.host); - ReactDOM.unmountComponentAtNode(cleanupShadowRoot); + act(() => {root.unmount();}); }); it('should fire press events based on pointer events', function () { From f1fe3644450a67e3f02f7567ad57f6191bf98bc1 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 22 May 2024 02:20:01 +0300 Subject: [PATCH 041/102] Fix tests.? --- .../@react-aria/focus/test/FocusScope.test.js | 35 ++++++++----------- .../focus/test/focusSafely.test.js | 21 ++++------- .../interactions/test/useFocus.test.js | 34 +++++++----------- .../interactions/test/usePress.test.js | 9 ++--- packages/dev/test-utils/src/reactCompat.js | 21 +++++++++++ 5 files changed, 58 insertions(+), 62 deletions(-) create mode 100644 packages/dev/test-utils/src/reactCompat.js diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index f5807283262..6ade390049f 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -11,7 +11,6 @@ */ import {act, createShadowRoot, fireEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; -import {createRoot} from 'react-dom/client'; import {defaultTheme} from '@adobe/react-spectrum'; import {DialogContainer} from '@react-spectrum/dialog'; import {FocusScope, useFocusManager} from '../'; @@ -19,6 +18,7 @@ import {focusScopeTree} from '../src/FocusScope'; import {Provider} from '@react-spectrum/provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; +import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; import userEvent from '@testing-library/user-event'; @@ -1641,11 +1641,9 @@ describe('FocusScope with Shadow DOM', function () {
); - // Using createRoot to mount the component - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1671,7 +1669,7 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowRoot.host); act(() => { - root.unmount(); + unmount(root); }); }); @@ -1681,14 +1679,12 @@ describe('FocusScope with Shadow DOM', function () { parentShadowRoot.appendChild(nestedDiv); const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); - // Using createRoot to mount the component - const root = createRoot(childShadowRoot); - + let root; act(() => { - root.render( + root = reactDomRenderer( - ); + , childShadowRoot); }); const input1 = childShadowRoot.querySelector('[data-testid=input1]'); @@ -1703,7 +1699,7 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(parentShadowRoot.host); act(() => { - root.unmount(); + unmount(root); }); }); @@ -1737,11 +1733,9 @@ describe('FocusScope with Shadow DOM', function () {
); - // Using createRoot to mount the component - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1754,7 +1748,7 @@ describe('FocusScope with Shadow DOM', function () { act(() => { jest.runAllTimers(); - root.unmount(); + unmount(root); }); expect(document.activeElement).toBe(externalInput); @@ -1775,10 +1769,9 @@ describe('FocusScope with Shadow DOM', function () { ); - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1804,7 +1797,7 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowHost); act(() => { - root.unmount(); + unmount(root); }); }); }); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 2e8575a7bb1..1b04acc346c 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -12,10 +12,10 @@ import {act, render} from '@react-spectrum/test-utils-internal'; -import {createRoot} from 'react-dom/client'; import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '@react-aria/utils'; +import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; import {setInteractionModality} from '@react-aria/interactions'; function createShadowRoot() { @@ -84,18 +84,15 @@ describe('focusSafely', () => { const Example = () => ; - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const button = shadowRoot.querySelector('button'); requestAnimationFrame(() => { - act(() => { - root.unmount(); - }); + act(() => {unmount(root);}); document.body.removeChild(shadowHost); }); expect(button).toBeTruthy(); @@ -113,11 +110,10 @@ describe('focusSafely', () => { setInteractionModality('virtual'); const Example = () => ; - // Using createRoot to mount the component - const root = createRoot(shadowRoot); + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const button = shadowRoot.querySelector('button'); @@ -131,10 +127,7 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); - // Cleanup using the new API - act(() => { - root.unmount(); - }); + act(() => {unmount(root);}); shadowRoot.host.remove(); }); }); diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index a0e618e0f8f..60dc440a71d 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -11,8 +11,8 @@ */ import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; -import {createRoot} from 'react-dom/client'; import React from 'react'; +import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; import {useFocus} from '../'; function Example(props) { @@ -166,11 +166,9 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - // Using createRoot to mount the component - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -188,7 +186,7 @@ describe('useFocus', function () { // Cleanup act(() => { - root.unmount(); + unmount(root); }); document.body.removeChild(shadowHost); }); @@ -204,11 +202,9 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - // Using createRoot to mount the component - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -220,7 +216,7 @@ describe('useFocus', function () { // Cleanup act(() => { - root.unmount(); + unmount(root); }); document.body.removeChild(shadowHost); }); @@ -240,11 +236,9 @@ describe('useFocus', function () {
); - // Using createRoot to mount the component - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -259,7 +253,7 @@ describe('useFocus', function () { // Cleanup act(() => { - root.unmount(); + unmount(root); }); document.body.removeChild(shadowHost); }); @@ -279,11 +273,9 @@ describe('useFocus', function () {
); - // Using createRoot to mount the component - const root = createRoot(shadowRoot); - + let root; act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -298,7 +290,7 @@ describe('useFocus', function () { // Cleanup act(() => { - root.unmount(); + unmount(root); }); document.body.removeChild(shadowHost); }); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index f0f5353daf5..dc3da007648 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -12,12 +12,12 @@ import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; -import {createRoot} from 'react-dom/client'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {render as ReactDOMRender} from 'react-dom'; +import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; import {theme} from '@react-spectrum/theme-default'; import {usePress} from '../'; @@ -3324,11 +3324,8 @@ describe('usePress', function () { {...extraProps} /> ); - // Using createRoot to mount the component - root = createRoot(shadowRoot); - act(() => { - root.render(); + root = reactDomRenderer(, shadowRoot); }); return shadowRoot; @@ -3341,7 +3338,7 @@ describe('usePress', function () { afterEach(() => { act(() => {jest.runAllTimers();}); document.body.removeChild(cleanupShadowRoot.host); - act(() => {root.unmount();}); + act(() => {unmount(root);}); }); it('should fire press events based on pointer events', function () { diff --git a/packages/dev/test-utils/src/reactCompat.js b/packages/dev/test-utils/src/reactCompat.js new file mode 100644 index 00000000000..8bfc0f5654c --- /dev/null +++ b/packages/dev/test-utils/src/reactCompat.js @@ -0,0 +1,21 @@ +import React from 'react'; + +let reactDomRenderer, unmount; + +if (React.version.startsWith('16') || React.version.startsWith('17')) { + const ReactDOM = require('react-dom'); + reactDomRenderer = (element, container) => ReactDOM.render(element, container); + unmount = (container) => ReactDOM.unmountComponentAtNode(container); +} else { // For React 18 + const ReactDOMClient = require('react-dom/client'); + reactDomRenderer = (element, container) => { + const root = ReactDOMClient.createRoot(container); + root.render(element); + return root; // Returning root is necessary to manage the lifecycle + }; + unmount = (root) => { + root.unmount(); + }; +} + +export {reactDomRenderer, unmount}; From 24550d63298fcf91c39c9b2252bd9acc60b0bc58 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 22 May 2024 02:38:42 +0300 Subject: [PATCH 042/102] Fix tests.? --- .../@react-aria/focus/test/FocusScope.test.js | 25 ++++++++++++++++--- .../focus/test/focusSafely.test.js | 16 ++++++++++-- .../interactions/test/useFocus.test.js | 24 +++++++++++++++--- .../interactions/test/usePress.test.js | 8 +++++- packages/dev/test-utils/src/reactCompat.js | 11 +++++--- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 6ade390049f..75eeb70bd26 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1669,7 +1669,11 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowRoot.host); act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); }); @@ -1699,7 +1703,11 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(parentShadowRoot.host); act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(childShadowRoot); + } else { // For React 18 + unmount(root); + } }); }); @@ -1748,7 +1756,12 @@ describe('FocusScope with Shadow DOM', function () { act(() => { jest.runAllTimers(); - unmount(root); + + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); expect(document.activeElement).toBe(externalInput); @@ -1797,7 +1810,11 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowHost); act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); }); }); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 1b04acc346c..8548faf45d1 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -92,7 +92,13 @@ describe('focusSafely', () => { const button = shadowRoot.querySelector('button'); requestAnimationFrame(() => { - act(() => {unmount(root);}); + act(() => { + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } + }); document.body.removeChild(shadowHost); }); expect(button).toBeTruthy(); @@ -127,7 +133,13 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); - act(() => {unmount(root);}); + act(() => { + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } + }); shadowRoot.host.remove(); }); }); diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 60dc440a71d..277d8d5b4d2 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -186,7 +186,11 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); document.body.removeChild(shadowHost); }); @@ -216,7 +220,11 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); document.body.removeChild(shadowHost); }); @@ -253,7 +261,11 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); document.body.removeChild(shadowHost); }); @@ -290,7 +302,11 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(root); + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(shadowRoot); + } else { // For React 18 + unmount(root); + } }); document.body.removeChild(shadowHost); }); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index dc3da007648..baf99829953 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -3338,7 +3338,13 @@ describe('usePress', function () { afterEach(() => { act(() => {jest.runAllTimers();}); document.body.removeChild(cleanupShadowRoot.host); - act(() => {unmount(root);}); + act(() => { + if (root instanceof HTMLElement) { // For React 16 and 17 + unmount(cleanupShadowRoot); + } else { // For React 18 + unmount(root); + } + }); }); it('should fire press events based on pointer events', function () { diff --git a/packages/dev/test-utils/src/reactCompat.js b/packages/dev/test-utils/src/reactCompat.js index 8bfc0f5654c..b98f234fdfc 100644 --- a/packages/dev/test-utils/src/reactCompat.js +++ b/packages/dev/test-utils/src/reactCompat.js @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; let reactDomRenderer, unmount; @@ -11,10 +12,14 @@ if (React.version.startsWith('16') || React.version.startsWith('17')) { reactDomRenderer = (element, container) => { const root = ReactDOMClient.createRoot(container); root.render(element); - return root; // Returning root is necessary to manage the lifecycle + return root; // Returning root for lifecycle management }; - unmount = (root) => { - root.unmount(); + unmount = (rootOrContainer) => { + if (rootOrContainer instanceof HTMLElement) { + ReactDOM.unmountComponentAtNode(rootOrContainer); + } else { + rootOrContainer.unmount(); // Assuming rootOrContainer is a root object + } }; } From b9a11ee5166c0d5c9314ce1553d5f3d4d3b3a9e3 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 22 May 2024 03:23:22 +0300 Subject: [PATCH 043/102] Fix tests.? --- .../@react-aria/focus/test/FocusScope.test.js | 40 +++++-------------- .../focus/test/focusSafely.test.js | 20 +++------- .../interactions/test/useFocus.test.js | 36 ++++------------- .../interactions/test/usePress.test.js | 13 +++--- packages/dev/test-utils/src/reactCompat.js | 7 +--- 5 files changed, 29 insertions(+), 87 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 75eeb70bd26..eb05fbcca02 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1632,7 +1632,7 @@ describe('FocusScope with Shadow DOM', function () { }); it('should contain focus within the shadow DOM scope', async function () { - const {shadowRoot} = createShadowRoot(); + const {shadowRoot, shadowHost} = createShadowRoot(); const FocusableComponent = () => ( @@ -1641,9 +1641,8 @@ describe('FocusScope with Shadow DOM', function () { ); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1669,23 +1668,18 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowRoot.host); act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); }); it('should manage focus within nested shadow DOMs', async function () { - const {shadowRoot: parentShadowRoot} = createShadowRoot(); + const {shadowRoot: parentShadowRoot, shadowHost} = createShadowRoot(); const nestedDiv = document.createElement('div'); parentShadowRoot.appendChild(nestedDiv); const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); - let root; act(() => { - root = reactDomRenderer( + reactDomRenderer( , childShadowRoot); @@ -1703,11 +1697,7 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(parentShadowRoot.host); act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(childShadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); }); @@ -1741,9 +1731,8 @@ describe('FocusScope with Shadow DOM', function () { ); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1757,11 +1746,7 @@ describe('FocusScope with Shadow DOM', function () { act(() => { jest.runAllTimers(); - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); expect(document.activeElement).toBe(externalInput); @@ -1782,9 +1767,8 @@ describe('FocusScope with Shadow DOM', function () { ); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1810,11 +1794,7 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowHost); act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); }); }); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 8548faf45d1..f1f3011e628 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -84,20 +84,15 @@ describe('focusSafely', () => { const Example = () => ; - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const button = shadowRoot.querySelector('button'); requestAnimationFrame(() => { act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); document.body.removeChild(shadowHost); }); @@ -112,14 +107,13 @@ describe('focusSafely', () => { }); it("should focus on the element if it's connected within shadow DOM", async function () { - const {shadowRoot} = createShadowRoot(); + const {shadowRoot, shadowHost} = createShadowRoot(); setInteractionModality('virtual'); const Example = () => ; - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const button = shadowRoot.querySelector('button'); @@ -134,11 +128,7 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); shadowRoot.host.remove(); }); diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 277d8d5b4d2..cd4018b2c74 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -166,9 +166,8 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -186,11 +185,7 @@ describe('useFocus', function () { // Cleanup act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); document.body.removeChild(shadowHost); }); @@ -206,9 +201,8 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -220,11 +214,7 @@ describe('useFocus', function () { // Cleanup act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); document.body.removeChild(shadowHost); }); @@ -244,9 +234,8 @@ describe('useFocus', function () {
); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -261,11 +250,7 @@ describe('useFocus', function () { // Cleanup act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); document.body.removeChild(shadowHost); }); @@ -285,9 +270,8 @@ describe('useFocus', function () {
); - let root; act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -302,11 +286,7 @@ describe('useFocus', function () { // Cleanup act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(shadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(shadowHost); }); document.body.removeChild(shadowHost); }); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index baf99829953..cf7b6f5d160 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -3305,13 +3305,14 @@ describe('usePress', function () { describe('FocusScope with Shadow DOM', function () { installPointerEvent(); - let cleanupShadowRoot, root; + let cleanupShadowRoot, cleanupShadowHost; let events = []; let addEvent = (e) => events.push(e); function setupShadowDOMTest(extraProps = {}) { - const {shadowRoot} = createShadowRoot(); + const {shadowRoot, shadowHost} = createShadowRoot(); cleanupShadowRoot = shadowRoot; + cleanupShadowHost = shadowHost; events = []; addEvent = (e) => events.push(e); const ExampleComponent = () => ( @@ -3325,7 +3326,7 @@ describe('usePress', function () { ); act(() => { - root = reactDomRenderer(, shadowRoot); + reactDomRenderer(, shadowRoot); }); return shadowRoot; @@ -3339,11 +3340,7 @@ describe('usePress', function () { act(() => {jest.runAllTimers();}); document.body.removeChild(cleanupShadowRoot.host); act(() => { - if (root instanceof HTMLElement) { // For React 16 and 17 - unmount(cleanupShadowRoot); - } else { // For React 18 - unmount(root); - } + unmount(cleanupShadowHost); }); }); diff --git a/packages/dev/test-utils/src/reactCompat.js b/packages/dev/test-utils/src/reactCompat.js index b98f234fdfc..add8bf64f68 100644 --- a/packages/dev/test-utils/src/reactCompat.js +++ b/packages/dev/test-utils/src/reactCompat.js @@ -1,5 +1,4 @@ import React from 'react'; -import ReactDOM from 'react-dom'; let reactDomRenderer, unmount; @@ -15,11 +14,7 @@ if (React.version.startsWith('16') || React.version.startsWith('17')) { return root; // Returning root for lifecycle management }; unmount = (rootOrContainer) => { - if (rootOrContainer instanceof HTMLElement) { - ReactDOM.unmountComponentAtNode(rootOrContainer); - } else { - rootOrContainer.unmount(); // Assuming rootOrContainer is a root object - } + rootOrContainer.unmount(); }; } From 97d73f290ce1ee6a476ad4ee4c8bea9c1cd705c4 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 22 May 2024 03:47:43 +0300 Subject: [PATCH 044/102] Fix tests.? --- .../@react-aria/focus/test/FocusScope.test.js | 32 ++++++++++++++----- .../focus/test/focusSafely.test.js | 10 +++--- .../interactions/test/useFocus.test.js | 32 ++++++++++++++----- .../interactions/test/usePress.test.js | 9 ++++-- packages/dev/test-utils/src/reactCompat.js | 4 +-- 5 files changed, 62 insertions(+), 25 deletions(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index eb05fbcca02..aadc8e25268 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1641,8 +1641,9 @@ describe('FocusScope with Shadow DOM', function () {
); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1668,7 +1669,10 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowRoot.host); act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); }); @@ -1678,8 +1682,9 @@ describe('FocusScope with Shadow DOM', function () { parentShadowRoot.appendChild(nestedDiv); const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); + let root; act(() => { - reactDomRenderer( + root = reactDomRenderer( , childShadowRoot); @@ -1697,7 +1702,10 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(parentShadowRoot.host); act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); }); @@ -1731,8 +1739,9 @@ describe('FocusScope with Shadow DOM', function () { ); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1746,7 +1755,10 @@ describe('FocusScope with Shadow DOM', function () { act(() => { jest.runAllTimers(); - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); expect(document.activeElement).toBe(externalInput); @@ -1767,8 +1779,9 @@ describe('FocusScope with Shadow DOM', function () { ); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); @@ -1794,7 +1807,10 @@ describe('FocusScope with Shadow DOM', function () { // Cleanup document.body.removeChild(shadowHost); act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); }); }); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index f1f3011e628..53763b058b5 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -84,15 +84,16 @@ describe('focusSafely', () => { const Example = () => ; + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const button = shadowRoot.querySelector('button'); requestAnimationFrame(() => { act(() => { - unmount(shadowHost); + unmount({container: shadowHost, root}); }); document.body.removeChild(shadowHost); }); @@ -112,8 +113,9 @@ describe('focusSafely', () => { const Example = () => ; + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const button = shadowRoot.querySelector('button'); @@ -128,7 +130,7 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); act(() => { - unmount(shadowHost); + unmount({container: shadowHost, root}); }); shadowRoot.host.remove(); }); diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index cd4018b2c74..316a26afa5f 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -166,8 +166,9 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -185,7 +186,10 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); document.body.removeChild(shadowHost); }); @@ -201,8 +205,9 @@ describe('useFocus', function () { onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> ); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -214,7 +219,10 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); document.body.removeChild(shadowHost); }); @@ -234,8 +242,9 @@ describe('useFocus', function () { ); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -250,7 +259,10 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); document.body.removeChild(shadowHost); }); @@ -270,8 +282,9 @@ describe('useFocus', function () { ); + let root; act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); const el = shadowRoot.querySelector('[data-testid="example"]'); @@ -286,7 +299,10 @@ describe('useFocus', function () { // Cleanup act(() => { - unmount(shadowHost); + unmount({ + container: shadowHost, + root + }); }); document.body.removeChild(shadowHost); }); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index cf7b6f5d160..f38673d2c80 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -3305,7 +3305,7 @@ describe('usePress', function () { describe('FocusScope with Shadow DOM', function () { installPointerEvent(); - let cleanupShadowRoot, cleanupShadowHost; + let cleanupShadowRoot, cleanupShadowHost, root; let events = []; let addEvent = (e) => events.push(e); @@ -3326,7 +3326,7 @@ describe('usePress', function () { ); act(() => { - reactDomRenderer(, shadowRoot); + root = reactDomRenderer(, shadowRoot); }); return shadowRoot; @@ -3340,7 +3340,10 @@ describe('usePress', function () { act(() => {jest.runAllTimers();}); document.body.removeChild(cleanupShadowRoot.host); act(() => { - unmount(cleanupShadowHost); + unmount({ + container: cleanupShadowHost, + root + }); }); }); diff --git a/packages/dev/test-utils/src/reactCompat.js b/packages/dev/test-utils/src/reactCompat.js index add8bf64f68..828934268e2 100644 --- a/packages/dev/test-utils/src/reactCompat.js +++ b/packages/dev/test-utils/src/reactCompat.js @@ -5,7 +5,7 @@ let reactDomRenderer, unmount; if (React.version.startsWith('16') || React.version.startsWith('17')) { const ReactDOM = require('react-dom'); reactDomRenderer = (element, container) => ReactDOM.render(element, container); - unmount = (container) => ReactDOM.unmountComponentAtNode(container); + unmount = (rootOrContainer) => ReactDOM.unmountComponentAtNode(rootOrContainer.container); } else { // For React 18 const ReactDOMClient = require('react-dom/client'); reactDomRenderer = (element, container) => { @@ -14,7 +14,7 @@ if (React.version.startsWith('16') || React.version.startsWith('17')) { return root; // Returning root for lifecycle management }; unmount = (rootOrContainer) => { - rootOrContainer.unmount(); + rootOrContainer.root.unmount(); }; } From 1f6ecf39994fab748bbf58306b08ebcf1585f45e Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad <36645103+MahmoudElsayad@users.noreply.github.com> Date: Tue, 28 May 2024 12:42:22 +0300 Subject: [PATCH 045/102] Apply suggestions from code review Co-authored-by: Robert Snow --- packages/@react-aria/utils/src/domHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index d1e7abc717d..c7a3be8ee7b 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -52,7 +52,7 @@ export const getRootBody = (root: Document | ShadowRoot): HTMLElement => { export const getDeepActiveElement = () => { let activeElement = document.activeElement; while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { - activeElement = activeElement?.shadowRoot?.activeElement; + activeElement = activeElement.shadowRoot.activeElement; } return activeElement; }; From 13278d934008a2905ffb2d895b5205abc7d4c794 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad <36645103+MahmoudElsayad@users.noreply.github.com> Date: Tue, 28 May 2024 12:43:02 +0300 Subject: [PATCH 046/102] Update packages/@react-aria/interactions/test/usePress.test.js Co-authored-by: Robert Snow --- packages/@react-aria/interactions/test/usePress.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index f38673d2c80..9029fa9506b 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -3303,7 +3303,7 @@ describe('usePress', function () { }); }); - describe('FocusScope with Shadow DOM', function () { + describe('usePress with Shadow DOM', function () { installPointerEvent(); let cleanupShadowRoot, cleanupShadowHost, root; let events = []; From 93a5071440cc5e8e0dec313e287a816e525f1a8a Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 3 Jun 2024 05:00:07 +0300 Subject: [PATCH 047/102] - Update tests to use `createShadowRoot` util. - Update `getRootNode` to return null for disconnected nodes. - Update `usePress.test.js` shadow DOM test. - Test getting rid of reactDomRenderer. --- .../focus/test/focusSafely.test.js | 9 +-- .../interactions/test/useFocus.test.js | 79 +++++-------------- .../test/useInteractOutside.test.js | 15 ++-- .../interactions/test/usePress.test.js | 34 ++++---- packages/@react-aria/utils/src/domHelpers.ts | 15 ++-- 5 files changed, 53 insertions(+), 99 deletions(-) diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 53763b058b5..533625df762 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -11,20 +11,13 @@ */ -import {act, render} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, render} from '@react-spectrum/test-utils-internal'; import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '@react-aria/utils'; import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; import {setInteractionModality} from '@react-aria/interactions'; -function createShadowRoot() { - const div = document.createElement('div'); - document.body.appendChild(div); - const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; -} - jest.mock('@react-aria/utils', () => { let original = jest.requireActual('@react-aria/utils'); return { diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 316a26afa5f..95f035dd064 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -12,7 +12,7 @@ import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; import React from 'react'; -import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; +import ReactDOM from 'react-dom'; import {useFocus} from '../'; function Example(props) { @@ -159,24 +159,19 @@ describe('useFocus', function () { it('handles focus events', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const events = []; - const ExampleComponent = () => ( + const ExampleComponent = () => ReactDOM.createPortal( events.push({type: 'focus', target: e.target})} onBlur={(e) => events.push({type: 'blur', target: e.target})} - onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> - ); + onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} />, shadowRoot); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const el = shadowRoot.querySelector('[data-testid="example"]'); act(() => {el.focus();}); act(() => {el.blur();}); - // Assertions similar to your original test, but ensure you're checking for events triggered within the shadow DOM expect(events).toEqual([ {type: 'focus', target: el}, {type: 'focuschange', isFocused: true}, @@ -185,45 +180,31 @@ describe('useFocus', function () { ]); // Cleanup - act(() => { - unmount({ - container: shadowHost, - root - }); - }); + unmount(); document.body.removeChild(shadowHost); }); it('does not handle focus events if disabled', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const events = []; - const ExampleComponent = () => ( + const ExampleComponent = () => ReactDOM.createPortal( events.push({type: 'focus', target: e.target})} onBlur={(e) => events.push({type: 'blur', target: e.target})} - onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} /> + onFocusChange={isFocused => events.push({type: 'focuschange', isFocused})} />, shadowRoot ); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const el = shadowRoot.querySelector('[data-testid="example"]'); - act(() => {el.focus();}); act(() => {el.blur();}); expect(events).toEqual([]); // Cleanup - act(() => { - unmount({ - container: shadowHost, - root - }); - }); + unmount(); document.body.removeChild(shadowHost); }); @@ -234,21 +215,15 @@ describe('useFocus', function () { const onInnerFocus = jest.fn(e => e.stopPropagation()); const onInnerBlur = jest.fn(e => e.stopPropagation()); - const WrapperComponent = () => ( + const WrapperComponent = () => ReactDOM.createPortal(
- -
+ + , shadowRoot ); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const el = shadowRoot.querySelector('[data-testid="example"]'); - act(() => {el.focus();}); act(() => {el.blur();}); @@ -258,12 +233,7 @@ describe('useFocus', function () { expect(onWrapperBlur).not.toHaveBeenCalled(); // Cleanup - act(() => { - unmount({ - container: shadowHost, - root - }); - }); + unmount(); document.body.removeChild(shadowHost); }); @@ -274,21 +244,15 @@ describe('useFocus', function () { const onInnerFocus = jest.fn(); const onInnerBlur = jest.fn(); - const WrapperComponent = () => ( + const WrapperComponent = () => ReactDOM.createPortal(
- -
+ + , shadowRoot ); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const el = shadowRoot.querySelector('[data-testid="example"]'); - act(() => {el.focus();}); act(() => {el.blur();}); @@ -298,12 +262,7 @@ describe('useFocus', function () { expect(onWrapperBlur).toHaveBeenCalledTimes(1); // Cleanup - act(() => { - unmount({ - container: shadowHost, - root - }); - }); + unmount(); document.body.removeChild(shadowHost); }); }); diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index 4e4b94570a1..c5df0c01b6b 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {act} from 'react-dom/test-utils'; import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import React, {useEffect, useRef} from 'react'; import ReactDOM, {render as ReactDOMRender} from 'react-dom'; @@ -572,19 +573,19 @@ describe('useInteractOutside shadow DOM extended tests', function () { } it('correctly identifies interaction with dynamically added external elements', function () { + jest.useFakeTimers(); const onInteractOutside = jest.fn(); const {cleanup} = createShadowRootAndRender( ); - // Wait for dynamic element to be added - setTimeout(() => { - const dynamicEl = document.getElementById('dynamic-outside'); - fireEvent.mouseDown(dynamicEl); - fireEvent.mouseUp(dynamicEl); + act(() => {jest.runAllTimers();}); - expect(onInteractOutside).toHaveBeenCalledTimes(1); - }, 0); + const dynamicEl = document.getElementById('dynamic-outside'); + fireEvent.mouseDown(dynamicEl); + fireEvent.mouseUp(dynamicEl); + + expect(onInteractOutside).toHaveBeenCalledTimes(1); cleanup(); }); diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 9029fa9506b..fb219ffb1ab 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -13,6 +13,7 @@ import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; +import {getDeepActiveElement} from '@react-aria/utils'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -3305,24 +3306,25 @@ describe('usePress', function () { describe('usePress with Shadow DOM', function () { installPointerEvent(); - let cleanupShadowRoot, cleanupShadowHost, root; + let cleanupShadowHost, root; let events = []; let addEvent = (e) => events.push(e); - function setupShadowDOMTest(extraProps = {}) { + function setupShadowDOMTest(extraProps = {}, isDraggable = false) { const {shadowRoot, shadowHost} = createShadowRoot(); - cleanupShadowRoot = shadowRoot; cleanupShadowHost = shadowHost; events = []; addEvent = (e) => events.push(e); const ExampleComponent = () => ( - addEvent({type: 'presschange', pressed})} - onPress={addEvent} - onPressUp={addEvent} - {...extraProps} /> +
+ addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent} + {...extraProps} /> +
); act(() => { @@ -3338,7 +3340,6 @@ describe('usePress', function () { afterEach(() => { act(() => {jest.runAllTimers();}); - document.body.removeChild(cleanupShadowRoot.host); act(() => { unmount({ container: cleanupShadowHost, @@ -3703,12 +3704,15 @@ describe('usePress', function () { }); it('should not focus the target on click if preventFocusOnPress is true', function () { - const shadowRoot = setupShadowDOMTest(); - + const shadowRoot = setupShadowDOMTest({preventFocusOnPress: true}); const el = shadowRoot.getElementById('testElement'); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); - expect(document.activeElement).not.toBe(el); + const deepActiveElement = getDeepActiveElement(); + + expect(deepActiveElement).not.toBe(el); + expect(deepActiveElement).not.toBe(shadowRoot); }); it('should focus the target on click by default', function () { @@ -3732,7 +3736,7 @@ describe('usePress', function () { }); it('should still prevent default when pressing on a non draggable + pressable item in a draggable container', function () { - const shadowRoot = setupShadowDOMTest(); + const shadowRoot = setupShadowDOMTest({}, true); const el = shadowRoot.getElementById('testElement'); let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index c7a3be8ee7b..0f9709ddbfe 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -13,22 +13,19 @@ export const getOwnerWindow = ( return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc.defaultView || window; }; -export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot => { - // Fallback to document if the element is null or undefined +export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { if (!el) { - return document; + return null; } const rootNode = el.getRootNode ? el.getRootNode() : document; - // Check if the rootNode is a Document, or if the element is disconnected from the DOM - // In such cases, rootNode could either be the actual Document or a ShadowRoot, - // but for disconnected nodes, we want to ensure consistency by returning the Document. - if (rootNode instanceof Document || !(el.isConnected)) { - return el?.ownerDocument ?? document; + // Return null if the element is disconnected + if (!el.isConnected) { + return null; } - return rootNode as ShadowRoot; + return rootNode instanceof Document ? el.ownerDocument ?? document : rootNode; }; /** From 9fa339d0492c167055f7e1defbf225509f512824 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 04:18:52 +0300 Subject: [PATCH 048/102] - Update tests and remove reactCompat. --- .../@react-aria/focus/test/FocusScope.test.js | 76 ++++++------------- .../focus/test/focusSafely.test.js | 26 ++----- .../interactions/src/useFocusVisible.ts | 40 +++++----- .../interactions/test/usePress.test.js | 25 +++--- packages/@react-aria/utils/src/domHelpers.ts | 17 +++-- packages/dev/test-utils/src/reactCompat.js | 21 ----- 6 files changed, 72 insertions(+), 133 deletions(-) delete mode 100644 packages/dev/test-utils/src/reactCompat.js diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index aadc8e25268..1ab8aa9faa2 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -18,7 +18,6 @@ import {focusScopeTree} from '../src/FocusScope'; import {Provider} from '@react-spectrum/provider'; import React, {useEffect, useState} from 'react'; import ReactDOM from 'react-dom'; -import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; import {Example as StorybookExample} from '../stories/FocusScope.stories'; import userEvent from '@testing-library/user-event'; @@ -1632,19 +1631,17 @@ describe('FocusScope with Shadow DOM', function () { }); it('should contain focus within the shadow DOM scope', async function () { - const {shadowRoot, shadowHost} = createShadowRoot(); - const FocusableComponent = () => ( + const {shadowRoot} = createShadowRoot(); + const FocusableComponent = () => ReactDOM.createPortal( - + , + shadowRoot ); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); @@ -1667,28 +1664,22 @@ describe('FocusScope with Shadow DOM', function () { expect(shadowRoot.activeElement).toBe(input1); // Cleanup + unmount(); document.body.removeChild(shadowRoot.host); - act(() => { - unmount({ - container: shadowHost, - root - }); - }); }); it('should manage focus within nested shadow DOMs', async function () { - const {shadowRoot: parentShadowRoot, shadowHost} = createShadowRoot(); + const {shadowRoot: parentShadowRoot} = createShadowRoot(); const nestedDiv = document.createElement('div'); parentShadowRoot.appendChild(nestedDiv); const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); - let root; - act(() => { - root = reactDomRenderer( - - - , childShadowRoot); - }); + const FocusableComponent = () => ReactDOM.createPortal( + + + , childShadowRoot); + + const {unmount} = render(); const input1 = childShadowRoot.querySelector('[data-testid=input1]'); const input2 = childShadowRoot.querySelector('[data-testid=input2]'); @@ -1700,13 +1691,8 @@ describe('FocusScope with Shadow DOM', function () { expect(childShadowRoot.activeElement).toBe(input2); // Cleanup + unmount(); document.body.removeChild(parentShadowRoot.host); - act(() => { - unmount({ - container: shadowHost, - root - }); - }); }); /** @@ -1731,18 +1717,16 @@ describe('FocusScope with Shadow DOM', function () { const shadowHost = document.getElementById('shadow-host'); const shadowRoot = shadowHost.attachShadow({mode: 'open'}); - const FocusableComponent = () => ( + const FocusableComponent = () => ReactDOM.createPortal( - + , + shadowRoot ); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); act(() => { input1.focus(); }); @@ -1754,13 +1738,10 @@ describe('FocusScope with Shadow DOM', function () { act(() => { jest.runAllTimers(); - - unmount({ - container: shadowHost, - root - }); }); + unmount(); + expect(document.activeElement).toBe(externalInput); }); @@ -1771,18 +1752,16 @@ describe('FocusScope with Shadow DOM', function () { it('should autofocus and lock tab navigation inside shadow DOM', async function () { const {shadowRoot, shadowHost} = createShadowRoot(); - const FocusableComponent = () => ( + const FocusableComponent = () => ReactDOM.createPortal( - + , + shadowRoot ); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const input1 = shadowRoot.querySelector('[data-testid="input1"]'); const input2 = shadowRoot.querySelector('[data-testid="input2"]'); @@ -1805,13 +1784,8 @@ describe('FocusScope with Shadow DOM', function () { expect(shadowRoot.activeElement).toBe(input1); // Cleanup + unmount(); document.body.removeChild(shadowHost); - act(() => { - unmount({ - container: shadowHost, - root - }); - }); }); }); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index 533625df762..c07fd86a265 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -15,7 +15,7 @@ import {act, createShadowRoot, render} from '@react-spectrum/test-utils-internal import {focusSafely} from '../'; import React from 'react'; import * as ReactAriaUtils from '@react-aria/utils'; -import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; +import ReactDOM from 'react-dom'; import {setInteractionModality} from '@react-aria/interactions'; jest.mock('@react-aria/utils', () => { @@ -75,19 +75,14 @@ describe('focusSafely', () => { const {shadowRoot, shadowHost} = createShadowRoot(); setInteractionModality('virtual'); - const Example = () => ; + const Example = () => ReactDOM.createPortal(, shadowRoot); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const button = shadowRoot.querySelector('button'); requestAnimationFrame(() => { - act(() => { - unmount({container: shadowHost, root}); - }); + unmount(); document.body.removeChild(shadowHost); }); expect(button).toBeTruthy(); @@ -101,15 +96,12 @@ describe('focusSafely', () => { }); it("should focus on the element if it's connected within shadow DOM", async function () { - const {shadowRoot, shadowHost} = createShadowRoot(); + const {shadowRoot} = createShadowRoot(); setInteractionModality('virtual'); - const Example = () => ; + const Example = () => ReactDOM.createPortal(, shadowRoot); - let root; - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount} = render(); const button = shadowRoot.querySelector('button'); @@ -122,9 +114,7 @@ describe('focusSafely', () => { expect(focusWithoutScrollingSpy).toBeCalledTimes(1); - act(() => { - unmount({container: shadowHost, root}); - }); + unmount(); shadowRoot.host.remove(); }); }); diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index f528d833b84..4283724cb01 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -135,9 +135,9 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); }; - documentObject.addEventListener('keydown', handleKeyboardEvent as EventListener, true); - documentObject.addEventListener('keyup', handleKeyboardEvent as EventListener, true); - documentObject.addEventListener('click', handleClickEvent as EventListener, true); + documentObject?.addEventListener('keydown', handleKeyboardEvent as EventListener, true); + documentObject?.addEventListener('keyup', handleKeyboardEvent as EventListener, true); + documentObject?.addEventListener('click', handleClickEvent as EventListener, true); // Register focus events on the window so they are sure to happen // before React's event listeners (registered on the document). @@ -145,13 +145,13 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { windowObject.addEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject.addEventListener('pointerdown', handlePointerEvent as EventListener, true); - documentObject.addEventListener('pointermove', handlePointerEvent as EventListener, true); - documentObject.addEventListener('pointerup', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('pointerdown', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('pointermove', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('pointerup', handlePointerEvent as EventListener, true); } else { - documentObject.addEventListener('mousedown', handlePointerEvent as EventListener, true); - documentObject.addEventListener('mousemove', handlePointerEvent as EventListener, true); - documentObject.addEventListener('mouseup', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('mousedown', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('mousemove', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('mouseup', handlePointerEvent as EventListener, true); } // Add unmount handler @@ -166,28 +166,28 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { const windowObject = getOwnerWindow(element); const documentObject = getRootNode(element); if (loadListener) { - documentObject.removeEventListener('DOMContentLoaded', loadListener); + documentObject?.removeEventListener('DOMContentLoaded', loadListener); } if (!hasSetupGlobalListeners.has(windowObject)) { return; } windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus; - documentObject.removeEventListener('keydown', handleKeyboardEvent as EventListener, true); - documentObject.removeEventListener('keyup', handleKeyboardEvent as EventListener, true); - documentObject.removeEventListener('click', handleClickEvent as EventListener, true); + documentObject?.removeEventListener('keydown', handleKeyboardEvent as EventListener, true); + documentObject?.removeEventListener('keyup', handleKeyboardEvent as EventListener, true); + documentObject?.removeEventListener('click', handleClickEvent as EventListener, true); windowObject.removeEventListener('focus', handleFocusEvent, true); windowObject.removeEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject.removeEventListener('pointerdown', handlePointerEvent as EventListener, true); - documentObject.removeEventListener('pointermove', handlePointerEvent as EventListener, true); - documentObject.removeEventListener('pointerup', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('pointerdown', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('pointermove', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('pointerup', handlePointerEvent as EventListener, true); } else { - documentObject.removeEventListener('mousedown', handlePointerEvent as EventListener, true); - documentObject.removeEventListener('mousemove', handlePointerEvent as EventListener, true); - documentObject.removeEventListener('mouseup', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('mousedown', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('mousemove', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('mouseup', handlePointerEvent as EventListener, true); } hasSetupGlobalListeners.delete(windowObject); @@ -215,7 +215,7 @@ export function addWindowFocusTracking(element?: HTMLElement | null): () => void let loadListener; // Shadow root doesn't have a readyState, so we can assume it's ready in case of there is a shadow root. - if (rootNode instanceof ShadowRoot || (rootNode.readyState !== 'loading')) { + if (rootNode instanceof ShadowRoot || (rootNode?.readyState !== 'loading')) { setupGlobalFocusEvents(element); } else { loadListener = () => { diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index fb219ffb1ab..3a79fa61e87 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -17,8 +17,7 @@ import {getDeepActiveElement} from '@react-aria/utils'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; -import {render as ReactDOMRender} from 'react-dom'; -import {reactDomRenderer, unmount} from '@react-spectrum/test-utils-internal/src/reactCompat'; +import ReactDOM, {render as ReactDOMRender} from 'react-dom'; import {theme} from '@react-spectrum/theme-default'; import {usePress} from '../'; @@ -3306,16 +3305,15 @@ describe('usePress', function () { describe('usePress with Shadow DOM', function () { installPointerEvent(); - let cleanupShadowHost, root; + let unmount; let events = []; let addEvent = (e) => events.push(e); function setupShadowDOMTest(extraProps = {}, isDraggable = false) { - const {shadowRoot, shadowHost} = createShadowRoot(); - cleanupShadowHost = shadowHost; + const {shadowRoot} = createShadowRoot(); events = []; addEvent = (e) => events.push(e); - const ExampleComponent = () => ( + const ExampleComponent = () => ReactDOM.createPortal(
-
+ , + shadowRoot ); - act(() => { - root = reactDomRenderer(, shadowRoot); - }); + const {unmount: _unmount} = render(); + unmount = _unmount; return shadowRoot; } @@ -3340,12 +3338,7 @@ describe('usePress', function () { afterEach(() => { act(() => {jest.runAllTimers();}); - act(() => { - unmount({ - container: cleanupShadowHost, - root - }); - }); + unmount(); }); it('should fire press events based on pointer events', function () { diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 0f9709ddbfe..482d7f9b2cc 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -10,22 +10,25 @@ export const getOwnerWindow = ( } const doc = getRootNode(el as Element | null | undefined); - return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc.defaultView || window; + return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc?.defaultView || window; }; -export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { +export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot => { + // Fallback to document if the element is null or undefined if (!el) { - return null; + return document; } const rootNode = el.getRootNode ? el.getRootNode() : document; - // Return null if the element is disconnected - if (!el.isConnected) { - return null; + // Check if the rootNode is a Document, or if the element is disconnected from the DOM + // In such cases, rootNode could either be the actual Document or a ShadowRoot, + // but for disconnected nodes, we want to ensure consistency by returning the Document. + if (rootNode instanceof Document || !(el.isConnected)) { + return el?.ownerDocument ?? document; } - return rootNode instanceof Document ? el.ownerDocument ?? document : rootNode; + return rootNode as ShadowRoot; }; /** diff --git a/packages/dev/test-utils/src/reactCompat.js b/packages/dev/test-utils/src/reactCompat.js deleted file mode 100644 index 828934268e2..00000000000 --- a/packages/dev/test-utils/src/reactCompat.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -let reactDomRenderer, unmount; - -if (React.version.startsWith('16') || React.version.startsWith('17')) { - const ReactDOM = require('react-dom'); - reactDomRenderer = (element, container) => ReactDOM.render(element, container); - unmount = (rootOrContainer) => ReactDOM.unmountComponentAtNode(rootOrContainer.container); -} else { // For React 18 - const ReactDOMClient = require('react-dom/client'); - reactDomRenderer = (element, container) => { - const root = ReactDOMClient.createRoot(container); - root.render(element); - return root; // Returning root for lifecycle management - }; - unmount = (rootOrContainer) => { - rootOrContainer.root.unmount(); - }; -} - -export {reactDomRenderer, unmount}; From 90e52b96199b1503d9def5f1744309c1cdec6da3 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 04:55:19 +0300 Subject: [PATCH 049/102] - Leftover. --- packages/@react-aria/focus/src/FocusScope.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 9d7b3e032f3..b1276b4473d 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -133,7 +133,6 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { - // eslint-disable-next-line no-undef const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; let scope: TreeNode | null = null; @@ -751,9 +750,23 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM } ); - if (opts?.from) { - walker.currentNode = opts.from; - } + // Custom function to handle shadow DOM traversal + const originalNextNode = walker.nextNode; + walker.nextNode = function () { + let next = originalNextNode.call(this); + while (next && next.shadowRoot) { + // Enter shadow DOM + let shadowWalker = getFocusableTreeWalker(next.shadowRoot, opts, scope); + let shadowNode = shadowWalker.nextNode(); + if (shadowNode) { + return shadowNode; + } else { + // If no focusable elements in the shadow DOM, continue in the light DOM + next = originalNextNode.call(this); + } + } + return next; + }; return walker; } From 717a8ab0f9153c22063011c96b750a834cc2102e Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 04:59:00 +0300 Subject: [PATCH 050/102] - Revert changes to getFocusableTreeWalker. --- packages/@react-aria/focus/src/FocusScope.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index a213ba8410b..fba5bdaad65 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -774,23 +774,9 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM } ); - // Custom function to handle shadow DOM traversal - const originalNextNode = walker.nextNode; - walker.nextNode = function () { - let next = originalNextNode.call(this); - while (next && next.shadowRoot) { - // Enter shadow DOM - let shadowWalker = getFocusableTreeWalker(next.shadowRoot, opts, scope); - let shadowNode = shadowWalker.nextNode(); - if (shadowNode) { - return shadowNode; - } else { - // If no focusable elements in the shadow DOM, continue in the light DOM - next = originalNextNode.call(this); - } - } - return next; - }; + if (opts?.from) { + walker.currentNode = opts.from; + } return walker; } From 979a542f2a00ce2880e2a6e9cdf1b191378392ec Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 05:19:19 +0300 Subject: [PATCH 051/102] - Remove casting. --- packages/@react-aria/utils/src/domHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 482d7f9b2cc..f4febf73e59 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -9,7 +9,7 @@ export const getOwnerWindow = ( return el; } - const doc = getRootNode(el as Element | null | undefined); + const doc = getRootNode(el); return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc?.defaultView || window; }; From fbe8f89a844f6de16c42e9b8558917cda226cff8 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 05:23:44 +0300 Subject: [PATCH 052/102] - return null in case element is disconnected in `getRootNode`. --- packages/@react-aria/utils/src/domHelpers.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index f4febf73e59..fc108844b14 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -13,22 +13,21 @@ export const getOwnerWindow = ( return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc?.defaultView || window; }; -export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot => { - // Fallback to document if the element is null or undefined +export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { if (!el) { + // Return the main document if the element is null or undefined return document; } const rootNode = el.getRootNode ? el.getRootNode() : document; - // Check if the rootNode is a Document, or if the element is disconnected from the DOM - // In such cases, rootNode could either be the actual Document or a ShadowRoot, - // but for disconnected nodes, we want to ensure consistency by returning the Document. - if (rootNode instanceof Document || !(el.isConnected)) { - return el?.ownerDocument ?? document; + // If the element is disconnected, return null + if (!el.isConnected) { + return null; } - return rootNode as ShadowRoot; + // Return the root node, which can be either a Document or a ShadowRoot + return rootNode instanceof Document ? rootNode : rootNode as ShadowRoot; }; /** From bd2229193cb9e2b4a8a06326b837b1959af23a59 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 05:28:22 +0300 Subject: [PATCH 053/102] - Casting. --- packages/@react-aria/utils/src/domHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index fc108844b14..d5700f2a665 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -9,7 +9,7 @@ export const getOwnerWindow = ( return el; } - const doc = getRootNode(el); + const doc = getRootNode(el as Element | null | undefined); return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc?.defaultView || window; }; From 6548170b670df39cd67ae84e2ee6c8f2d4bdb454 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 6 Jun 2024 05:45:37 +0300 Subject: [PATCH 054/102] - Update unit test. --- packages/@react-aria/utils/test/domHelpers.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index c2af47d3c68..3086b7edfe3 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -27,9 +27,9 @@ describe('getRootNode', () => { expect(getRootNode(div)).toBe(document); }); - it('returns the document if object passed in does not have an ownerdocument', () => { - const div = document.createElement('div'); - expect(getRootNode(div)).toBe(document); + it('returns null if the element is disconnected from the document', () => { + const div = document.createElement('div'); // div is not appended to the document + expect(getRootNode(div)).toBe(null); }); it('returns the document if nothing is passed in', () => { From db93e53d1ecec2e10409501c8438b03fefb63b73 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 25 Jun 2024 06:28:56 +0300 Subject: [PATCH 055/102] - Handle focus movements between shadow DOMs. --- packages/@react-aria/focus/src/FocusScope.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index fba5bdaad65..b53eb0ef613 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -778,6 +778,46 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.currentNode = opts.from; } + // Preserve the original nextNode and previousNode methods + const originalNextNode = walker.nextNode.bind(walker); + const originalPreviousNode = walker.previousNode.bind(walker); + + walker.nextNode = function () { + let nextElement = originalNextNode(); + if (!nextElement && scope && scope.length > 0) { + let currentShadowHost = scope[0].getRootNode().host; + let nextShadowHost = currentShadowHost?.nextElementSibling; + while (nextShadowHost) { + if (nextShadowHost.shadowRoot) { + let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); + if (nextShadowScope.length > 0) { + return nextShadowScope[0]; + } + } + nextShadowHost = nextShadowHost.nextElementSibling; + } + } + return nextElement; + }; + + walker.previousNode = function () { + let previousElement = originalPreviousNode(); + if (!previousElement && scope && scope.length > 0) { + let currentShadowHost = scope[0].getRootNode().host; + let previousShadowHost = currentShadowHost?.previousElementSibling; + while (previousShadowHost) { + if (previousShadowHost.shadowRoot) { + let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); + if (previousShadowScope.length > 0) { + return previousShadowScope[previousShadowScope.length - 1]; + } + } + previousShadowHost = previousShadowHost.previousElementSibling; + } + } + return previousElement; + }; + return walker; } From 98e255e1c588566463bde52df111ccd588745d27 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 25 Jun 2024 06:49:04 +0300 Subject: [PATCH 056/102] - TS fixes. --- packages/@react-aria/focus/src/FocusScope.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index b53eb0ef613..36a762c73e4 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -785,7 +785,8 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.nextNode = function () { let nextElement = originalNextNode(); if (!nextElement && scope && scope.length > 0) { - let currentShadowHost = scope[0].getRootNode().host; + const currentShadowRoot = scope[0].getRootNode(); + let currentShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host : null; let nextShadowHost = currentShadowHost?.nextElementSibling; while (nextShadowHost) { if (nextShadowHost.shadowRoot) { @@ -803,7 +804,8 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.previousNode = function () { let previousElement = originalPreviousNode(); if (!previousElement && scope && scope.length > 0) { - let currentShadowHost = scope[0].getRootNode().host; + const currentShadowRoot = scope[0].getRootNode(); + let currentShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host : null; let previousShadowHost = currentShadowHost?.previousElementSibling; while (previousShadowHost) { if (previousShadowHost.shadowRoot) { From 9453cac2fdfc574cc21292469af1915b55bedfc7 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 28 Jun 2024 02:49:34 +0300 Subject: [PATCH 057/102] Update usePress.test.js --- .../interactions/test/usePress.test.js | 236 ++++++++++-------- 1 file changed, 127 insertions(+), 109 deletions(-) diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 7293f2dfa2b..563db07d139 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -3625,20 +3625,22 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressup', target: el, pointerType: 'mouse', @@ -3646,8 +3648,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3655,12 +3657,12 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - }, - { + }), + expect.objectContaining({ type: 'press', target: el, pointerType: 'mouse', @@ -3668,7 +3670,7 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - } + }) ]); }); @@ -3683,20 +3685,22 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3704,11 +3708,11 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - } + }) ]); events = []; @@ -3718,7 +3722,7 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', @@ -3726,12 +3730,12 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3739,12 +3743,12 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - }, - { + }), + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', @@ -3752,12 +3756,12 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressup', target: el, pointerType: 'mouse', @@ -3765,8 +3769,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3774,12 +3778,12 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - }, - { + }), + expect.objectContaining({ type: 'press', target: el, pointerType: 'mouse', @@ -3787,7 +3791,7 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - } + }) ]); }); @@ -3800,20 +3804,22 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointercancel', {pointerId: 1, pointerType: 'mouse'})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3821,11 +3827,11 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - } + }) ]); }); @@ -3838,20 +3844,22 @@ describe('usePress', function () { fireEvent(el, new MouseEvent('dragstart', {bubbles: true, cancelable: true, composed: true})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3859,11 +3867,11 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - } + }) ]); }); @@ -3877,20 +3885,22 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3898,11 +3908,11 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - } + }) ]); }); @@ -3915,20 +3925,22 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', ctrlKey: true, clientX: 0, clientY: 0})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: true, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'presschange', pressed: true - }, - { + }), + expect.objectContaining({ type: 'pressup', target: el, pointerType: 'mouse', @@ -3936,8 +3948,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -3945,12 +3957,12 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'presschange', pressed: false - }, - { + }), + expect.objectContaining({ type: 'press', target: el, pointerType: 'mouse', @@ -3958,7 +3970,7 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - } + }) ]); }); @@ -4037,16 +4049,18 @@ describe('usePress', function () { fireEvent.click(el); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'virtual', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'pressup', target: el, pointerType: 'virtual', @@ -4054,8 +4068,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'virtual', @@ -4063,8 +4077,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'press', target: el, pointerType: 'virtual', @@ -4072,7 +4086,7 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - } + }) ]); }); @@ -4086,16 +4100,18 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0, clientX: 0, clientY: 0})); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'mouse', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'pressup', target: el, pointerType: 'mouse', @@ -4103,8 +4119,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'mouse', @@ -4112,8 +4128,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'press', target: el, pointerType: 'mouse', @@ -4121,7 +4137,7 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - } + }) ]); uaMock.mockRestore(); @@ -4140,16 +4156,18 @@ describe('usePress', function () { // Virtual pointer event sets pointerType and onClick handles the rest fireEvent.click(el, {pointerType: 'mouse', width: 1, height: 1, detail: 1}); expect(events).toEqual([ - { + expect.objectContaining({ type: 'pressstart', target: el, pointerType: 'virtual', ctrlKey: false, metaKey: false, shiftKey: false, - altKey: false - }, - { + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ type: 'pressup', target: el, pointerType: 'virtual', @@ -4157,8 +4175,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'pressend', target: el, pointerType: 'virtual', @@ -4166,8 +4184,8 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - }, - { + }), + expect.objectContaining({ type: 'press', target: el, pointerType: 'virtual', @@ -4175,7 +4193,7 @@ describe('usePress', function () { metaKey: false, shiftKey: false, altKey: false - } + }) ]); }); From debb4ba7880b56421850a6a20ae75702d4fa174c Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 28 Jun 2024 04:02:32 +0300 Subject: [PATCH 058/102] Refactors and TS errors. --- packages/@react-aria/focus/src/FocusScope.tsx | 112 +++++++++--------- packages/@react-aria/focus/src/focusSafely.ts | 4 +- .../@react-aria/interactions/src/useFocus.ts | 2 +- .../@react-aria/interactions/src/usePress.ts | 31 ++++- .../test/useInteractOutside.test.js | 3 - 5 files changed, 86 insertions(+), 66 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 1f6db53e47e..a08f177cc5f 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -143,7 +143,7 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { - const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; + const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined)?.activeElement; let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { @@ -207,7 +207,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusNext(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getRootNode(scope[0]).activeElement!; + let node = from || getRootNode(scope[0])?.activeElement!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -225,7 +225,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusPrevious(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getRootNode(scope[0]).activeElement!; + let node = from || getRootNode(scope[0])?.activeElement!; let sentinel = scope[scope.length - 1].nextElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -288,7 +288,7 @@ const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ' focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); -export function isFocusable(element: HTMLElement) { +export function isFocusable(element: Element) { return element.matches(FOCUSABLE_ELEMENT_SELECTOR); } @@ -324,7 +324,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo return; } - const ownerDocument = getRootNode(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); // Handle the Tab key to contain focus within the scope let onKeyDown = (e) => { @@ -525,7 +525,7 @@ function useActiveScopeTracker(scopeRef: RefObject, restore?: } let scope = scopeRef.current; - const ownerDocument = getRootNode(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); let onFocus = (e) => { let target = e.target as Element; @@ -567,7 +567,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b // restoring-non-containing scopes should only care if they become active so they can perform the restore useLayoutEffect(() => { let scope = scopeRef.current; - const ownerDocument = getRootNode(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); if (!restoreFocus || contain) { return; } @@ -592,7 +592,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b }, [scopeRef, contain]); useLayoutEffect(() => { - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -671,7 +671,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously. useLayoutEffect(() => { - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); const rootBody = getRootBody(ownerDocument); if (!restoreFocus) { @@ -747,8 +747,7 @@ function restoreFocusToElement(node: FocusableElement) { */ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; - // Adjusted to directly handle root being a Document or ShadowRoot - let doc = root instanceof ShadowRoot ? root : getRootNode(root); + let doc = root instanceof ShadowRoot ? root : (getRootNode(root) || getOwnerDocument(root)); let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc; let walker = effectiveDocument.createTreeWalker( root || doc, @@ -777,47 +776,12 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.currentNode = opts.from; } - // Preserve the original nextNode and previousNode methods - const originalNextNode = walker.nextNode.bind(walker); - const originalPreviousNode = walker.previousNode.bind(walker); - - walker.nextNode = function () { - let nextElement = originalNextNode(); - if (!nextElement && scope && scope.length > 0) { - const currentShadowRoot = scope[0].getRootNode(); - let currentShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host : null; - let nextShadowHost = currentShadowHost?.nextElementSibling; - while (nextShadowHost) { - if (nextShadowHost.shadowRoot) { - let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); - if (nextShadowScope.length > 0) { - return nextShadowScope[0]; - } - } - nextShadowHost = nextShadowHost.nextElementSibling; - } - } - return nextElement; - }; - - walker.previousNode = function () { - let previousElement = originalPreviousNode(); - if (!previousElement && scope && scope.length > 0) { - const currentShadowRoot = scope[0].getRootNode(); - let currentShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host : null; - let previousShadowHost = currentShadowHost?.previousElementSibling; - while (previousShadowHost) { - if (previousShadowHost.shadowRoot) { - let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); - if (previousShadowScope.length > 0) { - return previousShadowScope[previousShadowScope.length - 1]; - } - } - previousShadowHost = previousShadowHost.previousElementSibling; - } - } - return previousElement; - }; + if (root instanceof ShadowRoot) { + const originalNextNode = walker.nextNode.bind(walker); + const originalPreviousNode = walker.previousNode.bind(walker); + walker.nextNode = getNextShadowNode(originalNextNode, scope); + walker.previousNode = getPreviousShadowNode(originalPreviousNode, scope); + } return walker; } @@ -833,7 +797,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || getRootNode(root).activeElement; + let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -854,7 +818,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || getRootNode(root).activeElement; + let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -921,6 +885,46 @@ function last(walker: TreeWalker) { return next; } +function getNextShadowNode(originalNextNode: () => Node | null, scope?: Element[]) { + return function () { + let nextElement = originalNextNode(); + if (!nextElement && scope && scope.length > 0) { + let currentShadowRoot = scope[0].getRootNode(); + let nextShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.nextElementSibling : null; + while (nextShadowHost) { + if (nextShadowHost.shadowRoot) { + let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); + if (nextShadowScope.length > 0) { + return nextShadowScope[0]; + } + } + nextShadowHost = nextShadowHost.nextElementSibling; + } + } + return nextElement; + }; +} + +function getPreviousShadowNode(originalPreviousNode: () => Node | null, scope?: Element[]) { + return function () { + let previousElement = originalPreviousNode(); + if (!previousElement && scope && scope.length > 0) { + let currentShadowRoot = scope[0].getRootNode(); + let previousShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.previousElementSibling : null; + while (previousShadowHost) { + if (previousShadowHost.shadowRoot) { + let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); + if (previousShadowScope.length > 0) { + return previousShadowScope[previousShadowScope.length - 1]; + } + } + previousShadowHost = previousShadowHost.previousElementSibling; + } + } + return previousElement; + }; +} + class Tree { root: TreeNode; diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index 6a4dc1bfe3d..2fb91ce0fd8 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -26,10 +26,10 @@ export function focusSafely(element: FocusableElement) { // from off the screen. const ownerDocument = getRootNode(element); if (getInteractionModality() === 'virtual') { - let lastFocusedElement = ownerDocument.activeElement; + let lastFocusedElement = ownerDocument?.activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. - if (ownerDocument.activeElement === lastFocusedElement && element.isConnected) { + if (ownerDocument?.activeElement === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 961d9a5d781..6345292d7c0 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -64,7 +64,7 @@ export function useFocus(pro // focus handler already moved focus somewhere else. const ownerDocument = getRootNode(e.target); - const activeElement = ownerDocument instanceof ShadowRoot ? getDeepActiveElement() : ownerDocument.activeElement; + const activeElement = ownerDocument instanceof ShadowRoot ? getDeepActiveElement() : ownerDocument?.activeElement; if (e.target === e.currentTarget && activeElement === e.target) { if (onFocusProp) { onFocusProp(e); diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index fb8943840ee..f14a3ad454a 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -15,7 +15,21 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {chain, focusWithoutScrolling, getOwnerWindow, getRootNode, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils'; +import { + chain, + focusWithoutScrolling, + getOwnerDocument, + getOwnerWindow, + getRootNode, + isMac, + isVirtualClick, + isVirtualPointerEvent, + mergeProps, + openLink, + useEffectEvent, + useGlobalListeners, + useSyncRef +} from '@react-aria/utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents} from '@react-types/shared'; import {PressResponderContext} from './context'; @@ -303,7 +317,9 @@ export function usePress(props: PressHookProps): PressResult { } }; - addGlobalListener(getRootNode(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); + const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); + + addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); } if (shouldStopPropagation) { @@ -433,9 +449,11 @@ export function usePress(props: PressHookProps): PressResult { shouldStopPropagation = triggerPressStart(e, state.pointerType); - addGlobalListener(getRootNode(e.currentTarget), 'pointermove', onPointerMove, false); - addGlobalListener(getRootNode(e.currentTarget), 'pointerup', onPointerUp, false); - addGlobalListener(getRootNode(e.currentTarget), 'pointercancel', onPointerCancel, false); + const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); + + addGlobalListener(ownerDocument, 'pointermove', onPointerMove, false); + addGlobalListener(ownerDocument, 'pointerup', onPointerUp, false); + addGlobalListener(ownerDocument, 'pointercancel', onPointerCancel, false); } if (shouldStopPropagation) { @@ -556,8 +574,9 @@ export function usePress(props: PressHookProps): PressResult { if (shouldStopPropagation) { e.stopPropagation(); } + const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); - addGlobalListener(getRootNode(e.currentTarget), 'mouseup', onMouseUp, false); + addGlobalListener(ownerDocument, 'mouseup', onMouseUp, false); }; pressProps.onMouseEnter = (e) => { diff --git a/packages/@react-aria/interactions/test/useInteractOutside.test.js b/packages/@react-aria/interactions/test/useInteractOutside.test.js index dadca2b3e1b..a13c1c6d3e3 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -10,7 +10,6 @@ * governing permissions and limitations under the License. */ -import {act} from 'react-dom/test-utils'; import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import React, {useEffect, useRef} from 'react'; import ReactDOM, {createPortal, render as ReactDOMRender} from 'react-dom'; @@ -575,8 +574,6 @@ describe('useInteractOutside shadow DOM extended tests', function () { ); - act(() => {jest.runAllTimers();}); - const dynamicEl = document.getElementById('dynamic-outside'); fireEvent.mouseDown(dynamicEl); fireEvent.mouseUp(dynamicEl); From cd6a2d77d6821a4113068f0592c71a41315a898d Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 28 Jun 2024 04:14:17 +0300 Subject: [PATCH 059/102] Update fix. --- packages/@react-aria/focus/src/FocusScope.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index a08f177cc5f..b1b17dd2875 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -776,7 +776,7 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.currentNode = opts.from; } - if (root instanceof ShadowRoot) { + if (doc instanceof ShadowRoot) { const originalNextNode = walker.nextNode.bind(walker); const originalPreviousNode = walker.previousNode.bind(walker); walker.nextNode = getNextShadowNode(originalNextNode, scope); From 7673da1735bf59851234b7a3c7f86e480b4ae33c Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 28 Jun 2024 04:18:48 +0300 Subject: [PATCH 060/102] Remove broken sandbox link. --- packages/@react-aria/focus/test/FocusScope.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index f6a04b6ae63..e2945304b25 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1812,7 +1812,6 @@ describe('FocusScope with Shadow DOM', function () { /** * Test case: https://github.com/adobe/react-spectrum/issues/1472 - * sandbox example: https://codesandbox.io/p/sandbox/vigilant-hofstadter-3wf4i?file=%2Fsrc%2Findex.js%3A28%2C30 */ it('should autofocus and lock tab navigation inside shadow DOM', async function () { const {shadowRoot, shadowHost} = createShadowRoot(); From 173cb03f295debe404926ef9280350216049644a Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 14 Aug 2024 03:59:16 +0300 Subject: [PATCH 061/102] Refactor `getRootNode` to improve root node handling. --- packages/@react-aria/utils/src/domHelpers.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index d5700f2a665..6adc203cb81 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -19,15 +19,18 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo return document; } - const rootNode = el.getRootNode ? el.getRootNode() : document; - // If the element is disconnected, return null if (!el.isConnected) { return null; } - // Return the root node, which can be either a Document or a ShadowRoot - return rootNode instanceof Document ? rootNode : rootNode as ShadowRoot; + const rootNode = el.getRootNode ? el.getRootNode() : document; + + if (rootNode instanceof Document || rootNode instanceof ShadowRoot) { + return rootNode; + } + + return null; }; /** From 9d40d58cb8466fef8f7e248cddfb217cfcd32d9d Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 14 Aug 2024 04:27:45 +0300 Subject: [PATCH 062/102] Use `getDeepActiveElement` inside focusSafely.ts to get the active element. --- packages/@react-aria/focus/src/focusSafely.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index 2fb91ce0fd8..ea4818d7b26 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getRootNode, runAfterTransition} from '@react-aria/utils'; +import {focusWithoutScrolling, getDeepActiveElement, runAfterTransition} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; /** @@ -24,12 +24,12 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const ownerDocument = getRootNode(element); + const activeElement = getDeepActiveElement(); if (getInteractionModality() === 'virtual') { - let lastFocusedElement = ownerDocument?.activeElement; + let lastFocusedElement = activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. - if (ownerDocument?.activeElement === lastFocusedElement && element.isConnected) { + if (activeElement === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); From 40cd5dd2e6f88d02680fb847a2500d4bbecff51a Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 14 Aug 2024 04:54:24 +0300 Subject: [PATCH 063/102] Refactor event listener registration Introduce `createEventListener` function to streamline event listener registration. This enhances readability and maintainability, ensuring consistency across event handling logic. --- .../interactions/src/useFocusVisible.ts | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 4283724cb01..f5c489d6bad 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -35,6 +35,10 @@ export interface FocusVisibleResult { isFocusVisible: boolean } +function createEventListener(handler: (e: T) => void): EventListener { + return (e: Event) => handler(e as T); +} + let currentModality: null | Modality = null; let changeHandlers = new Set(); interface GlobalListenerData { @@ -135,9 +139,9 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); }; - documentObject?.addEventListener('keydown', handleKeyboardEvent as EventListener, true); - documentObject?.addEventListener('keyup', handleKeyboardEvent as EventListener, true); - documentObject?.addEventListener('click', handleClickEvent as EventListener, true); + documentObject?.addEventListener('keydown', createEventListener(handleKeyboardEvent), true); + documentObject?.addEventListener('keyup', createEventListener(handleKeyboardEvent), true); + documentObject?.addEventListener('click', createEventListener(handleClickEvent), true); // Register focus events on the window so they are sure to happen // before React's event listeners (registered on the document). @@ -145,13 +149,13 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { windowObject.addEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject?.addEventListener('pointerdown', handlePointerEvent as EventListener, true); - documentObject?.addEventListener('pointermove', handlePointerEvent as EventListener, true); - documentObject?.addEventListener('pointerup', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('pointerdown', createEventListener(handlePointerEvent), true); + documentObject?.addEventListener('pointermove', createEventListener(handlePointerEvent), true); + documentObject?.addEventListener('pointerup', createEventListener(handlePointerEvent), true); } else { - documentObject?.addEventListener('mousedown', handlePointerEvent as EventListener, true); - documentObject?.addEventListener('mousemove', handlePointerEvent as EventListener, true); - documentObject?.addEventListener('mouseup', handlePointerEvent as EventListener, true); + documentObject?.addEventListener('mousedown', createEventListener(handlePointerEvent), true); + documentObject?.addEventListener('mousemove', createEventListener(handlePointerEvent), true); + documentObject?.addEventListener('mouseup', createEventListener(handlePointerEvent), true); } // Add unmount handler @@ -173,21 +177,21 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { } windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus; - documentObject?.removeEventListener('keydown', handleKeyboardEvent as EventListener, true); - documentObject?.removeEventListener('keyup', handleKeyboardEvent as EventListener, true); - documentObject?.removeEventListener('click', handleClickEvent as EventListener, true); + documentObject?.removeEventListener('keydown', createEventListener(handleKeyboardEvent), true); + documentObject?.removeEventListener('keyup', createEventListener(handleKeyboardEvent), true); + documentObject?.removeEventListener('click', createEventListener(handleClickEvent), true); windowObject.removeEventListener('focus', handleFocusEvent, true); windowObject.removeEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject?.removeEventListener('pointerdown', handlePointerEvent as EventListener, true); - documentObject?.removeEventListener('pointermove', handlePointerEvent as EventListener, true); - documentObject?.removeEventListener('pointerup', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('pointerdown', createEventListener(handlePointerEvent), true); + documentObject?.removeEventListener('pointermove', createEventListener(handlePointerEvent), true); + documentObject?.removeEventListener('pointerup', createEventListener(handlePointerEvent), true); } else { - documentObject?.removeEventListener('mousedown', handlePointerEvent as EventListener, true); - documentObject?.removeEventListener('mousemove', handlePointerEvent as EventListener, true); - documentObject?.removeEventListener('mouseup', handlePointerEvent as EventListener, true); + documentObject?.removeEventListener('mousedown', createEventListener(handlePointerEvent), true); + documentObject?.removeEventListener('mousemove', createEventListener(handlePointerEvent), true); + documentObject?.removeEventListener('mouseup', createEventListener(handlePointerEvent), true); } hasSetupGlobalListeners.delete(windowObject); From ae30def50362ae2452b70108635e6b0990550e47 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 14 Aug 2024 05:57:32 +0300 Subject: [PATCH 064/102] Remove `ownerDocument` fallback in usePress.ts --- packages/@react-aria/interactions/src/usePress.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 2b8c199006a..9ffe267f04a 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -317,9 +317,11 @@ export function usePress(props: PressHookProps): PressResult { } }; - const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); + const ownerDocument = getRootNode(e.currentTarget); - addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); + if (ownerDocument) { + addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); + } } if (shouldStopPropagation) { From c5ddb2423ac07abbcd036391218fc23bb65acfee Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 14 Aug 2024 06:32:16 +0300 Subject: [PATCH 065/102] Refactor `createEventListener` for type-safe caching. --- .../interactions/src/useFocusVisible.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index f5c489d6bad..e0ad41b62ad 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -30,15 +30,27 @@ export interface FocusVisibleProps { autoFocus?: boolean } +/** + * This function creates a type-safe event listener wrapper that ensures consistent function references for event handling. + * It uses a WeakMap to cache wrapped handlers, guaranteeing that the same function instance is always returned for a given handler, which is crucial for proper event listener cleanup and prevents unnecessary function creation. + */ +const handlerCache = new WeakMap(); +function createEventListener(handler: (e: E) => void): EventListener { + if (typeof handler === 'function') { + if (!handlerCache.has(handler)) { + const wrappedHandler: EventListener = (e: Event) => handler(e as E); + handlerCache.set(handler, wrappedHandler); + } + return handlerCache.get(handler)!; + } + return handler; +} + export interface FocusVisibleResult { /** Whether keyboard focus is visible globally. */ isFocusVisible: boolean } -function createEventListener(handler: (e: T) => void): EventListener { - return (e: Event) => handler(e as T); -} - let currentModality: null | Modality = null; let changeHandlers = new Set(); interface GlobalListenerData { From 2d847576b31e49e960ce2c59e9b4cf14266b5db8 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 24 Sep 2024 05:18:32 +0300 Subject: [PATCH 066/102] - Test out the updated getOwnerWindow to fix iframe focus issues. --- packages/@react-aria/utils/src/domHelpers.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 6adc203cb81..a08fb4c4314 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -9,10 +9,19 @@ export const getOwnerWindow = ( return el; } - const doc = getRootNode(el as Element | null | undefined); - return doc instanceof ShadowRoot ? doc.ownerDocument.defaultView || window : doc?.defaultView || window; + const rootNode = el instanceof Element ? el.getRootNode() : null; + + // If the root node is a ShadowRoot, get its owner document's defaultView (window) + if (rootNode instanceof ShadowRoot) { + return rootNode.ownerDocument.defaultView || window; + } + + // Fallback to getOwnerDocument for non-Shadow DOM and iframe elements + const doc = getOwnerDocument(el as Element | null | undefined); + return doc.defaultView || window; }; + export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { if (!el) { // Return the main document if the element is null or undefined From a690850b39b4b05db421e70b6b9481bc25354520 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Tue, 24 Sep 2024 06:16:12 +0300 Subject: [PATCH 067/102] - Test out the updated getOwnerWindow to fix iframe focus issues. --- packages/@react-aria/utils/src/domHelpers.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index a08fb4c4314..56766ee2e9b 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -9,19 +9,10 @@ export const getOwnerWindow = ( return el; } - const rootNode = el instanceof Element ? el.getRootNode() : null; - - // If the root node is a ShadowRoot, get its owner document's defaultView (window) - if (rootNode instanceof ShadowRoot) { - return rootNode.ownerDocument.defaultView || window; - } - - // Fallback to getOwnerDocument for non-Shadow DOM and iframe elements const doc = getOwnerDocument(el as Element | null | undefined); return doc.defaultView || window; }; - export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { if (!el) { // Return the main document if the element is null or undefined From 638d7ebcf6ed9a12caf55cb8842947a0adf7a84a Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 25 Sep 2024 04:17:05 +0300 Subject: [PATCH 068/102] - Test? --- .../focus/test/FocusScopeOwnerDocument.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js b/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js index 07181967c54..53a95f3730c 100644 --- a/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js +++ b/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js @@ -44,7 +44,17 @@ describe('FocusScope', function () { act(() => {jest.runAllTimers();}); // Iframe teardown - iframe.remove(); + if (iframe && iframe.parentNode) { + iframe.parentNode.removeChild(iframe); + } + iframe = null; + iframeRoot = null; + + // Reset the document body + document.body.innerHTML = ''; + + // Clear any lingering event listeners + jest.restoreAllMocks(); }); describe('focus containment', function () { From 2c4a9bcee1256fd66f4d790f011783373d4f3f62 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 25 Sep 2024 05:05:25 +0300 Subject: [PATCH 069/102] - Revert Focus scope changes, for testing. --- packages/@react-aria/focus/src/FocusScope.tsx | 96 +++++-------------- packages/@react-aria/focus/src/focusSafely.ts | 8 +- .../test/FocusScopeOwnerDocument.test.js | 12 +-- 3 files changed, 27 insertions(+), 89 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index d0c30650896..8e85500c736 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,7 @@ import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect} from '@react-aria/utils'; +import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -143,7 +143,7 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { - const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined)?.activeElement; + const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { @@ -207,7 +207,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusNext(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getRootNode(scope[0])?.activeElement!; + let node = from || getOwnerDocument(scope[0]).activeElement!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -225,7 +225,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusPrevious(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getRootNode(scope[0])?.activeElement!; + let node = from || getOwnerDocument(scope[0]).activeElement!; let sentinel = scope[scope.length - 1].nextElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -288,7 +288,7 @@ const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ' focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); -export function isFocusable(element: Element) { +export function isFocusable(element: HTMLElement) { return element.matches(FOCUSABLE_ELEMENT_SELECTOR); } @@ -324,7 +324,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo return; } - const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); // Handle the Tab key to contain focus within the scope let onKeyDown = (e) => { @@ -384,7 +384,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { activeScope = scopeRef; - if (getRootBody(ownerDocument).contains(e.target)) { + if (ownerDocument.body.contains(e.target)) { focusedNode.current = e.target; focusedNode.current?.focus(); } else if (activeScope.current) { @@ -525,7 +525,7 @@ function useActiveScopeTracker(scopeRef: RefObject, restore?: } let scope = scopeRef.current; - const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); let onFocus = (e) => { let target = e.target as Element; @@ -567,7 +567,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b // restoring-non-containing scopes should only care if they become active so they can perform the restore useLayoutEffect(() => { let scope = scopeRef.current; - const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); if (!restoreFocus || contain) { return; } @@ -592,7 +592,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b }, [scopeRef, contain]); useLayoutEffect(() => { - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -617,16 +617,14 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b } let nodeToRestore = treeNode.nodeToRestore; - const rootBody = getRootBody(ownerDocument); - // Create a DOM tree walker that matches all tabbable elements - let walker = getFocusableTreeWalker(rootBody, {tabbable: true}); + let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true}); // Find the next tabbable element after the currently focused element walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; - if (!nodeToRestore || !rootBody.contains(nodeToRestore) || nodeToRestore === rootBody) { + if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) { nodeToRestore = undefined; treeNode.nodeToRestore = undefined; } @@ -659,20 +657,19 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b }; if (!contain) { - ownerDocument.addEventListener('keydown', onKeyDown as EventListener, true); + ownerDocument.addEventListener('keydown', onKeyDown, true); } return () => { if (!contain) { - ownerDocument.removeEventListener('keydown', onKeyDown as EventListener, true); + ownerDocument.removeEventListener('keydown', onKeyDown, true); } }; }, [scopeRef, restoreFocus, contain]); // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously. useLayoutEffect(() => { - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); - const rootBody = getRootBody(ownerDocument); + const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -696,14 +693,14 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b && nodeToRestore && ( // eslint-disable-next-line react-hooks/exhaustive-deps - (isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef))) + (isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef))) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it 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 === rootBody) { + if (ownerDocument.activeElement === 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) { @@ -745,12 +742,10 @@ function restoreFocusToElement(node: FocusableElement) { * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ -export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) { +export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; - let doc = root instanceof ShadowRoot ? root : (getRootNode(root) || getOwnerDocument(root)); - let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc; - let walker = effectiveDocument.createTreeWalker( - root || doc, + let walker = getOwnerDocument(root).createTreeWalker( + root, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { @@ -776,13 +771,6 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.currentNode = opts.from; } - if (doc instanceof ShadowRoot) { - const originalNextNode = walker.nextNode.bind(walker); - const originalPreviousNode = walker.previousNode.bind(walker); - walker.nextNode = getNextShadowNode(originalNextNode, scope); - walker.previousNode = getPreviousShadowNode(originalPreviousNode, scope); - } - return walker; } @@ -797,7 +785,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; + let node = from || getOwnerDocument(root).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -818,7 +806,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; + let node = from || getOwnerDocument(root).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -885,46 +873,6 @@ function last(walker: TreeWalker) { return next; } -function getNextShadowNode(originalNextNode: () => Node | null, scope?: Element[]) { - return function () { - let nextElement = originalNextNode(); - if (!nextElement && scope && scope.length > 0) { - let currentShadowRoot = scope[0].getRootNode(); - let nextShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.nextElementSibling : null; - while (nextShadowHost) { - if (nextShadowHost.shadowRoot) { - let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); - if (nextShadowScope.length > 0) { - return nextShadowScope[0]; - } - } - nextShadowHost = nextShadowHost.nextElementSibling; - } - } - return nextElement; - }; -} - -function getPreviousShadowNode(originalPreviousNode: () => Node | null, scope?: Element[]) { - return function () { - let previousElement = originalPreviousNode(); - if (!previousElement && scope && scope.length > 0) { - let currentShadowRoot = scope[0].getRootNode(); - let previousShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.previousElementSibling : null; - while (previousShadowHost) { - if (previousShadowHost.shadowRoot) { - let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); - if (previousShadowScope.length > 0) { - return previousShadowScope[previousShadowScope.length - 1]; - } - } - previousShadowHost = previousShadowHost.previousElementSibling; - } - } - return previousElement; - }; -} - class Tree { root: TreeNode; diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index ea4818d7b26..e0bc52e9465 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getDeepActiveElement, runAfterTransition} from '@react-aria/utils'; +import {focusWithoutScrolling, getOwnerDocument, runAfterTransition} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; /** @@ -24,12 +24,12 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const activeElement = getDeepActiveElement(); + const ownerDocument = getOwnerDocument(element); if (getInteractionModality() === 'virtual') { - let lastFocusedElement = activeElement; + let lastFocusedElement = ownerDocument.activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. - if (activeElement === lastFocusedElement && element.isConnected) { + if (ownerDocument.activeElement === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); diff --git a/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js b/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js index 53a95f3730c..07181967c54 100644 --- a/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js +++ b/packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js @@ -44,17 +44,7 @@ describe('FocusScope', function () { act(() => {jest.runAllTimers();}); // Iframe teardown - if (iframe && iframe.parentNode) { - iframe.parentNode.removeChild(iframe); - } - iframe = null; - iframeRoot = null; - - // Reset the document body - document.body.innerHTML = ''; - - // Clear any lingering event listeners - jest.restoreAllMocks(); + iframe.remove(); }); describe('focus containment', function () { From 9210f8e258e63932fd2fefb0d2e60d9687dad04f Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Wed, 25 Sep 2024 06:00:43 +0300 Subject: [PATCH 070/102] - Fix tests? --- packages/@react-aria/focus/src/FocusScope.tsx | 96 ++++++++++++++----- packages/@react-aria/focus/src/focusSafely.ts | 8 +- .../@react-aria/interactions/src/usePress.ts | 8 +- packages/@react-aria/utils/src/domHelpers.ts | 11 +-- 4 files changed, 85 insertions(+), 38 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 8e85500c736..d0c30650896 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,7 @@ import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils'; +import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -143,7 +143,7 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { - const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement; + const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined)?.activeElement; let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { @@ -207,7 +207,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusNext(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getOwnerDocument(scope[0]).activeElement!; + let node = from || getRootNode(scope[0])?.activeElement!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -225,7 +225,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusPrevious(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getOwnerDocument(scope[0]).activeElement!; + let node = from || getRootNode(scope[0])?.activeElement!; let sentinel = scope[scope.length - 1].nextElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -288,7 +288,7 @@ const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ' focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); -export function isFocusable(element: HTMLElement) { +export function isFocusable(element: Element) { return element.matches(FOCUSABLE_ELEMENT_SELECTOR); } @@ -324,7 +324,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo return; } - const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); // Handle the Tab key to contain focus within the scope let onKeyDown = (e) => { @@ -384,7 +384,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { activeScope = scopeRef; - if (ownerDocument.body.contains(e.target)) { + if (getRootBody(ownerDocument).contains(e.target)) { focusedNode.current = e.target; focusedNode.current?.focus(); } else if (activeScope.current) { @@ -525,7 +525,7 @@ function useActiveScopeTracker(scopeRef: RefObject, restore?: } let scope = scopeRef.current; - const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); let onFocus = (e) => { let target = e.target as Element; @@ -567,7 +567,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b // restoring-non-containing scopes should only care if they become active so they can perform the restore useLayoutEffect(() => { let scope = scopeRef.current; - const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); if (!restoreFocus || contain) { return; } @@ -592,7 +592,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b }, [scopeRef, contain]); useLayoutEffect(() => { - const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -617,14 +617,16 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b } let nodeToRestore = treeNode.nodeToRestore; + const rootBody = getRootBody(ownerDocument); + // Create a DOM tree walker that matches all tabbable elements - let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true}); + let walker = getFocusableTreeWalker(rootBody, {tabbable: true}); // Find the next tabbable element after the currently focused element walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; - if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) { + if (!nodeToRestore || !rootBody.contains(nodeToRestore) || nodeToRestore === rootBody) { nodeToRestore = undefined; treeNode.nodeToRestore = undefined; } @@ -657,19 +659,20 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b }; if (!contain) { - ownerDocument.addEventListener('keydown', onKeyDown, true); + ownerDocument.addEventListener('keydown', onKeyDown as EventListener, true); } return () => { if (!contain) { - ownerDocument.removeEventListener('keydown', onKeyDown, true); + ownerDocument.removeEventListener('keydown', onKeyDown as EventListener, true); } }; }, [scopeRef, restoreFocus, contain]); // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously. useLayoutEffect(() => { - const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const rootBody = getRootBody(ownerDocument); if (!restoreFocus) { return; @@ -693,14 +696,14 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b && nodeToRestore && ( // eslint-disable-next-line react-hooks/exhaustive-deps - (isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef))) + (isElementInScope(ownerDocument.activeElement, scopeRef.current) || (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef))) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it 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 (ownerDocument.activeElement === rootBody) { // look up the tree starting with our scope to find a nodeToRestore still in the DOM let treeNode = clonedTree.getTreeNode(scopeRef); while (treeNode) { @@ -742,10 +745,12 @@ function restoreFocusToElement(node: FocusableElement) { * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ -export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) { +export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; - let walker = getOwnerDocument(root).createTreeWalker( - root, + let doc = root instanceof ShadowRoot ? root : (getRootNode(root) || getOwnerDocument(root)); + let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc; + let walker = effectiveDocument.createTreeWalker( + root || doc, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { @@ -771,6 +776,13 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions walker.currentNode = opts.from; } + if (doc instanceof ShadowRoot) { + const originalNextNode = walker.nextNode.bind(walker); + const originalPreviousNode = walker.previousNode.bind(walker); + walker.nextNode = getNextShadowNode(originalNextNode, scope); + walker.previousNode = getPreviousShadowNode(originalPreviousNode, scope); + } + return walker; } @@ -785,7 +797,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || getOwnerDocument(root).activeElement; + let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -806,7 +818,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || getOwnerDocument(root).activeElement; + let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -873,6 +885,46 @@ function last(walker: TreeWalker) { return next; } +function getNextShadowNode(originalNextNode: () => Node | null, scope?: Element[]) { + return function () { + let nextElement = originalNextNode(); + if (!nextElement && scope && scope.length > 0) { + let currentShadowRoot = scope[0].getRootNode(); + let nextShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.nextElementSibling : null; + while (nextShadowHost) { + if (nextShadowHost.shadowRoot) { + let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); + if (nextShadowScope.length > 0) { + return nextShadowScope[0]; + } + } + nextShadowHost = nextShadowHost.nextElementSibling; + } + } + return nextElement; + }; +} + +function getPreviousShadowNode(originalPreviousNode: () => Node | null, scope?: Element[]) { + return function () { + let previousElement = originalPreviousNode(); + if (!previousElement && scope && scope.length > 0) { + let currentShadowRoot = scope[0].getRootNode(); + let previousShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.previousElementSibling : null; + while (previousShadowHost) { + if (previousShadowHost.shadowRoot) { + let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); + if (previousShadowScope.length > 0) { + return previousShadowScope[previousShadowScope.length - 1]; + } + } + previousShadowHost = previousShadowHost.previousElementSibling; + } + } + return previousElement; + }; +} + class Tree { root: TreeNode; diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index e0bc52e9465..ea4818d7b26 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerDocument, runAfterTransition} from '@react-aria/utils'; +import {focusWithoutScrolling, getDeepActiveElement, runAfterTransition} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; /** @@ -24,12 +24,12 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const ownerDocument = getOwnerDocument(element); + const activeElement = getDeepActiveElement(); if (getInteractionModality() === 'virtual') { - let lastFocusedElement = ownerDocument.activeElement; + let lastFocusedElement = activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. - if (ownerDocument.activeElement === lastFocusedElement && element.isConnected) { + if (activeElement === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 586b2ae647b..9613d6ed6f3 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -317,11 +317,9 @@ export function usePress(props: PressHookProps): PressResult { } }; - const ownerDocument = getRootNode(e.currentTarget); + const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); - if (ownerDocument) { - addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); - } + addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); } if (shouldStopPropagation) { @@ -963,7 +961,7 @@ function shouldPreventDefaultUp(target: Element) { if (target instanceof HTMLInputElement) { return false; } - + if (target instanceof HTMLButtonElement) { return target.type !== 'submit' && target.type !== 'reset'; } diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 56766ee2e9b..cb91133b9f3 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -19,18 +19,15 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo return document; } + const rootNode = el.getRootNode ? el.getRootNode() : document; + // If the element is disconnected, return null if (!el.isConnected) { return null; } - const rootNode = el.getRootNode ? el.getRootNode() : document; - - if (rootNode instanceof Document || rootNode instanceof ShadowRoot) { - return rootNode; - } - - return null; +// Return the root node, which can be either a Document or a ShadowRoot + return rootNode instanceof Document ? rootNode : rootNode as ShadowRoot; }; /** From 3cb08744f517d6011fcd5577fd24ea10d7ed9ae5 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 02:54:21 +0300 Subject: [PATCH 071/102] - Fix tests? --- packages/@react-aria/utils/src/domHelpers.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index cb91133b9f3..56766ee2e9b 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -19,15 +19,18 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo return document; } - const rootNode = el.getRootNode ? el.getRootNode() : document; - // If the element is disconnected, return null if (!el.isConnected) { return null; } -// Return the root node, which can be either a Document or a ShadowRoot - return rootNode instanceof Document ? rootNode : rootNode as ShadowRoot; + const rootNode = el.getRootNode ? el.getRootNode() : document; + + if (rootNode instanceof Document || rootNode instanceof ShadowRoot) { + return rootNode; + } + + return null; }; /** From 0181bc3ac28be98cda6e19858ec411c6bf499df5 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 03:06:24 +0300 Subject: [PATCH 072/102] - Fix tests? --- packages/@react-aria/focus/src/FocusScope.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index d0c30650896..1f27204e2c9 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -422,13 +422,11 @@ function isElementInAnyScope(element: Element) { } function isElementInScope(element?: Element | null, scope?: Element[] | null) { - if (!element) { + if (!element || !scope) { return false; } - if (!scope) { - return false; - } - return scope.some(node => node.contains(element)); + const rootNode = getRootNode(scope[0]); + return scope.some(node => node.contains(element) && getRootNode(node) === rootNode); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { From 930a2c3d5db2a3426cbbeb503c47966320a88dae Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 03:18:37 +0300 Subject: [PATCH 073/102] - Revert the changes to getRootNode. --- packages/@react-aria/utils/src/domHelpers.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 56766ee2e9b..905ba911aec 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -26,11 +26,8 @@ export const getRootNode = (el: Element | null | undefined): Document | ShadowRo const rootNode = el.getRootNode ? el.getRootNode() : document; - if (rootNode instanceof Document || rootNode instanceof ShadowRoot) { - return rootNode; - } - - return null; +// Return the root node, which can be either a Document or a ShadowRoot + return rootNode instanceof Document ? rootNode : rootNode as ShadowRoot; }; /** From 1cecef0198586de332b32a99227b6efdafe2aee0 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 03:19:09 +0300 Subject: [PATCH 074/102] - Revert `isElementInScope` as well. --- packages/@react-aria/focus/src/FocusScope.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 1f27204e2c9..d0c30650896 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -422,11 +422,13 @@ function isElementInAnyScope(element: Element) { } function isElementInScope(element?: Element | null, scope?: Element[] | null) { - if (!element || !scope) { + if (!element) { return false; } - const rootNode = getRootNode(scope[0]); - return scope.some(node => node.contains(element) && getRootNode(node) === rootNode); + if (!scope) { + return false; + } + return scope.some(node => node.contains(element)); } function isElementInChildScope(element: Element, scope: ScopeRef = null) { From 2bd25ddd01be2b3503f924f3870a2e03ca870c97 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 03:46:20 +0300 Subject: [PATCH 075/102] - Test out if instance check failure across context for iframes is what is causing the issue. --- packages/@react-aria/utils/src/domHelpers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 905ba911aec..a22c6cdabf0 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -15,19 +15,21 @@ export const getOwnerWindow = ( export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { if (!el) { - // Return the main document if the element is null or undefined return document; } - // If the element is disconnected, return null if (!el.isConnected) { return null; } const rootNode = el.getRootNode ? el.getRootNode() : document; -// Return the root node, which can be either a Document or a ShadowRoot - return rootNode instanceof Document ? rootNode : rootNode as ShadowRoot; + // Use nodeType to check the type of the rootNode + if (rootNode.nodeType === Node.DOCUMENT_NODE || rootNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + return rootNode as Document | ShadowRoot; + } + + return null; }; /** From bc7d1886e59da1f28e60434eb8840d70f48b57e2 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 04:42:47 +0300 Subject: [PATCH 076/102] - Replace the use of `instanceof` with `nodeType` to correctly identify the node type across contexts. - Revert changes made for `usePress`. --- .../@react-aria/interactions/src/usePress.ts | 6 +++-- packages/@react-aria/utils/src/domHelpers.ts | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 9613d6ed6f3..d1748e7f035 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -317,9 +317,11 @@ export function usePress(props: PressHookProps): PressResult { } }; - const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); + const ownerDocument = getRootNode(e.currentTarget); - addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); + if (ownerDocument) { + addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); + } } if (shouldStopPropagation) { diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index a22c6cdabf0..101e8948cb3 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -13,25 +13,46 @@ export const getOwnerWindow = ( return doc.defaultView || window; }; -export const getRootNode = (el: Element | null | undefined): Document | ShadowRoot | null => { +export const getRootNode = ( + el: Element | null | undefined +): Document | ShadowRoot | null => { if (!el) { + // Return the main document if the element is null or undefined return document; } + // If the element is disconnected from the DOM, return null if (!el.isConnected) { return null; } + // Get the root node of the element, or default to the document const rootNode = el.getRootNode ? el.getRootNode() : document; // Use nodeType to check the type of the rootNode - if (rootNode.nodeType === Node.DOCUMENT_NODE || rootNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return rootNode as Document | ShadowRoot; + // We use nodeType instead of instanceof checks because instanceof fails across different + // contexts (e.g., iframes or windows), as each context has its own global objects and constructors. + // nodeType is a primitive value and is consistent across different contexts, making it + // reliable for cross-context type checking. + + const nodeType = rootNode.nodeType; + + if (nodeType === Node.DOCUMENT_NODE) { + // rootNode is a Document + return rootNode as Document; } + if (nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in rootNode) { + // rootNode is a ShadowRoot (a specialized type of DocumentFragment) + // We check for the presence of the 'host' property to distinguish ShadowRoot from other DocumentFragments + return rootNode as ShadowRoot; + } + + // For other types of nodes or DocumentFragments that are not ShadowRoots, return null return null; }; + /** * Retrieves a reference to the most appropriate "body" element for a given DOM context, * accommodating both traditional DOM and Shadow DOM environments. When used with a Shadow DOM, From 3b6a4cd9a89e44d3b1ce9556f4340cabebe24ffb Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 26 Sep 2024 05:40:46 +0300 Subject: [PATCH 077/102] - Fix ESlint errors. --- packages/@react-aria/interactions/src/useFocusVisible.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index e0ad41b62ad..4c7ed45cc65 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -34,7 +34,8 @@ export interface FocusVisibleProps { * This function creates a type-safe event listener wrapper that ensures consistent function references for event handling. * It uses a WeakMap to cache wrapped handlers, guaranteeing that the same function instance is always returned for a given handler, which is crucial for proper event listener cleanup and prevents unnecessary function creation. */ -const handlerCache = new WeakMap(); +const handlerCache = new WeakMap(); + function createEventListener(handler: (e: E) => void): EventListener { if (typeof handler === 'function') { if (!handlerCache.has(handler)) { From 51932a550d2a51e10fddb8b12738e6b220d5355d Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 24 Oct 2024 04:11:01 +0300 Subject: [PATCH 078/102] - Update the usages of `instanceof` to use `nodeType` instead. --- packages/@react-aria/focus/src/FocusScope.tsx | 17 +++++++++++++++-- .../interactions/src/useFocusVisible.ts | 13 ++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6bdc7a1a5de..fb9ec4baca8 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -747,8 +747,21 @@ function restoreFocusToElement(node: FocusableElement) { */ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; - let doc = root instanceof ShadowRoot ? root : (getRootNode(root) || getOwnerDocument(root)); - let effectiveDocument = doc instanceof ShadowRoot ? doc.ownerDocument : doc; + + // Ensure that root is an Element or fall back appropriately + let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; + + // Determine the document to use + let doc = root?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in root + ? root + : (getRootNode(rootElement) || getOwnerDocument(rootElement)); + + // Ensure effectiveDocument is always a Document + let effectiveDocument = doc?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in doc + ? (doc as ShadowRoot).ownerDocument + : (doc as Document); + + // Create a TreeWalker, ensuring the root is an Element or Document let walker = effectiveDocument.createTreeWalker( root || doc, NodeFilter.SHOW_ELEMENT, diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 4c7ed45cc65..bd02d1686bb 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -231,10 +231,17 @@ export function addWindowFocusTracking(element?: HTMLElement | null): () => void const rootNode = getRootNode(element); let loadListener; - // Shadow root doesn't have a readyState, so we can assume it's ready in case of there is a shadow root. - if (rootNode instanceof ShadowRoot || (rootNode?.readyState !== 'loading')) { + const isDocument = rootNode?.nodeType === Node.DOCUMENT_NODE; + const isShadowRoot = rootNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in rootNode; + // Use nodeType to check if it's either a Document or a ShadowRoot + if ( + (isDocument && (rootNode as Document).readyState !== 'loading') || + (isShadowRoot) + ) { + // If it's a Document that's ready or a ShadowRoot setupGlobalFocusEvents(element); - } else { + } else if (rootNode?.nodeType === Node.DOCUMENT_NODE) { + // If it's a Document that's still loading loadListener = () => { setupGlobalFocusEvents(element); }; From 541dd5f5f7853c682f733a651cff7c73ad9102b7 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad <36645103+MahmoudElsayad@users.noreply.github.com> Date: Fri, 25 Oct 2024 07:35:13 +0300 Subject: [PATCH 079/102] Update packages/@react-aria/interactions/src/useFocusVisible.ts Co-authored-by: Robert Snow --- packages/@react-aria/interactions/src/useFocusVisible.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index bd02d1686bb..5703b64b9fb 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -240,7 +240,7 @@ export function addWindowFocusTracking(element?: HTMLElement | null): () => void ) { // If it's a Document that's ready or a ShadowRoot setupGlobalFocusEvents(element); - } else if (rootNode?.nodeType === Node.DOCUMENT_NODE) { + } else if (isDocument) { // If it's a Document that's still loading loadListener = () => { setupGlobalFocusEvents(element); From 3fa8c44dd5b735193fb436ded6b16c41fdb7676b Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 25 Oct 2024 08:12:25 +0300 Subject: [PATCH 080/102] - Update the usages of `instanceof` to use `nodeType` instead. - Introduce new helpers `isShadowRoot` and `isDocument`. --- packages/@react-aria/focus/src/FocusScope.tsx | 12 +++---- .../@react-aria/interactions/src/useFocus.ts | 4 +-- .../interactions/src/useFocusVisible.ts | 12 +++---- packages/@react-aria/utils/src/domHelpers.ts | 36 +++++++++++++++---- packages/@react-aria/utils/src/index.ts | 2 +- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index fb9ec4baca8..e5b6f0e615e 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,7 @@ import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect} from '@react-aria/utils'; +import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect, isShadowRoot} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -752,12 +752,12 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; // Determine the document to use - let doc = root?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in root + let doc = isShadowRoot(rootElement) ? root : (getRootNode(rootElement) || getOwnerDocument(rootElement)); // Ensure effectiveDocument is always a Document - let effectiveDocument = doc?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in doc + let effectiveDocument = isShadowRoot(doc) ? (doc as ShadowRoot).ownerDocument : (doc as Document); @@ -789,7 +789,7 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.currentNode = opts.from; } - if (doc instanceof ShadowRoot) { + if (isShadowRoot(doc)) { const originalNextNode = walker.nextNode.bind(walker); const originalPreviousNode = walker.previousNode.bind(walker); walker.nextNode = getNextShadowNode(originalNextNode, scope); @@ -903,7 +903,7 @@ function getNextShadowNode(originalNextNode: () => Node | null, scope?: Element[ let nextElement = originalNextNode(); if (!nextElement && scope && scope.length > 0) { let currentShadowRoot = scope[0].getRootNode(); - let nextShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.nextElementSibling : null; + let nextShadowHost = isShadowRoot(currentShadowRoot) ? currentShadowRoot.host.nextElementSibling : null; while (nextShadowHost) { if (nextShadowHost.shadowRoot) { let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); @@ -923,7 +923,7 @@ function getPreviousShadowNode(originalPreviousNode: () => Node | null, scope?: let previousElement = originalPreviousNode(); if (!previousElement && scope && scope.length > 0) { let currentShadowRoot = scope[0].getRootNode(); - let previousShadowHost = currentShadowRoot instanceof ShadowRoot ? currentShadowRoot.host.previousElementSibling : null; + let previousShadowHost = isShadowRoot(currentShadowRoot) ? currentShadowRoot.host.previousElementSibling : null; while (previousShadowHost) { if (previousShadowHost.shadowRoot) { let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 6345292d7c0..f3d54a2a602 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,7 +17,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared'; import {FocusEvent, useCallback} from 'react'; -import {getDeepActiveElement, getRootNode} from '@react-aria/utils'; +import {getDeepActiveElement, getRootNode, isShadowRoot} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { @@ -64,7 +64,7 @@ export function useFocus(pro // focus handler already moved focus somewhere else. const ownerDocument = getRootNode(e.target); - const activeElement = ownerDocument instanceof ShadowRoot ? getDeepActiveElement() : ownerDocument?.activeElement; + const activeElement = isShadowRoot(ownerDocument) ? getDeepActiveElement() : ownerDocument?.activeElement; if (e.target === e.currentTarget && activeElement === e.target) { if (onFocusProp) { onFocusProp(e); diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 5703b64b9fb..91aaa6b8ec8 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 {getOwnerWindow, getRootNode, isMac, isVirtualClick} from '@react-aria/utils'; +import {getOwnerWindow, getRootNode, isMac, isVirtualClick, isDocument, isShadowRoot} from '@react-aria/utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -231,16 +231,16 @@ export function addWindowFocusTracking(element?: HTMLElement | null): () => void const rootNode = getRootNode(element); let loadListener; - const isDocument = rootNode?.nodeType === Node.DOCUMENT_NODE; - const isShadowRoot = rootNode?.nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in rootNode; + const isRootNodeDocument = isDocument(rootNode); + const isRootNodeShadowRoot = isShadowRoot(rootNode); // Use nodeType to check if it's either a Document or a ShadowRoot if ( - (isDocument && (rootNode as Document).readyState !== 'loading') || - (isShadowRoot) + (isRootNodeDocument && (rootNode as Document).readyState !== 'loading') || + (isRootNodeShadowRoot) ) { // If it's a Document that's ready or a ShadowRoot setupGlobalFocusEvents(element); - } else if (isDocument) { + } else if (isRootNodeDocument) { // If it's a Document that's still loading loadListener = () => { setupGlobalFocusEvents(element); diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 101e8948cb3..d651a2bb3ee 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -34,15 +34,12 @@ export const getRootNode = ( // contexts (e.g., iframes or windows), as each context has its own global objects and constructors. // nodeType is a primitive value and is consistent across different contexts, making it // reliable for cross-context type checking. - - const nodeType = rootNode.nodeType; - - if (nodeType === Node.DOCUMENT_NODE) { + if (isDocument(rootNode)) { // rootNode is a Document return rootNode as Document; } - if (nodeType === Node.DOCUMENT_FRAGMENT_NODE && 'host' in rootNode) { + if (isShadowRoot(rootNode)) { // rootNode is a ShadowRoot (a specialized type of DocumentFragment) // We check for the presence of the 'host' property to distinguish ShadowRoot from other DocumentFragments return rootNode as ShadowRoot; @@ -63,7 +60,7 @@ export const getRootNode = ( * @returns {HTMLElement} - The "body" element of the document, or the document's body associated with the shadow root. */ export const getRootBody = (root: Document | ShadowRoot): HTMLElement => { - if (root instanceof ShadowRoot) { + if (isShadowRoot(root)) { return root.ownerDocument?.body; } else { return root.body; @@ -78,3 +75,30 @@ export const getDeepActiveElement = () => { } return activeElement; }; + +/** + * Type guard for checking if a value is a Node + */ +function isNode(value: unknown): value is Node { + return value !== null && + typeof value === 'object' && + 'nodeType' in value && + typeof (value as Node).nodeType === 'number'; +} + +/** + * Type guard for Document nodes + */ +export function isDocument(node: Node | null): node is Document { + return isNode(node) && node.nodeType === Node.DOCUMENT_NODE; +} + +/** + * Type guard for ShadowRoot nodes + * Uses both nodeType and host property checks to distinguish ShadowRoot from other DocumentFragments + */ +export function isShadowRoot(node: Node | null): node is ShadowRoot { + return isNode(node) && + node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && + 'host' in node; +} diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 16ce04d8300..a712b446fc6 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -11,7 +11,7 @@ */ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; -export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, getDeepActiveElement} from './domHelpers'; +export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, getDeepActiveElement, isDocument, isShadowRoot} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; From ab9f8dbde0ceea7c2324cfdca3e0e3e87354bfd2 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 25 Oct 2024 08:19:48 +0300 Subject: [PATCH 081/102] - Lint. --- packages/@react-aria/utils/src/domHelpers.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index d651a2bb3ee..ee0b301f6b2 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -49,7 +49,6 @@ export const getRootNode = ( return null; }; - /** * Retrieves a reference to the most appropriate "body" element for a given DOM context, * accommodating both traditional DOM and Shadow DOM environments. When used with a Shadow DOM, @@ -77,7 +76,7 @@ export const getDeepActiveElement = () => { }; /** - * Type guard for checking if a value is a Node + * Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType property. */ function isNode(value: unknown): value is Node { return value !== null && @@ -87,15 +86,15 @@ function isNode(value: unknown): value is Node { } /** - * Type guard for Document nodes + * Type guard that checks if a node is a Document node. Uses nodeType for cross-context compatibility. */ export function isDocument(node: Node | null): node is Document { return isNode(node) && node.nodeType === Node.DOCUMENT_NODE; } /** - * Type guard for ShadowRoot nodes - * Uses both nodeType and host property checks to distinguish ShadowRoot from other DocumentFragments + * Type guard that checks if a node is a ShadowRoot. Uses nodeType and host property checks to + * distinguish ShadowRoot from other DocumentFragments. */ export function isShadowRoot(node: Node | null): node is ShadowRoot { return isNode(node) && From 47be39338570e0732c80cb6a0de3732d116073db Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 25 Oct 2024 08:29:56 +0300 Subject: [PATCH 082/102] - Lint. --- packages/@react-aria/focus/src/FocusScope.tsx | 2 +- packages/@react-aria/interactions/src/useFocusVisible.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index e5b6f0e615e..65392f942e3 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,7 @@ import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getOwnerDocument, getRootBody, getRootNode, useLayoutEffect, isShadowRoot} from '@react-aria/utils'; +import {getOwnerDocument, getRootBody, getRootNode, isShadowRoot, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 91aaa6b8ec8..b8d8ffbd6ec 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 {getOwnerWindow, getRootNode, isMac, isVirtualClick, isDocument, isShadowRoot} from '@react-aria/utils'; +import {getOwnerWindow, getRootNode, isDocument, isMac, isShadowRoot, isVirtualClick} from '@react-aria/utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; From 54cfe242704b59e01003cc7ef83717ae9d5d8899 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Thu, 31 Oct 2024 05:11:01 +0300 Subject: [PATCH 083/102] - Update `getDeepActiveElement` to accept an optional document or shadowRoot. - Fix an issue where opening any popover, the focus wasn't restored to the trigger element in shadow DOM. --- packages/@react-aria/focus/src/FocusScope.tsx | 11 +++++++++-- packages/@react-aria/utils/src/domHelpers.ts | 8 +++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 65392f942e3..94b500c6dff 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -12,7 +12,14 @@ import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; -import {getOwnerDocument, getRootBody, getRootNode, isShadowRoot, useLayoutEffect} from '@react-aria/utils'; +import { + getDeepActiveElement, + getOwnerDocument, + getRootBody, + getRootNode, + isShadowRoot, + useLayoutEffect +} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -561,7 +568,7 @@ function shouldRestoreFocus(scopeRef: ScopeRef) { function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, contain?: boolean) { // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts. // eslint-disable-next-line no-restricted-globals - const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null); + const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getDeepActiveElement(getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined)) as FocusableElement : null); // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus // restoring-non-containing scopes should only care if they become active so they can perform the restore diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index ee0b301f6b2..cc53f7513c3 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -66,12 +66,14 @@ export const getRootBody = (root: Document | ShadowRoot): HTMLElement => { } }; +export const getDeepActiveElement = (doc: Document | ShadowRoot = document) => { + let activeElement: Element | null = doc.activeElement; -export const getDeepActiveElement = () => { - let activeElement = document.activeElement; - while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { + while (activeElement && 'shadowRoot' in activeElement && + activeElement.shadowRoot?.activeElement) { activeElement = activeElement.shadowRoot.activeElement; } + return activeElement; }; From 95a6b64fb6a1324abd703b5809dd039f41aae838 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 1 Nov 2024 08:50:38 +0200 Subject: [PATCH 084/102] - Add extra unit test for `getDeepActiveElement`. --- packages/@react-aria/utils/test/domHelpers.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index 3086b7edfe3..5dabc8f0645 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -217,4 +217,18 @@ describe('getDeepActiveElement', () => { document.body.removeChild(hostDiv); document.body.removeChild(bodyInput); }); + + it('returns the active element within an iframe', () => { + const iframe = document.createElement('iframe'); + const input = document.createElement('input'); + window.document.body.appendChild(iframe); + iframe.contentWindow.document.body.appendChild(input); + + act(() => {input.focus();}); + + expect(getDeepActiveElement(iframe.contentWindow.document)).toBe(input); + + // Teardown + iframe.remove(); + }); }); From c830aabba3ea66230e235fabf54b7baf7eb3016e Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 1 Nov 2024 09:18:18 +0200 Subject: [PATCH 085/102] - Update `getDeepActiveElement` to always rely on `getRootNode`. --- packages/@react-aria/focus/src/focusSafely.ts | 10 ++++++++-- packages/@react-aria/interactions/src/useFocus.ts | 4 ++-- .../@react-aria/interactions/src/useFocusWithin.ts | 6 ++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index ea4818d7b26..fdabeffbe84 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -11,8 +11,13 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getDeepActiveElement, runAfterTransition} from '@react-aria/utils'; +import { + focusWithoutScrolling, + getDeepActiveElement, + runAfterTransition +} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; +import {getRootNode} from '@react-aria/utils/src'; /** * A utility function that focuses an element while avoiding undesired side effects such @@ -24,7 +29,8 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const activeElement = getDeepActiveElement(); + const rootNode = getRootNode(element); + const activeElement = rootNode ? getDeepActiveElement(rootNode) : getDeepActiveElement(); if (getInteractionModality() === 'virtual') { let lastFocusedElement = activeElement; runAfterTransition(() => { diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index f3d54a2a602..719fed332df 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,7 +17,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared'; import {FocusEvent, useCallback} from 'react'; -import {getDeepActiveElement, getRootNode, isShadowRoot} from '@react-aria/utils'; +import {getDeepActiveElement, getRootNode} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { @@ -64,7 +64,7 @@ export function useFocus(pro // focus handler already moved focus somewhere else. const ownerDocument = getRootNode(e.target); - const activeElement = isShadowRoot(ownerDocument) ? getDeepActiveElement() : ownerDocument?.activeElement; + const activeElement = ownerDocument ? getDeepActiveElement(ownerDocument) : getDeepActiveElement(); if (e.target === e.currentTarget && activeElement === e.target) { if (onFocusProp) { onFocusProp(e); diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 8ad74562304..1e843dbacd6 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -17,7 +17,7 @@ import {DOMAttributes} from '@react-types/shared'; import {FocusEvent, useCallback, useRef} from 'react'; -import {getDeepActiveElement} from '@react-aria/utils'; +import {getDeepActiveElement, getRootNode} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusWithinProps { @@ -71,7 +71,9 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onFocus = useCallback((e: FocusEvent) => { // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - if (!state.current.isFocusWithin && getDeepActiveElement() === e.target) { + const ownerDocument = getRootNode(e.target); + const activeElement = ownerDocument ? getDeepActiveElement(ownerDocument) : getDeepActiveElement(); + if (!state.current.isFocusWithin && activeElement === e.target) { if (onFocusWithin) { onFocusWithin(e); } From 319617f474b758a2b6ed724ccbb11edf38c65efa Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 1 Nov 2024 09:28:17 +0200 Subject: [PATCH 086/102] - Update `getDeepActiveElement` to always rely on `getRootNode`. --- packages/@react-aria/focus/src/focusSafely.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index fdabeffbe84..55ea9704ab5 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -14,10 +14,10 @@ import {FocusableElement} from '@react-types/shared'; import { focusWithoutScrolling, getDeepActiveElement, + getRootNode, runAfterTransition } from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; -import {getRootNode} from '@react-aria/utils/src'; /** * A utility function that focuses an element while avoiding undesired side effects such From 03c23c4d41d65cebf685a9d1d46f1bb73f8c77cc Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 4 Dec 2024 10:48:44 +1100 Subject: [PATCH 087/102] refactor usePress to still have global listeners for cleanup across boundaries --- .../@react-aria/interactions/src/usePress.ts | 130 +++++++++++------- .../interactions/test/usePress.test.js | 92 +++++++++++-- 2 files changed, 160 insertions(+), 62 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 766d9192ee2..c1bbfd92bdb 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -293,8 +293,8 @@ export function usePress(props: PressHookProps): PressResult { let state = ref.current; let pressProps: DOMAttributes = { onKeyDown(e) { - if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && e.currentTarget.contains(e.target as Element)) { - if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) { + if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (shouldPreventDefaultKeyboard(e.nativeEvent.composedPath()[0] as Element, e.key)) { e.preventDefault(); } @@ -312,16 +312,12 @@ export function usePress(props: PressHookProps): PressResult { // 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) => { - if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && originalTarget.contains(e.target as Element) && state.target) { + if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, e.composedPath()[0] as Element) && state.target) { triggerPressUp(createEvent(state.target, e), 'keyboard'); } }; - const ownerDocument = getRootNode(e.currentTarget); - - if (ownerDocument) { - addGlobalListener(ownerDocument, 'keyup', chain(pressUp, onKeyUp), true); - } + addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); } if (shouldStopPropagation) { @@ -343,7 +339,7 @@ export function usePress(props: PressHookProps): PressResult { } }, onClick(e) { - if (e && !e.currentTarget.contains(e.target as Element)) { + if (e && !nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -378,18 +374,18 @@ export function usePress(props: PressHookProps): PressResult { let onKeyUp = (e: KeyboardEvent) => { if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { - if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) { + if (shouldPreventDefaultKeyboard(e.composedPath()[0] as Element, e.key)) { e.preventDefault(); } - let target = e.target as Element; - triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target)); + let target = e.composedPath()[0] as Element; + triggerPressEnd(createEvent(state.target, e), 'keyboard', nodeContains(state.target, e.composedPath()[0] as Element)); 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) && state.target.contains(target) && !e[LINK_CLICKED]) { + 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; @@ -413,7 +409,7 @@ export function usePress(props: PressHookProps): PressResult { if (typeof PointerEvent !== 'undefined') { pressProps.onPointerDown = (e) => { // Only handle left clicks, and ignore events that bubbled through portals. - if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) { + if (e.button !== 0 || !nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -439,10 +435,10 @@ export function usePress(props: PressHookProps): PressResult { state.isPressed = true; state.isOverTarget = true; state.activePointerId = e.pointerId; - state.target = e.currentTarget; + state.target = e.nativeEvent.composedPath()[0] as FocusableElement; if (!isDisabled && !preventFocusOnPress) { - focusWithoutScrolling(e.currentTarget); + focusWithoutScrolling(state.target); } if (!allowTextSelectionOnPress) { @@ -451,11 +447,15 @@ export function usePress(props: PressHookProps): PressResult { shouldStopPropagation = triggerPressStart(e, state.pointerType); - const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); + // Release pointer capture so that touch interactions can leave the original target. + // This enables onPointerLeave and onPointerEnter to fire. + let target = e.nativeEvent.composedPath()[0] as Element; + if ('releasePointerCapture' in target) { + target.releasePointerCapture(e.pointerId); + } - addGlobalListener(ownerDocument, 'pointermove', onPointerMove, false); - addGlobalListener(ownerDocument, 'pointerup', onPointerUp, false); - addGlobalListener(ownerDocument, 'pointercancel', onPointerCancel, false); + addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false); + addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false); } if (shouldStopPropagation) { @@ -464,7 +464,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseDown = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -482,32 +482,25 @@ export function usePress(props: PressHookProps): PressResult { pressProps.onPointerUp = (e) => { // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown. - if (!e.currentTarget.contains(e.target as Element) || state.pointerType === 'virtual') { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element) || state.pointerType === 'virtual') { return; } // Only handle left clicks - // Safari on iOS sometimes fires pointerup events, even - // when the touch isn't over the target, so double check. - if (e.button === 0 && isOverTarget(e, e.currentTarget)) { + if (e.button === 0) { triggerPressUp(e, state.pointerType || e.pointerType); } }; - // Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly. - // Use pointer move events instead to implement our own hit testing. - // See https://bugs.webkit.org/show_bug.cgi?id=199803 - let onPointerMove = (e: PointerEvent) => { - if (e.pointerId !== state.activePointerId) { - return; + pressProps.onPointerEnter = (e) => { + if (e.pointerId === state.activePointerId && state.target && !state.isOverTarget && state.pointerType != null) { + state.isOverTarget = true; + triggerPressStart(createEvent(state.target, e), state.pointerType); } + }; - if (state.target && isOverTarget(e, state.target)) { - if (!state.isOverTarget && state.pointerType != null) { - state.isOverTarget = true; - triggerPressStart(createEvent(state.target, e), state.pointerType); - } - } else if (state.target && state.isOverTarget && state.pointerType != null) { + pressProps.onPointerLeave = (e) => { + if (e.pointerId === state.activePointerId && state.target && state.isOverTarget && state.pointerType != null) { state.isOverTarget = false; triggerPressEnd(createEvent(state.target, e), state.pointerType, false); cancelOnPointerExit(e); @@ -516,7 +509,7 @@ export function usePress(props: PressHookProps): PressResult { let onPointerUp = (e: PointerEvent) => { if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { - if (isOverTarget(e, state.target) && state.pointerType != null) { + if (nodeContains(state.target, e.composedPath()[0] as Element) && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType); } else if (state.isOverTarget && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType, false); @@ -557,7 +550,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -567,7 +560,7 @@ export function usePress(props: PressHookProps): PressResult { } else { pressProps.onMouseDown = (e) => { // Only handle left clicks - if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) { + if (e.button !== 0 || !nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -595,13 +588,12 @@ export function usePress(props: PressHookProps): PressResult { if (shouldStopPropagation) { e.stopPropagation(); } - const ownerDocument = getRootNode(e.currentTarget) || getOwnerDocument(e.currentTarget); - addGlobalListener(ownerDocument, 'mouseup', onMouseUp, false); + addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false); }; pressProps.onMouseEnter = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -617,7 +609,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseLeave = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -634,7 +626,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseUp = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -667,7 +659,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchStart = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -701,7 +693,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchMove = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -729,7 +721,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchEnd = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -762,7 +754,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchCancel = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -773,7 +765,7 @@ export function usePress(props: PressHookProps): PressResult { }; let onScroll = (e: Event) => { - if (state.isPressed && (e.target as Element).contains(state.target)) { + if (state.isPressed && nodeContains(e.composedPath()[0] as Element, state.target)) { cancel({ currentTarget: state.target, shiftKey: false, @@ -785,7 +777,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { return; } @@ -808,7 +800,7 @@ export function usePress(props: PressHookProps): PressResult { ]); // Remove user-select: none in case component unmounts immediately after pressStart - + useEffect(() => { return () => { if (!allowTextSelectionOnPress) { @@ -1001,3 +993,37 @@ function isValidInputKey(target: HTMLInputElement, key: string) { ? key === ' ' : nonTextInputTypes.has(target.type); } + +// https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 +export function nodeContains( + node: Node | null | undefined, + otherNode: Node | null | undefined +): boolean { + if (!node || !otherNode) { + return false; + } + + let currentNode: HTMLElement | Node | null | undefined = otherNode; + + while (currentNode) { + if (currentNode === node) { + return true; + } + + if ( + typeof (currentNode as HTMLSlotElement).assignedElements !== + 'function' && + (currentNode as HTMLElement).assignedSlot?.parentNode + ) { + // Element is slotted + currentNode = (currentNode as HTMLElement).assignedSlot?.parentNode; + } else if (currentNode.nodeType === document.DOCUMENT_FRAGMENT_NODE) { + // Element is in shadow root + currentNode = (currentNode as ShadowRoot).host; + } else { + currentNode = currentNode.parentNode; + } + } + + return false; +} diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 411cd60e453..ddff2ac92fc 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -28,7 +28,7 @@ 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, composed: true}); Object.assign(evt, { ctrlKey: false, metaKey: false, @@ -145,11 +145,9 @@ describe('usePress', function () { el.releasePointerCapture = jest.fn(); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(el.releasePointerCapture).toHaveBeenCalled(); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); // react listens for pointerout and pointerover instead of pointerleave and pointerenter... fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); fireEvent(document, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ @@ -3743,11 +3741,13 @@ describe('usePress', function () { const shadowRoot = setupShadowDOMTest(); const el = shadowRoot.getElementById('testElement'); + el.releasePointerCapture = jest.fn(); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + expect(el.releasePointerCapture).toHaveBeenCalled(); + // react listens for pointerout and pointerover instead of pointerleave and pointerenter... + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(document, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); expect(events).toEqual([ expect.objectContaining({ @@ -3782,8 +3782,9 @@ describe('usePress', function () { events = []; fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + // react listens for pointerout and pointerover instead of pointerleave and pointerenter... + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ @@ -3940,14 +3941,85 @@ describe('usePress', function () { ]); }); + it('should clean up press state if pointerup was outside the shadow dom', function () { + const shadowRoot = setupShadowDOMTest({shouldCancelOnPointerExit: true}); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(document.body, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + fireEvent(document.body, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ + type: 'presschange', + pressed: true + }), + expect.objectContaining({ + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'presschange', + pressed: false + }), + expect.objectContaining({ + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ + type: 'presschange', + pressed: true + }), + expect.objectContaining({ + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'presschange', + pressed: false + }) + ]); + }); + it('should cancel press when moving outside and the shouldCancelOnPointerExit option is set', function () { const shadowRoot = setupShadowDOMTest({shouldCancelOnPointerExit: true}); const el = shadowRoot.getElementById('testElement'); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); - fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100})); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); expect(events).toEqual([ expect.objectContaining({ From cf3f567bfd2b735f01bed1fa8fe750681bc16eb4 Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 4 Dec 2024 11:09:16 +1100 Subject: [PATCH 088/102] fix lint and test --- packages/@react-aria/interactions/src/usePress.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index c1bbfd92bdb..b5ff74d6127 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -20,7 +20,6 @@ import { focusWithoutScrolling, getOwnerDocument, getOwnerWindow, - getRootNode, isMac, isVirtualClick, isVirtualPointerEvent, @@ -435,7 +434,7 @@ export function usePress(props: PressHookProps): PressResult { state.isPressed = true; state.isOverTarget = true; state.activePointerId = e.pointerId; - state.target = e.nativeEvent.composedPath()[0] as FocusableElement; + state.target = e.currentTarget as FocusableElement; if (!isDisabled && !preventFocusOnPress) { focusWithoutScrolling(state.target); @@ -1000,7 +999,7 @@ export function nodeContains( otherNode: Node | null | undefined ): boolean { if (!node || !otherNode) { - return false; + return false; } let currentNode: HTMLElement | Node | null | undefined = otherNode; From fc82be09b0a150eddb3829fb27775df0c5f66259 Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 13 Dec 2024 10:04:57 +1100 Subject: [PATCH 089/102] restore remaining document level listeners --- packages/@react-aria/focus/src/FocusScope.tsx | 115 ++------ packages/@react-aria/focus/src/focusSafely.ts | 10 +- .../@react-aria/interactions/src/useFocus.ts | 6 +- .../interactions/src/useFocusVisible.ts | 61 ++-- .../interactions/src/useFocusWithin.ts | 6 +- .../@react-aria/interactions/src/usePress.ts | 35 +-- .../interactions/test/usePress.test.js | 4 +- packages/@react-aria/utils/src/domHelpers.ts | 11 - packages/@react-aria/utils/src/index.ts | 4 +- .../utils/src/shadowdom/DOMFunctions.ts | 85 ++++++ .../utils/src/shadowdom/ShadowTreeWalker.ts | 268 ++++++++++++++++++ .../@react-aria/utils/test/domHelpers.test.js | 16 +- .../list/test/ListView.test.js | 43 ++- .../list/test/ListViewDnd.test.js | 122 ++++---- .../@react-spectrum/table/test/Table.test.js | 253 +++++++++-------- .../table/test/TableDnd.test.js | 178 +++++++----- .../table/test/TreeGridTable.test.tsx | 174 ++++++++---- .../test/GridList.test.js | 70 +++-- .../react-aria-components/test/Table.test.js | 45 ++- 19 files changed, 953 insertions(+), 553 deletions(-) create mode 100644 packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts create mode 100644 packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 1090dcf5ea6..d4803fa2396 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -10,14 +10,12 @@ * governing permissions and limitations under the License. */ +import {createShadowTreeWalker, ShadowTreeWalker} from '@react-aria/utils/src/shadowdom/ShadowTreeWalker'; import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; import { - getDeepActiveElement, + getActiveElement, getOwnerDocument, - getRootBody, - getRootNode, - isShadowRoot, useLayoutEffect } from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; @@ -150,7 +148,7 @@ export function FocusScope(props: FocusScopeProps) { // This needs to be an effect so that activeScope is updated after the FocusScope tree is complete. // It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet. useEffect(() => { - const activeElement = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined)?.activeElement; + const activeElement = getActiveElement(getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined)); let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { @@ -214,7 +212,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusNext(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getRootNode(scope[0])?.activeElement!; + let node = from || getActiveElement(getOwnerDocument(scope[0] ?? undefined))!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -232,11 +230,11 @@ function createFocusManagerForScope(scopeRef: React.RefObject) focusPrevious(opts: FocusManagerOptions = {}) { let scope = scopeRef.current!; let {from, tabbable, wrap, accept} = opts; - let node = from || getRootNode(scope[0])?.activeElement!; + let node = from || getActiveElement(getOwnerDocument(scope[0] ?? undefined))!; let sentinel = scope[scope.length - 1].nextElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); - walker.currentNode = isElementInScope(node, scope) ? node : sentinel; + walker.currentNode = isElementInScope(node, scope) ? node : sentinel; let previousNode = walker.previousNode() as FocusableElement; if (!previousNode && wrap) { walker.currentNode = sentinel; @@ -331,7 +329,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo return; } - const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); // Handle the Tab key to contain focus within the scope let onKeyDown = (e) => { @@ -339,7 +337,7 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo return; } - let focusedElement = ownerDocument.activeElement; + let focusedElement = getActiveElement(ownerDocument); let scope = scopeRef.current; if (!scope || !isElementInScope(focusedElement, scope)) { return; @@ -389,9 +387,10 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo } raf.current = requestAnimationFrame(() => { // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe - if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { + let activeElement = getActiveElement(ownerDocument); + if (activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef)) { activeScope = scopeRef; - if (getRootBody(ownerDocument).contains(e.target)) { + if (e.target.isConnected) { focusedNode.current = e.target; focusedNode.current?.focus(); } else if (activeScope.current) { @@ -532,7 +531,7 @@ function useActiveScopeTracker(scopeRef: RefObject, restore?: } let scope = scopeRef.current; - const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); let onFocus = (e) => { let target = e.target as Element; @@ -568,13 +567,13 @@ function shouldRestoreFocus(scopeRef: ScopeRef) { function useRestoreFocus(scopeRef: RefObject, restoreFocus?: boolean, contain?: boolean) { // create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts. // eslint-disable-next-line no-restricted-globals - const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getDeepActiveElement(getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined)) as FocusableElement : null); + const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getActiveElement(getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined)) as FocusableElement : null); // restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus // restoring-non-containing scopes should only care if they become active so they can perform the restore useLayoutEffect(() => { let scope = scopeRef.current; - const ownerDocument = getRootNode(scope ? scope[0] : undefined) || getOwnerDocument(scope ? scope[0] : undefined); + const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); if (!restoreFocus || contain) { return; } @@ -583,7 +582,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && - isElementInScope(ownerDocument.activeElement, scopeRef.current) + isElementInScope(getActiveElement(ownerDocument), scopeRef.current) ) { activeScope = scopeRef; } @@ -599,7 +598,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b }, [scopeRef, contain]); useLayoutEffect(() => { - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); + const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -624,16 +623,14 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b } let nodeToRestore = treeNode.nodeToRestore; - const rootBody = getRootBody(ownerDocument); - // Create a DOM tree walker that matches all tabbable elements - let walker = getFocusableTreeWalker(rootBody, {tabbable: true}); + let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true}); // Find the next tabbable element after the currently focused element walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; - if (!nodeToRestore || !rootBody.contains(nodeToRestore) || nodeToRestore === rootBody) { + if (!nodeToRestore || !nodeToRestore.isConnected || nodeToRestore === ownerDocument.body) { nodeToRestore = undefined; treeNode.nodeToRestore = undefined; } @@ -678,8 +675,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b // useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously. useLayoutEffect(() => { - const ownerDocument = getRootNode(scopeRef.current ? scopeRef.current[0] : undefined) || getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); - const rootBody = getRootBody(ownerDocument); + const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); if (!restoreFocus) { return; @@ -698,18 +694,19 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b let nodeToRestore = treeNode.nodeToRestore; // if we already lost focus to the body and this was the active scope, then we should attempt to restore + let activeElement = getActiveElement(ownerDocument); if ( restoreFocus && nodeToRestore && ( - ((ownerDocument.activeElement && isElementInChildScope(ownerDocument.activeElement, scopeRef)) || (ownerDocument.activeElement === rootBody && shouldRestoreFocus(scopeRef))) + ((activeElement && isElementInChildScope(activeElement, scopeRef)) || (activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef))) ) ) { // freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it 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 === rootBody) { + if (ownerDocument.activeElement === 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) { @@ -751,24 +748,19 @@ function restoreFocusToElement(node: FocusableElement) { * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ -export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusManagerOptions, scope?: Element[]) { +export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]): ShadowTreeWalker { let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; // Ensure that root is an Element or fall back appropriately let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; // Determine the document to use - let doc = isShadowRoot(rootElement) - ? root - : (getRootNode(rootElement) || getOwnerDocument(rootElement)); - - // Ensure effectiveDocument is always a Document - let effectiveDocument = isShadowRoot(doc) - ? (doc as ShadowRoot).ownerDocument - : (doc as Document); + let doc = getOwnerDocument(rootElement); + // console.log('doc', doc, root); // Create a TreeWalker, ensuring the root is an Element or Document - let walker = effectiveDocument.createTreeWalker( + let walker = createShadowTreeWalker( + doc, root || doc, NodeFilter.SHOW_ELEMENT, { @@ -795,13 +787,6 @@ export function getFocusableTreeWalker(root: Element | ShadowRoot, opts?: FocusM walker.currentNode = opts.from; } - if (isShadowRoot(doc)) { - const originalNextNode = walker.nextNode.bind(walker); - const originalPreviousNode = walker.previousNode.bind(walker); - walker.nextNode = getNextShadowNode(originalNextNode, scope); - walker.previousNode = getPreviousShadowNode(originalPreviousNode, scope); - } - return walker; } @@ -816,7 +801,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; + let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -837,7 +822,7 @@ export function createFocusManager(ref: RefObject, defaultOption return null; } let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts; - let node = from || (getRootNode(root) || getOwnerDocument(root)).activeElement; + let node = from || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -892,7 +877,7 @@ export function createFocusManager(ref: RefObject, defaultOption }; } -function last(walker: TreeWalker) { +function last(walker: ShadowTreeWalker) { let next: FocusableElement | undefined = undefined; let last: FocusableElement; do { @@ -904,46 +889,6 @@ function last(walker: TreeWalker) { return next; } -function getNextShadowNode(originalNextNode: () => Node | null, scope?: Element[]) { - return function () { - let nextElement = originalNextNode(); - if (!nextElement && scope && scope.length > 0) { - let currentShadowRoot = scope[0].getRootNode(); - let nextShadowHost = isShadowRoot(currentShadowRoot) ? currentShadowRoot.host.nextElementSibling : null; - while (nextShadowHost) { - if (nextShadowHost.shadowRoot) { - let nextShadowScope = Array.from(nextShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); - if (nextShadowScope.length > 0) { - return nextShadowScope[0]; - } - } - nextShadowHost = nextShadowHost.nextElementSibling; - } - } - return nextElement; - }; -} - -function getPreviousShadowNode(originalPreviousNode: () => Node | null, scope?: Element[]) { - return function () { - let previousElement = originalPreviousNode(); - if (!previousElement && scope && scope.length > 0) { - let currentShadowRoot = scope[0].getRootNode(); - let previousShadowHost = isShadowRoot(currentShadowRoot) ? currentShadowRoot.host.previousElementSibling : null; - while (previousShadowHost) { - if (previousShadowHost.shadowRoot) { - let previousShadowScope = Array.from(previousShadowHost.shadowRoot.querySelectorAll('*')).filter(isFocusable); - if (previousShadowScope.length > 0) { - return previousShadowScope[previousShadowScope.length - 1]; - } - } - previousShadowHost = previousShadowHost.previousElementSibling; - } - } - return previousElement; - }; -} - class Tree { root: TreeNode; diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index 55ea9704ab5..10873ba00ab 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -13,8 +13,8 @@ import {FocusableElement} from '@react-types/shared'; import { focusWithoutScrolling, - getDeepActiveElement, - getRootNode, + getActiveElement, + getOwnerDocument, runAfterTransition } from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; @@ -29,13 +29,13 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const rootNode = getRootNode(element); - const activeElement = rootNode ? getDeepActiveElement(rootNode) : getDeepActiveElement(); + const rootNode = getOwnerDocument(element); + const activeElement = getActiveElement(rootNode); if (getInteractionModality() === 'virtual') { let lastFocusedElement = activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. - if (activeElement === lastFocusedElement && element.isConnected) { + if (getActiveElement(rootNode) === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 719fed332df..47cf8727da3 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,7 +17,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared'; import {FocusEvent, useCallback} from 'react'; -import {getDeepActiveElement, getRootNode} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { @@ -63,8 +63,8 @@ export function useFocus(pro // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getRootNode(e.target); - const activeElement = ownerDocument ? getDeepActiveElement(ownerDocument) : getDeepActiveElement(); + const ownerDocument = getOwnerDocument(e.target); + const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement(); if (e.target === e.currentTarget && activeElement === e.target) { if (onFocusProp) { onFocusProp(e); diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index b8d8ffbd6ec..482b63c891f 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 {getOwnerWindow, getRootNode, isDocument, isMac, isShadowRoot, isVirtualClick} from '@react-aria/utils'; +import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -140,7 +140,7 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { } const windowObject = getOwnerWindow(element); - const documentObject = getRootNode(element); + const documentObject = getOwnerDocument(element); // Programmatic focus() calls shouldn't affect the current input modality. // However, we need to detect other cases when a focus event occurs without @@ -152,9 +152,9 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); }; - documentObject?.addEventListener('keydown', createEventListener(handleKeyboardEvent), true); - documentObject?.addEventListener('keyup', createEventListener(handleKeyboardEvent), true); - documentObject?.addEventListener('click', createEventListener(handleClickEvent), true); + documentObject.addEventListener('keydown', createEventListener(handleKeyboardEvent), true); + documentObject.addEventListener('keyup', createEventListener(handleKeyboardEvent), true); + documentObject.addEventListener('click', createEventListener(handleClickEvent), true); // Register focus events on the window so they are sure to happen // before React's event listeners (registered on the document). @@ -162,13 +162,13 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { windowObject.addEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject?.addEventListener('pointerdown', createEventListener(handlePointerEvent), true); - documentObject?.addEventListener('pointermove', createEventListener(handlePointerEvent), true); - documentObject?.addEventListener('pointerup', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('pointerdown', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('pointermove', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('pointerup', createEventListener(handlePointerEvent), true); } else { - documentObject?.addEventListener('mousedown', createEventListener(handlePointerEvent), true); - documentObject?.addEventListener('mousemove', createEventListener(handlePointerEvent), true); - documentObject?.addEventListener('mouseup', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('mousedown', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('mousemove', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('mouseup', createEventListener(handlePointerEvent), true); } // Add unmount handler @@ -181,30 +181,30 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { const windowObject = getOwnerWindow(element); - const documentObject = getRootNode(element); + const documentObject = getOwnerDocument(element); if (loadListener) { - documentObject?.removeEventListener('DOMContentLoaded', loadListener); + documentObject.removeEventListener('DOMContentLoaded', loadListener); } if (!hasSetupGlobalListeners.has(windowObject)) { return; } windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus; - documentObject?.removeEventListener('keydown', createEventListener(handleKeyboardEvent), true); - documentObject?.removeEventListener('keyup', createEventListener(handleKeyboardEvent), true); - documentObject?.removeEventListener('click', createEventListener(handleClickEvent), true); + documentObject.removeEventListener('keydown', createEventListener(handleKeyboardEvent), true); + documentObject.removeEventListener('keyup', createEventListener(handleKeyboardEvent), true); + documentObject.removeEventListener('click', createEventListener(handleClickEvent), true); windowObject.removeEventListener('focus', handleFocusEvent, true); windowObject.removeEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject?.removeEventListener('pointerdown', createEventListener(handlePointerEvent), true); - documentObject?.removeEventListener('pointermove', createEventListener(handlePointerEvent), true); - documentObject?.removeEventListener('pointerup', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('pointerdown', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('pointermove', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('pointerup', createEventListener(handlePointerEvent), true); } else { - documentObject?.removeEventListener('mousedown', createEventListener(handlePointerEvent), true); - documentObject?.removeEventListener('mousemove', createEventListener(handlePointerEvent), true); - documentObject?.removeEventListener('mouseup', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('mousedown', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('mousemove', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('mouseup', createEventListener(handlePointerEvent), true); } hasSetupGlobalListeners.delete(windowObject); @@ -228,24 +228,15 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { * @returns A function to remove the event listeners and cleanup the state. */ export function addWindowFocusTracking(element?: HTMLElement | null): () => void { - const rootNode = getRootNode(element); + const documentObject = getOwnerDocument(element); let loadListener; - - const isRootNodeDocument = isDocument(rootNode); - const isRootNodeShadowRoot = isShadowRoot(rootNode); - // Use nodeType to check if it's either a Document or a ShadowRoot - if ( - (isRootNodeDocument && (rootNode as Document).readyState !== 'loading') || - (isRootNodeShadowRoot) - ) { - // If it's a Document that's ready or a ShadowRoot + if (documentObject.readyState !== 'loading') { setupGlobalFocusEvents(element); - } else if (isRootNodeDocument) { - // If it's a Document that's still loading + } else { loadListener = () => { setupGlobalFocusEvents(element); }; - rootNode.addEventListener('DOMContentLoaded', loadListener); + documentObject.addEventListener('DOMContentLoaded', loadListener); } return () => tearDownWindowFocusTracking(element, loadListener); diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 1e843dbacd6..58e8a22a33e 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -17,7 +17,7 @@ import {DOMAttributes} from '@react-types/shared'; import {FocusEvent, useCallback, useRef} from 'react'; -import {getDeepActiveElement, getRootNode} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusWithinProps { @@ -71,8 +71,8 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { let onFocus = useCallback((e: FocusEvent) => { // Double check that document.activeElement actually matches e.target in case a previously chained // focus handler already moved focus somewhere else. - const ownerDocument = getRootNode(e.target); - const activeElement = ownerDocument ? getDeepActiveElement(ownerDocument) : getDeepActiveElement(); + const ownerDocument = getOwnerDocument(e.target); + const activeElement = getActiveElement(ownerDocument); if (!state.current.isFocusWithin && activeElement === e.target) { if (onFocusWithin) { onFocusWithin(e); diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index b5ff74d6127..491a102d77a 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -24,6 +24,7 @@ import { isVirtualClick, isVirtualPointerEvent, mergeProps, + nodeContains, openLink, useEffectEvent, useGlobalListeners, @@ -992,37 +993,3 @@ function isValidInputKey(target: HTMLInputElement, key: string) { ? key === ' ' : nonTextInputTypes.has(target.type); } - -// https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 -export function nodeContains( - node: Node | null | undefined, - otherNode: Node | null | undefined -): boolean { - if (!node || !otherNode) { - return false; - } - - let currentNode: HTMLElement | Node | null | undefined = otherNode; - - while (currentNode) { - if (currentNode === node) { - return true; - } - - if ( - typeof (currentNode as HTMLSlotElement).assignedElements !== - 'function' && - (currentNode as HTMLElement).assignedSlot?.parentNode - ) { - // Element is slotted - currentNode = (currentNode as HTMLElement).assignedSlot?.parentNode; - } else if (currentNode.nodeType === document.DOCUMENT_FRAGMENT_NODE) { - // Element is in shadow root - currentNode = (currentNode as ShadowRoot).host; - } else { - currentNode = currentNode.parentNode; - } - } - - return false; -} diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index ddff2ac92fc..2da8b9ea8a9 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -13,7 +13,7 @@ import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; -import {getDeepActiveElement} from '@react-aria/utils'; +import {getActiveElement} from '@react-aria/utils'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -4127,7 +4127,7 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); - const deepActiveElement = getDeepActiveElement(); + const deepActiveElement = getActiveElement(); expect(deepActiveElement).not.toBe(el); expect(deepActiveElement).not.toBe(shadowRoot); diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index cc53f7513c3..27e2407e45a 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -66,17 +66,6 @@ export const getRootBody = (root: Document | ShadowRoot): HTMLElement => { } }; -export const getDeepActiveElement = (doc: Document | ShadowRoot = document) => { - let activeElement: Element | null = doc.activeElement; - - while (activeElement && 'shadowRoot' in activeElement && - activeElement.shadowRoot?.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; - } - - return activeElement; -}; - /** * Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType property. */ diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index b1980c3f4b4..e3209e5771f 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -11,7 +11,9 @@ */ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; -export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, getDeepActiveElement, isDocument, isShadowRoot} from './domHelpers'; +export {createShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; +export {getActiveElement, nodeContains} from './shadowdom/DOMFunctions'; +export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, isDocument, isShadowRoot} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts new file mode 100644 index 00000000000..ca0e2afaa17 --- /dev/null +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -0,0 +1,85 @@ +// Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 + +export function nodeContains( + node: Node | null | undefined, + otherNode: Node | null | undefined +): boolean { + if (!node || !otherNode) { + return false; + } + + let currentNode: HTMLElement | Node | null | undefined = otherNode; + + while (currentNode) { + if (currentNode === node) { + return true; + } + + if ( + typeof (currentNode as HTMLSlotElement).assignedElements !== + 'function' && + (currentNode as HTMLElement).assignedSlot?.parentNode + ) { + // Element is slotted + currentNode = (currentNode as HTMLElement).assignedSlot?.parentNode; + } else if (currentNode.nodeType === document.DOCUMENT_FRAGMENT_NODE) { + // Element is in shadow root + currentNode = (currentNode as ShadowRoot).host; + } else { + currentNode = currentNode.parentNode; + } + } + + return false; +} + +export const getActiveElement = (doc: Document = document) => { + let activeElement: Element | null = doc.activeElement; + + while (activeElement && 'shadowRoot' in activeElement && + activeElement.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement; +}; + +export function getLastChild(node: Node | null | undefined): ChildNode | null { + if (!node) { + return null; + } + + if (!node.lastChild && (node as Element).shadowRoot) { + return getLastChild((node as Element).shadowRoot); + } + + return node.lastChild; +} + +export function getPreviousSibling( + node: Node | null | undefined +): ChildNode | null { + if (!node) { + return null; + } + + let sibling = node.previousSibling; + + if (!sibling && node.parentElement?.shadowRoot) { + sibling = getLastChild(node.parentElement.shadowRoot); + } + + return sibling; +} + +export function getLastElementChild( + element: Element | null | undefined +): Element | null { + let child = getLastChild(element); + + while (child && child.nodeType !== Node.ELEMENT_NODE) { + child = getPreviousSibling(child); + } + + return child as Element | null; +} diff --git a/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts new file mode 100644 index 00000000000..4927d5af455 --- /dev/null +++ b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts @@ -0,0 +1,268 @@ +// https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/ShadowTreeWalker.ts + +import {nodeContains} from './DOMFunctions'; + +export class ShadowTreeWalker implements TreeWalker { + public readonly filter: NodeFilter | null; + public readonly root: Node; + public readonly whatToShow: number; + + private _doc: Document; + private _walkerStack: Array = []; + private _currentNode: Node; + private _currentSetFor: Set = new Set(); + + constructor( + doc: Document, + root: Node, + whatToShow?: number, + filter?: NodeFilter | null + ) { + this._doc = doc; + this.root = root; + this.filter = filter ?? null; + this.whatToShow = whatToShow ?? NodeFilter.SHOW_ALL; + this._currentNode = root; + + this._walkerStack.unshift( + doc.createTreeWalker(root, whatToShow, this._acceptNode) + ); + + const shadowRoot = (root as Element).shadowRoot; + + if (shadowRoot) { + const walker = this._doc.createTreeWalker( + shadowRoot, + this.whatToShow, + {acceptNode: this._acceptNode} + ); + + this._walkerStack.unshift(walker); + } + } + + private _acceptNode = (node: Node): number => { + if (node.nodeType === Node.ELEMENT_NODE) { + const shadowRoot = (node as Element).shadowRoot; + + if (shadowRoot) { + const walker = this._doc.createTreeWalker( + shadowRoot, + this.whatToShow, + {acceptNode: this._acceptNode} + ); + + this._walkerStack.unshift(walker); + + return NodeFilter.FILTER_ACCEPT; + } else { + if (typeof this.filter === 'function') { + return this.filter(node); + } else if (this.filter?.acceptNode) { + return this.filter.acceptNode(node); + } else if (this.filter === null) { + return NodeFilter.FILTER_ACCEPT; + } + } + } + + return NodeFilter.FILTER_SKIP; + }; + + public get currentNode(): Node { + return this._currentNode; + } + + public set currentNode(node: Node) { + if (!nodeContains(this.root, node)) { + throw new Error( + 'Cannot set currentNode to a node that is not contained by the root node.' + ); + } + + const walkers: TreeWalker[] = []; + let curNode: Node | null | undefined = node; + let currentWalkerCurrentNode = node; + + this._currentNode = node; + + while (curNode && curNode !== this.root) { + if (curNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + const shadowRoot = curNode as ShadowRoot; + + const walker = this._doc.createTreeWalker( + shadowRoot, + this.whatToShow, + {acceptNode: this._acceptNode} + ); + + walkers.push(walker); + + walker.currentNode = currentWalkerCurrentNode; + + this._currentSetFor.add(walker); + + curNode = currentWalkerCurrentNode = shadowRoot.host; + } else { + curNode = curNode.parentNode; + } + } + + const walker = this._doc.createTreeWalker(this.root, this.whatToShow, { + acceptNode: this._acceptNode + }); + + walkers.push(walker); + + walker.currentNode = currentWalkerCurrentNode; + + this._currentSetFor.add(walker); + + this._walkerStack = walkers; + } + + public get doc(): Document { + return this._doc; + } + + public firstChild(): Node | null { + let walker = this._walkerStack[0]; + return walker.firstChild(); + } + + public lastChild(): Node | null { + let walker = this._walkerStack[0]; + return walker.lastChild(); + } + + public nextNode(): Node | null { + const nextNode = this._walkerStack[0].nextNode(); + + if (nextNode) { + const shadowRoot = (nextNode as Element).shadowRoot; + + if (shadowRoot) { + let nodeResult: number | undefined; + + if (typeof this.filter === 'function') { + nodeResult = this.filter(nextNode); + } else if (this.filter?.acceptNode) { + nodeResult = this.filter.acceptNode(nextNode); + } + + if (nodeResult === NodeFilter.FILTER_ACCEPT) { + return nextNode; + } + + // _acceptNode should have added new walker for this shadow, + // go in recursively. + return this.nextNode(); + } + + return nextNode; + } else { + if (this._walkerStack.length > 1) { + this._walkerStack.shift(); + + return this.nextNode(); + } else { + return null; + } + } + } + + public previousNode(): Node | null { + const currentWalker = this._walkerStack[0]; + + if (currentWalker.currentNode === currentWalker.root) { + if (this._currentSetFor.has(currentWalker)) { + this._currentSetFor.delete(currentWalker); + + if (this._walkerStack.length > 1) { + this._walkerStack.shift(); + return this.previousNode(); + } else { + return null; + } + } + + return null; + } + + const previousNode = currentWalker.previousNode(); + + if (previousNode) { + const shadowRoot = (previousNode as Element).shadowRoot; + + if (shadowRoot) { + let nodeResult: number | undefined; + + if (typeof this.filter === 'function') { + nodeResult = this.filter(previousNode); + } else if (this.filter?.acceptNode) { + nodeResult = this.filter.acceptNode(previousNode); + } + + if (nodeResult === NodeFilter.FILTER_ACCEPT) { + return previousNode; + } + + // _acceptNode should have added new walker for this shadow, + // go in recursively. + return this.previousNode(); + } + + return previousNode; + } else { + if (this._walkerStack.length > 1) { + this._walkerStack.shift(); + + return this.previousNode(); + } else { + return null; + } + } + } + + /** + * @deprecated + */ + public nextSibling(): Node | null { + // if (__DEV__) { + // throw new Error("Method not implemented."); + // } + + return null; + } + + /** + * @deprecated + */ + public previousSibling(): Node | null { + // if (__DEV__) { + // throw new Error("Method not implemented."); + // } + + return null; + } + + /** + * @deprecated + */ + public parentNode(): Node | null { + // if (__DEV__) { + // throw new Error("Method not implemented."); + // } + + return null; + } +} + +export function createShadowTreeWalker( + doc: Document, + root: Node, + whatToShow?: number, + filter?: NodeFilter | null +) { + return new ShadowTreeWalker(doc, root, whatToShow, filter); +} diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index 5dabc8f0645..c6102b2171d 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -12,7 +12,7 @@ import {act} from 'react-dom/test-utils'; -import {getDeepActiveElement, getOwnerWindow, getRootNode} from '../'; +import {getActiveElement, getOwnerWindow, getRootNode} from '../'; import React, {createRef} from 'react'; import {render} from '@react-spectrum/test-utils-internal'; @@ -148,17 +148,17 @@ describe('getOwnerWindow', () => { }); }); -describe('getDeepActiveElement', () => { +describe('getActiveElement', () => { it('returns the body as the active element by default', () => { act(() => {document.body.focus();}); // Ensure the body is focused, clearing any specific active element - expect(getDeepActiveElement()).toBe(document.body); + expect(getActiveElement()).toBe(document.body); }); it('returns the active element in the light DOM', () => { const btn = document.createElement('button'); document.body.appendChild(btn); act(() => {btn.focus();}); - expect(getDeepActiveElement()).toBe(btn); + expect(getActiveElement()).toBe(btn); document.body.removeChild(btn); }); @@ -172,7 +172,7 @@ describe('getDeepActiveElement', () => { act(() => {btnInShadow.focus();}); - expect(getDeepActiveElement()).toBe(btnInShadow); + expect(getActiveElement()).toBe(btnInShadow); document.body.removeChild(div); }); @@ -192,7 +192,7 @@ describe('getDeepActiveElement', () => { act(() => {input.focus();}); - expect(getDeepActiveElement()).toBe(input); + expect(getActiveElement()).toBe(input); document.body.removeChild(outerHost); }); @@ -212,7 +212,7 @@ describe('getDeepActiveElement', () => { act(() => {shadowInput.focus();}); act(() => {bodyInput.focus();}); - expect(getDeepActiveElement()).toBe(bodyInput); + expect(getActiveElement()).toBe(bodyInput); document.body.removeChild(hostDiv); document.body.removeChild(bodyInput); @@ -226,7 +226,7 @@ describe('getDeepActiveElement', () => { act(() => {input.focus();}); - expect(getDeepActiveElement(iframe.contentWindow.document)).toBe(input); + expect(getActiveElement(iframe.contentWindow.document)).toBe(input); // Teardown iframe.remove(); diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 57fb545e7d3..d619267429b 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -980,27 +980,26 @@ describe('ListView', function () { expect(onAction).not.toHaveBeenCalled(); }); - it('should not trigger action when deselecting with keyboard', function () { + it('should not trigger action when deselecting with keyboard', async function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); - let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', onAction, defaultSelectedKeys: ['foo']}); - let rows = tree.getAllByRole('row'); + renderSelectionList({onSelectionChange, selectionMode: 'multiple', onAction, defaultSelectedKeys: ['foo']}); - fireEvent.keyDown(rows[0], {key: ' '}); - fireEvent.keyUp(rows[0], {key: ' '}); + await user.tab(); + await user.keyboard(' '); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onAction).not.toHaveBeenCalled(); }); - it('should not trigger action or selection when pressing Enter while in selection mode', function () { + it('should not trigger action or selection when pressing Enter while in selection mode', async function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); onSelectionChange.mockReset(); let tree = renderSelectionList({onSelectionChange, selectionMode: 'multiple', onAction, defaultSelectedKeys: ['foo']}); - let rows = tree.getAllByRole('row'); + tree.getAllByRole('row'); - fireEvent.keyDown(rows[0], {key: 'Enter'}); - fireEvent.keyUp(rows[0], {key: 'Enter'}); + await user.tab(); + await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(onAction).not.toHaveBeenCalled(); }); @@ -1575,6 +1574,9 @@ describe('ListView', function () { } let onClick = mockClickDefault(); + if (type === 'keyboard') { + await user.tab(); + } await trigger(items[0]); expect(onClick).toHaveBeenCalledTimes(1); expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); @@ -1598,6 +1600,9 @@ describe('ListView', function () { } let onClick = mockClickDefault(); + if (type === 'keyboard') { + await user.tab(); + } await trigger(items[0]); expect(onClick).toHaveBeenCalledTimes(1); expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); @@ -1606,6 +1611,9 @@ describe('ListView', function () { await user.click(within(items[0]).getByRole('checkbox')); expect(items[0]).toHaveAttribute('aria-selected', 'true'); + if (type === 'keyboard') { + await user.keyboard('{ArrowDown}'); + } await trigger(items[1], ' '); expect(onClick).toHaveBeenCalledTimes(1); expect(items[1]).toHaveAttribute('aria-selected', 'true'); @@ -1632,8 +1640,14 @@ describe('ListView', function () { if (type === 'mouse') { await user.click(items[0]); } else { - fireEvent.keyDown(items[0], {key: ' '}); - fireEvent.keyUp(items[0], {key: ' '}); + if (type === 'keyboard') { + await user.tab(); + await user.keyboard(' '); + if (selectionMode === 'single') { + // single selection with replace will follow focus + await user.keyboard(' '); + } + } } expect(onClick).not.toHaveBeenCalled(); expect(items[0]).toHaveAttribute('aria-selected', 'true'); @@ -1661,12 +1675,19 @@ describe('ListView', function () { ); let items = getAllByRole('row'); + + if (type === 'keyboard') { + await user.tab(); + } await trigger(items[0]); expect(navigate).toHaveBeenCalledWith('/one', {foo: 'bar'}); navigate.mockReset(); let onClick = mockClickDefault(); + if (type === 'keyboard') { + await user.keyboard('{ArrowDown}'); + } await trigger(items[1]); expect(navigate).not.toHaveBeenCalled(); expect(onClick).toHaveBeenCalledTimes(1); diff --git a/packages/@react-spectrum/list/test/ListViewDnd.test.js b/packages/@react-spectrum/list/test/ListViewDnd.test.js index 12b8b3ea9e0..d81e4fcb480 100644 --- a/packages/@react-spectrum/list/test/ListViewDnd.test.js +++ b/packages/@react-spectrum/list/test/ListViewDnd.test.js @@ -1913,13 +1913,15 @@ describe('ListView', function () { expect(cell).toHaveTextContent('Adobe Photoshop'); expect(row).toHaveAttribute('draggable', 'true'); + await user.tab(); await user.tab(); let draghandle = within(cell).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ @@ -1931,8 +1933,7 @@ describe('ListView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onDrop).toHaveBeenCalledTimes(1); expect(await onDrop.mock.calls[0][0].items[0].getText('text/plain')).toBe('Adobe Photoshop'); @@ -1953,6 +1954,8 @@ describe('ListView', function () { ); + await user.tab(); + let droppable = getByText('Drop here'); let rows = getAllByRole('row'); @@ -1971,13 +1974,13 @@ describe('ListView', function () { let cellD = within(rows[3]).getByRole('gridcell'); expect(cellD).toHaveTextContent('Adobe InDesign'); expect(rows[3]).toHaveAttribute('draggable', 'true'); - - await user.tab(); let draghandle = within(cellA).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ @@ -1989,8 +1992,7 @@ describe('ListView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onDrop).toHaveBeenCalledTimes(1); @@ -2025,8 +2027,10 @@ describe('ListView', function () { let draghandle = within(cellA).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); let dndState = globalDndState; expect(dndState).toEqual({draggingCollectionRef: expect.any(Object), draggingKeys: new Set(['a', 'b', 'c', 'd'])}); @@ -2034,8 +2038,7 @@ describe('ListView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); dndState = globalDndState; expect(dndState).toEqual({draggingKeys: new Set()}); @@ -2063,8 +2066,10 @@ describe('ListView', function () { let draghandle = within(cellA).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); let dndState = globalDndState; expect(dndState).toEqual({draggingCollectionRef: expect.any(Object), draggingKeys: new Set(['a', 'b', 'c', 'd'])}); @@ -2072,8 +2077,7 @@ describe('ListView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Escape'}); - fireEvent.keyUp(droppable, {key: 'Escape'}); + await user.keyboard('{Escape}'); dndState = globalDndState; expect(dndState).toEqual({draggingKeys: new Set()}); @@ -2092,8 +2096,9 @@ describe('ListView', function () { let draghandle = within(cell).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); // First drop target should be an internal folder, hence setting dropCollectionRef @@ -2101,8 +2106,7 @@ describe('ListView', function () { expect(dndState.dropCollectionRef.current).toBe(list); // Canceling the drop operation should clear dropCollectionRef before onDragEnd fires, resulting in isInternal = false - fireEvent.keyDown(document.body, {key: 'Escape'}); - fireEvent.keyUp(document.body, {key: 'Escape'}); + await user.keyboard('{Escape}'); dndState = globalDndState; expect(dndState.dropCollectionRef).toBeFalsy(); expect(onDragEnd).toHaveBeenCalledTimes(1); @@ -2128,8 +2132,9 @@ describe('ListView', function () { let draghandle = within(cell).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); } @@ -2424,20 +2429,19 @@ describe('ListView', function () { await beginDrag(tree); await user.tab(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); // Should allow insert since we provide all handlers expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); - fireEvent.keyDown(document.activeElement, {key: 'Escape'}); - fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + await user.keyboard('{Escape}'); tree.rerender(); + await user.tab({shift: true}); + await user.tab({shift: true}); await beginDrag(tree); await user.tab(); // Should automatically jump to the folder target since we didn't provide onRootDrop and onInsert expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Pictures'); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Apps'); }); @@ -3124,24 +3128,20 @@ describe('ListView', function () { let rows = getAllByRole('row'); expect(rows).toHaveLength(9); let droppable = rows[8]; - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['1', '2', '3'])); let draghandle = within(rows[3]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - moveFocus('ArrowRight'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ @@ -3155,8 +3155,7 @@ describe('ListView', function () { let droppableButton = await within(droppable).findByLabelText('Drop on Folder 2', {hidden: true}); expect(document.activeElement).toBe(droppableButton); - fireEvent.keyDown(droppableButton, {key: 'Enter'}); - fireEvent.keyUp(droppableButton, {key: 'Enter'}); + await user.keyboard('{Enter}'); await act(async () => Promise.resolve()); act(() => jest.runAllTimers()); @@ -3178,18 +3177,18 @@ describe('ListView', function () { expect(rows).toHaveLength(6); // Select the folder and perform a drag. Drag start shouldn't include the previously selected items - moveFocus('ArrowDown'); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); // Selection change event still has all keys expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1', '2', '3', '8'])); draghandle = within(rows[0]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - moveFocus('ArrowRight'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowUp}'.repeat(5)); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onDragStart).toHaveBeenCalledTimes(1); @@ -3200,8 +3199,7 @@ describe('ListView', function () { y: 25 }); - fireEvent.keyDown(document.body, {key: 'Escape'}); - fireEvent.keyUp(document.body, {key: 'Escape'}); + await user.keyboard('{Escape}'); }); it('should automatically focus the newly added dropped item', async function () { @@ -3305,13 +3303,14 @@ describe('ListView', function () { await user.tab(); let draghandle = within(cell).getAllByRole('button')[0]; - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(getAllowedDropOperations).toHaveBeenCalledTimes(1); @@ -3372,7 +3371,7 @@ describe('ListView', function () { expect(dragButtonD).toHaveAttribute('aria-label', 'Drag 3 selected items'); }); - it('disabled rows and invalid drop targets should become aria-hidden when keyboard drag session starts', function () { + it('disabled rows and invalid drop targets should become aria-hidden when keyboard drag session starts', async function () { let {getAllByRole} = render( ); @@ -3386,8 +3385,10 @@ describe('ListView', function () { let cell = within(row).getByRole('gridcell'); let draghandle = within(cell).getAllByRole('button')[0]; expect(row).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); for (let [index, row] of rows.entries()) { @@ -3403,8 +3404,7 @@ describe('ListView', function () { } } - fireEvent.keyDown(document.body, {key: 'Escape'}); - fireEvent.keyUp(document.body, {key: 'Escape'}); + await user.keyboard('{Escape}'); }); }); }); diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index 4955f80dcdb..d9b79ca2f2f 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -1930,12 +1930,6 @@ export let tableTests = () => { } }; - let pressWithKeyboard = (element, key = ' ') => { - fireEvent.keyDown(element, {key}); - act(() => {element.focus();}); - fireEvent.keyUp(element, {key}); - }; - describe('row selection', function () { it('should select a row from checkbox', async function () { let onSelectionChange = jest.fn(); @@ -1976,39 +1970,44 @@ export let tableTests = () => { checkSelectAll(tree); }); - it('should select a row by pressing the Enter key on a row', function () { + it('should select a row by pressing the Enter key on a row', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; - expect(row).toHaveAttribute('aria-selected', 'false'); - fireEvent.keyDown(row, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); checkSelectAll(tree); }); - it('should select a row by pressing the Space key on a cell', function () { + it('should select a row by pressing the Space key on a cell', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: ' '}); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRigth}'); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); checkSelectAll(tree); }); - it('should select a row by pressing the Enter key on a cell', function () { + it('should select a row by pressing the Enter key on a cell', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); @@ -2049,7 +2048,7 @@ export let tableTests = () => { checkSelectAll(tree, 'indeterminate'); }); - it('should support selecting multiple with the Space key', function () { + it('should support selecting multiple with the Space key', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2057,7 +2056,9 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 1')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); @@ -2065,7 +2066,8 @@ export let tableTests = () => { checkSelectAll(tree, 'indeterminate'); onSelectionChange.mockReset(); - pressWithKeyboard(getCell(tree, 'Baz 2')); + await user.keyboard('{ArrowDown}'); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1', 'Foo 2']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); @@ -2075,7 +2077,7 @@ export let tableTests = () => { // Deselect onSelectionChange.mockReset(); - pressWithKeyboard(getCell(tree, 'Baz 2')); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); @@ -2113,14 +2115,16 @@ export let tableTests = () => { expect(checkbox.checked).toBeFalsy(); }); - it('should not allow the user to select a disabled row via keyboard', function () { + it('should not allow the user to select a disabled row via keyboard', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange, disabledKeys: ['Foo 1']}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => {fireEvent.keyDown(row, {key: ' '});}); - act(() => {fireEvent.keyDown(row, {key: 'Enter'});}); + + act(() => row.focus()); + await user.keyboard(' '); + await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(row).toHaveAttribute('aria-selected', 'false'); @@ -2130,7 +2134,7 @@ export let tableTests = () => { }); describe('Space key with focus on a link within a cell', () => { - it('should toggle selection and prevent scrolling of the table', () => { + it('should toggle selection and prevent scrolling of the table', async () => { let tree = render( @@ -2152,20 +2156,18 @@ export let tableTests = () => { let link = within(row).getAllByRole('link')[0]; expect(link.textContent).toBe('Foo 1'); - act(() => { + await act(async () => { link.focus(); - fireEvent.keyDown(link, {key: ' '}); - fireEvent.keyUp(link, {key: ' '}); + await user.keyboard(' '); jest.runAllTimers(); }); row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'true'); - act(() => { + await act(async () => { link.focus(); - fireEvent.keyDown(link, {key: ' '}); - fireEvent.keyUp(link, {key: ' '}); + await user.keyboard(' '); jest.runAllTimers(); }); @@ -2240,7 +2242,7 @@ export let tableTests = () => { checkRowSelection(rows.slice(11), false); }); - it('should extend a selection with Shift + ArrowDown', function () { + it('should extend a selection with Shift + ArrowDown', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2248,10 +2250,13 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 10')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); checkSelection(onSelectionChange, ['Foo 10', 'Foo 11']); checkRowSelection(rows.slice(1, 10), false); @@ -2259,7 +2264,7 @@ export let tableTests = () => { checkRowSelection(rows.slice(12), false); }); - it('should extend a selection with Shift + ArrowUp', function () { + it('should extend a selection with Shift + ArrowUp', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2267,10 +2272,13 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 10')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'ArrowUp', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); checkSelection(onSelectionChange, ['Foo 9', 'Foo 10']); checkRowSelection(rows.slice(1, 9), false); @@ -2278,7 +2286,7 @@ export let tableTests = () => { checkRowSelection(rows.slice(11), false); }); - it('should extend a selection with Ctrl + Shift + Home', function () { + it('should extend a selection with Ctrl + Shift + Home', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2286,10 +2294,13 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 10')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'Home', shiftKey: true, ctrlKey: true}); + await user.keyboard('{Shift>}{Control>}{Home}{/Control}{/Shift}'); checkSelection(onSelectionChange, [ 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', @@ -2300,7 +2311,7 @@ export let tableTests = () => { checkRowSelection(rows.slice(11), false); }); - it('should extend a selection with Ctrl + Shift + End', function () { + it('should extend a selection with Ctrl + Shift + End', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2308,10 +2319,13 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 10')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'End', shiftKey: true, ctrlKey: true}); + await user.keyboard('{Shift>}{Control>}{End}{/Control}{/Shift}'); let expected = []; for (let i = 10; i <= 100; i++) { @@ -2321,7 +2335,7 @@ export let tableTests = () => { checkSelection(onSelectionChange, expected); }); - it('should extend a selection with Shift + PageDown', function () { + it('should extend a selection with Shift + PageDown', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2329,10 +2343,13 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 10')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'PageDown', shiftKey: true}); + await user.keyboard('{Shift>}{PageDown}{/Shift}'); let expected = []; for (let i = 10; i <= 34; i++) { @@ -2342,7 +2359,7 @@ export let tableTests = () => { checkSelection(onSelectionChange, expected); }); - it('should extend a selection with Shift + PageUp', function () { + it('should extend a selection with Shift + PageUp', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2350,10 +2367,13 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 10')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}'); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'PageUp', shiftKey: true}); + await user.keyboard('{Shift>}{PageUp}{/Shift}'); checkSelection(onSelectionChange, [ 'Foo 1', 'Foo 2', 'Foo 3', 'Foo 4', 'Foo 5', @@ -2412,7 +2432,7 @@ export let tableTests = () => { checkSelectAll(tree, 'checked'); }); - it('should support selecting all via ctrl + A', function () { + it('should support selecting all via ctrl + A', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); @@ -2421,14 +2441,16 @@ export let tableTests = () => { let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'a', ctrlKey: true}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Control>}a{/Control}'); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); checkRowSelection(rows.slice(1), true); checkSelectAll(tree, 'checked'); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'a', ctrlKey: true}); + await user.keyboard('{Control>}a{/Control}'); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); @@ -2516,7 +2538,8 @@ export let tableTests = () => { checkSelectAll(tree, 'indeterminate'); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'Escape'}); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{Escape}'); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); @@ -2529,7 +2552,9 @@ export let tableTests = () => { let tree = renderTable({onSelectionChange}); checkSelectAll(tree, 'unchecked'); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'Escape'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Escape}'); expect(onSelectionChange).not.toHaveBeenCalled(); await user.click(tree.getByLabelText('Select All')); @@ -2537,7 +2562,8 @@ export let tableTests = () => { expect(onSelectionChange).toHaveBeenLastCalledWith('all'); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'Escape'}); + await user.keyboard('{ArrowDown}{ArrowRight}{ArrowRight}'); + await user.keyboard('{Escape}'); expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set()); }); @@ -2871,22 +2897,22 @@ export let tableTests = () => { }); }); - it('should trigger onAction when pressing Enter', function () { + it('should trigger onAction when pressing Enter', async function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); let tree = renderTable({onSelectionChange, onAction}); let rows = tree.getAllByRole('row'); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'Enter'}); - fireEvent.keyUp(getCell(tree, 'Baz 10'), {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowDown}'.repeat(9)); + await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(onAction).toHaveBeenCalledTimes(1); expect(onAction).toHaveBeenLastCalledWith('Foo 10'); checkRowSelection(rows.slice(1), false); onAction.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: ' '}); - fireEvent.keyUp(getCell(tree, 'Baz 10'), {key: ' '}); + await user.keyboard(' '); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onAction).not.toHaveBeenCalled(); checkRowSelection([rows[10]], true); @@ -3191,22 +3217,24 @@ export let tableTests = () => { }); }); - it('should support Enter to perform onAction with keyboard', function () { + it('should support Enter to perform onAction with keyboard', async function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: ' '}); - fireEvent.keyUp(getCell(tree, 'Baz 10'), {key: ' '}); + await user.tab(); + await user.keyboard('{ArrowDown}'.repeat(8)); + await user.keyboard('{ArrowRight}{ArrowRight}'); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); checkSelection(onSelectionChange, ['Foo 10']); - // screen reader automatically handles this one - expect(announce).not.toHaveBeenCalled(); expect(onAction).not.toHaveBeenCalled(); - announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(getCell(tree, 'Baz 5'), {key: 'Enter'}); - fireEvent.keyUp(getCell(tree, 'Baz 5'), {key: 'Enter'}); + await user.keyboard('{ArrowUp}'.repeat(5)); + onSelectionChange.mockReset(); + announce.mockReset(); + await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(announce).not.toHaveBeenCalled(); expect(onAction).toHaveBeenCalledTimes(1); @@ -3236,20 +3264,17 @@ export let tableTests = () => { announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(announce).toHaveBeenCalledWith('Foo 6 selected.'); checkSelection(onSelectionChange, ['Foo 6']); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + await user.keyboard('{ArrowUp}'); expect(announce).toHaveBeenCalledWith('Foo 5 selected.'); checkSelection(onSelectionChange, ['Foo 5']); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); expect(announce).toHaveBeenCalledWith('Foo 6 selected. 2 items selected.'); checkSelection(onSelectionChange, ['Foo 5', 'Foo 6']); }); @@ -3265,15 +3290,13 @@ export let tableTests = () => { announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); expect(announce).toHaveBeenCalledWith('Foo 6 selected. 2 items selected.'); checkSelection(onSelectionChange, ['Foo 5', 'Foo 6']); announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(announce).toHaveBeenCalledWith('Foo 7 selected. 1 item selected.'); checkSelection(onSelectionChange, ['Foo 7']); }); @@ -3316,22 +3339,23 @@ export let tableTests = () => { checkSelection(onSelectionChange, ['Foo 7']); }); - it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', function () { + it('should not call onSelectionChange when hitting Space/Enter on the currently selected row', async function () { let onSelectionChange = jest.fn(); let onAction = jest.fn(); - let tree = renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); + renderTable({onSelectionChange, selectionStyle: 'highlight', onAction}); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: ' '}); - fireEvent.keyUp(getCell(tree, 'Baz 10'), {key: ' '}); + await user.tab(); + await user.keyboard('{ArrowDown}'.repeat(8)); + await user.keyboard('{ArrowRight}{ArrowRight}'); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowDown}'); checkSelection(onSelectionChange, ['Foo 10']); expect(onAction).not.toHaveBeenCalled(); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: ' '}); - fireEvent.keyUp(getCell(tree, 'Baz 10'), {key: ' '}); + await user.keyboard(' '); expect(onSelectionChange).toHaveBeenCalledTimes(1); - fireEvent.keyDown(getCell(tree, 'Baz 10'), {key: 'Enter'}); - fireEvent.keyUp(getCell(tree, 'Baz 10'), {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onAction).toHaveBeenCalledTimes(1); expect(onAction).toHaveBeenCalledWith('Foo 10'); expect(onSelectionChange).toHaveBeenCalledTimes(1); @@ -3348,15 +3372,13 @@ export let tableTests = () => { announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'a', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'a', ctrlKey: true}); + await user.keyboard('{Control>}a{/Control}'); expect(onSelectionChange.mock.calls[0][0]).toEqual('all'); expect(announce).toHaveBeenCalledWith('All items selected.'); announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(announce).toHaveBeenCalledWith('Foo 6 selected. 1 item selected.'); checkSelection(onSelectionChange, ['Foo 6']); }); @@ -3392,14 +3414,6 @@ export let tableTests = () => { } }; - let pressWithKeyboard = (element, key = ' ') => { - act(() => { - fireEvent.keyDown(element, {key}); - element.focus(); - fireEvent.keyUp(element, {key}); - }); - }; - describe('row selection', function () { it('should select a row from checkbox', async function () { let onSelectionChange = jest.fn(); @@ -3425,49 +3439,57 @@ export let tableTests = () => { expect(row).toHaveAttribute('aria-selected', 'true'); }); - it('should select a row by pressing the Space key on a row', function () { + it('should select a row by pressing the Space key on a row', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => {fireEvent.keyDown(row, {key: ' '});}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); }); - it('should select a row by pressing the Enter key on a row', function () { + it('should select a row by pressing the Enter key on a row', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => {fireEvent.keyDown(row, {key: 'Enter'});}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); }); - it('should select a row by pressing the Space key on a cell', function () { + it('should select a row by pressing the Space key on a cell', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => {fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: ' '});}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); }); - it('should select a row by pressing the Enter key on a cell', function () { + it('should select a row by pressing the Enter key on a cell', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => {fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'Enter'});}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, ['Foo 1']); expect(row).toHaveAttribute('aria-selected', 'true'); @@ -3535,13 +3557,15 @@ export let tableTests = () => { checkRowSelection(rows.slice(2), false); }); - it('should support selecting single row only with the Space key', function () { + it('should support selecting single row only with the Space key', async function () { let onSelectionChange = jest.fn(); let tree = renderTable({onSelectionChange}); let rows = tree.getAllByRole('row'); checkRowSelection(rows.slice(1), false); - pressWithKeyboard(getCell(tree, 'Baz 1')); + await user.tab(); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1']); expect(rows[1]).toHaveAttribute('aria-selected', 'true'); @@ -3549,7 +3573,8 @@ export let tableTests = () => { checkRowSelection(rows.slice(2), false); onSelectionChange.mockReset(); - pressWithKeyboard(getCell(tree, 'Baz 2')); + await user.keyboard('{ArrowDown}'); + await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 2']); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); @@ -3558,7 +3583,7 @@ export let tableTests = () => { // Deselect onSelectionChange.mockReset(); - pressWithKeyboard(getCell(tree, 'Baz 2')); + await user.keyboard(' '); checkSelection(onSelectionChange, []); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); @@ -3574,9 +3599,11 @@ export let tableTests = () => { expect(row).toHaveAttribute('aria-selected', 'false'); await user.click(within(row).getByRole('checkbox')); await user.click(getCell(tree, 'Baz 1')); - fireEvent.keyDown(row, {key: ' '}); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: ' '}); - fireEvent.keyDown(getCell(tree, 'Bar 1'), {key: 'Enter'}); + await user.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}'); + await user.keyboard(' '); + await user.keyboard('{ArrowRight}{ArrowRight}'); + await user.keyboard(' '); + await user.keyboard('{Enter}'); expect(row).toHaveAttribute('aria-selected', 'false'); expect(onSelectionChange).not.toHaveBeenCalled(); @@ -4687,13 +4714,11 @@ export let tableTests = () => { await user.click(column2Button); act(() => {jest.runAllTimers();}); expect(tree.queryAllByRole('menuitem')).toBeTruthy(); - fireEvent.keyDown(document.activeElement, {key: 'Escape'}); - fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + await user.keyboard('{Escape}'); act(() => {jest.runAllTimers();}); act(() => {jest.runAllTimers();}); expect(document.activeElement).toBe(column2Button); - fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft', code: 37, charCode: 37}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft', code: 37, charCode: 37}); + await user.keyboard('{ArrowLeft}'); expect(document.activeElement).toBe(column1Button); await user.click(toggleButton); diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index 58b305f8a3b..6f22cc89660 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -1763,8 +1763,9 @@ describe('TableView', function () { expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ @@ -1776,8 +1777,7 @@ describe('TableView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onDrop).toHaveBeenCalledTimes(1); expect(await onDrop.mock.calls[0][0].items[0].getText('text/plain')).toBe('Vin Charlet'); @@ -1796,7 +1796,7 @@ describe('TableView', function () { it('should allow drag and drop of multiple rows', async function () { let {getByRole, getByText} = render( - ); + ); let droppable = getByText('Drop here'); let grid = getByRole('grid'); @@ -1819,12 +1819,14 @@ describe('TableView', function () { expect(cellD).toHaveTextContent('Dodie'); expect(rows[3]).toHaveAttribute('draggable', 'true'); + await user.tab(); await user.tab(); let draghandle = within(rows[0]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ @@ -1836,8 +1838,7 @@ describe('TableView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onDrop).toHaveBeenCalledTimes(1); @@ -1872,8 +1873,10 @@ describe('TableView', function () { let draghandle = within(rows[0]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); let dndState = globalDndState; expect(dndState).toEqual({draggingCollectionRef: expect.any(Object), draggingKeys: new Set(['a', 'b', 'c', 'd'])}); @@ -1881,8 +1884,7 @@ describe('TableView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); dndState = globalDndState; expect(dndState).toEqual({draggingKeys: new Set()}); @@ -1910,8 +1912,10 @@ describe('TableView', function () { let draghandle = within(rows[0]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); let dndState = globalDndState; expect(dndState).toEqual({draggingCollectionRef: expect.any(Object), draggingKeys: new Set(['a', 'b', 'c', 'd'])}); @@ -1919,8 +1923,7 @@ describe('TableView', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Escape'}); - fireEvent.keyUp(droppable, {key: 'Escape'}); + await user.keyboard('{Escape}'); dndState = globalDndState; expect(dndState).toEqual({draggingKeys: new Set()}); @@ -1939,8 +1942,8 @@ describe('TableView', function () { let draghandle = within(rows[0]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); // First drop target should be an internal folder, hence setting dropCollectionRef @@ -1948,8 +1951,7 @@ describe('TableView', function () { expect(dndState.dropCollectionRef.current).toBe(table); // Canceling the drop operation should clear dropCollectionRef before onDragEnd fires, resulting in isInternal = false - fireEvent.keyDown(document.body, {key: 'Escape'}); - fireEvent.keyUp(document.body, {key: 'Escape'}); + await user.keyboard('{Escape}'); dndState = globalDndState; expect(dndState.dropCollectionRef).toBeFalsy(); expect(onDragEnd).toHaveBeenCalledTimes(1); @@ -1976,8 +1978,9 @@ describe('TableView', function () { let draghandle = within(row).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); } @@ -1989,12 +1992,9 @@ describe('TableView', function () { await beginDrag(tree); // Move to 2nd table's first insert indicator await user.tab(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - + await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onReorder).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledTimes(0); @@ -2100,14 +2100,11 @@ describe('TableView', function () { await beginDrag(tree); await user.tab(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Pictures'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onReorder).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledTimes(1); @@ -2144,16 +2141,34 @@ describe('TableView', function () { type: 'file', name: 'Adobe Photoshop' }); + act(() => jest.runAllTimers()); + await user.tab({shift: true}); + await user.tab({shift: true}); + await user.keyboard('{ArrowLeft}'); // Drop on folder in same table - await beginDrag(tree); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + async function beginDrag2(tree) { + let grids = tree.getAllByRole('grid'); + let rowgroup = within(grids[0]).getAllByRole('rowgroup')[1]; + let row = within(rowgroup).getAllByRole('row')[0]; + let cell = within(row).getAllByRole('rowheader')[0]; + expect(cell).toHaveTextContent('Adobe Photoshop'); + expect(row).toHaveAttribute('draggable', 'true'); + + let draghandle = within(row).getAllByRole('button')[0]; + expect(draghandle).toBeTruthy(); + expect(draghandle).toHaveAttribute('draggable', 'true'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); + } + + await beginDrag2(tree); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Documents'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(onReorder).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledTimes(2); @@ -2278,20 +2293,34 @@ describe('TableView', function () { await beginDrag(tree); await user.tab(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); // Should allow insert since we provide all handlers expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); - fireEvent.keyDown(document.activeElement, {key: 'Escape'}); - fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + await user.keyboard('{Escape}'); tree.rerender(); - await beginDrag(tree); + await user.tab({shift: true}); + await user.tab({shift: true}); + await user.keyboard('{ArrowLeft}'); + await user.keyboard('{ArrowRight}'); + + let grids = tree.getAllByRole('grid'); + let rowgroup = within(grids[0]).getAllByRole('rowgroup')[1]; + let row = within(rowgroup).getAllByRole('row')[0]; + let cell = within(row).getAllByRole('rowheader')[0]; + expect(cell).toHaveTextContent('Adobe Photoshop'); + expect(row).toHaveAttribute('draggable', 'true'); + + let draghandle = within(row).getAllByRole('button')[0]; + expect(draghandle).toBeTruthy(); + expect(draghandle).toHaveAttribute('draggable', 'true'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); + act(() => jest.runAllTimers()); await user.tab(); // Should automatically jump to the folder target since we didn't provide onRootDrop and onInsert expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Pictures'); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(document.activeElement).toHaveAttribute('aria-label', 'Drop on Apps'); }); @@ -2996,24 +3025,20 @@ describe('TableView', function () { let rowgroups = within(grid).getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(9); - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); expect(new Set(onSelectionChange.mock.calls[2][0])).toEqual(new Set(['1', '2', '3'])); let draghandle = within(rows[2]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - moveFocus('ArrowRight'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ @@ -3027,8 +3052,7 @@ describe('TableView', function () { let droppableButton = await within(document).findByLabelText('Drop on Folder 2', {hidden: true}); expect(document.activeElement).toBe(droppableButton); - fireEvent.keyDown(droppableButton, {key: 'Enter'}); - fireEvent.keyUp(droppableButton, {key: 'Enter'}); + await user.keyboard('{Enter}'); await act(async () => Promise.resolve()); act(() => jest.runAllTimers()); @@ -3053,24 +3077,22 @@ describe('TableView', function () { expect(rows).toHaveLength(6); // Select the folder and perform a drag. Drag start shouldn't include the previously selected items - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); // Selection change event still has all keys expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1', '2', '3', '8'])); draghandle = within(rows[0]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); - moveFocus('ArrowRight'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ type: 'dragstart', - keys: new Set(['0']), + keys: new Set(['8']), x: 50, y: 25 }); @@ -3183,16 +3205,16 @@ describe('TableView', function () { let rows = within(rowgroups[1]).getAllByRole('row'); let row = rows[0]; + await user.tab(); await user.tab(); let draghandle = within(row).getAllByRole('button')[0]; - - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(document.activeElement).toBe(droppable); - fireEvent.keyDown(droppable, {key: 'Enter'}); - fireEvent.keyUp(droppable, {key: 'Enter'}); + await user.keyboard('{Enter}'); expect(getAllowedDropOperations).toHaveBeenCalledTimes(1); @@ -3248,7 +3270,7 @@ describe('TableView', function () { expect(dragButtonD).toHaveAttribute('aria-label', 'Drag 3 selected items'); }); - it('disabled rows and invalid drop targets should become aria-hidden when keyboard drag session starts', function () { + it('disabled rows and invalid drop targets should become aria-hidden when keyboard drag session starts', async function () { let {getByRole} = render( ); @@ -3263,8 +3285,10 @@ describe('TableView', function () { let row = rows[0]; let draghandle = within(row).getAllByRole('button')[0]; expect(row).toHaveAttribute('draggable', 'true'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(draghandle); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); for (let [index, row] of rows.entries()) { diff --git a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx index d5fc7af728d..4cca024b418 100644 --- a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx +++ b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx @@ -954,14 +954,19 @@ describe('TableView with expandable rows', function () { checkSelectAll(treegrid); }); - it('should select a row by pressing the Enter key on a chevron cell', function () { + it('should select a row by pressing the Enter key on a chevron cell', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); - let cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + let cell = within(rows[0]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(cell, 'Enter'); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, [ 'Row 1 Lvl 1' ]); @@ -969,14 +974,19 @@ describe('TableView with expandable rows', function () { checkSelectAll(treegrid); }); - it('should select a row by pressing the Space key on a chevron cell', function () { + it('should select a row by pressing the Space key on a chevron cell', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); - let cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); + let cell = within(rows[0]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(cell); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard(' '); checkSelection(onSelectionChange, [ 'Row 1 Lvl 1' ]); @@ -1103,33 +1113,36 @@ describe('TableView with expandable rows', function () { }); describe('with keyboard', function () { - it('should extend a selection with Shift + ArrowDown through nested keys', function () { + it('should extend a selection with Shift + ArrowDown through nested keys', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[0]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard(' '); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 1 Lvl 1', 'Row 1 Lvl 2' ]); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3' ]); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 1 Lvl 1', 'Row 1 Lvl 2', 'Row 1 Lvl 3', 'Row 2 Lvl 1' @@ -1139,33 +1152,39 @@ describe('TableView with expandable rows', function () { checkRowSelection(rows.slice(4), false); }); - it('should extend a selection with Shift + ArrowUp through nested keys', function () { + it('should extend a selection with Shift + ArrowUp through nested keys', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[3]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 2, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowUp', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowUp', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 2 Lvl 1', 'Row 1 Lvl 3' ]); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowUp', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowUp', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 2 Lvl 1', 'Row 1 Lvl 3', 'Row 1 Lvl 2' ]); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowUp', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowUp', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowUp}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 2 Lvl 1', 'Row 1 Lvl 3', 'Row 1 Lvl 2', 'Row 1 Lvl 1' @@ -1175,17 +1194,28 @@ describe('TableView with expandable rows', function () { checkRowSelection(rows.slice(4), false); }); - it('should extend a selection with Ctrl + Shift + Home', function () { + it('should extend a selection with Ctrl + Shift + Home', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[6]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'Home', shiftKey: true, ctrlKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'Home', shiftKey: true, ctrlKey: true}); + await user.keyboard('{Shift>}{Control>}{Home}{/Control}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ @@ -1196,33 +1226,55 @@ describe('TableView with expandable rows', function () { checkRowSelection(rows.slice(7), false); }); - it('should extend a selection with Ctrl + Shift + End', function () { + it('should extend a selection with Ctrl + Shift + End', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[6]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'End', shiftKey: true, ctrlKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'End', shiftKey: true, ctrlKey: true}); + await user.keyboard('{Shift>}{Control>}{End}{/Control}{/Shift}'); act(() => jest.runAllTimers()); checkRowSelection(rows.slice(6), true); }); - it('should extend a selection with Shift + PageDown', function () { + it('should extend a selection with Shift + PageDown', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[6]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'PageDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'PageDown', shiftKey: true}); + await user.keyboard('{Shift>}{PageDown}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ @@ -1234,17 +1286,28 @@ describe('TableView with expandable rows', function () { ]); }); - it('should extend a selection with Shift + PageUp', function () { + it('should extend a selection with Shift + PageUp', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[6]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 3, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'PageUp', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'PageUp', shiftKey: true}); + await user.keyboard('{Shift>}{PageUp}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ @@ -1252,20 +1315,24 @@ describe('TableView with expandable rows', function () { ]); }); - it('should not include disabled rows', function () { + it('should not include disabled rows', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); + let cell = within(rows[0]).getByRole('checkbox'); checkRowSelection(rows, false); - pressWithKeyboard(getCell(treegrid, 'Row 1, Lvl 1, Foo')); + await user.tab(); + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(cell); + await user.keyboard('{Enter}'); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); act(() => jest.runAllTimers()); - fireEvent.keyDown(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement!, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, [ 'Row 1 Lvl 1', 'Row 1 Lvl 3' @@ -1314,14 +1381,20 @@ describe('TableView with expandable rows', function () { checkRowSelection([rows[0], rows[2]], true); }); - it('should trigger onAction when pressing Enter', function () { + it('should trigger onAction when pressing Enter', async function () { let treegrid = render(); let rowgroups = treegrid.getAllByRole('rowgroup'); let rows = within(rowgroups[1]).getAllByRole('row'); - let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); - - fireEvent.keyDown(cell, {key: 'Enter'}); - fireEvent.keyUp(cell, {key: 'Enter'}); + let cell = within(rows[2]).getByRole('checkbox'); + await user.tab(); + await user.tab(); + await user.tab(); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); + expect(document.activeElement).toBe(cell); act(() => jest.runAllTimers()); expect(onSelectionChange).not.toHaveBeenCalled(); expect(onAction).toHaveBeenCalledTimes(1); @@ -1329,8 +1402,7 @@ describe('TableView with expandable rows', function () { checkRowSelection(rows, false); onAction.mockReset(); - fireEvent.keyDown(cell, {key: ' '}); - fireEvent.keyUp(cell, {key: ' '}); + await user.keyboard(' '); act(() => jest.runAllTimers()); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onAction).not.toHaveBeenCalled(); diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index dcce7016e61..9936cfe81a7 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -499,22 +499,22 @@ describe('GridList', () => { expect(button).toHaveAttribute('aria-label', 'Drag Cat'); }); - it('should render drop indicators', () => { + it('should render drop indicators', async () => { let onReorder = jest.fn(); let {getAllByRole} = render( Test} />); - let button = getAllByRole('button')[0]; - fireEvent.keyDown(button, {key: 'Enter'}); - fireEvent.keyUp(button, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); let rows = getAllByRole('row'); expect(rows).toHaveLength(5); expect(rows[0]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[0]).toHaveAttribute('data-drop-target', 'true'); + expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); expect(rows[0]).toHaveTextContent('Test'); expect(within(rows[0]).getByRole('button')).toHaveAttribute('aria-label', 'Insert before Cat'); expect(rows[2]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[2]).not.toHaveAttribute('data-drop-target'); + expect(rows[2]).toHaveAttribute('data-drop-target'); expect(within(rows[2]).getByRole('button')).toHaveAttribute('aria-label', 'Insert between Cat and Dog'); expect(rows[3]).toHaveAttribute('class', 'react-aria-DropIndicator'); expect(rows[3]).not.toHaveAttribute('data-drop-target'); @@ -523,30 +523,29 @@ describe('GridList', () => { expect(rows[4]).not.toHaveAttribute('data-drop-target'); expect(within(rows[4]).getByRole('button')).toHaveAttribute('aria-label', 'Insert after Kangaroo'); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Cat and Dog'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Dog and Kangaroo'); expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); - expect(rows[2]).toHaveAttribute('data-drop-target', 'true'); + expect(rows[2]).not.toHaveAttribute('data-drop-target', 'true'); + expect(rows[3]).toHaveAttribute('data-drop-target', 'true'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onReorder).toHaveBeenCalledTimes(1); }); - it('should support dropping on rows', () => { + it('should support dropping on rows', async () => { let onItemDrop = jest.fn(); let {getAllByRole} = render(<> ); - let button = getAllByRole('button')[0]; - fireEvent.keyDown(button, {key: 'Enter'}); - fireEvent.keyUp(button, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); let grids = getAllByRole('grid'); @@ -561,23 +560,22 @@ describe('GridList', () => { expect(document.activeElement).toBe(within(rows[0]).getByRole('button')); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onItemDrop).toHaveBeenCalledTimes(1); }); - it('should support dropping on the root', () => { + it('should support dropping on the root', async () => { let onRootDrop = jest.fn(); let {getAllByRole} = render(<> ); - let button = getAllByRole('button')[0]; - fireEvent.keyDown(button, {key: 'Enter'}); - fireEvent.keyUp(button, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); let grids = getAllByRole('grid'); @@ -587,8 +585,7 @@ describe('GridList', () => { expect(document.activeElement).toBe(within(rows[0]).getByRole('button')); expect(grids[1]).toHaveAttribute('data-drop-target', 'true'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onRootDrop).toHaveBeenCalledTimes(1); @@ -621,6 +618,9 @@ describe('GridList', () => { } let onClick = mockClickDefault(); + if (type === 'keyboard') { + await user.tab(); + } await trigger(items[0]); expect(onClick).toHaveBeenCalledTimes(1); expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); @@ -642,6 +642,9 @@ describe('GridList', () => { } let onClick = mockClickDefault(); + if (type === 'keyboard') { + await user.tab(); + } await trigger(items[0]); expect(onClick).toHaveBeenCalledTimes(1); expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); @@ -650,6 +653,10 @@ describe('GridList', () => { await user.click(within(items[0]).getByRole('checkbox')); expect(items[0]).toHaveAttribute('aria-selected', 'true'); + + if (type === 'keyboard') { + await user.keyboard('{ArrowDown}'); + } await trigger(items[1], ' '); expect(onClick).toHaveBeenCalledTimes(1); expect(items[1]).toHaveAttribute('aria-selected', 'true'); @@ -673,8 +680,12 @@ describe('GridList', () => { if (type === 'mouse') { await user.click(items[0]); } else { - fireEvent.keyDown(items[0], {key: ' '}); - fireEvent.keyUp(items[0], {key: ' '}); + await user.tab(); + await user.keyboard(' '); + if (selectionMode === 'single') { + // single selection with replace will follow focus + await user.keyboard(' '); + } } expect(onClick).not.toHaveBeenCalled(); expect(items[0]).toHaveAttribute('aria-selected', 'true'); @@ -682,8 +693,7 @@ describe('GridList', () => { if (type === 'mouse') { await user.dblClick(items[0], {pointerType: 'mouse'}); } else { - fireEvent.keyDown(items[0], {key: 'Enter'}); - fireEvent.keyUp(items[0], {key: 'Enter'}); + await user.keyboard('{Enter}'); } expect(onClick).toHaveBeenCalledTimes(1); expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); @@ -703,6 +713,10 @@ describe('GridList', () => { let items = getAllByRole('row'); expect(items[0]).toHaveAttribute('data-href', '/base/foo'); + + if (type === 'keyboard') { + await user.tab(); + } await trigger(items[0]); expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'bar'}); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 9bae0de17ea..ea3a58c7057 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -902,22 +902,22 @@ describe('Table', () => { expect(button).toHaveAttribute('aria-label', 'Drag Games'); }); - it('should render drop indicators', () => { + it('should render drop indicators', async () => { let onReorder = jest.fn(); let {getAllByRole} = render( Test} />); - let button = getAllByRole('button')[0]; - fireEvent.keyDown(button, {key: 'Enter'}); - fireEvent.keyUp(button, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); let rows = getAllByRole('row'); expect(rows).toHaveLength(5); expect(rows[0]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[0]).toHaveAttribute('data-drop-target', 'true'); + expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); expect(rows[0]).toHaveTextContent('Test'); expect(within(rows[0]).getByRole('button')).toHaveAttribute('aria-label', 'Insert before Games'); expect(rows[2]).toHaveAttribute('class', 'react-aria-DropIndicator'); - expect(rows[2]).not.toHaveAttribute('data-drop-target'); + expect(rows[2]).toHaveAttribute('data-drop-target'); expect(within(rows[2]).getByRole('button')).toHaveAttribute('aria-label', 'Insert between Games and Program Files'); expect(rows[3]).toHaveAttribute('class', 'react-aria-DropIndicator'); expect(rows[3]).not.toHaveAttribute('data-drop-target'); @@ -926,30 +926,29 @@ describe('Table', () => { expect(rows[4]).not.toHaveAttribute('data-drop-target'); expect(within(rows[4]).getByRole('button')).toHaveAttribute('aria-label', 'Insert after bootmgr'); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Games and Program Files'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Program Files and bootmgr'); expect(rows[0]).not.toHaveAttribute('data-drop-target', 'true'); - expect(rows[2]).toHaveAttribute('data-drop-target', 'true'); + expect(rows[2]).not.toHaveAttribute('data-drop-target', 'true'); + expect(rows[3]).toHaveAttribute('data-drop-target', 'true'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onReorder).toHaveBeenCalledTimes(1); }); - it('should support dropping on rows', () => { + it('should support dropping on rows', async () => { let onItemDrop = jest.fn(); let {getAllByRole} = render(<> ); - let button = getAllByRole('button')[0]; - fireEvent.keyDown(button, {key: 'Enter'}); - fireEvent.keyUp(button, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); let grids = getAllByRole('grid'); @@ -964,23 +963,22 @@ describe('Table', () => { expect(document.activeElement).toBe(within(rows[0]).getByRole('button')); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onItemDrop).toHaveBeenCalledTimes(1); }); - it('should support dropping on the root', () => { + it('should support dropping on the root', async () => { let onRootDrop = jest.fn(); let {getAllByRole} = render(<> ); - let button = getAllByRole('button')[0]; - fireEvent.keyDown(button, {key: 'Enter'}); - fireEvent.keyUp(button, {key: 'Enter'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); let grids = getAllByRole('grid'); @@ -990,8 +988,7 @@ describe('Table', () => { expect(document.activeElement).toBe(within(rows[0]).getByRole('button')); expect(grids[1]).toHaveAttribute('data-drop-target', 'true'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); expect(onRootDrop).toHaveBeenCalledTimes(1); From b62d83fdcebe32121078ec88e9dc0c122193c78f Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 13 Dec 2024 14:53:09 +1100 Subject: [PATCH 090/102] fix tests --- packages/@react-aria/focus/src/FocusScope.tsx | 2 +- packages/@react-aria/utils/src/index.ts | 2 +- .../@react-spectrum/table/test/Table.test.js | 20 +++++++++---------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index d4803fa2396..2a216648d65 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {createShadowTreeWalker, ShadowTreeWalker} from '@react-aria/utils/src/shadowdom/ShadowTreeWalker'; +import {createShadowTreeWalker, ShadowTreeWalker} from '@react-aria/utils'; import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; import { diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index e3209e5771f..8403aae1db1 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -11,7 +11,7 @@ */ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; -export {createShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; +export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; export {getActiveElement, nodeContains} from './shadowdom/DOMFunctions'; export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, isDocument, isShadowRoot} from './domHelpers'; export {mergeProps} from './mergeProps'; diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index d9b79ca2f2f..ce2c42d2012 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -2122,7 +2122,7 @@ export let tableTests = () => { let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); - act(() => row.focus()); + await user.tab(); await user.keyboard(' '); await user.keyboard('{Enter}'); @@ -2156,20 +2156,18 @@ export let tableTests = () => { let link = within(row).getAllByRole('link')[0]; expect(link.textContent).toBe('Foo 1'); - await act(async () => { - link.focus(); - await user.keyboard(' '); - jest.runAllTimers(); - }); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(link); + await user.keyboard(' '); + jest.runAllTimers(); row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'true'); - await act(async () => { - link.focus(); - await user.keyboard(' '); - jest.runAllTimers(); - }); + await user.keyboard(' '); + jest.runAllTimers(); row = tree.getAllByRole('row')[1]; link = within(row).getAllByRole('link')[0]; From 366565cba80a9e8c5695d73202a1ea4b5540c2df Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 13 Dec 2024 15:05:00 +1100 Subject: [PATCH 091/102] fix lint --- packages/@react-aria/focus/src/FocusScope.tsx | 7 ++++--- packages/@react-spectrum/table/test/Table.test.js | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 2a216648d65..f6c02c05e73 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -10,14 +10,15 @@ * governing permissions and limitations under the License. */ -import {createShadowTreeWalker, ShadowTreeWalker} from '@react-aria/utils'; -import {FocusableElement, RefObject} from '@react-types/shared'; -import {focusSafely} from './focusSafely'; import { + createShadowTreeWalker, getActiveElement, getOwnerDocument, + ShadowTreeWalker, useLayoutEffect } from '@react-aria/utils'; +import {FocusableElement, RefObject} from '@react-types/shared'; +import {focusSafely} from './focusSafely'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index ce2c42d2012..c92bdecbd6e 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -2161,13 +2161,13 @@ export let tableTests = () => { await user.keyboard('{ArrowRight}'); expect(document.activeElement).toBe(link); await user.keyboard(' '); - jest.runAllTimers(); + act(() => {jest.runAllTimers();}); row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'true'); await user.keyboard(' '); - jest.runAllTimers(); + act(() => {jest.runAllTimers();}); row = tree.getAllByRole('row')[1]; link = within(row).getAllByRole('link')[0]; From edc9892bee8f6ee8a621b2f27969880ea3f91276 Mon Sep 17 00:00:00 2001 From: GitHub Date: Tue, 17 Dec 2024 08:09:29 +1100 Subject: [PATCH 092/102] simplify --- packages/@react-aria/focus/src/focusSafely.ts | 6 +-- .../interactions/src/useFocusVisible.ts | 53 +++++++------------ .../interactions/src/useFocusWithin.ts | 2 +- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index 10873ba00ab..be22242e624 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -29,13 +29,13 @@ export function focusSafely(element: FocusableElement) { // the page before shifting focus. This avoids issues with VoiceOver on iOS // causing the page to scroll when moving focus if the element is transitioning // from off the screen. - const rootNode = getOwnerDocument(element); - const activeElement = getActiveElement(rootNode); + const ownerDocument = getOwnerDocument(element); + const activeElement = getActiveElement(ownerDocument); if (getInteractionModality() === 'virtual') { let lastFocusedElement = activeElement; runAfterTransition(() => { // If focus did not move and the element is still in the document, focus it. - if (getActiveElement(rootNode) === lastFocusedElement && element.isConnected) { + if (getActiveElement(ownerDocument) === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 482b63c891f..2512608abe7 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -30,23 +30,6 @@ export interface FocusVisibleProps { autoFocus?: boolean } -/** - * This function creates a type-safe event listener wrapper that ensures consistent function references for event handling. - * It uses a WeakMap to cache wrapped handlers, guaranteeing that the same function instance is always returned for a given handler, which is crucial for proper event listener cleanup and prevents unnecessary function creation. - */ -const handlerCache = new WeakMap(); - -function createEventListener(handler: (e: E) => void): EventListener { - if (typeof handler === 'function') { - if (!handlerCache.has(handler)) { - const wrappedHandler: EventListener = (e: Event) => handler(e as E); - handlerCache.set(handler, wrappedHandler); - } - return handlerCache.get(handler)!; - } - return handler; -} - export interface FocusVisibleResult { /** Whether keyboard focus is visible globally. */ isFocusVisible: boolean @@ -152,9 +135,9 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]); }; - documentObject.addEventListener('keydown', createEventListener(handleKeyboardEvent), true); - documentObject.addEventListener('keyup', createEventListener(handleKeyboardEvent), true); - documentObject.addEventListener('click', createEventListener(handleClickEvent), true); + documentObject.addEventListener('keydown', handleKeyboardEvent, true); + documentObject.addEventListener('keyup', handleKeyboardEvent, true); + documentObject.addEventListener('click', handleClickEvent, true); // Register focus events on the window so they are sure to happen // before React's event listeners (registered on the document). @@ -162,13 +145,13 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) { windowObject.addEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject.addEventListener('pointerdown', createEventListener(handlePointerEvent), true); - documentObject.addEventListener('pointermove', createEventListener(handlePointerEvent), true); - documentObject.addEventListener('pointerup', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('pointerdown', handlePointerEvent, true); + documentObject.addEventListener('pointermove', handlePointerEvent, true); + documentObject.addEventListener('pointerup', handlePointerEvent, true); } else { - documentObject.addEventListener('mousedown', createEventListener(handlePointerEvent), true); - documentObject.addEventListener('mousemove', createEventListener(handlePointerEvent), true); - documentObject.addEventListener('mouseup', createEventListener(handlePointerEvent), true); + documentObject.addEventListener('mousedown', handlePointerEvent, true); + documentObject.addEventListener('mousemove', handlePointerEvent, true); + documentObject.addEventListener('mouseup', handlePointerEvent, true); } // Add unmount handler @@ -190,21 +173,21 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { } windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus; - documentObject.removeEventListener('keydown', createEventListener(handleKeyboardEvent), true); - documentObject.removeEventListener('keyup', createEventListener(handleKeyboardEvent), true); - documentObject.removeEventListener('click', createEventListener(handleClickEvent), true); + documentObject.removeEventListener('keydown', handleKeyboardEvent, true); + documentObject.removeEventListener('keyup', handleKeyboardEvent, true); + documentObject.removeEventListener('click', handleClickEvent, true); windowObject.removeEventListener('focus', handleFocusEvent, true); windowObject.removeEventListener('blur', handleWindowBlur, false); if (typeof PointerEvent !== 'undefined') { - documentObject.removeEventListener('pointerdown', createEventListener(handlePointerEvent), true); - documentObject.removeEventListener('pointermove', createEventListener(handlePointerEvent), true); - documentObject.removeEventListener('pointerup', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('pointerdown', handlePointerEvent, true); + documentObject.removeEventListener('pointermove', handlePointerEvent, true); + documentObject.removeEventListener('pointerup', handlePointerEvent, true); } else { - documentObject.removeEventListener('mousedown', createEventListener(handlePointerEvent), true); - documentObject.removeEventListener('mousemove', createEventListener(handlePointerEvent), true); - documentObject.removeEventListener('mouseup', createEventListener(handlePointerEvent), true); + documentObject.removeEventListener('mousedown', handlePointerEvent, true); + documentObject.removeEventListener('mousemove', handlePointerEvent, true); + documentObject.removeEventListener('mouseup', handlePointerEvent, true); } hasSetupGlobalListeners.delete(windowObject); diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 58e8a22a33e..e88a6f27a0c 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -90,7 +90,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { if (isDisabled) { return { focusWithinProps: { - // These should not have been null, that would conflict in mergeProps + // These cannot be null, that would conflict in mergeProps onFocus: undefined, onBlur: undefined } From 9b08f75e20a43d3875c151ed764c91a09618b83c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 17 Dec 2024 08:11:40 +1100 Subject: [PATCH 093/102] Update packages/@react-aria/focus/src/FocusScope.tsx --- packages/@react-aria/focus/src/FocusScope.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index f6c02c05e73..422d0e71992 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -757,7 +757,6 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions // Determine the document to use let doc = getOwnerDocument(rootElement); - // console.log('doc', doc, root); // Create a TreeWalker, ensuring the root is an Element or Document let walker = createShadowTreeWalker( From 4c4e0066b0bf4378764bce4d0199abfcf89d23c3 Mon Sep 17 00:00:00 2001 From: GitHub Date: Tue, 17 Dec 2024 09:01:33 +1100 Subject: [PATCH 094/102] fix autofocus --- packages/@react-aria/focus/src/FocusScope.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 422d0e71992..0dea1887b55 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -515,7 +515,7 @@ function useAutoFocus(scopeRef: RefObject, autoFocus?: boolean if (autoFocusRef.current) { activeScope = scopeRef; const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined); - if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) { + if (!isElementInScope(getActiveElement(ownerDocument), activeScope.current) && scopeRef.current) { focusFirstInScope(scopeRef.current); } } From 96b91665b80645ad56ec5c2b7e64c76d411c9275 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Wed, 18 Dec 2024 15:07:19 -0800 Subject: [PATCH 095/102] minor test updates to preserve test intent --- packages/@react-spectrum/table/test/Table.test.js | 2 +- packages/@react-spectrum/table/test/TableDnd.test.js | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index c92bdecbd6e..2ae7e6e85cc 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -1991,7 +1991,7 @@ export let tableTests = () => { let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); await user.tab(); - await user.keyboard('{ArrowRight}{ArrowRigth}'); + await user.keyboard('{ArrowRight}{ArrowRight}'); await user.keyboard(' '); checkSelection(onSelectionChange, ['Foo 1']); diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index 6f22cc89660..0700ba44043 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -3076,15 +3076,18 @@ describe('TableView', function () { rows = within(rowgroups[1]).getAllByRole('row'); expect(rows).toHaveLength(6); - // Select the folder and perform a drag. Drag start shouldn't include the previously selected items + // Select the folder and perform a drag on a different item that isn't selected. Drag start shouldn't include the previously selected items/folders await user.keyboard('{ArrowDown}'); await user.keyboard('{Enter}'); // Selection change event still has all keys expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1', '2', '3', '8'])); - draghandle = within(rows[0]).getAllByRole('button')[0]; + draghandle = within(rows[4]).getAllByRole('button')[0]; expect(draghandle).toBeTruthy(); expect(draghandle).toHaveAttribute('draggable', 'true'); + + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); await user.keyboard('{ArrowRight}'); await user.keyboard('{Enter}'); act(() => jest.runAllTimers()); @@ -3092,7 +3095,7 @@ describe('TableView', function () { expect(onDragStart).toHaveBeenCalledTimes(1); expect(onDragStart).toHaveBeenCalledWith({ type: 'dragstart', - keys: new Set(['8']), + keys: new Set(['6']), x: 50, y: 25 }); From 87b35422c70d564c573d810e3f64fff786f06188 Mon Sep 17 00:00:00 2001 From: GitHub Date: Thu, 19 Dec 2024 16:21:37 +1100 Subject: [PATCH 096/102] review comments --- NOTICE.txt | 29 ++ .../@react-aria/interactions/src/usePress.ts | 47 +-- packages/@react-aria/utils/src/domHelpers.ts | 61 ---- packages/@react-aria/utils/src/index.ts | 4 +- .../utils/src/shadowdom/DOMFunctions.ts | 30 +- .../utils/src/shadowdom/ShadowTreeWalker.ts | 132 ++++++--- .../@react-aria/utils/test/domHelpers.test.js | 108 +------ .../utils/test/shadowTreeWalker.test.tsx | 274 ++++++++++++++++++ packages/@react-spectrum/utils/src/Slots.tsx | 2 +- packages/dev/test-utils/src/shadowDOM.js | 18 +- 10 files changed, 453 insertions(+), 252 deletions(-) create mode 100644 packages/@react-aria/utils/test/shadowTreeWalker.test.tsx diff --git a/NOTICE.txt b/NOTICE.txt index 1aee959d016..6f68f1ceb60 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -211,3 +211,32 @@ This codebase contains a modified portion of code from Yarn berry which can be o Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +------------------------------------------------------------------------------- +This codebase contains a modified portion of code from Yarn berry which can be obtained at: + * SOURCE: + * https://github.com/microsoft/tabster + + * LICENSE: + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 491a102d77a..3dfbb2bc963 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -32,6 +32,7 @@ import { } from '@react-aria/utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; +import {getEventTarget} from '@react-aria/utils/src/shadowdom/DOMFunctions'; import {PressResponderContext} from './context'; import {TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react'; @@ -293,8 +294,8 @@ export function usePress(props: PressHookProps): PressResult { let state = ref.current; let pressProps: DOMAttributes = { onKeyDown(e) { - if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { - if (shouldPreventDefaultKeyboard(e.nativeEvent.composedPath()[0] as Element, e.key)) { + if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { + if (shouldPreventDefaultKeyboard(getEventTarget(e.nativeEvent), e.key)) { e.preventDefault(); } @@ -312,7 +313,7 @@ export function usePress(props: PressHookProps): PressResult { // 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) => { - if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, e.composedPath()[0] as Element) && state.target) { + if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { triggerPressUp(createEvent(state.target, e), 'keyboard'); } }; @@ -339,7 +340,7 @@ export function usePress(props: PressHookProps): PressResult { } }, onClick(e) { - if (e && !nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (e && !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -374,12 +375,12 @@ export function usePress(props: PressHookProps): PressResult { let onKeyUp = (e: KeyboardEvent) => { if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { - if (shouldPreventDefaultKeyboard(e.composedPath()[0] as Element, e.key)) { + if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { e.preventDefault(); } - let target = e.composedPath()[0] as Element; - triggerPressEnd(createEvent(state.target, e), 'keyboard', nodeContains(state.target, e.composedPath()[0] as Element)); + let target = getEventTarget(e); + triggerPressEnd(createEvent(state.target, e), 'keyboard', nodeContains(state.target, getEventTarget(e))); removeAllGlobalListeners(); // If a link was triggered with a key other than Enter, open the URL ourselves. @@ -409,7 +410,7 @@ export function usePress(props: PressHookProps): PressResult { if (typeof PointerEvent !== 'undefined') { pressProps.onPointerDown = (e) => { // Only handle left clicks, and ignore events that bubbled through portals. - if (e.button !== 0 || !nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -449,7 +450,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 = e.nativeEvent.composedPath()[0] as Element; + let target = getEventTarget(e.nativeEvent); if ('releasePointerCapture' in target) { target.releasePointerCapture(e.pointerId); } @@ -464,7 +465,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseDown = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -482,7 +483,7 @@ export function usePress(props: PressHookProps): PressResult { pressProps.onPointerUp = (e) => { // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown. - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element) || state.pointerType === 'virtual') { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent)) || state.pointerType === 'virtual') { return; } @@ -509,7 +510,7 @@ export function usePress(props: PressHookProps): PressResult { let onPointerUp = (e: PointerEvent) => { if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { - if (nodeContains(state.target, e.composedPath()[0] as Element) && state.pointerType != null) { + if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType); } else if (state.isOverTarget && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType, false); @@ -550,7 +551,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -560,7 +561,7 @@ export function usePress(props: PressHookProps): PressResult { } else { pressProps.onMouseDown = (e) => { // Only handle left clicks - if (e.button !== 0 || !nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -593,7 +594,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseEnter = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -609,7 +610,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseLeave = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -626,7 +627,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseUp = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -659,7 +660,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchStart = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -693,7 +694,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchMove = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -721,7 +722,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchEnd = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -754,7 +755,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchCancel = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -765,7 +766,7 @@ export function usePress(props: PressHookProps): PressResult { }; let onScroll = (e: Event) => { - if (state.isPressed && nodeContains(e.composedPath()[0] as Element, state.target)) { + if (state.isPressed && nodeContains(getEventTarget(e), state.target)) { cancel({ currentTarget: state.target, shiftKey: false, @@ -777,7 +778,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!nodeContains(e.currentTarget, e.nativeEvent.composedPath()[0] as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index 27e2407e45a..7c661d5be85 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -13,59 +13,6 @@ export const getOwnerWindow = ( return doc.defaultView || window; }; -export const getRootNode = ( - el: Element | null | undefined -): Document | ShadowRoot | null => { - if (!el) { - // Return the main document if the element is null or undefined - return document; - } - - // If the element is disconnected from the DOM, return null - if (!el.isConnected) { - return null; - } - - // Get the root node of the element, or default to the document - const rootNode = el.getRootNode ? el.getRootNode() : document; - - // Use nodeType to check the type of the rootNode - // We use nodeType instead of instanceof checks because instanceof fails across different - // contexts (e.g., iframes or windows), as each context has its own global objects and constructors. - // nodeType is a primitive value and is consistent across different contexts, making it - // reliable for cross-context type checking. - if (isDocument(rootNode)) { - // rootNode is a Document - return rootNode as Document; - } - - if (isShadowRoot(rootNode)) { - // rootNode is a ShadowRoot (a specialized type of DocumentFragment) - // We check for the presence of the 'host' property to distinguish ShadowRoot from other DocumentFragments - return rootNode as ShadowRoot; - } - - // For other types of nodes or DocumentFragments that are not ShadowRoots, return null - return null; -}; - -/** - * Retrieves a reference to the most appropriate "body" element for a given DOM context, - * accommodating both traditional DOM and Shadow DOM environments. When used with a Shadow DOM, - * it returns the body of the document to which the shadow root belongs, as shadow root is a document fragment, - * meaning that it doesn't have a body. When used with a regular document, it simply returns the document's body. - * - * @param {Document | ShadowRoot} root - The root document or shadow root from which to find the body. - * @returns {HTMLElement} - The "body" element of the document, or the document's body associated with the shadow root. - */ -export const getRootBody = (root: Document | ShadowRoot): HTMLElement => { - if (isShadowRoot(root)) { - return root.ownerDocument?.body; - } else { - return root.body; - } -}; - /** * Type guard that checks if a value is a Node. Verifies the presence and type of the nodeType property. */ @@ -75,14 +22,6 @@ function isNode(value: unknown): value is Node { 'nodeType' in value && typeof (value as Node).nodeType === 'number'; } - -/** - * Type guard that checks if a node is a Document node. Uses nodeType for cross-context compatibility. - */ -export function isDocument(node: Node | null): node is Document { - return isNode(node) && node.nodeType === Node.DOCUMENT_NODE; -} - /** * Type guard that checks if a node is a ShadowRoot. Uses nodeType and host property checks to * distinguish ShadowRoot from other DocumentFragments. diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 8403aae1db1..302efaea780 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -12,8 +12,8 @@ export {useId, mergeIds, useSlotId} from './useId'; export {chain} from './chain'; export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; -export {getActiveElement, nodeContains} from './shadowdom/DOMFunctions'; -export {getOwnerDocument, getOwnerWindow, getRootNode, getRootBody, isDocument, isShadowRoot} from './domHelpers'; +export {getActiveElement, getEventTarget, nodeContains} from './shadowdom/DOMFunctions'; +export {getOwnerDocument, getOwnerWindow, isShadowRoot} from './domHelpers'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; export {filterDOMProps} from './filterDOMProps'; diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index ca0e2afaa17..dc2b23a3a61 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,5 +1,7 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 +import {isShadowRoot} from '../domHelpers'; + export function nodeContains( node: Node | null | undefined, otherNode: Node | null | undefined @@ -10,21 +12,18 @@ export function nodeContains( let currentNode: HTMLElement | Node | null | undefined = otherNode; - while (currentNode) { + while (currentNode !== null) { if (currentNode === node) { return true; } - if ( - typeof (currentNode as HTMLSlotElement).assignedElements !== - 'function' && - (currentNode as HTMLElement).assignedSlot?.parentNode - ) { - // Element is slotted - currentNode = (currentNode as HTMLElement).assignedSlot?.parentNode; - } else if (currentNode.nodeType === document.DOCUMENT_FRAGMENT_NODE) { - // Element is in shadow root - currentNode = (currentNode as ShadowRoot).host; + if ((currentNode as HTMLSlotElement).tagName === 'SLOT' && + (currentNode as HTMLSlotElement).assignedSlot) { + // Element is slotted + currentNode = (currentNode as HTMLSlotElement).assignedSlot!.parentNode; + } else if (isShadowRoot(currentNode)) { + // Element is in shadow root + currentNode = currentNode.host; } else { currentNode = currentNode.parentNode; } @@ -83,3 +82,12 @@ export function getLastElementChild( return child as Element | null; } + +export function getEventTarget(event): Element { + if (event.target.shadowRoot) { + if (event.composedPath) { + return event.composedPath()[0]; + } + } + return event.target; +} diff --git a/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts index 4927d5af455..6fd9c05cdc3 100644 --- a/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts +++ b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts @@ -13,10 +13,10 @@ export class ShadowTreeWalker implements TreeWalker { private _currentSetFor: Set = new Set(); constructor( - doc: Document, - root: Node, - whatToShow?: number, - filter?: NodeFilter | null + doc: Document, + root: Node, + whatToShow?: number, + filter?: NodeFilter | null ) { this._doc = doc; this.root = root; @@ -25,17 +25,17 @@ export class ShadowTreeWalker implements TreeWalker { this._currentNode = root; this._walkerStack.unshift( - doc.createTreeWalker(root, whatToShow, this._acceptNode) - ); + doc.createTreeWalker(root, whatToShow, this._acceptNode) + ); const shadowRoot = (root as Element).shadowRoot; if (shadowRoot) { const walker = this._doc.createTreeWalker( - shadowRoot, - this.whatToShow, - {acceptNode: this._acceptNode} - ); + shadowRoot, + this.whatToShow, + {acceptNode: this._acceptNode} + ); this._walkerStack.unshift(walker); } @@ -47,10 +47,10 @@ export class ShadowTreeWalker implements TreeWalker { if (shadowRoot) { const walker = this._doc.createTreeWalker( - shadowRoot, - this.whatToShow, - {acceptNode: this._acceptNode} - ); + shadowRoot, + this.whatToShow, + {acceptNode: this._acceptNode} + ); this._walkerStack.unshift(walker); @@ -76,8 +76,8 @@ export class ShadowTreeWalker implements TreeWalker { public set currentNode(node: Node) { if (!nodeContains(this.root, node)) { throw new Error( - 'Cannot set currentNode to a node that is not contained by the root node.' - ); + 'Cannot set currentNode to a node that is not contained by the root node.' + ); } const walkers: TreeWalker[] = []; @@ -91,10 +91,10 @@ export class ShadowTreeWalker implements TreeWalker { const shadowRoot = curNode as ShadowRoot; const walker = this._doc.createTreeWalker( - shadowRoot, - this.whatToShow, - {acceptNode: this._acceptNode} - ); + shadowRoot, + this.whatToShow, + {acceptNode: this._acceptNode} + ); walkers.push(walker); @@ -108,9 +108,11 @@ export class ShadowTreeWalker implements TreeWalker { } } - const walker = this._doc.createTreeWalker(this.root, this.whatToShow, { - acceptNode: this._acceptNode - }); + const walker = this._doc.createTreeWalker( + this.root, + this.whatToShow, + {acceptNode: this._acceptNode} + ); walkers.push(walker); @@ -126,13 +128,25 @@ export class ShadowTreeWalker implements TreeWalker { } public firstChild(): Node | null { - let walker = this._walkerStack[0]; - return walker.firstChild(); + let currentNode = this.currentNode; + let newNode = this.nextNode(); + if (!nodeContains(currentNode, newNode)) { + this.currentNode = currentNode; + return null; + } + if (newNode) { + this.currentNode = newNode; + } + return newNode; } public lastChild(): Node | null { let walker = this._walkerStack[0]; - return walker.lastChild(); + let newNode = walker.lastChild(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; } public nextNode(): Node | null { @@ -151,20 +165,32 @@ export class ShadowTreeWalker implements TreeWalker { } if (nodeResult === NodeFilter.FILTER_ACCEPT) { + this.currentNode = nextNode; return nextNode; } - // _acceptNode should have added new walker for this shadow, - // go in recursively. - return this.nextNode(); + // _acceptNode should have added new walker for this shadow, + // go in recursively. + let newNode = this.nextNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; } + if (nextNode) { + this.currentNode = nextNode; + } return nextNode; } else { if (this._walkerStack.length > 1) { this._walkerStack.shift(); - return this.nextNode(); + let newNode = this.nextNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; } else { return null; } @@ -180,7 +206,11 @@ export class ShadowTreeWalker implements TreeWalker { if (this._walkerStack.length > 1) { this._walkerStack.shift(); - return this.previousNode(); + let newNode = this.previousNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; } else { return null; } @@ -204,20 +234,34 @@ export class ShadowTreeWalker implements TreeWalker { } if (nodeResult === NodeFilter.FILTER_ACCEPT) { + if (previousNode) { + this.currentNode = previousNode; + } return previousNode; } - // _acceptNode should have added new walker for this shadow, - // go in recursively. - return this.previousNode(); + // _acceptNode should have added new walker for this shadow, + // go in recursively. + let newNode = this.lastChild(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; } + if (previousNode) { + this.currentNode = previousNode; + } return previousNode; } else { if (this._walkerStack.length > 1) { this._walkerStack.shift(); - return this.previousNode(); + let newNode = this.previousNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; } else { return null; } @@ -228,9 +272,9 @@ export class ShadowTreeWalker implements TreeWalker { * @deprecated */ public nextSibling(): Node | null { - // if (__DEV__) { - // throw new Error("Method not implemented."); - // } + // if (__DEV__) { + // throw new Error("Method not implemented."); + // } return null; } @@ -239,9 +283,9 @@ export class ShadowTreeWalker implements TreeWalker { * @deprecated */ public previousSibling(): Node | null { - // if (__DEV__) { - // throw new Error("Method not implemented."); - // } + // if (__DEV__) { + // throw new Error("Method not implemented."); + // } return null; } @@ -250,9 +294,9 @@ export class ShadowTreeWalker implements TreeWalker { * @deprecated */ public parentNode(): Node | null { - // if (__DEV__) { - // throw new Error("Method not implemented."); - // } + // if (__DEV__) { + // throw new Error("Method not implemented."); + // } return null; } diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index c6102b2171d..95772fb0d75 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -12,113 +12,7 @@ import {act} from 'react-dom/test-utils'; -import {getActiveElement, getOwnerWindow, getRootNode} from '../'; -import React, {createRef} from 'react'; -import {render} from '@react-spectrum/test-utils-internal'; - -describe('getRootNode', () => { - test.each([null, undefined])('returns the document if the argument is %p', (value) => { - expect(getRootNode(value)).toBe(document); - }); - - it('returns the document if the element is in the document', () => { - const div = document.createElement('div'); - window.document.body.appendChild(div); - expect(getRootNode(div)).toBe(document); - }); - - it('returns null if the element is disconnected from the document', () => { - const div = document.createElement('div'); // div is not appended to the document - expect(getRootNode(div)).toBe(null); - }); - - it('returns the document if nothing is passed in', () => { - expect(getRootNode()).toBe(document); - expect(getRootNode(null)).toBe(document); - expect(getRootNode(undefined)).toBe(document); - }); - - it('returns the document if ref exists, but is not associated with an element', () => { - const ref = createRef(); - - expect(getRootNode(ref.current)).toBe(document); - }); - - it("returns the iframe's document if the element is in an iframe", () => { - const iframe = document.createElement('iframe'); - const iframeDiv = document.createElement('div'); - window.document.body.appendChild(iframe); - iframe.contentWindow.document.body.appendChild(iframeDiv); - - expect(getRootNode(iframeDiv)).not.toBe(document); - expect(getRootNode(iframeDiv)).toBe(iframe.contentWindow.document); - expect(getRootNode(iframeDiv)).toBe(iframe.contentDocument); - - // Teardown - iframe.remove(); - }); - - it("returns the iframe's document if the ref is in an iframe", () => { - const ref = createRef(); - const iframe = document.createElement('iframe'); - const iframeDiv = document.createElement('div'); - window.document.body.appendChild(iframe); - iframe.contentWindow.document.body.appendChild(iframeDiv); - - render(
, { - container: iframeDiv - }); - - expect(getRootNode(ref.current)).not.toBe(document); - expect(getRootNode(ref.current)).toBe(iframe.contentWindow.document); - expect(getRootNode(ref.current)).toBe(iframe.contentDocument); - }); - - it('returns the shadow root if the element is in a shadow DOM', () => { - // Setup shadow DOM - const hostDiv = document.createElement('div'); - const shadowRoot = hostDiv.attachShadow({mode: 'open'}); - const shadowDiv = document.createElement('div'); - shadowRoot.appendChild(shadowDiv); - document.body.appendChild(hostDiv); - - expect(getRootNode(shadowDiv)).toBe(shadowRoot); - - // Teardown - document.body.removeChild(hostDiv); - }); - - it('returns the correct shadow root for nested shadow DOMs', () => { - // Setup nested shadow DOM - const outerHostDiv = document.createElement('div'); - const outerShadowRoot = outerHostDiv.attachShadow({mode: 'open'}); - const innerHostDiv = document.createElement('div'); - outerShadowRoot.appendChild(innerHostDiv); - const innerShadowRoot = innerHostDiv.attachShadow({mode: 'open'}); - const shadowDiv = document.createElement('div'); - innerShadowRoot.appendChild(shadowDiv); - document.body.appendChild(outerHostDiv); - - expect(getRootNode(shadowDiv)).toBe(innerShadowRoot); - - // Teardown - document.body.removeChild(outerHostDiv); - }); - - it('returns the document for elements directly inside the shadow host', () => { - const hostDiv = document.createElement('div'); - document.body.appendChild(hostDiv); - hostDiv.attachShadow({mode: 'open'}); - const directChildDiv = document.createElement('div'); - hostDiv.appendChild(directChildDiv); - - expect(getRootNode(directChildDiv)).toBe(document); - - // Teardown - document.body.removeChild(hostDiv); - }); - -}); +import {getActiveElement, getOwnerWindow} from '../'; describe('getOwnerWindow', () => { test.each([null, undefined])('returns the window if the argument is %p', (value) => { diff --git a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx new file mode 100644 index 00000000000..4295d8d7f83 --- /dev/null +++ b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx @@ -0,0 +1,274 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {createShadowRoot, render} from '@react-spectrum/test-utils-internal'; +import {createShadowTreeWalker} from '../src'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +describe('ShadowTreeWalker', () => { + describe('Shadow free', () => { + it('walks through the dom', () => { + render( + <> +
+ +
+ +
+ +
+ + ); + let realTreeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ALL); + let walker = createShadowTreeWalker(document, document.body); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.previousNode()).toBe(realTreeWalker.previousNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.lastChild()).toBe(realTreeWalker.lastChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + }); + + it('walks through the dom with a filter function', () => { + render( + <> +
+ +
+ +
+ +
+ + ); + let filterFn = (node) => { + if (node.tagName === 'INPUT') { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + let realTreeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ALL, filterFn); + let walker = createShadowTreeWalker(document, document.body, undefined, filterFn); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.previousNode()).toBe(realTreeWalker.previousNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.lastChild()).toBe(realTreeWalker.lastChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + }); + + it('walks through nested dom with a filter object', () => { + render( + <> +
+ +
+ +
+
+ +
+
+ + ); + let realFilterFn = jest.fn((node) => { + if (node.tagName === 'INPUT') { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }); + let filterFn = jest.fn((node) => { + if (node.tagName === 'INPUT') { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }); + let realTreeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_ALL, realFilterFn); + let walker = createShadowTreeWalker(document, document.body, undefined, filterFn); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.previousNode()).toBe(realTreeWalker.previousNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.lastChild()).toBe(realTreeWalker.lastChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + + expect(filterFn).toHaveBeenCalledTimes(realFilterFn.mock.calls.length); + for (let i = 0; i < realFilterFn.mock.calls.length; i++) { + expect(filterFn.mock.calls[i][0]).toBe(realFilterFn.mock.calls[i][0]); + } + }); + }); + + describe('Shadow dom at root', () => { + it('walks through the dom with shadow dom', () => { + let {shadowRoot, cleanup} = createShadowRoot(); + let Contents = () => ReactDOM.createPortal( + <> +
+ +
+ +
+ +
+ + , shadowRoot); + let {unmount} = render(); + let filterFn = (node) => { + if (node.tagName === 'INPUT') { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + let realTreeWalker = document.createTreeWalker(shadowRoot, NodeFilter.SHOW_ALL, filterFn); + let walker = createShadowTreeWalker(document, document.body, undefined, filterFn); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.previousNode()).toBe(realTreeWalker.previousNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.lastChild()).toBe(realTreeWalker.lastChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.lastChild()).toBe(realTreeWalker.lastChild()); + cleanup(); + unmount(); + }); + }); + + describe('multiple shadow doms', () => { + it('walks through the dom with multiple peer level shadow doms', () => { + let {shadowRoot, shadowHost, cleanup} = createShadowRoot(); + shadowHost.setAttribute('id', 'num-1'); + let {shadowRoot: shadowRoot2, shadowHost: shadowHost2, cleanup: cleanup2} = createShadowRoot(); + shadowHost2.setAttribute('id', 'num-2'); + let Contents = () => ReactDOM.createPortal( + <> +
+ +
+ +
+ +
+ + , shadowRoot); + let Contents2 = () => ReactDOM.createPortal( + <> +
+ +
+ +
+ +
+ + , shadowRoot2); + let {unmount} = render(<>); + let filterFn = (node) => { + if (node.tagName === 'INPUT') { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + let realTreeWalker = document.createTreeWalker(shadowRoot, NodeFilter.SHOW_ALL, filterFn); + let walker = createShadowTreeWalker(document, document.body, undefined, filterFn); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.previousNode()).toBe(realTreeWalker.previousNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + let realTreeWalker2 = document.createTreeWalker(shadowRoot2, NodeFilter.SHOW_ALL, filterFn); + expect(walker.nextNode()).toBe(realTreeWalker2.nextNode()); + expect(walker.previousNode()).toBe(realTreeWalker.currentNode); + + cleanup(); + cleanup2(); + unmount(); + }); + + it('walks through the dom with multiple nested shadow doms', () => { + let {shadowHost, cleanup} = createShadowRoot(); + shadowHost.setAttribute('id', 'parent'); + let {shadowRoot: shadowRoot1, shadowHost: shadowHost1, cleanup: cleanup2} = createShadowRoot(shadowHost); + shadowHost1.setAttribute('id', 'num-1'); + let {shadowRoot: shadowRoot2, shadowHost: shadowHost2, cleanup: cleanup3} = createShadowRoot(shadowHost); + shadowHost2.setAttribute('id', 'num-2'); + let Contents = () => ReactDOM.createPortal( + <> +
+ +
+ +
+ +
+ + , shadowRoot1); + let Contents2 = () => ReactDOM.createPortal( + <> +
+ +
+ +
+ +
+ + , shadowRoot2); + let {unmount} = render(<>); + let filterFn = (node) => { + if (node.tagName === 'INPUT') { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }; + let realTreeWalker = document.createTreeWalker(shadowRoot1, NodeFilter.SHOW_ALL, filterFn); + let walker = createShadowTreeWalker(document, document.body, undefined, filterFn); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.firstChild()).toBe(realTreeWalker.firstChild()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.previousNode()).toBe(realTreeWalker.previousNode()); + expect(walker.currentNode).toBe(realTreeWalker.currentNode); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + expect(walker.nextNode()).toBe(realTreeWalker.nextNode()); + let realTreeWalker2 = document.createTreeWalker(shadowRoot2, NodeFilter.SHOW_ALL, filterFn); + expect(walker.nextNode()).toBe(realTreeWalker2.nextNode()); + expect(walker.previousNode()).toBe(realTreeWalker.currentNode); + + cleanup3(); + cleanup2(); + cleanup(); + unmount(); + }); + }); +}); diff --git a/packages/@react-spectrum/utils/src/Slots.tsx b/packages/@react-spectrum/utils/src/Slots.tsx index e910ccd7048..d6aa6204ccf 100644 --- a/packages/@react-spectrum/utils/src/Slots.tsx +++ b/packages/@react-spectrum/utils/src/Slots.tsx @@ -36,7 +36,7 @@ export function cssModuleToSlots(cssModule) { export function SlotProvider(props) { const emptyObj = useMemo(() => ({}), []); - // eslint-disable-next-line react-hooks/exhaustive-deps + let parentSlots = useContext(SlotContext) || emptyObj; let {slots = emptyObj, children} = props; diff --git a/packages/dev/test-utils/src/shadowDOM.js b/packages/dev/test-utils/src/shadowDOM.js index aad9b296dfe..84a2b06746e 100644 --- a/packages/dev/test-utils/src/shadowDOM.js +++ b/packages/dev/test-utils/src/shadowDOM.js @@ -1,6 +1,18 @@ -export function createShadowRoot() { +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export function createShadowRoot(attachTo = document.body) { const div = document.createElement('div'); - document.body.appendChild(div); + attachTo.appendChild(div); const shadowRoot = div.attachShadow({mode: 'open'}); - return {shadowHost: div, shadowRoot}; + return {shadowHost: div, shadowRoot, cleanup: () => attachTo.removeChild(div)}; } From 650bf4af5fc552884028772200d1ba6a325425c4 Mon Sep 17 00:00:00 2001 From: GitHub Date: Mon, 23 Dec 2024 21:51:51 +1100 Subject: [PATCH 097/102] fix esm test --- packages/@react-aria/interactions/src/usePress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 3dfbb2bc963..364aa305071 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -32,7 +32,7 @@ import { } from '@react-aria/utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; -import {getEventTarget} from '@react-aria/utils/src/shadowdom/DOMFunctions'; +import {getEventTarget} from '@react-aria/utils'; import {PressResponderContext} from './context'; import {TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react'; From 1660aad183a5a90ea8ce3606ca97eb2f162f61c6 Mon Sep 17 00:00:00 2001 From: GitHub Date: Mon, 23 Dec 2024 22:18:03 +1100 Subject: [PATCH 098/102] fix lint --- packages/@react-aria/interactions/src/usePress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 364aa305071..41a3246493a 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -18,6 +18,7 @@ import { chain, focusWithoutScrolling, + getEventTarget, getOwnerDocument, getOwnerWindow, isMac, @@ -32,7 +33,6 @@ import { } from '@react-aria/utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; -import {getEventTarget} from '@react-aria/utils'; import {PressResponderContext} from './context'; import {TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react'; From 9bf04b18d301b3d6553c1aebf8f06ccd081c1aff Mon Sep 17 00:00:00 2001 From: GitHub Date: Thu, 16 Jan 2025 13:54:44 +1100 Subject: [PATCH 099/102] check in speed tests --- .../utils/test/shadowTreeWalker.test.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx index 4295d8d7f83..94420ba9f94 100644 --- a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx +++ b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx @@ -272,3 +272,90 @@ describe('ShadowTreeWalker', () => { }); }); }); + +describe.skip('speed test', () => { + let Component = (props) => { + if (props.depth === 0) { + return
hello
+ } + return
+ } + it.each` + Name | createTreeWalker + ${'native'} | ${() => document.createTreeWalker(document.body, NodeFilter.SHOW_ALL)} + ${'shadow'} | ${() => createShadowTreeWalker(document, document.body)} + `('$Name', ({createTreeWalker}) => { + render( + <> +
+ + +
+ + +
+ + +
+ + + ); + let walker = createTreeWalker(); + let start = performance.now(); + for (let i = 0; i < 10000; i++) { + walker.firstChild(); + walker.nextNode(); + walker.previousNode(); + walker.lastChild(); + } + let end = performance.now(); + console.log(`Time taken for 10000 iterations: ${end - start}ms`); + }); +}); + +// describe('checking if node is contained', () => { +// let user; +// beforeAll(() => { +// user = userEvent.setup({delay: null, pointerMap}); +// }); +// it.only("benchmark native contains", async () => { +// let Component = (props) => { +// if (props.depth === 0) { +// return
hello
+// } +// return
+// } +// let {getByTestId} = render( +// +// ); +// let target = getByTestId('hello'); +// let handler = jest.fn((e) => { +// expect(e.currentTarget.contains(e.target)).toBe(true); +// }); +// document.body.addEventListener('click', handler); +// for (let i = 0; i < 50; i++) { +// await user.click(target); +// expect(handler).toHaveBeenCalledTimes(i + 1); +// } +// }); +// it.only("benchmark nodeContains", async () => { +// let Component = (props) => { +// if (props.depth === 0) { +// return
hello
+// } +// return
+// } +// let {getByTestId} = render( +// +// ); +// let target = getByTestId('hello'); +// let handler = jest.fn((e) => { +// expect(nodeContains(e.currentTarget, e.composedPath()[0])).toBe(true); +// }); +// document.body.addEventListener('click', handler); +// for (let i = 0; i < 50; i++) { +// await user.click(target); +// expect(handler).toHaveBeenCalledTimes(i + 1); +// } +// }); +// }); From 21b80bdde3471210826027c31d2714f9b2fff712 Mon Sep 17 00:00:00 2001 From: GitHub Date: Thu, 16 Jan 2025 14:10:19 +1100 Subject: [PATCH 100/102] fix lint --- packages/@react-aria/utils/test/shadowTreeWalker.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx index 94420ba9f94..bfda0a0c8c5 100644 --- a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx +++ b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx @@ -276,10 +276,10 @@ describe('ShadowTreeWalker', () => { describe.skip('speed test', () => { let Component = (props) => { if (props.depth === 0) { - return
hello
+ return
hello
; } - return
- } + return
; + }; it.each` Name | createTreeWalker ${'native'} | ${() => document.createTreeWalker(document.body, NodeFilter.SHOW_ALL)} @@ -313,6 +313,7 @@ describe.skip('speed test', () => { }); }); + // describe('checking if node is contained', () => { // let user; // beforeAll(() => { From 393c69826c592bbdda1e5d537481d9718df478ae Mon Sep 17 00:00:00 2001 From: GitHub Date: Tue, 28 Jan 2025 13:31:43 +1100 Subject: [PATCH 101/102] Add feature flag and fix a couple probable bugs --- packages/@react-aria/focus/src/FocusScope.tsx | 24 ++++---- .../@react-aria/focus/test/FocusScope.test.js | 2 + .../@react-aria/interactions/package.json | 1 + .../@react-aria/interactions/src/useFocus.ts | 4 +- .../interactions/src/useFocusWithin.ts | 4 +- .../interactions/test/useFocus.test.js | 4 ++ .../interactions/test/usePress.test.js | 2 + packages/@react-aria/utils/package.json | 1 + .../utils/src/shadowdom/DOMFunctions.ts | 59 ++++++------------- .../utils/src/shadowdom/ShadowTreeWalker.ts | 9 ++- .../@react-aria/utils/test/domHelpers.test.js | 7 +++ .../utils/test/shadowTreeWalker.test.tsx | 4 ++ packages/@react-stately/flags/src/index.ts | 9 +++ yarn.lock | 2 + 14 files changed, 75 insertions(+), 57 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 7bb0f930a9d..15089cd3482 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -13,6 +13,7 @@ import { createShadowTreeWalker, getActiveElement, + getEventTarget, getOwnerDocument, isAndroid, isChrome, @@ -342,13 +343,13 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo } }; - let onFocus = (e) => { + let onFocus: EventListener = (e) => { // If focusing an element in a child scope of the currently active scope, the child becomes active. // Moving out of the active scope to an ancestor is not allowed. - if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && isElementInScope(e.target, scopeRef.current)) { + if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && isElementInScope(getEventTarget(e) as Element, scopeRef.current)) { activeScope = scopeRef; - focusedNode.current = e.target; - } else if (shouldContainFocus(scopeRef) && !isElementInChildScope(e.target, scopeRef)) { + focusedNode.current = getEventTarget(e) as FocusableElement; + } else if (shouldContainFocus(scopeRef) && !isElementInChildScope(getEventTarget(e) as Element, scopeRef)) { // If a focus event occurs outside the active scope (e.g. user tabs from browser location bar), // restore focus to the previously focused node or the first tabbable element in the active scope. if (focusedNode.current) { @@ -357,11 +358,11 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo focusFirstInScope(activeScope.current); } } else if (shouldContainFocus(scopeRef)) { - focusedNode.current = e.target; + focusedNode.current = getEventTarget(e) as FocusableElement; } }; - let onBlur = (e) => { + let onBlur: EventListener = (e) => { // Firefox doesn't shift focus back to the Dialog properly without this if (raf.current) { cancelAnimationFrame(raf.current); @@ -377,8 +378,9 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo let activeElement = getActiveElement(ownerDocument); if (!shouldSkipFocusRestore && activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef)) { activeScope = scopeRef; - if (e.target.isConnected) { - focusedNode.current = e.target; + let target = getEventTarget(e) as FocusableElement; + if (target && target.isConnected) { + focusedNode.current = target; focusedNode.current?.focus(); } else if (activeScope.current) { focusFirstInScope(activeScope.current); @@ -521,7 +523,7 @@ function useActiveScopeTracker(scopeRef: RefObject, restore?: const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined); let onFocus = (e) => { - let target = e.target as Element; + let target = getEventTarget(e) as Element; if (isElementInScope(target, scopeRef.current)) { activeScope = scopeRef; } else if (!isElementInAnyScope(target)) { @@ -735,7 +737,7 @@ function restoreFocusToElement(node: FocusableElement) { * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} * that matches all focusable/tabbable elements. */ -export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]): ShadowTreeWalker { +export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]): ShadowTreeWalker | TreeWalker { let filter = opts?.tabbable ? isTabbable : isFocusable; // Ensure that root is an Element or fall back appropriately @@ -863,7 +865,7 @@ export function createFocusManager(ref: RefObject, defaultOption }; } -function last(walker: ShadowTreeWalker) { +function last(walker: ShadowTreeWalker | TreeWalker) { let next: FocusableElement | undefined = undefined; let last: FocusableElement; do { diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 8f7fc2b9315..b9a53f4d20f 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -13,6 +13,7 @@ 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'; @@ -1723,6 +1724,7 @@ describe('FocusScope with Shadow DOM', function () { let user; beforeAll(() => { + enableShadowDOM(); user = userEvent.setup({delay: null, pointerMap}); }); diff --git a/packages/@react-aria/interactions/package.json b/packages/@react-aria/interactions/package.json index d0dfaf988d7..4525033285c 100644 --- a/packages/@react-aria/interactions/package.json +++ b/packages/@react-aria/interactions/package.json @@ -24,6 +24,7 @@ "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-aria/utils": "^3.27.0", + "@react-stately/flags": "^3.0.5", "@react-types/shared": "^3.27.0", "@swc/helpers": "^0.5.0" }, diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index 47cf8727da3..d2c910ecedd 100644 --- a/packages/@react-aria/interactions/src/useFocus.ts +++ b/packages/@react-aria/interactions/src/useFocus.ts @@ -17,7 +17,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared'; import {FocusEvent, useCallback} from 'react'; -import {getActiveElement, getOwnerDocument} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, getOwnerDocument} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { @@ -65,7 +65,7 @@ export function useFocus(pro const ownerDocument = getOwnerDocument(e.target); const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement(); - if (e.target === e.currentTarget && activeElement === e.target) { + if (e.target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusProp) { onFocusProp(e); } diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index e88a6f27a0c..6f8f392c57a 100644 --- a/packages/@react-aria/interactions/src/useFocusWithin.ts +++ b/packages/@react-aria/interactions/src/useFocusWithin.ts @@ -17,7 +17,7 @@ import {DOMAttributes} from '@react-types/shared'; import {FocusEvent, useCallback, useRef} from 'react'; -import {getActiveElement, getOwnerDocument} from '@react-aria/utils'; +import {getActiveElement, getEventTarget, getOwnerDocument} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusWithinProps { @@ -73,7 +73,7 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { // focus handler already moved focus somewhere else. const ownerDocument = getOwnerDocument(e.target); const activeElement = getActiveElement(ownerDocument); - if (!state.current.isFocusWithin && activeElement === e.target) { + if (!state.current.isFocusWithin && activeElement === getEventTarget(e.nativeEvent)) { if (onFocusWithin) { onFocusWithin(e); } diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 95f035dd064..0d9a279bdcb 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -11,6 +11,7 @@ */ import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; import React from 'react'; import ReactDOM from 'react-dom'; import {useFocus} from '../'; @@ -156,6 +157,9 @@ describe('useFocus', function () { }); describe('useFocus with Shadow DOM', function () { + beforeAll(() => { + enableShadowDOM(); + }); it('handles focus events', function () { const {shadowRoot, shadowHost} = createShadowRoot(); const events = []; diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 55ef6a34e80..caef811ea79 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -13,6 +13,7 @@ import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; +import {enableShadowDOM} from '@react-stately/flags'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -3809,6 +3810,7 @@ describe('usePress', function () { } beforeAll(() => { + enableShadowDOM(); jest.useFakeTimers(); }); diff --git a/packages/@react-aria/utils/package.json b/packages/@react-aria/utils/package.json index 7a5ad30e35d..921b684806a 100644 --- a/packages/@react-aria/utils/package.json +++ b/packages/@react-aria/utils/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@react-aria/ssr": "^3.9.7", + "@react-stately/flags": "^3.0.5", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.27.0", "@swc/helpers": "^0.5.0", diff --git a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts index dc2b23a3a61..c4b0234136b 100644 --- a/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -1,11 +1,19 @@ // Source: https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/DOMFunctions.ts#L16 import {isShadowRoot} from '../domHelpers'; +import {shadowDOM} from '@react-stately/flags'; +/** + * ShadowDOM safe version of Node.contains. + */ export function nodeContains( node: Node | null | undefined, otherNode: Node | null | undefined ): boolean { + if (!shadowDOM()) { + return otherNode && node ? node.contains(otherNode) : false; + } + if (!node || !otherNode) { return false; } @@ -32,7 +40,13 @@ export function nodeContains( return false; } +/** + * ShadowDOM safe version of document.activeElement. + */ export const getActiveElement = (doc: Document = document) => { + if (!shadowDOM()) { + return doc.activeElement; + } let activeElement: Element | null = doc.activeElement; while (activeElement && 'shadowRoot' in activeElement && @@ -43,48 +57,11 @@ export const getActiveElement = (doc: Document = document) => { return activeElement; }; -export function getLastChild(node: Node | null | undefined): ChildNode | null { - if (!node) { - return null; - } - - if (!node.lastChild && (node as Element).shadowRoot) { - return getLastChild((node as Element).shadowRoot); - } - - return node.lastChild; -} - -export function getPreviousSibling( - node: Node | null | undefined -): ChildNode | null { - if (!node) { - return null; - } - - let sibling = node.previousSibling; - - if (!sibling && node.parentElement?.shadowRoot) { - sibling = getLastChild(node.parentElement.shadowRoot); - } - - return sibling; -} - -export function getLastElementChild( - element: Element | null | undefined -): Element | null { - let child = getLastChild(element); - - while (child && child.nodeType !== Node.ELEMENT_NODE) { - child = getPreviousSibling(child); - } - - return child as Element | null; -} - +/** + * ShadowDOM safe version of event.target. + */ export function getEventTarget(event): Element { - if (event.target.shadowRoot) { + if (shadowDOM() && event.target.shadowRoot) { if (event.composedPath) { return event.composedPath()[0]; } diff --git a/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts index 6fd9c05cdc3..fcb80d0add7 100644 --- a/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts +++ b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts @@ -1,6 +1,7 @@ // https://github.com/microsoft/tabster/blob/a89fc5d7e332d48f68d03b1ca6e344489d1c3898/src/Shadowdomize/ShadowTreeWalker.ts import {nodeContains} from './DOMFunctions'; +import {shadowDOM} from '@react-stately/flags'; export class ShadowTreeWalker implements TreeWalker { public readonly filter: NodeFilter | null; @@ -302,11 +303,17 @@ export class ShadowTreeWalker implements TreeWalker { } } +/** + * ShadowDOM safe version of document.createTreeWalker. + */ export function createShadowTreeWalker( doc: Document, root: Node, whatToShow?: number, filter?: NodeFilter | null ) { - return new ShadowTreeWalker(doc, root, whatToShow, filter); + if (shadowDOM()) { + return new ShadowTreeWalker(doc, root, whatToShow, filter); + } + return doc.createTreeWalker(root, whatToShow, filter); } diff --git a/packages/@react-aria/utils/test/domHelpers.test.js b/packages/@react-aria/utils/test/domHelpers.test.js index 95772fb0d75..8aa315f4ca1 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -12,9 +12,13 @@ import {act} from 'react-dom/test-utils'; +import {enableShadowDOM} from '@react-stately/flags'; import {getActiveElement, getOwnerWindow} from '../'; describe('getOwnerWindow', () => { + beforeAll(() => { + enableShadowDOM(); + }); test.each([null, undefined])('returns the window if the argument is %p', (value) => { expect(getOwnerWindow(value)).toBe(window); }); @@ -43,6 +47,9 @@ describe('getOwnerWindow', () => { }); describe('getActiveElement', () => { + beforeAll(() => { + enableShadowDOM(); + }); it('returns the body as the active element by default', () => { act(() => {document.body.focus();}); // Ensure the body is focused, clearing any specific active element expect(getActiveElement()).toBe(document.body); diff --git a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx index bfda0a0c8c5..a454e16a1d9 100644 --- a/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx +++ b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx @@ -12,10 +12,14 @@ import {createShadowRoot, render} from '@react-spectrum/test-utils-internal'; import {createShadowTreeWalker} from '../src'; +import {enableShadowDOM} from '@react-stately/flags'; import React from 'react'; import ReactDOM from 'react-dom'; describe('ShadowTreeWalker', () => { + beforeAll(() => { + enableShadowDOM(); + }); describe('Shadow free', () => { it('walks through the dom', () => { render( diff --git a/packages/@react-stately/flags/src/index.ts b/packages/@react-stately/flags/src/index.ts index 63fdb85538b..e0bef6dbec2 100644 --- a/packages/@react-stately/flags/src/index.ts +++ b/packages/@react-stately/flags/src/index.ts @@ -11,6 +11,7 @@ */ let _tableNestedRows = false; +let _shadowDOM = false; export function enableTableNestedRows() { _tableNestedRows = true; @@ -19,3 +20,11 @@ export function enableTableNestedRows() { export function tableNestedRows() { return _tableNestedRows; } + +export function enableShadowDOM() { + _shadowDOM = true; +} + +export function shadowDOM() { + return _shadowDOM; +} diff --git a/yarn.lock b/yarn.lock index 898fc94894c..63412e198eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6251,6 +6251,7 @@ __metadata: dependencies: "@react-aria/ssr": "npm:^3.9.7" "@react-aria/utils": "npm:^3.27.0" + "@react-stately/flags": "npm:^3.0.5" "@react-types/shared": "npm:^3.27.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: @@ -6807,6 +6808,7 @@ __metadata: resolution: "@react-aria/utils@workspace:packages/@react-aria/utils" dependencies: "@react-aria/ssr": "npm:^3.9.7" + "@react-stately/flags": "npm:^3.0.5" "@react-stately/utils": "npm:^3.10.5" "@react-types/shared": "npm:^3.27.0" "@swc/helpers": "npm:^0.5.0" From dc4ca82c9e12b172160f1eeadd46e4eb71ab00a5 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Thu, 6 Feb 2025 12:38:27 +1100 Subject: [PATCH 102/102] Update NOTICE.txt --- NOTICE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NOTICE.txt b/NOTICE.txt index 6f68f1ceb60..07a501089b6 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -214,7 +214,7 @@ This codebase contains a modified portion of code from Yarn berry which can be o ------------------------------------------------------------------------------- -This codebase contains a modified portion of code from Yarn berry which can be obtained at: +This codebase contains a modified portion of code from Microsoft which can be obtained at: * SOURCE: * https://github.com/microsoft/tabster