diff --git a/.circleci/comment.js b/.circleci/comment.js index 2e86d9c831b..114aecd6790 100644 --- a/.circleci/comment.js +++ b/.circleci/comment.js @@ -41,7 +41,21 @@ async function run() { break; } } - } else if (process.env.CIRCLE_BRANCH === 'main') { + } else if (true) { + console.log(`Verdaccio builds: + [CRA Test App](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/build/index.html) + [NextJS Test App](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/next/index.html) + [RAC Tailwind Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/rac-tailwind/index.html) + [RAC Spectrum + Tailwind Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/rac-spectrum-tailwind/index.html) + [S2 Parcel Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/s2-parcel-example/index.html) + [S2 Webpack Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/s2-webpack-5-example/index.html) + [CRA Test App Size](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/build-stats.txt) + [NextJS App Size](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/next-build-stats.txt) + [Publish stats](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/publish.json) + [Size diff since last release](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/size-diff.txt) + [Docs](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/docs/index.html) + [Storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/storybook/index.html) + [S2 Storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/storybook-s2/index.html)`); //If it isn't a PR commit, then we are on main. Create a comment for the test app and docs build await octokit.repos.createCommitComment({ owner: 'adobe', @@ -71,6 +85,20 @@ async function run() { } console.log('PR number to comment on', pr); if (pr != null) { + console.log(`Verdaccio builds: + [CRA Test App](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/build/index.html) + [NextJS Test App](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/next/index.html) + [RAC Tailwind Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/rac-tailwind/index.html) + [RAC Spectrum + Tailwind Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/rac-spectrum-tailwind/index.html) + [S2 Parcel Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/s2-parcel-example/index.html) + [S2 Webpack Example](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/s2-webpack-5-example/index.html) + [CRA Test App Size](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/build-stats.txt) + [NextJS App Size](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/next-build-stats.txt) + [Publish stats](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/publish.json) + [Size diff since last release](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/publish-stats/size-diff.txt) + [Docs](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/verdaccio/docs/index.html) + [Storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/storybook/index.html) + [S2 Storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/${process.env.CIRCLE_SHA1}/storybook-s2/index.html)`); try { await octokit.issues.createComment({ owner: 'adobe', diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e273f4d264..972aaa96d56 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -647,9 +647,6 @@ workflows: requires: - install - docs-verdaccio: - filters: - branches: - only: main requires: - install - deploy: 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/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index e9c48a7ec1b..7bb0f930a9d 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -10,10 +10,20 @@ * governing permissions and limitations under the License. */ +import { + createShadowTreeWalker, + getActiveElement, + getOwnerDocument, + isAndroid, + isChrome, + isFocusable, + isTabbable, + ShadowTreeWalker, + useLayoutEffect +} from '@react-aria/utils'; import {FocusableElement, RefObject} from '@react-types/shared'; import {focusSafely} from './focusSafely'; import {getInteractionModality} from '@react-aria/interactions'; -import {getOwnerDocument, isAndroid, isChrome, isFocusable, isTabbable, useLayoutEffect} from '@react-aria/utils'; import {isElementVisible} from './isElementVisible'; import React, {ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; @@ -55,7 +65,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 } @@ -144,7 +154,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 = getActiveElement(getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined)); let scope: TreeNode | null = null; if (isElementInScope(activeElement, scopeRef.current)) { @@ -208,7 +218,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 || getActiveElement(getOwnerDocument(scope[0] ?? undefined))!; let sentinel = scope[0].previousElementSibling!; let scopeRoot = getScopeRoot(scope); let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope); @@ -226,11 +236,11 @@ 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 || 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; @@ -308,7 +318,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; @@ -364,9 +374,10 @@ function useFocusContainment(scopeRef: RefObject, contain?: bo let shouldSkipFocusRestore = (modality === 'virtual' || modality === null) && isAndroid() && isChrome(); // Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe - if (!shouldSkipFocusRestore && ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) { + let activeElement = getActiveElement(ownerDocument); + if (!shouldSkipFocusRestore && activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(activeElement, scopeRef)) { activeScope = scopeRef; - if (ownerDocument.body.contains(e.target)) { + if (e.target.isConnected) { focusedNode.current = e.target; focusedNode.current?.focus(); } else if (activeScope.current) { @@ -490,7 +501,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); } } @@ -543,7 +554,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' ? 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 @@ -558,7 +569,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; } @@ -570,7 +581,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b 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(() => { @@ -606,7 +617,7 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b walker.currentNode = focusedElement; let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement; - if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) { + if (!nodeToRestore || !nodeToRestore.isConnected || nodeToRestore === ownerDocument.body) { nodeToRestore = undefined; treeNode.nodeToRestore = undefined; } @@ -626,9 +637,9 @@ function useRestoreFocus(scopeRef: RefObject, restoreFocus?: b 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 { @@ -639,12 +650,12 @@ 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]); @@ -670,11 +681,12 @@ 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 === ownerDocument.body && 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 @@ -723,10 +735,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, opts?: FocusManagerOptions, scope?: Element[]) { +export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]): ShadowTreeWalker { let filter = opts?.tabbable ? isTabbable : isFocusable; - let walker = getOwnerDocument(root).createTreeWalker( - root, + + // 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 = getOwnerDocument(rootElement); + + // Create a TreeWalker, ensuring the root is an Element or Document + let walker = createShadowTreeWalker( + doc, + root || doc, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { @@ -766,7 +787,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 || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -787,7 +808,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 || getActiveElement(getOwnerDocument(root)); let walker = getFocusableTreeWalker(root, {tabbable, accept}); if (root.contains(node)) { walker.currentNode = node!; @@ -842,7 +863,7 @@ export function createFocusManager(ref: RefObject, defaultOption }; } -function last(walker: TreeWalker) { +function last(walker: ShadowTreeWalker) { let next: FocusableElement | undefined = undefined; let last: FocusableElement; do { diff --git a/packages/@react-aria/focus/src/focusSafely.ts b/packages/@react-aria/focus/src/focusSafely.ts index e0bc52e9465..be22242e624 100644 --- a/packages/@react-aria/focus/src/focusSafely.ts +++ b/packages/@react-aria/focus/src/focusSafely.ts @@ -11,7 +11,12 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerDocument, runAfterTransition} from '@react-aria/utils'; +import { + focusWithoutScrolling, + getActiveElement, + getOwnerDocument, + runAfterTransition +} from '@react-aria/utils'; import {getInteractionModality} from '@react-aria/interactions'; /** @@ -25,11 +30,12 @@ export function focusSafely(element: FocusableElement) { // causing the page to scroll when moving focus if the element is transitioning // from off the screen. const ownerDocument = getOwnerDocument(element); + const activeElement = getActiveElement(ownerDocument); 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 (getActiveElement(ownerDocument) === lastFocusedElement && element.isConnected) { focusWithoutScrolling(element); } }); diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 1093af778af..8f7fc2b9315 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; +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 '../'; @@ -22,7 +22,6 @@ import {Example as StorybookExample} from '../stories/FocusScope.stories'; import {useEvent} from '@react-aria/utils'; import userEvent from '@testing-library/user-event'; - describe('FocusScope', function () { let user; @@ -1720,6 +1719,179 @@ 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 = () => ReactDOM.createPortal( + + + + + , + shadowRoot + ); + + const {unmount} = render(); + + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const input3 = shadowRoot.querySelector('[data-testid="input3"]'); + + // Simulate focusing the first input + act(() => {input1.focus();}); + 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 + unmount(); + document.body.removeChild(shadowRoot.host); + }); + + it('should manage focus within nested shadow DOMs', async function () { + const {shadowRoot: parentShadowRoot} = createShadowRoot(); + const nestedDiv = document.createElement('div'); + parentShadowRoot.appendChild(nestedDiv); + const childShadowRoot = nestedDiv.attachShadow({mode: 'open'}); + + const FocusableComponent = () => ReactDOM.createPortal( + + + , childShadowRoot); + + const {unmount} = render(); + + 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 + unmount(); + 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.createPortal( + + + + + , + shadowRoot + ); + + const {unmount} = render(); + + 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); + + act(() => { + jest.runAllTimers(); + }); + + unmount(); + + expect(document.activeElement).toBe(externalInput); + }); + + /** + * Test case: https://github.com/adobe/react-spectrum/issues/1472 + */ + it('should autofocus and lock tab navigation inside shadow DOM', async function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + + const FocusableComponent = () => ReactDOM.createPortal( + + + + + , + shadowRoot + ); + + const {unmount} = render(); + + const input1 = shadowRoot.querySelector('[data-testid="input1"]'); + const input2 = shadowRoot.querySelector('[data-testid="input2"]'); + const button = shadowRoot.querySelector('[data-testid="button"]'); + + // Simulate focusing the first input and tab through the elements + act(() => {input1.focus();}); + 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 + unmount(); + document.body.removeChild(shadowHost); + }); +}); + describe('Unmounting cleanup', () => { beforeAll(() => { jest.useFakeTimers(); diff --git a/packages/@react-aria/focus/test/focusSafely.test.js b/packages/@react-aria/focus/test/focusSafely.test.js index c576554fd8c..c07fd86a265 100644 --- a/packages/@react-aria/focus/test/focusSafely.test.js +++ b/packages/@react-aria/focus/test/focusSafely.test.js @@ -11,10 +11,11 @@ */ -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 ReactDOM from 'react-dom'; import {setInteractionModality} from '@react-aria/interactions'; jest.mock('@react-aria/utils', () => { @@ -66,4 +67,55 @@ describe('focusSafely', () => { expect(ReactAriaUtils.focusWithoutScrolling).toBeCalledTimes(1); }); + + describe('focusSafely with Shadow DOM', function () { + 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 = () => ReactDOM.createPortal(, shadowRoot); + + const {unmount} = render(); + + const button = shadowRoot.querySelector('button'); + + requestAnimationFrame(() => { + unmount(); + 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 = () => ReactDOM.createPortal(, shadowRoot); + + const {unmount} = render(); + + const button = shadowRoot.querySelector('button'); + + expect(button).toBeTruthy(); + focusSafely(button); + + act(() => { + jest.runAllTimers(); + }); + + expect(focusWithoutScrollingSpy).toBeCalledTimes(1); + + unmount(); + shadowRoot.host.remove(); + }); + }); }); diff --git a/packages/@react-aria/interactions/src/useFocus.ts b/packages/@react-aria/interactions/src/useFocus.ts index bf877c9988d..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 {getOwnerDocument} from '@react-aria/utils'; +import {getActiveElement, getOwnerDocument} 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 = getOwnerDocument(e.target); - - if (e.target === e.currentTarget && ownerDocument.activeElement === 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 0d56e412729..40e739717b8 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -181,6 +181,7 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => { 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); diff --git a/packages/@react-aria/interactions/src/useFocusWithin.ts b/packages/@react-aria/interactions/src/useFocusWithin.ts index 0c0bf39ccf9..e88a6f27a0c 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 {getActiveElement, getOwnerDocument} from '@react-aria/utils'; import {useSyntheticBlurEvent} from './utils'; export interface FocusWithinProps { @@ -70,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 && document.activeElement === e.target) { + const ownerDocument = getOwnerDocument(e.target); + const activeElement = getActiveElement(ownerDocument); + if (!state.current.isFocusWithin && activeElement === e.target) { if (onFocusWithin) { onFocusWithin(e); } @@ -87,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 } diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index f72218621e1..c00c00e6802 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -116,19 +116,25 @@ 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; } } - return ref.current && !ref.current.contains(event.target); + if (!ref.current) { + return false; + } + + // 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); } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index e24cb11b32f..fb617cb3ace 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -15,7 +15,22 @@ // 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, + getEventTarget, + getOwnerDocument, + getOwnerWindow, + isMac, + isVirtualClick, + isVirtualPointerEvent, + mergeProps, + nodeContains, + openLink, + useEffectEvent, + useGlobalListeners, + useSyncRef +} from '@react-aria/utils'; import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -284,8 +299,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, getEventTarget(e.nativeEvent))) { + if (shouldPreventDefaultKeyboard(getEventTarget(e.nativeEvent), e.key)) { e.preventDefault(); } @@ -304,7 +319,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 && originalTarget.contains(e.target as Element) && state.target) { + if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { triggerPressUp(createEvent(state.target, e), 'keyboard'); } }; @@ -331,7 +346,7 @@ export function usePress(props: PressHookProps): PressResult { } }, onClick(e) { - if (e && !e.currentTarget.contains(e.target as Element)) { + if (e && !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -365,18 +380,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(getEventTarget(e), e.key)) { e.preventDefault(); } - let target = e.target as Element; - triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target)); + 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. // 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; @@ -400,7 +415,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, getEventTarget(e.nativeEvent))) { return; } @@ -420,7 +435,7 @@ export function usePress(props: PressHookProps): PressResult { state.isPressed = true; state.isOverTarget = true; state.activePointerId = e.pointerId; - state.target = e.currentTarget; + state.target = e.currentTarget as FocusableElement; if (!allowTextSelectionOnPress) { disableTextSelection(state.target); @@ -430,7 +445,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.target as Element; + let target = getEventTarget(e.nativeEvent); if ('releasePointerCapture' in target) { target.releasePointerCapture(e.pointerId); } @@ -445,7 +460,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseDown = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -463,7 +478,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 (!e.currentTarget.contains(e.target as Element) || state.pointerType === 'virtual') { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent)) || state.pointerType === 'virtual') { return; } @@ -490,7 +505,7 @@ export function usePress(props: PressHookProps): PressResult { let onPointerUp = (e: PointerEvent) => { if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { - if (state.target.contains(e.target as Element) && state.pointerType != null) { + if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { // Wait for onClick to fire onPress. This avoids browser issues when the DOM // is mutated between onPointerUp and onClick, and is more compatible with third party libraries. // https://github.com/adobe/react-spectrum/issues/1513 @@ -528,7 +543,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -541,7 +556,7 @@ export function usePress(props: PressHookProps): PressResult { 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, getEventTarget(e.nativeEvent))) { return; } @@ -572,7 +587,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseEnter = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -588,7 +603,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseLeave = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -605,7 +620,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onMouseUp = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -636,7 +651,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchStart = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -664,7 +679,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchMove = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -692,7 +707,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchEnd = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -725,7 +740,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onTouchCancel = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } @@ -736,7 +751,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(getEventTarget(e), state.target)) { cancel({ currentTarget: state.target, shiftKey: false, @@ -748,7 +763,7 @@ export function usePress(props: PressHookProps): PressResult { }; pressProps.onDragStart = (e) => { - if (!e.currentTarget.contains(e.target as Element)) { + if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } diff --git a/packages/@react-aria/interactions/test/useFocus.test.js b/packages/@react-aria/interactions/test/useFocus.test.js index 85b1196b66a..95f035dd064 100644 --- a/packages/@react-aria/interactions/test/useFocus.test.js +++ b/packages/@react-aria/interactions/test/useFocus.test.js @@ -10,8 +10,9 @@ * governing permissions and limitations under the License. */ -import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; import React from 'react'; +import ReactDOM from 'react-dom'; import {useFocus} from '../'; function Example(props) { @@ -153,4 +154,116 @@ describe('useFocus', function () { // MutationObserver is async await waitFor(() => expect(onBlur).toHaveBeenCalled()); }); + + describe('useFocus with Shadow DOM', function () { + it('handles focus events', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const events = []; + 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})} />, shadowRoot); + + const {unmount} = render(); + + const el = shadowRoot.querySelector('[data-testid="example"]'); + + act(() => {el.focus();}); + act(() => {el.blur();}); + + expect(events).toEqual([ + {type: 'focus', target: el}, + {type: 'focuschange', isFocused: true}, + {type: 'blur', target: el}, + {type: 'focuschange', isFocused: false} + ]); + + // Cleanup + unmount(); + document.body.removeChild(shadowHost); + }); + + it('does not handle focus events if disabled', function () { + const {shadowRoot, shadowHost} = createShadowRoot(); + const events = []; + 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})} />, shadowRoot + ); + + const {unmount} = render(); + + const el = shadowRoot.querySelector('[data-testid="example"]'); + act(() => {el.focus();}); + act(() => {el.blur();}); + + expect(events).toEqual([]); + + // Cleanup + unmount(); + document.body.removeChild(shadowHost); + }); + + it('events do not bubble when stopPropagation is called', 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 = () => ReactDOM.createPortal( +
+ +
, shadowRoot + ); + + const {unmount} = render(); + + 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 + unmount(); + 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.createPortal( +
+ +
, shadowRoot + ); + + const {unmount} = render(); + + 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 + 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 7d26eb6d9d3..a13c1c6d3e3 100644 --- a/packages/@react-aria/interactions/test/useInteractOutside.test.js +++ b/packages/@react-aria/interactions/test/useInteractOutside.test.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ -import {createPortal} from 'react-dom'; import {fireEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; -import React, {useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; +import ReactDOM, {createPortal, render as ReactDOMRender} from 'react-dom'; import {useInteractOutside} from '../'; function Example(props) { @@ -438,3 +438,148 @@ 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 () { + jest.useFakeTimers(); + const onInteractOutside = jest.fn(); + const {cleanup} = createShadowRootAndRender( + + ); + + 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 5f0abbc8f1f..55ef6a34e80 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -10,24 +10,24 @@ * 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 {createPortal} from 'react-dom'; 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, {createPortal} 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' ? props.children || 'test' : undefined}; + return {ElementType !== 'input' ? props.children || 'test' : undefined}; } 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, @@ -415,11 +415,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([ @@ -3780,6 +3778,685 @@ describe('usePress', function () { ]); }); }); + + describe('usePress with Shadow DOM', function () { + installPointerEvent(); + let unmount; + let events = []; + let addEvent = (e) => events.push(e); + + function setupShadowDOMTest(extraProps = {}, isDraggable = false) { + const {shadowRoot} = createShadowRoot(); + events = []; + addEvent = (e) => events.push(e); + const ExampleComponent = () => ReactDOM.createPortal( +
+ addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent} + {...extraProps} /> +
, + shadowRoot + ); + + const {unmount: _unmount} = render(); + unmount = _unmount; + + return shadowRoot; + } + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + unmount(); + }); + + 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})); + fireEvent.click(el); + + 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: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + 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: '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'); + el.releasePointerCapture = jest.fn(); + fireEvent(el, pointerEvent('pointerdown', {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})); + fireEvent(el, pointerEvent('pointerover', {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 + }) + ]); + + events = []; + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + 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(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent.click(el); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + 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 + }), + expect.objectContaining({ + type: 'presschange', + pressed: true + }), + expect.objectContaining({ + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + 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: '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([ + 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 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([ + 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 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('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({ + 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 handle modifier keys', function () { + const shadowRoot = setupShadowDOMTest(); + + const el = shadowRoot.getElementById('testElement'); + + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', shiftKey: true, clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', ctrlKey: true, clientX: 0, clientY: 0})); + fireEvent.click(el, {ctrlKey: true}); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'pressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: true, + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ + type: 'presschange', + pressed: true + }), + expect.objectContaining({ + type: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'presschange', + pressed: false + }), + expect.objectContaining({ + 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 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([ + expect.objectContaining({ + type: 'pressstart', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ + type: 'pressup', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'pressend', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + 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})); + fireEvent.click(el); + + 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: 'pressup', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'pressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + 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([ + expect.objectContaining({ + type: 'pressstart', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }), + expect.objectContaining({ + type: 'pressup', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + type: 'pressend', + target: el, + pointerType: 'virtual', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false + }), + expect.objectContaining({ + 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})); + fireEvent.click(el, {clientX: 10, clientY: 10}); + + 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'})); + fireEvent.click(el); + expect(el).not.toHaveStyle('user-select: none'); + }); + }); }); diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 31be6884dda..090660ea955 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import {createShadowTreeWalker, getOwnerDocument} from '@react-aria/utils'; + // Keeps a ref count of all hidden elements. Added to when hiding an element, and // subtracted from when showing it again. When it reaches zero, aria-hidden is removed. let refCountMap = new WeakMap(); @@ -59,7 +61,8 @@ export function ariaHideOutside(targets: Element[], root = document.body) { return NodeFilter.FILTER_ACCEPT; }; - let walker = document.createTreeWalker( + let walker = createShadowTreeWalker( + getOwnerDocument(root), root, NodeFilter.SHOW_ELEMENT, {acceptNode} @@ -103,7 +106,10 @@ export function ariaHideOutside(targets: Element[], root = document.body) { observerStack[observerStack.length - 1].disconnect(); } + let startTime = Date.now(); walk(root); + let endTime = Date.now(); + console.log(`walk took ${endTime - startTime}ms`); let observer = new MutationObserver(changes => { for (let change of changes) { diff --git a/packages/@react-aria/utils/src/domHelpers.ts b/packages/@react-aria/utils/src/domHelpers.ts index b8be4d07ee2..7c661d5be85 100644 --- a/packages/@react-aria/utils/src/domHelpers.ts +++ b/packages/@react-aria/utils/src/domHelpers.ts @@ -12,3 +12,22 @@ export const getOwnerWindow = ( const doc = getOwnerDocument(el as Element | null | undefined); return doc.defaultView || window; }; + +/** + * 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 && + typeof value === 'object' && + 'nodeType' in value && + typeof (value as Node).nodeType === 'number'; +} +/** + * 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) && + 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 260f16a176c..413e1f46639 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} from './domHelpers'; +export {createShadowTreeWalker, ShadowTreeWalker} from './shadowdom/ShadowTreeWalker'; +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 new file mode 100644 index 00000000000..dc2b23a3a61 --- /dev/null +++ b/packages/@react-aria/utils/src/shadowdom/DOMFunctions.ts @@ -0,0 +1,93 @@ +// 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 +): boolean { + if (!node || !otherNode) { + return false; + } + + let currentNode: HTMLElement | Node | null | undefined = otherNode; + + while (currentNode !== null) { + if (currentNode === node) { + return true; + } + + 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; + } + } + + 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; +} + +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 new file mode 100644 index 00000000000..6fd9c05cdc3 --- /dev/null +++ b/packages/@react-aria/utils/src/shadowdom/ShadowTreeWalker.ts @@ -0,0 +1,312 @@ +// 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 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]; + let newNode = walker.lastChild(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; + } + + 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) { + this.currentNode = nextNode; + return 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(); + + let newNode = this.nextNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; + } 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(); + let newNode = this.previousNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; + } 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) { + if (previousNode) { + this.currentNode = previousNode; + } + return 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(); + + let newNode = this.previousNode(); + if (newNode) { + this.currentNode = newNode; + } + return newNode; + } 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 241157545e5..95772fb0d75 100644 --- a/packages/@react-aria/utils/test/domHelpers.test.js +++ b/packages/@react-aria/utils/test/domHelpers.test.js @@ -11,92 +11,116 @@ */ -import {getOwnerDocument, getOwnerWindow} from '../'; -import React, {createRef} from 'react'; -import {render} from '@react-spectrum/test-utils-internal'; +import {act} from 'react-dom/test-utils'; +import {getActiveElement, getOwnerWindow} from '../'; -describe('getOwnerDocument', () => { - test.each([null, undefined])('returns the document if the argument is %p', (value) => { - expect(getOwnerDocument(value)).toBe(document); +describe('getOwnerWindow', () => { + test.each([null, undefined])('returns the window if the argument is %p', (value) => { + expect(getOwnerWindow(value)).toBe(window); }); - it('returns the document if the element is in the document', () => { + it('returns the window if the element is in the window', () => { const div = document.createElement('div'); window.document.body.appendChild(div); - expect(getOwnerDocument(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); - }); - - it('returns the document if nothing is passed in', () => { - expect(getOwnerDocument()).toBe(document); - expect(getOwnerDocument(null)).toBe(document); - expect(getOwnerDocument(undefined)).toBe(document); + expect(getOwnerWindow(div)).toBe(window); }); - it('returns the document if ref exists, but is not associated with an element', () => { - const ref = createRef(); - - expect(getOwnerDocument(ref.current)).toBe(document); + it('returns the window if the element is the window', () => { + expect(getOwnerWindow(window)).toBe(window); }); - it("returns the iframe's document if the element is in an iframe", () => { + it("returns the iframe's window if the element is in the iframe", () => { const iframe = document.createElement('iframe'); const iframeDiv = document.createElement('div'); 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(getOwnerWindow(iframeDiv)).toBe(iframe.contentWindow); // 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 - }); +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(getActiveElement()).toBe(document.body); + }); - expect(getOwnerDocument(ref.current)).not.toBe(document); - expect(getOwnerDocument(ref.current)).toBe(iframe.contentWindow.document); - expect(getOwnerDocument(ref.current)).toBe(iframe.contentDocument); + it('returns the active element in the light DOM', () => { + const btn = document.createElement('button'); + document.body.appendChild(btn); + act(() => {btn.focus();}); + expect(getActiveElement()).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'); -describe('getOwnerWindow', () => { - test.each([null, undefined])('returns the window if the argument is %p', (value) => { - expect(getOwnerWindow(value)).toBe(window); + shadowRoot.appendChild(btnInShadow); + document.body.appendChild(div); + + act(() => {btnInShadow.focus();}); + + expect(getActiveElement()).toBe(btnInShadow); + + document.body.removeChild(div); }); - it('returns the window if the element is in the window', () => { - const div = document.createElement('div'); - window.document.body.appendChild(div); - expect(getOwnerWindow(div)).toBe(window); + 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(getActiveElement()).toBe(input); + + document.body.removeChild(outerHost); }); - it('returns the window if the element is the window', () => { - expect(getOwnerWindow(window)).toBe(window); + 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(getActiveElement()).toBe(bodyInput); + + document.body.removeChild(hostDiv); + document.body.removeChild(bodyInput); }); - it("returns the iframe's window if the element is in the iframe", () => { + it('returns the active element within an iframe', () => { const iframe = document.createElement('iframe'); - const iframeDiv = document.createElement('div'); + const input = document.createElement('input'); window.document.body.appendChild(iframe); - iframe.contentWindow.document.body.appendChild(iframeDiv); + iframe.contentWindow.document.body.appendChild(input); - expect(getOwnerWindow(iframeDiv)).toBe(iframe.contentWindow); + act(() => {input.focus();}); + + expect(getActiveElement(iframe.contentWindow.document)).toBe(input); // Teardown iframe.remove(); 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..bfda0a0c8c5 --- /dev/null +++ b/packages/@react-aria/utils/test/shadowTreeWalker.test.tsx @@ -0,0 +1,362 @@ +/* + * 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(); + }); + }); +}); + +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); +// } +// }); +// }); diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index bd2c9a54670..53e94238691 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -974,27 +974,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(); }); @@ -1555,6 +1554,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); @@ -1578,6 +1580,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); @@ -1586,6 +1591,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'); @@ -1612,8 +1620,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'); @@ -1641,12 +1655,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 de9626e410f..9d3e8bdfddc 100644 --- a/packages/@react-spectrum/list/test/ListViewDnd.test.js +++ b/packages/@react-spectrum/list/test/ListViewDnd.test.js @@ -1915,13 +1915,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({ @@ -1933,8 +1935,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'); @@ -1955,6 +1956,8 @@ describe('ListView', function () { ); + await user.tab(); + let droppable = getByText('Drop here'); let rows = getAllByRole('row'); @@ -1973,13 +1976,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({ @@ -1991,8 +1994,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); @@ -2027,8 +2029,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'])}); @@ -2036,8 +2040,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()}); @@ -2065,8 +2068,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'])}); @@ -2074,8 +2079,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()}); @@ -2094,8 +2098,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 @@ -2103,8 +2108,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); @@ -2130,8 +2134,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()); } @@ -2426,20 +2431,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'); }); @@ -3127,24 +3131,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({ @@ -3158,8 +3158,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()); @@ -3181,18 +3180,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); @@ -3203,8 +3202,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 () { @@ -3308,13 +3306,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); @@ -3375,7 +3374,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( ); @@ -3389,8 +3388,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()) { @@ -3406,8 +3407,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 61818bd704c..4d1f5683475 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -1929,12 +1929,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(); @@ -1975,39 +1969,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}{ArrowRight}'); + 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'); @@ -2048,7 +2047,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}); @@ -2056,7 +2055,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'); @@ -2064,7 +2065,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'); @@ -2074,7 +2076,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'); @@ -2112,14 +2114,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'});}); + + await user.tab(); + await user.keyboard(' '); + await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(row).toHaveAttribute('aria-selected', 'false'); @@ -2129,7 +2133,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( @@ -2151,22 +2155,18 @@ export let tableTests = () => { let link = within(row).getAllByRole('link')[0]; expect(link.textContent).toBe('Foo 1'); - act(() => { - link.focus(); - fireEvent.keyDown(link, {key: ' '}); - fireEvent.keyUp(link, {key: ' '}); - jest.runAllTimers(); - }); + await user.tab(); + await user.keyboard('{ArrowRight}'); + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(link); + await user.keyboard(' '); + act(() => {jest.runAllTimers();}); row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'true'); - act(() => { - link.focus(); - fireEvent.keyDown(link, {key: ' '}); - fireEvent.keyUp(link, {key: ' '}); - jest.runAllTimers(); - }); + await user.keyboard(' '); + act(() => {jest.runAllTimers();}); row = tree.getAllByRole('row')[1]; link = within(row).getAllByRole('link')[0]; @@ -2239,7 +2239,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}); @@ -2247,10 +2247,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); @@ -2258,7 +2261,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}); @@ -2266,10 +2269,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); @@ -2277,7 +2283,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}); @@ -2285,10 +2291,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', @@ -2299,7 +2308,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}); @@ -2307,10 +2316,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++) { @@ -2320,7 +2332,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}); @@ -2328,10 +2340,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++) { @@ -2341,7 +2356,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}); @@ -2349,10 +2364,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', @@ -2411,7 +2429,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}); @@ -2420,14 +2438,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'); @@ -2515,7 +2535,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()); @@ -2528,7 +2549,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')); @@ -2536,7 +2559,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()); }); @@ -2870,22 +2894,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); @@ -3195,17 +3219,21 @@ export let tableTests = () => { 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}); - act(() => getCell(tree, 'Baz 10').focus()); - await user.keyboard(' '); + 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(announce).toHaveBeenCalledWith('Foo 10 selected.'); expect(onAction).not.toHaveBeenCalled(); + onSelectionChange.mockReset(); + await user.keyboard('{ArrowUp}'.repeat(5)); + onSelectionChange.mockReset(); announce.mockReset(); onSelectionChange.mockReset(); - act(() => getCell(tree, 'Baz 5').focus()); await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(announce).not.toHaveBeenCalled(); @@ -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']); }); @@ -3312,22 +3335,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); @@ -3344,15 +3368,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']); }); @@ -3388,14 +3410,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(); @@ -3421,49 +3435,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'); @@ -3531,13 +3553,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'); @@ -3545,7 +3569,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'); @@ -3554,7 +3579,7 @@ export let tableTests = () => { // Deselect onSelectionChange.mockReset(); - pressWithKeyboard(getCell(tree, 'Baz 2')); + await user.keyboard(' '); checkSelection(onSelectionChange, []); expect(rows[1]).toHaveAttribute('aria-selected', 'false'); @@ -3570,9 +3595,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(); @@ -4683,13 +4710,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 9d03a788470..d4be98204c6 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'); }); @@ -2997,24 +3026,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({ @@ -3028,8 +3053,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,25 +3077,26 @@ 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 - moveFocus('ArrowDown'); - fireEvent.keyDown(document.activeElement, {key: 'Enter'}); - fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + // 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'); - moveFocus('ArrowRight'); - fireEvent.keyDown(draghandle, {key: 'Enter'}); - fireEvent.keyUp(draghandle, {key: 'Enter'}); + + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + 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(['6']), x: 50, y: 25 }); @@ -3184,16 +3209,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); @@ -3249,7 +3274,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( ); @@ -3264,8 +3289,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 d27c0ca507c..c148b947b67 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/dev/test-utils/src/index.ts b/packages/dev/test-utils/src/index.ts index a01ad7155ea..7a58398a1f1 100644 --- a/packages/dev/test-utils/src/index.ts +++ b/packages/dev/test-utils/src/index.ts @@ -17,5 +17,6 @@ export * from './renderOverride'; export * from './StrictModeWrapper'; export * from './mockImplementation'; export * from './events'; +export * from './shadowDOM'; export * from './types'; export * from '@react-spectrum/test-utils'; diff --git a/packages/dev/test-utils/src/shadowDOM.js b/packages/dev/test-utils/src/shadowDOM.js new file mode 100644 index 00000000000..84a2b06746e --- /dev/null +++ b/packages/dev/test-utils/src/shadowDOM.js @@ -0,0 +1,18 @@ +/* + * 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'); + attachTo.appendChild(div); + const shadowRoot = div.attachShadow({mode: 'open'}); + return {shadowHost: div, shadowRoot, cleanup: () => attachTo.removeChild(div)}; +} diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index c4c6c0507aa..bb072553e80 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 5c5f5e61228..a1bcbbf1df9 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);