diff --git a/packages/@react-aria/breadcrumbs/test/useBreadcrumbItem.test.js b/packages/@react-aria/breadcrumbs/test/useBreadcrumbItem.test.js index 48ee36a83e6..3aadc9dbd85 100644 --- a/packages/@react-aria/breadcrumbs/test/useBreadcrumbItem.test.js +++ b/packages/@react-aria/breadcrumbs/test/useBreadcrumbItem.test.js @@ -44,7 +44,7 @@ describe('useBreadcrumbItem', function () { it('handles descendant link with href', function () { let {itemProps} = renderLinkHook({children: Breadcrumb Item}); - expect(itemProps.tabIndex).toBeUndefined(); + expect(itemProps.tabIndex).toBe(0); expect(itemProps.role).toBeUndefined(); expect(itemProps['aria-disabled']).toBeUndefined(); expect(typeof itemProps.onKeyDown).toBe('function'); diff --git a/packages/@react-aria/button/src/useButton.ts b/packages/@react-aria/button/src/useButton.ts index dbc4bb090e9..3cd988db95f 100644 --- a/packages/@react-aria/button/src/useButton.ts +++ b/packages/@react-aria/button/src/useButton.ts @@ -74,7 +74,6 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< } else { additionalProps = { role: 'button', - tabIndex: isDisabled ? undefined : 0, href: elementType === 'a' && !isDisabled ? href : undefined, target: elementType === 'a' ? target : undefined, type: elementType === 'input' ? type : undefined, diff --git a/packages/@react-aria/dnd/test/dnd.test.js b/packages/@react-aria/dnd/test/dnd.test.js index 72bbd4269f2..a1a83586da6 100644 --- a/packages/@react-aria/dnd/test/dnd.test.js +++ b/packages/@react-aria/dnd/test/dnd.test.js @@ -2466,7 +2466,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); - fireEvent.focus(draggable); + act(() => draggable.focus()); fireEvent(draggable, pointerEvent('pointerdown', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); fireEvent(draggable, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); await user.click(draggable); @@ -2496,7 +2496,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); let droppable = tree.getByText('Drop here'); - fireEvent.focus(draggable); + act(() => draggable.focus()); fireEvent(draggable, pointerEvent('pointerdown', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); fireEvent(draggable, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0})); await user.click(draggable); @@ -2505,7 +2505,7 @@ describe('useDrag and useDrop', function () { // Android Talkback fires with click event of detail = 1, test that our onPointerDown listener detects that it is a virtual click - fireEvent.focus(droppable); + act(() => droppable.focus()); fireEvent(droppable, pointerEvent('pointerdown', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); fireEvent(droppable, pointerEvent('pointerup', {pointerId: 1, width: 1, height: 1, pressure: 0, detail: 0, pointerType: 'mouse'})); fireEvent.click(droppable, {detail: 1}); @@ -2535,7 +2535,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); @@ -2563,7 +2563,7 @@ describe('useDrag and useDrop', function () { expect(tree.getAllByRole('textbox')).toHaveLength(1); - fireEvent.focus(draggable); + act(() => draggable.focus()); fireEvent.click(draggable); act(() => jest.runAllTimers()); @@ -2596,7 +2596,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); @@ -2621,7 +2621,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); @@ -2644,7 +2644,7 @@ describe('useDrag and useDrop', function () { let droppable = tree.getByText('Drop here'); let input = tree.getByRole('textbox'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); @@ -2664,7 +2664,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); let input = tree.getByRole('textbox'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); @@ -2681,7 +2681,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); let droppable = tree.getByText('Drop here'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); @@ -2715,7 +2715,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); - fireEvent.focus(draggable); + act(() => draggable.focus()); fireEvent.click(draggable, {detail: 1}); act(() => jest.runAllTimers()); @@ -2731,7 +2731,7 @@ describe('useDrag and useDrop', function () { let draggable = tree.getByText('Drag me'); let droppable = tree.getByText('Drop here'); - fireEvent.focus(draggable); + act(() => draggable.focus()); await user.click(draggable); act(() => jest.runAllTimers()); expect(draggable).toHaveAttribute('data-dragging', 'true'); diff --git a/packages/@react-aria/dnd/test/useDraggableCollection.test.js b/packages/@react-aria/dnd/test/useDraggableCollection.test.js index 55affcdb23a..cf9e6a43811 100644 --- a/packages/@react-aria/dnd/test/useDraggableCollection.test.js +++ b/packages/@react-aria/dnd/test/useDraggableCollection.test.js @@ -781,7 +781,8 @@ describe('useDraggableCollection', () => { let dragButton = within(cells[1]).getByRole('button'); expect(dragButton).toHaveAttribute('aria-label', 'Drag Bar'); - fireEvent.focus(dragButton); + act(() => cells[1].focus()); + act(() => dragButton.focus()); fireEvent.click(dragButton); act(() => jest.runAllTimers()); expect(cells[0]).not.toHaveClass('is-dragging'); @@ -852,7 +853,7 @@ describe('useDraggableCollection', () => { let cells = within(grid).getAllByRole('gridcell'); expect(cells).toHaveLength(3); - fireEvent.focus(cells[0]); + act(() => cells[0].focus()); fireEvent.click(cells[0]); expect(rows[0]).toHaveAttribute('aria-selected', 'true'); fireEvent.click(cells[1]); @@ -860,7 +861,7 @@ describe('useDraggableCollection', () => { let dragButton = within(cells[1]).getByRole('button'); expect(dragButton).toHaveAttribute('aria-label', 'Drag 2 selected items'); - fireEvent.focus(dragButton); + act(() => dragButton.focus()); fireEvent.click(dragButton); act(() => jest.runAllTimers()); expect(cells[0]).toHaveClass('is-dragging'); @@ -939,13 +940,13 @@ describe('useDraggableCollection', () => { let cells = within(grid).getAllByRole('gridcell'); expect(cells).toHaveLength(3); - fireEvent.focus(cells[0]); + act(() => cells[0].focus()); fireEvent.click(cells[0]); expect(rows[0]).toHaveAttribute('aria-selected', 'true'); let dragButton = within(cells[1]).getByRole('button'); expect(dragButton).toHaveAttribute('aria-label', 'Drag Bar'); - fireEvent.focus(dragButton); + act(() => dragButton.focus()); fireEvent.click(dragButton); act(() => jest.runAllTimers()); expect(cells[0]).not.toHaveClass('is-dragging'); diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index b7b3f1bfef2..7bb0f930a9d 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -16,6 +16,8 @@ import { getOwnerDocument, isAndroid, isChrome, + isFocusable, + isTabbable, ShadowTreeWalker, useLayoutEffect } from '@react-aria/utils'; @@ -276,31 +278,6 @@ function createFocusManagerForScope(scopeRef: React.RefObject) }; } -const focusableElements = [ - 'input:not([disabled]):not([type=hidden])', - 'select:not([disabled])', - 'textarea:not([disabled])', - 'button:not([disabled])', - 'a[href]', - 'area[href]', - 'summary', - 'iframe', - 'object', - 'embed', - 'audio[controls]', - 'video[controls]', - '[contenteditable]:not([contenteditable^="false"])' -]; - -const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])'; - -focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); -const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); - -export function isFocusable(element: Element) { - return element.matches(FOCUSABLE_ELEMENT_SELECTOR); -} - function getScopeRoot(scope: Element[]) { return scope[0].parentElement!; } @@ -759,7 +736,7 @@ function restoreFocusToElement(node: FocusableElement) { * that matches all focusable/tabbable elements. */ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]): ShadowTreeWalker { - let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; + let filter = opts?.tabbable ? isTabbable : isFocusable; // Ensure that root is an Element or fall back appropriately let rootElement = root?.nodeType === Node.ELEMENT_NODE ? (root as Element) : null; @@ -779,7 +756,7 @@ export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions return NodeFilter.FILTER_REJECT; } - if ((node as Element).matches(selector) + if (filter(node as Element) && isElementVisible(node as Element) && (!scope || isElementInScope(node as Element, scope)) && (!opts?.accept || opts.accept(node as Element)) diff --git a/packages/@react-aria/focus/src/index.ts b/packages/@react-aria/focus/src/index.ts index 6a5fe283d12..ed8338ec408 100644 --- a/packages/@react-aria/focus/src/index.ts +++ b/packages/@react-aria/focus/src/index.ts @@ -10,12 +10,14 @@ * governing permissions and limitations under the License. */ -export {FocusScope, useFocusManager, getFocusableTreeWalker, createFocusManager, isElementInChildOfActiveScope, isFocusable} from './FocusScope'; +export {FocusScope, useFocusManager, getFocusableTreeWalker, createFocusManager, isElementInChildOfActiveScope} from './FocusScope'; export {FocusRing} from './FocusRing'; export {FocusableProvider, useFocusable} from './useFocusable'; export {useFocusRing} from './useFocusRing'; export {focusSafely} from './focusSafely'; export {useHasTabbableChild} from './useHasTabbableChild'; +// For backward compatibility. +export {isFocusable} from '@react-aria/utils'; export type {FocusScopeProps, FocusManager, FocusManagerOptions} from './FocusScope'; export type {FocusRingProps} from './FocusRing'; diff --git a/packages/@react-aria/focus/src/useFocusable.tsx b/packages/@react-aria/focus/src/useFocusable.tsx index aab6fec5362..91c7eeedae7 100644 --- a/packages/@react-aria/focus/src/useFocusable.tsx +++ b/packages/@react-aria/focus/src/useFocusable.tsx @@ -82,11 +82,17 @@ export function useFocusable(prop autoFocusRef.current = false; }, [domRef]); + // Always set a tabIndex so that Safari allows focusing native buttons and inputs. + let tabIndex: number | undefined = props.excludeFromTabOrder ? -1 : 0; + if (props.isDisabled) { + tabIndex = undefined; + } + return { focusableProps: mergeProps( { ...interactions, - tabIndex: props.excludeFromTabOrder && !props.isDisabled ? -1 : undefined + tabIndex }, interactionProps ) diff --git a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts index 100b93cefc5..d76537a03ca 100644 --- a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts +++ b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts @@ -15,9 +15,9 @@ import {Collection, Key, Node, Selection} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; import {SelectionManager} from '@react-stately/selection'; +import {useEffectEvent, useUpdateEffect} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useRef} from 'react'; -import {useUpdateEffect} from '@react-aria/utils'; export interface GridSelectionAnnouncementProps { /** @@ -46,8 +46,8 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement // We do this using an ARIA live region. let selection = state.selectionManager.rawSelection; let lastSelection = useRef(selection); - useUpdateEffect(() => { - if (!state.selectionManager.isFocused) { + let announceSelectionChange = useEffectEvent(() => { + if (!state.selectionManager.isFocused || selection === lastSelection.current) { lastSelection.current = selection; return; @@ -96,7 +96,17 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement } lastSelection.current = selection; - }, [selection]); + }); + + useUpdateEffect(() => { + if (state.selectionManager.isFocused) { + announceSelectionChange(); + } else { + // Wait a frame in case the collection is about to become focused (e.g. on mouse down). + let raf = requestAnimationFrame(announceSelectionChange); + return () => cancelAnimationFrame(raf); + } + }, [selection, state.selectionManager.isFocused]); } function diffSelection(a: Selection, b: Selection): Set { diff --git a/packages/@react-aria/interactions/src/useFocusVisible.ts b/packages/@react-aria/interactions/src/useFocusVisible.ts index 2512608abe7..a3a609da003 100644 --- a/packages/@react-aria/interactions/src/useFocusVisible.ts +++ b/packages/@react-aria/interactions/src/useFocusVisible.ts @@ -16,6 +16,7 @@ // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils'; +import {ignoreFocusEvent} from './utils'; import {useEffect, useState} from 'react'; import {useIsSSR} from '@react-aria/ssr'; @@ -92,7 +93,7 @@ function handleFocusEvent(e: FocusEvent) { // Firefox fires two extra focus events when the user first clicks into an iframe: // first on the window, then on the document. We ignore these events so they don't // cause keyboard focus rings to appear. - if (e.target === window || e.target === document) { + if (e.target === window || e.target === document || ignoreFocusEvent) { return; } @@ -108,6 +109,10 @@ function handleFocusEvent(e: FocusEvent) { } function handleWindowBlur() { + if (ignoreFocusEvent) { + return; + } + // When the window is blurred, reset state. This is necessary when tabbing out of the window, // for example, since a subsequent focus event won't be fired. hasEventBeforeFocus = false; diff --git a/packages/@react-aria/interactions/src/useLongPress.ts b/packages/@react-aria/interactions/src/useLongPress.ts index af4a396c8c9..1f910e487f5 100644 --- a/packages/@react-aria/interactions/src/useLongPress.ts +++ b/packages/@react-aria/interactions/src/useLongPress.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, LongPressEvent} from '@react-types/shared'; -import {mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; +import {DOMAttributes, FocusableElement, LongPressEvent} from '@react-types/shared'; +import {focusWithoutScrolling, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils'; import {usePress} from './usePress'; import {useRef} from 'react'; @@ -81,6 +81,12 @@ export function useLongPress(props: LongPressProps): LongPressResult { timeRef.current = setTimeout(() => { // Prevent other usePress handlers from also handling this event. e.target.dispatchEvent(new PointerEvent('pointercancel', {bubbles: true})); + + // Ensure target is focused. On touch devices, browsers typically focus on pointer up. + if (getOwnerDocument(e.target).activeElement !== e.target) { + focusWithoutScrolling(e.target as FocusableElement); + } + if (onLongPress) { onLongPress({ ...e, diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 41a3246493a..96a0b938c00 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -33,7 +33,9 @@ import { } 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'; import {PressResponderContext} from './context'; +import {preventFocus} from './utils'; import {TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react'; export interface PressProps extends PressEvents { @@ -62,7 +64,6 @@ export interface PressHookProps extends PressProps { interface PressState { isPressed: boolean, ignoreEmulatedMouseEvents: boolean, - ignoreClickAfterPress: boolean, didFirePressStart: boolean, isTriggeringEvent: boolean, activePointerId: any, @@ -70,7 +71,8 @@ interface PressState { isOverTarget: boolean, pointerType: PointerType | null, userSelect?: string, - metaKeyEvents?: Map + metaKeyEvents?: Map, + disposables: Array<() => void> } interface EventBase { @@ -182,13 +184,13 @@ export function usePress(props: PressHookProps): PressResult { let ref = useRef({ isPressed: false, ignoreEmulatedMouseEvents: false, - ignoreClickAfterPress: false, didFirePressStart: false, isTriggeringEvent: false, activePointerId: null, target: null, isOverTarget: false, - pointerType: null + pointerType: null, + disposables: [] }); let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); @@ -223,7 +225,6 @@ export function usePress(props: PressHookProps): PressResult { return false; } - state.ignoreClickAfterPress = true; state.didFirePressStart = false; state.isTriggeringEvent = true; @@ -270,7 +271,7 @@ export function usePress(props: PressHookProps): PressResult { let cancel = useEffectEvent((e: EventBase) => { let state = ref.current; if (state.isPressed && state.target) { - if (state.isOverTarget && state.pointerType != null) { + if (state.didFirePressStart && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType, false); } state.isPressed = false; @@ -281,6 +282,10 @@ export function usePress(props: PressHookProps): PressResult { if (!allowTextSelectionOnPress) { restoreTextSelection(state.target); } + for (let dispose of state.disposables) { + dispose(); + } + state.disposables = []; } }); @@ -306,6 +311,7 @@ export function usePress(props: PressHookProps): PressResult { if (!state.isPressed && !e.repeat) { state.target = e.currentTarget; state.isPressed = true; + state.pointerType = 'keyboard'; shouldStopPropagation = triggerPressStart(e, 'keyboard'); // Focus may move before the key up event, so register the event on the document @@ -352,20 +358,19 @@ export function usePress(props: PressHookProps): PressResult { // If triggered from a screen reader or by using element.click(), // trigger as if it were a keyboard click. - if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { - // Ensure the element receives focus (VoiceOver on iOS does not do this) - if (!isDisabled && !preventFocusOnPress) { - focusWithoutScrolling(e.currentTarget); - } - + if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { let stopPressStart = triggerPressStart(e, 'virtual'); let stopPressUp = triggerPressUp(e, 'virtual'); let stopPressEnd = triggerPressEnd(e, 'virtual'); shouldStopPropagation = stopPressStart && stopPressUp && stopPressEnd; + } else if (state.isPressed && state.pointerType !== 'keyboard') { + let pointerType = state.pointerType || (e.nativeEvent as PointerEvent).pointerType as PointerType || 'virtual'; + shouldStopPropagation = triggerPressEnd(createEvent(e.currentTarget, e), pointerType, true); + state.isOverTarget = false; + cancel(e); } state.ignoreEmulatedMouseEvents = false; - state.ignoreClickAfterPress = false; if (shouldStopPropagation) { e.stopPropagation(); } @@ -423,12 +428,6 @@ export function usePress(props: PressHookProps): PressResult { return; } - // Due to browser inconsistencies, especially on mobile browsers, we prevent - // default on pointer down and handle focusing the pressable element ourselves. - if (shouldPreventDefaultDown(e.currentTarget as Element)) { - e.preventDefault(); - } - state.pointerType = e.pointerType; let shouldStopPropagation = true; @@ -438,10 +437,6 @@ export function usePress(props: PressHookProps): PressResult { state.activePointerId = e.pointerId; state.target = e.currentTarget as FocusableElement; - if (!isDisabled && !preventFocusOnPress) { - focusWithoutScrolling(state.target); - } - if (!allowTextSelectionOnPress) { disableTextSelection(state.target); } @@ -470,11 +465,11 @@ export function usePress(props: PressHookProps): PressResult { } if (e.button === 0) { - // Chrome and Firefox on touch Windows devices require mouse down events - // to be canceled in addition to pointer events, or an extra asynchronous - // focus event will be fired. - if (shouldPreventDefaultDown(e.currentTarget as Element)) { - e.preventDefault(); + if (preventFocusOnPress) { + let dispose = preventFocus(e.target as FocusableElement); + if (dispose) { + state.disposables.push(dispose); + } } e.stopPropagation(); @@ -511,38 +506,35 @@ export function usePress(props: PressHookProps): PressResult { let onPointerUp = (e: PointerEvent) => { if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { - triggerPressEnd(createEvent(state.target, e), state.pointerType); - } else if (state.isOverTarget && state.pointerType != null) { - triggerPressEnd(createEvent(state.target, e), state.pointerType, false); + // 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 + // https://issues.chromium.org/issues/40732224 + // However, iOS and Android do not focus or fire onClick after a long press. + // We work around this by triggering a click ourselves after a timeout. + // This timeout is canceled during the click event in case the real one fires first. + // In testing, a 0ms delay is too short. 5ms seems long enough for the browser to fire the real events. + let clicked = false; + let timeout = setTimeout(() => { + if (state.isPressed && state.target instanceof HTMLElement) { + if (clicked) { + cancel(e); + } else { + focusWithoutScrolling(state.target); + state.target.click(); + } + } + }, 5); + // Use a capturing listener to track if a click occurred. + // If stopPropagation is called it may never reach our handler. + addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true); + state.disposables.push(() => clearTimeout(timeout)); + } else { + cancel(e); } - state.isPressed = false; + // Ignore subsequent onPointerLeave event before onClick on touch devices. state.isOverTarget = false; - state.activePointerId = null; - state.pointerType = null; - removeAllGlobalListeners(); - if (!allowTextSelectionOnPress) { - restoreTextSelection(state.target); - } - - // Prevent subsequent touchend event from triggering onClick on unrelated elements on Android. See below. - // Both 'touch' and 'pen' pointerTypes trigger onTouchEnd, but 'mouse' does not. - if ('ontouchend' in state.target && e.pointerType !== 'mouse') { - addGlobalListener(state.target, 'touchend', onTouchEnd, {once: true}); - } - } - }; - - // This is a workaround for an Android Chrome/Firefox issue where click events are fired on an incorrect element - // if the original target is removed during onPointerUp (before onClick). - // https://github.com/adobe/react-spectrum/issues/1513 - // https://issues.chromium.org/issues/40732224 - // Note: this event must be registered directly on the element, not via React props in order to work. - // https://github.com/facebook/react/issues/9809 - let onTouchEnd = (e: TouchEvent) => { - // Don't preventDefault if we actually want the default (e.g. submit/link click). - if (shouldPreventDefaultUp(e.currentTarget as Element)) { - e.preventDefault(); } }; @@ -559,18 +551,15 @@ export function usePress(props: PressHookProps): PressResult { cancel(e); }; } else { + // NOTE: this fallback branch is almost entirely used by unit tests. + // All browsers now support pointer events, but JSDOM still does not. + pressProps.onMouseDown = (e) => { // Only handle left clicks if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; } - // Due to browser inconsistencies, especially on mobile browsers, we prevent - // default on mouse down and handle focusing the pressable element ourselves. - if (shouldPreventDefaultDown(e.currentTarget)) { - e.preventDefault(); - } - if (state.ignoreEmulatedMouseEvents) { e.stopPropagation(); return; @@ -581,15 +570,19 @@ export function usePress(props: PressHookProps): PressResult { state.target = e.currentTarget; state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse'; - if (!isDisabled && !preventFocusOnPress) { - focusWithoutScrolling(e.currentTarget); - } - - let shouldStopPropagation = triggerPressStart(e, state.pointerType); + // Flush sync so that focus moved during react re-renders occurs before we yield back to the browser. + let shouldStopPropagation = flushSync(() => triggerPressStart(e, state.pointerType!)); if (shouldStopPropagation) { e.stopPropagation(); } + if (preventFocusOnPress) { + let dispose = preventFocus(e.target as FocusableElement); + if (dispose) { + state.disposables.push(dispose); + } + } + addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false); }; @@ -642,18 +635,16 @@ export function usePress(props: PressHookProps): PressResult { return; } - state.isPressed = false; - removeAllGlobalListeners(); - if (state.ignoreEmulatedMouseEvents) { state.ignoreEmulatedMouseEvents = false; return; } - if (state.target && isOverTarget(e, state.target) && state.pointerType != null) { - triggerPressEnd(createEvent(state.target, e), state.pointerType); - } else if (state.target && state.isOverTarget && state.pointerType != null) { - triggerPressEnd(createEvent(state.target, e), state.pointerType, false); + if (state.target && nodeContains(state.target, e.target as Element) && state.pointerType != null) { + // Wait for onClick to fire onPress. This avoids browser issues when the DOM + // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. + } else { + cancel(e); } state.isOverTarget = false; @@ -675,12 +666,6 @@ export function usePress(props: PressHookProps): PressResult { state.target = e.currentTarget; state.pointerType = 'touch'; - // Due to browser inconsistencies, especially on mobile browsers, we prevent default - // on the emulated mouse event and handle focusing the pressable element ourselves. - if (!isDisabled && !preventFocusOnPress) { - focusWithoutScrolling(e.currentTarget); - } - if (!allowTextSelectionOnPress) { disableTextSelection(state.target); } @@ -803,11 +788,16 @@ export function usePress(props: PressHookProps): PressResult { // Remove user-select: none in case component unmounts immediately after pressStart useEffect(() => { + let state = ref.current; return () => { if (!allowTextSelectionOnPress) { - // eslint-disable-next-line react-hooks/exhaustive-deps - restoreTextSelection(ref.current.target ?? undefined); + + restoreTextSelection(state.target ?? undefined); + } + for (let dispose of state.disposables) { + dispose(); } + state.disposables = []; }; }, [allowTextSelectionOnPress]); @@ -947,11 +937,6 @@ function isOverTarget(point: EventPoint, target: Element) { return areRectanglesOverlapping(rect, pointRect); } -function shouldPreventDefaultDown(target: Element) { - // We cannot prevent default if the target is a draggable element. - return !(target instanceof HTMLElement) || !target.hasAttribute('draggable'); -} - function shouldPreventDefaultUp(target: Element) { if (target instanceof HTMLInputElement) { return false; diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index b5c2cd092b4..7c6442fd69b 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -10,8 +10,9 @@ * governing permissions and limitations under the License. */ +import {FocusableElement} from '@react-types/shared'; +import {focusWithoutScrolling, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, useCallback, useRef} from 'react'; -import {useEffectEvent, useLayoutEffect} from '@react-aria/utils'; export class SyntheticFocusEvent implements ReactFocusEvent { nativeEvent: FocusEvent; @@ -128,3 +129,81 @@ export function useSyntheticBlurEvent(onBlur: (e: ReactFocusEv } }, [dispatchBlur]); } + +export let ignoreFocusEvent = false; + +/** + * This function prevents the next focus event fired on `target`, without using `event.preventDefault()`. + * It works by waiting for the series of focus events to occur, and reverts focus back to where it was before. + * It also makes these events mostly non-observable by using a capturing listener on the window and stopping propagation. + */ +export function preventFocus(target: FocusableElement | null) { + // The browser will focus the nearest focusable ancestor of our target. + while (target && !isFocusable(target)) { + target = target.parentElement; + } + + let window = getOwnerWindow(target); + let activeElement = window.document.activeElement as FocusableElement | null; + if (!activeElement || activeElement === target) { + return; + } + + ignoreFocusEvent = true; + let isRefocusing = false; + let onBlur = (e: FocusEvent) => { + if (e.target === activeElement || isRefocusing) { + e.stopImmediatePropagation(); + } + }; + + let onFocusOut = (e: FocusEvent) => { + if (e.target === activeElement || isRefocusing) { + e.stopImmediatePropagation(); + + // If there was no focusable ancestor, we don't expect a focus event. + // Re-focus the original active element here. + if (!target && !isRefocusing) { + isRefocusing = true; + focusWithoutScrolling(activeElement); + cleanup(); + } + } + }; + + let onFocus = (e: FocusEvent) => { + if (e.target === target || isRefocusing) { + e.stopImmediatePropagation(); + } + }; + + let onFocusIn = (e: FocusEvent) => { + if (e.target === target || isRefocusing) { + e.stopImmediatePropagation(); + + if (!isRefocusing) { + isRefocusing = true; + focusWithoutScrolling(activeElement); + cleanup(); + } + } + }; + + window.addEventListener('blur', onBlur, true); + window.addEventListener('focusout', onFocusOut, true); + window.addEventListener('focusin', onFocusIn, true); + window.addEventListener('focus', onFocus, true); + + let cleanup = () => { + cancelAnimationFrame(raf); + window.removeEventListener('blur', onBlur, true); + window.removeEventListener('focusout', onFocusOut, true); + window.removeEventListener('focusin', onFocusIn, true); + window.removeEventListener('focus', onFocus, true); + ignoreFocusEvent = false; + isRefocusing = false; + }; + + let raf = requestAnimationFrame(cleanup); + return cleanup; +} diff --git a/packages/@react-aria/interactions/stories/usePress.stories.tsx b/packages/@react-aria/interactions/stories/usePress.stories.tsx index 2604ccc5e21..ac982394ce3 100644 --- a/packages/@react-aria/interactions/stories/usePress.stories.tsx +++ b/packages/@react-aria/interactions/stories/usePress.stories.tsx @@ -10,7 +10,15 @@ * governing permissions and limitations under the License. */ -import {Link} from 'react-aria-components'; +import { + Button, + Dialog, + DialogTrigger, + Heading, + Link, + Modal, + ModalOverlay +} from 'react-aria-components'; import React from 'react'; import styles from './usePress-stories.css'; import {usePress} from '@react-aria/interactions'; @@ -112,3 +120,117 @@ export const linkOnPress = { } } }; + +export function ClickOutsideIssue() { + const handleClick = () => { + alert('Clicked!'); + }; + + return ( +
+

+ before clicking the button please make sure 'desktop(touch)' mode is + active in the responsive dev tools +

+
+ {/* eslint-disable-next-line */} +
+ Help +
+
+ + + + + + Notice +

This is a modal with a custom modal overlay.

+ + +
+
+
+
+
+ ); +} + +export function SoftwareKeyboardIssue() { + return ( +
+

Focus the input to show the software keyboard, then press the buttons below.

+ + +
+ {/* eslint-disable-next-line */} + { + alert('I told you not to click me'); + }} + style={{fontSize: '64px'}}> + Don't click me + + +
+ + +
+
+
+ ); +} diff --git a/packages/@react-aria/interactions/test/useFocusVisible.test.js b/packages/@react-aria/interactions/test/useFocusVisible.test.js index 60859005dc6..e0de5313fc6 100644 --- a/packages/@react-aria/interactions/test/useFocusVisible.test.js +++ b/packages/@react-aria/interactions/test/useFocusVisible.test.js @@ -9,13 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {act, fireEvent, render, renderHook, screen, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, renderHook, screen, waitFor} from '@react-spectrum/test-utils-internal'; import {addWindowFocusTracking, useFocusVisible, useFocusVisibleListener} from '../'; import {hasSetupGlobalListeners} from '../src/useFocusVisible'; import {mergeProps} from '@react-aria/utils'; import React from 'react'; import {useButton} from '@react-aria/button'; import {useFocusRing} from '@react-aria/focus'; +import userEvent from '@testing-library/user-event'; function Example(props) { const {isFocusVisible} = useFocusVisible(); @@ -59,6 +60,11 @@ function toggleBrowserWindow() { } describe('useFocusVisible', function () { + let user; + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + beforeEach(() => { fireEvent.focus(document.body); }); @@ -306,10 +312,8 @@ describe('useFocusVisible', function () { const el = document.querySelector('iframe').contentWindow.document.body.querySelector('button[id="iframe-example"]'); - fireEvent.mouseDown(el); - fireEvent.mouseUp(el); - fireEvent.keyDown(el, {key: 'Esc'}); - fireEvent.keyUp(el, {key: 'Esc'}); + await user.pointer({target: el, keys: '[MouseLeft]'}); + await user.keyboard('{Esc}'); expect(el.textContent).toBe('example-focusVisible'); }); diff --git a/packages/@react-aria/interactions/test/useLongPress.test.js b/packages/@react-aria/interactions/test/useLongPress.test.js index a5956c9c156..ecb775cae82 100644 --- a/packages/@react-aria/interactions/test/useLongPress.test.js +++ b/packages/@react-aria/interactions/test/useLongPress.test.js @@ -295,6 +295,7 @@ describe('useLongPress', function () { fireEvent.pointerDown(el, {pointerType: 'touch'}); act(() => jest.advanceTimersByTime(300)); fireEvent.pointerUp(el, {pointerType: 'touch'}); + fireEvent.click(el, {detail: 1}); expect(events).toEqual([ { diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index 2da8b9ea8a9..e0470b8a537 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -10,21 +10,21 @@ * governing permissions and limitations under the License. */ -import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, fireEvent, installMouseEvent, installPointerEvent, pointerMap, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; -import {getActiveElement} from '@react-aria/utils'; import MatchMediaMock from 'jest-matchmedia-mock'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; -import ReactDOM, {createPortal, render as ReactDOMRender} from 'react-dom'; +import ReactDOM, {createPortal} from 'react-dom'; import {theme} from '@react-spectrum/theme-default'; import {usePress} from '../'; +import userEvent from '@testing-library/user-event'; function Example(props) { let {elementType: ElementType = 'div', style, draggable, ...otherProps} = props; let {pressProps} = usePress(otherProps); - return {ElementType !== 'input' ? 'test' : undefined}; + return {ElementType !== 'input' ? props.children || 'test' : undefined}; } function pointerEvent(type, opts) { @@ -55,7 +55,7 @@ describe('usePress', function () { describe('pointer events', function () { installPointerEvent(); - it('should fire press events based on pointer events', function () { + it('should fire press events based on pointer events with pointerType=mouse', function () { let events = []; let addEvent = (e) => events.push(e); let res = render( @@ -68,8 +68,21 @@ describe('usePress', function () { ); let el = res.getByText('test'); - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + let shouldFireMouseEvents = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + expect(shouldFireMouseEvents).toBe(true); + + let shouldFocus = fireEvent.mouseDown(el); + expect(shouldFocus).toBe(true); + act(() => el.focus()); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent.mouseUp(el); + + let shouldClick = fireEvent.click(el); + expect(shouldClick).toBe(true); + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); // How else to get the DOM node it renders the hook to? // let el = events[0].target; @@ -129,6 +142,264 @@ describe('usePress', function () { ]); }); + it('should fire press events based on pointer events with pointerType=touch', function () { + let events = []; + let addEvent = (e) => events.push(e); + let res = render( + addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent} /> + ); + + // Touch devices fire events in a different sequence than mouse. + // mousedown and focus is delayed until after pointerup. + let el = res.getByText('test'); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'touch', clientX: 0, clientY: 0})); + + let shouldFireCompatibilityEvents = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch', clientX: 0, clientY: 0})); + expect(shouldFireCompatibilityEvents).toBe(true); + + let shouldFocus = true; + shouldFocus = shouldFireCompatibilityEvents = fireEvent.touchStart(el, {targetTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + expect(shouldFireCompatibilityEvents).toBe(true); + expect(shouldFocus).toBe(true); + + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + shouldFocus = fireEvent.touchEnd(el, {targetTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + shouldFocus = fireEvent.mouseDown(el); + expect(shouldFocus).toBe(true); + act(() => el.focus()); + + fireEvent.mouseUp(el); + fireEvent.click(el); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'pressend', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'press', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + }); + + it('should fire press events on long press even if onClick is not fired by the browser', function () { + let events = []; + let addEvent = (e) => events.push(e); + let res = render( + addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent} /> + ); + + let el = res.getByText('test'); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'touch', clientX: 0, clientY: 0})); + + let shouldFireCompatibilityEvents = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch', clientX: 0, clientY: 0})); + expect(shouldFireCompatibilityEvents).toBe(true); + + shouldFireCompatibilityEvents = fireEvent.touchStart(el, {targetTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + expect(shouldFireCompatibilityEvents).toBe(true); + + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + let shouldFocus = fireEvent.touchEnd(el, {targetTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + expect(shouldFocus).toBe(true); + + // Mouse events are not fired in this case, and the browser does not focus the element. + act(() => jest.advanceTimersByTime(10)); + expect(document.activeElement).toBe(el); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'pressend', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'presschange', + pressed: false + }, + { + type: 'press', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + }); + + it('should cancel press if onClick propagation is stopped', function () { + let events = []; + let addEvent = (e) => events.push(e); + let res = render( + addEvent({type: 'presschange', pressed})} + onPress={addEvent} + onPressUp={addEvent}> + {/* eslint-disable-next-line */} +
e.stopPropagation()} /> + + ); + + let el = res.getByTestId('inner'); + fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + let shouldFireMouseEvents = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + expect(shouldFireMouseEvents).toBe(true); + + let shouldFocus = fireEvent.mouseDown(el); + expect(shouldFocus).toBe(true); + act(() => el.focus()); + + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + fireEvent.mouseUp(el); + + let shouldClick = fireEvent.click(el); + expect(shouldClick).toBe(true); + fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); + + act(() => jest.advanceTimersByTime(10)); + + expect(events).toEqual([ + { + type: 'pressstart', + target: el.parentElement, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'presschange', + pressed: true + }, + { + type: 'pressup', + target: el.parentElement, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'pressend', + target: el.parentElement, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'presschange', + pressed: false + } + ]); + }); + it('should fire press change events when moving pointer outside target', function () { let events = []; let addEvent = (e) => events.push(e); @@ -191,6 +462,7 @@ describe('usePress', function () { 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([ { @@ -447,6 +719,7 @@ describe('usePress', function () { let el = res.getByText('test'); 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}); // How else to get the DOM node it renders the hook to? // let el = events[0].target; @@ -521,6 +794,7 @@ describe('usePress', function () { let el = res.getByText('test'); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', button: 1})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', button: 1, clientX: 0, clientY: 0})); + fireEvent.click(el, {button: 1}); expect(events).toEqual([]); }); @@ -535,58 +809,6 @@ describe('usePress', function () { expect(document.activeElement).not.toBe(el); }); - it('should focus the target on click by default', function () { - let res = render( - - ); - - let el = res.getByText('test'); - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); - expect(document.activeElement).toBe(el); - }); - - it('should prevent default on pointerdown and mousedown by default', function () { - let res = render( - - ); - - let el = res.getByText('test'); - let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - expect(allowDefault).toBe(false); - - allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(false); - }); - - it('should still prevent default when pressing on a non draggable + pressable item in a draggable container', function () { - let res = render( -
- -
- ); - - let el = res.getByText('test'); - let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - expect(allowDefault).toBe(false); - - allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(false); - }); - - it('should not prevent default when pressing on a draggable item', function () { - let res = render( - - ); - - let el = res.getByText('test'); - let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - expect(allowDefault).toBe(true); - - allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(true); - }); - it('should ignore virtual pointer events', function () { let events = []; let addEvent = (e) => events.push(e); @@ -669,6 +891,7 @@ describe('usePress', function () { let el = res.getByText('test'); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0, clientX: 0, clientY: 0})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', width: 0, height: 0, clientX: 0, clientY: 0})); + fireEvent.click(el); expect(events).toEqual([ { @@ -816,6 +1039,7 @@ describe('usePress', function () { 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); expect(spy).toHaveBeenCalled(); }); @@ -829,63 +1053,9 @@ describe('usePress', function () { 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'); }); - - it('should preventDefault on touchend to prevent click events on the wrong element', function () { - let res = render(); - - let el = res.getByText('test'); - el.ontouchend = () => {}; // So that 'ontouchend' in target works - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); - let browserDefault = fireEvent.touchEnd(el); - expect(browserDefault).toBe(false); - }); - - it('should not preventDefault on touchend when element is a submit button', function () { - let res = render(); - - let el = res.getByText('test'); - el.ontouchend = () => {}; // So that 'ontouchend' in target works - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); - let browserDefault = fireEvent.touchEnd(el); - expect(browserDefault).toBe(true); - }); - - it('should not preventDefault on touchend when element is an ', function () { - let res = render(); - - let el = res.getByRole('button'); - el.ontouchend = () => {}; // So that 'ontouchend' in target works - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); - let browserDefault = fireEvent.touchEnd(el); - expect(browserDefault).toBe(true); - }); - - it('should not preventDefault on touchend when element is an ', function () { - let res = render(); - - let el = res.getByRole('checkbox'); - el.ontouchend = () => {}; // So that 'ontouchend' in target works - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); - let browserDefault = fireEvent.touchEnd(el); - expect(browserDefault).toBe(true); - }); - - it('should not preventDefault on touchend when element is a link', function () { - let res = render(); - - let el = res.getByText('test'); - el.ontouchend = () => {}; // So that 'ontouchend' in target works - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); - let browserDefault = fireEvent.touchEnd(el); - expect(browserDefault).toBe(true); - }); }); describe('mouse events', function () { @@ -902,9 +1072,12 @@ describe('usePress', function () { ); let el = res.getByText('test'); - fireEvent.mouseDown(el, {detail: 1}); + let shouldFocus = fireEvent.mouseDown(el, {detail: 1}); + expect(shouldFocus).toBe(true); + act(() => el.focus()); fireEvent.mouseUp(el, {detail: 1}); - fireEvent.click(el, {detail: 1}); + let shouldClick = fireEvent.click(el, {detail: 1}); + expect(shouldClick).toBe(true); expect(events).toEqual([ { @@ -1173,7 +1346,7 @@ describe('usePress', function () { let el = res.getByText('test'); fireEvent.mouseDown(el, {detail: 1, metaKey: true}); fireEvent.mouseUp(el, {detail: 1, shiftKey: true}); - fireEvent.click(el, {detail: 1}); + fireEvent.click(el, {detail: 1, shiftKey: true}); expect(events).toEqual([ { @@ -1270,46 +1443,16 @@ describe('usePress', function () { ); let el = res.getByText('test'); - fireEvent.mouseDown(el); + let shouldFocus = fireEvent.mouseDown(el); + if (shouldFocus) { + act(() => el.focus()); + } fireEvent.mouseUp(el); fireEvent.click(el); expect(document.activeElement).toBe(el); }); - it('should prevent default on mousedown by default', function () { - let res = render( - - ); - - let el = res.getByText('test'); - let allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(false); - }); - - it('should still prevent default when pressing on a non draggable + pressable item in a draggable container', function () { - let res = render( -
- -
- ); - - let el = res.getByText('test'); - let allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(false); - }); - - - it('should not prevent default when pressing on a draggable item', function () { - let res = render( - - ); - - let el = res.getByText('test'); - let allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(true); - }); - it('should cancel press on dragstart', function () { let events = []; let addEvent = (e) => events.push(e); @@ -1986,8 +2129,11 @@ describe('usePress', function () { ); let el = res.getByText('test'); - fireEvent.touchStart(el, {targetTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); - fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + let shouldFocus = fireEvent.touchStart(el, {targetTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + let shouldFocus2 = fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, clientX: 0, clientY: 0}]}); + if (shouldFocus && shouldFocus2) { + act(() => el.focus()); + } expect(document.activeElement).toBe(el); }); @@ -2823,17 +2969,6 @@ describe('usePress', function () { expect(document.activeElement).not.toBe(el); }); - it('should focus the target on virtual click by default', function () { - let {getByText} = render( - - ); - - let el = getByText('test'); - fireEvent.click(el); - - expect(document.activeElement).toBe(el); - }); - describe('disable text-selection when pressed', function () { let handler = jest.fn(); let mockUserSelect = 'contain'; @@ -3201,6 +3336,7 @@ describe('usePress', function () { let el = res.getByTestId('test'); start(el); end(el); + fireEvent.click(el); expect(outerPressMock.mock.calls).toHaveLength(0); expect(innerPressMock.mock.calls).toHaveLength(3); }); @@ -3228,6 +3364,7 @@ describe('usePress', function () { let el = res.getByTestId('test'); start(el); end(el); + fireEvent.click(el); expect(outerPressMock.mock.calls).toHaveLength(4); expect(innerPressMock.mock.calls).toHaveLength(4); }); @@ -3359,6 +3496,7 @@ describe('usePress', function () { const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[data-testid="example"]'); fireEvent(el, pointerEvent('pointerdown', {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([ { @@ -3672,12 +3810,13 @@ describe('usePress', function () { } beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); jest.useFakeTimers(); }); afterEach(() => { act(() => {jest.runAllTimers();}); - unmount(); + unmount?.(); }); it('should fire press events based on pointer events', function () { @@ -3686,6 +3825,7 @@ describe('usePress', function () { 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({ @@ -3742,12 +3882,12 @@ describe('usePress', function () { const el = shadowRoot.getElementById('testElement'); el.releasePointerCapture = jest.fn(); - - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + 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({ @@ -3781,11 +3921,14 @@ describe('usePress', function () { ]); events = []; - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); + 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({ @@ -4058,8 +4201,9 @@ describe('usePress', function () { const el = shadowRoot.getElementById('testElement'); - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', shiftKey: true})); + 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({ @@ -4121,60 +4265,6 @@ describe('usePress', function () { expect(events).toEqual([]); }); - it('should not focus the target on click if preventFocusOnPress is true', function () { - const shadowRoot = setupShadowDOMTest({preventFocusOnPress: true}); - const el = shadowRoot.getElementById('testElement'); - - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); - const deepActiveElement = getActiveElement(); - - expect(deepActiveElement).not.toBe(el); - expect(deepActiveElement).not.toBe(shadowRoot); - }); - - it('should focus the target on click by default', function () { - const shadowRoot = setupShadowDOMTest(); - - const el = shadowRoot.getElementById('testElement'); - fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0})); - expect(shadowRoot.activeElement).toBe(el); - }); - - it('should prevent default on pointerdown and mousedown by default', function () { - const shadowRoot = setupShadowDOMTest(); - - const el = shadowRoot.getElementById('testElement'); - let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - expect(allowDefault).toBe(false); - - allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(false); - }); - - it('should still prevent default when pressing on a non draggable + pressable item in a draggable container', function () { - const shadowRoot = setupShadowDOMTest({}, true); - - const el = shadowRoot.getElementById('testElement'); - let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - expect(allowDefault).toBe(false); - - allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(false); - }); - - it('should not prevent default when pressing on a draggable item', function () { - const shadowRoot = setupShadowDOMTest({draggable: true}); - - const el = shadowRoot.getElementById('testElement'); - let allowDefault = fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse'})); - expect(allowDefault).toBe(true); - - allowDefault = fireEvent.mouseDown(el); - expect(allowDefault).toBe(true); - }); - it('should ignore virtual pointer events', function () { const shadowRoot = setupShadowDOMTest({onPressChange: null}); @@ -4235,6 +4325,7 @@ describe('usePress', function () { 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({ @@ -4352,6 +4443,7 @@ describe('usePress', function () { 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(); }); @@ -4363,6 +4455,7 @@ describe('usePress', function () { 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'); }); }); @@ -4420,6 +4513,7 @@ describe('coordinates', () => { let el = res.getByText('test'); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 25, clientY: 0})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 75, clientY: 75})); + fireEvent.click(el, {clientX: 75, clientY: 75}); // How else to get the DOM node it renders the hook to? // let el = events[0].target; @@ -4497,6 +4591,7 @@ describe('coordinates', () => { let el = res.getByText('test'); fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch', clientX: 25, clientY: 0})); fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch', clientX: 75, clientY: 75})); + fireEvent.click(el, {clientX: 75, clientY: 75}); expect(events).toEqual([ { diff --git a/packages/@react-aria/link/test/useLink.test.js b/packages/@react-aria/link/test/useLink.test.js index 7af03681602..f2d2975c3aa 100644 --- a/packages/@react-aria/link/test/useLink.test.js +++ b/packages/@react-aria/link/test/useLink.test.js @@ -23,7 +23,7 @@ describe('useLink', function () { it('handles defaults', function () { let {linkProps} = renderLinkHook({children: 'Test Link'}); expect(linkProps.role).toBeUndefined(); - expect(linkProps.tabIndex).toBeUndefined(); + expect(linkProps.tabIndex).toBe(0); expect(typeof linkProps.onKeyDown).toBe('function'); }); diff --git a/packages/@react-aria/menu/src/useMenuTrigger.ts b/packages/@react-aria/menu/src/useMenuTrigger.ts index 3e22777bb1d..44c50a03ecf 100644 --- a/packages/@react-aria/menu/src/useMenuTrigger.ts +++ b/packages/@react-aria/menu/src/useMenuTrigger.ts @@ -12,14 +12,14 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaMenuOptions} from './useMenu'; +import {FocusableElement, RefObject} from '@react-types/shared'; +import {focusWithoutScrolling, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; import {MenuTriggerState} from '@react-stately/menu'; import {MenuTriggerType} from '@react-types/menu'; -import {RefObject} from '@react-types/shared'; -import {useId} from '@react-aria/utils'; +import {PressProps, useLongPress} from '@react-aria/interactions'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useLongPress} from '@react-aria/interactions'; import {useOverlayTrigger} from '@react-aria/overlays'; export interface AriaMenuTriggerProps { @@ -108,10 +108,14 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge } }); - let pressProps = { + let pressProps: PressProps = { + preventFocusOnPress: true, onPressStart(e) { // For consistency with native, open the menu on mouse/key down, but touch up. if (e.pointerType !== 'touch' && e.pointerType !== 'keyboard' && !isDisabled) { + // Ensure trigger has focus before opening the menu so it can be restored by FocusScope on close. + focusWithoutScrolling(e.target as FocusableElement); + // If opened with a screen reader, auto focus the first item. // Otherwise, the menu itself will be focused. state.open(e.pointerType === 'virtual' ? 'first' : null); diff --git a/packages/@react-aria/menu/test/useMenuTrigger.test.js b/packages/@react-aria/menu/test/useMenuTrigger.test.js index c31c577029a..8a5d9038011 100644 --- a/packages/@react-aria/menu/test/useMenuTrigger.test.js +++ b/packages/@react-aria/menu/test/useMenuTrigger.test.js @@ -77,7 +77,7 @@ describe('useMenuTrigger', function () { let {menuTriggerProps} = renderMenuTriggerHook(props, state); expect(typeof menuTriggerProps.onPressStart).toBe('function'); - menuTriggerProps.onPressStart({pointerType: 'mouse'}); + menuTriggerProps.onPressStart({pointerType: 'mouse', target: document.createElement('button')}); expect(setOpen).toHaveBeenCalledTimes(1); expect(setOpen).toHaveBeenCalledWith(!state.isOpen); expect(setFocusStrategy).toHaveBeenCalledTimes(1); diff --git a/packages/@react-aria/selection/test/useSelectableCollection.test.js b/packages/@react-aria/selection/test/useSelectableCollection.test.js index bb0f5d5d6e7..b83705e5248 100644 --- a/packages/@react-aria/selection/test/useSelectableCollection.test.js +++ b/packages/@react-aria/selection/test/useSelectableCollection.test.js @@ -77,11 +77,17 @@ describe('useSelectableCollection', () => { type | prepare | actions ${'VO Events'} | ${installPointerEvent}| ${[ (el) => fireEvent.pointerDown(el, {button: 0, pointerType: 'virtual'}), - (el) => fireEvent.pointerUp(el, {button: 0, pointerType: 'virtual'}) + (el) => { + fireEvent.pointerUp(el, {button: 0, pointerType: 'virtual'}); + fireEvent.click(el, {detail: 1}); + } ]} ${'Touch Pointer Events'} | ${installPointerEvent}| ${[ (el) => fireEvent.pointerDown(el, {button: 0, pointerType: 'touch', pointerId: 1}), - (el) => fireEvent.pointerUp(el, {button: 0, pointerType: 'touch', pointerId: 1}) + (el) => { + fireEvent.pointerUp(el, {button: 0, pointerType: 'touch', pointerId: 1}); + fireEvent.click(el, {detail: 1}); + } ]} `('always uses toggle for $type', ({prepare, actions: [start, end]}) => { prepare(); diff --git a/packages/@react-aria/slider/docs/useSlider.mdx b/packages/@react-aria/slider/docs/useSlider.mdx index ecdd4728e66..5760c1ecb58 100644 --- a/packages/@react-aria/slider/docs/useSlider.mdx +++ b/packages/@react-aria/slider/docs/useSlider.mdx @@ -375,7 +375,7 @@ function Example() { ### Custom value scale -By default, slider values are precentages between 0 and 100. A different scale can be used by setting the `minValue` and `maxValue` props. +By default, slider values are percentages between 0 and 100. A different scale can be used by setting the `minValue` and `maxValue` props. ```tsx example (props: AriaTableColumnHeaderProps, st return { columnHeaderProps: { ...mergeProps( + focusableProps, gridCellProps, pressProps, - focusableProps, descriptionProps, // If the table is empty, make all column headers untabbable shouldDisableFocus ? {tabIndex: -1} : null diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index aa8993c8984..6913138acb0 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -224,6 +224,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }; let {pressProps} = usePress({ + preventFocusOnPress: true, onPressStart: (e) => { if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey || e.pointerType === 'keyboard') { return; diff --git a/packages/@react-aria/test-utils/src/events.ts b/packages/@react-aria/test-utils/src/events.ts index 6683cbc60f4..73a048de8d8 100644 --- a/packages/@react-aria/test-utils/src/events.ts +++ b/packages/@react-aria/test-utils/src/events.ts @@ -22,14 +22,39 @@ export const DEFAULT_LONG_PRESS_TIME = 500; * @param opts.advanceTimer - Function that when called advances the timers in your test suite by a specific amount of time(ms). * @param opts.pointeropts - Options to pass to the simulated event. Defaults to mouse. See https://testing-library.com/docs/dom-testing-library/api-events/#fireevent for more info. */ -export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time?: number) => void | Promise, pointerOpts?: {}}) { +export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time?: number) => void | Promise, pointerOpts?: Record}) { // TODO: note that this only works if the code from installPointerEvent is called somewhere in the test BEFORE the // render. Perhaps we should rely on the user setting that up since I'm not sure there is a great way to set that up here in the // util before first render. Will need to document it well let {element, advanceTimer, pointerOpts = {}} = opts; - await fireEvent.pointerDown(element, {pointerType: 'mouse', ...pointerOpts}); + let pointerType = pointerOpts.pointerType ?? 'mouse'; + let shouldFireCompatibilityEvents = fireEvent.pointerDown(element, {pointerType, ...pointerOpts}); + let shouldFocus = true; + if (shouldFireCompatibilityEvents) { + if (pointerType === 'touch') { + shouldFocus = shouldFireCompatibilityEvents = fireEvent.touchStart(element, {targetTouches: [{identifier: pointerOpts.pointerId, clientX: pointerOpts.clientX, clientY: pointerOpts.clientY}]}); + } else if (pointerType === 'mouse') { + shouldFocus = fireEvent.mouseDown(element, pointerOpts); + if (shouldFocus) { + act(() => element.focus()); + } + } + } await act(async () => await advanceTimer(DEFAULT_LONG_PRESS_TIME)); - await fireEvent.pointerUp(element, {pointerType: 'mouse', ...pointerOpts}); + fireEvent.pointerUp(element, {pointerType, ...pointerOpts}); + if (shouldFireCompatibilityEvents) { + if (pointerType === 'touch') { + shouldFocus = fireEvent.touchEnd(element, {targetTouches: [{identifier: pointerOpts.pointerId, clientX: pointerOpts.clientX, clientY: pointerOpts.clientY}]}); + shouldFocus = fireEvent.mouseDown(element, pointerOpts); + if (shouldFocus) { + act(() => element.focus()); + } + fireEvent.mouseUp(element, pointerOpts); + } else if (pointerType === 'mouse') { + fireEvent.mouseUp(element, pointerOpts); + } + } + fireEvent.click(element, {detail: 1, ...pointerOpts}); } diff --git a/packages/@react-aria/test-utils/src/table.ts b/packages/@react-aria/test-utils/src/table.ts index 614f4f9c0b2..a7a14aaf0cf 100644 --- a/packages/@react-aria/test-utils/src/table.ts +++ b/packages/@react-aria/test-utils/src/table.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, waitFor, within} from '@testing-library/react'; +import {act, waitFor, within} from '@testing-library/react'; import {GridRowActionOpts, TableTesterOpts, ToggleGridRowOpts, UserOpts} from './types'; import {pressElement, triggerLongPress} from './events'; @@ -88,10 +88,6 @@ export class TableTester { // Note that long press interactions with rows is strictly touch only for grid rows await triggerLongPress({element: cell, advanceTimer: this._advanceTimer, pointerOpts: {pointerType: 'touch'}}); - // TODO: interestingly enough, we need to do a followup click otherwise future row selections may not fire properly? - // To reproduce, try removing this, forcing toggleRowSelection to hit "needsLongPress ? await triggerLongPress(cell) : await action(cell);" and - // run Table.test's "should support long press to enter selection mode on touch" test to see what happens - await fireEvent.click(cell); } else { await pressElement(this.user, cell, interactionType); } diff --git a/packages/@react-aria/tooltip/src/useTooltipTrigger.ts b/packages/@react-aria/tooltip/src/useTooltipTrigger.ts index 14becae8ed4..fecf751b24e 100644 --- a/packages/@react-aria/tooltip/src/useTooltipTrigger.ts +++ b/packages/@react-aria/tooltip/src/useTooltipTrigger.ts @@ -140,7 +140,8 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig 'aria-describedby': state.isOpen ? tooltipId : undefined, ...mergeProps(focusableProps, hoverProps, { onPointerDown: onPressStart, - onKeyDown: onPressStart + onKeyDown: onPressStart, + tabIndex: undefined }) }, tooltipProps: { diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 20e2ffd7b86..413e1f46639 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -49,3 +49,4 @@ export {inertValue} from './inertValue'; export {CLEAR_FOCUS_EVENT, FOCUS_EVENT, UPDATE_ACTIVEDESCENDANT} from './constants'; export {isCtrlKeyPressed} from './keyboard'; export {useEnterAnimation, useExitAnimation} from './animation'; +export {isFocusable, isTabbable} from './isFocusable'; diff --git a/packages/@react-aria/utils/src/isFocusable.ts b/packages/@react-aria/utils/src/isFocusable.ts new file mode 100644 index 00000000000..33780652c43 --- /dev/null +++ b/packages/@react-aria/utils/src/isFocusable.ts @@ -0,0 +1,28 @@ +const focusableElements = [ + 'input:not([disabled]):not([type=hidden])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'a[href]', + 'area[href]', + 'summary', + 'iframe', + 'object', + 'embed', + 'audio[controls]', + 'video[controls]', + '[contenteditable]:not([contenteditable^="false"])' +]; + +const FOCUSABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])'; + +focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); +const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); + +export function isFocusable(element: Element) { + return element.matches(FOCUSABLE_ELEMENT_SELECTOR); +} + +export function isTabbable(element: Element) { + return element.matches(TABBABLE_ELEMENT_SELECTOR); +} diff --git a/packages/@react-aria/utils/src/useGlobalListeners.ts b/packages/@react-aria/utils/src/useGlobalListeners.ts index 834dd2c5ffc..300517ad0b6 100644 --- a/packages/@react-aria/utils/src/useGlobalListeners.ts +++ b/packages/@react-aria/utils/src/useGlobalListeners.ts @@ -13,6 +13,7 @@ import {useCallback, useEffect, useRef} from 'react'; interface GlobalListeners { + addGlobalListener(el: Window, type: K, listener: (this: Document, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void, addGlobalListener(el: EventTarget, type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void, addGlobalListener(el: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void, removeGlobalListener(el: EventTarget, type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void, diff --git a/packages/@react-aria/utils/src/useUpdateEffect.ts b/packages/@react-aria/utils/src/useUpdateEffect.ts index fab098c0c4b..17ea4ec01e4 100644 --- a/packages/@react-aria/utils/src/useUpdateEffect.ts +++ b/packages/@react-aria/utils/src/useUpdateEffect.ts @@ -13,7 +13,7 @@ import {EffectCallback, useEffect, useRef} from 'react'; // Like useEffect, but only called for updates after the initial render. -export function useUpdateEffect(effect: EffectCallback, dependencies: any[]) { +export function useUpdateEffect(effect: EffectCallback, dependencies: any[]): (() => void) | void { const isInitialMount = useRef(true); const lastDeps = useRef(null); @@ -28,7 +28,7 @@ export function useUpdateEffect(effect: EffectCallback, dependencies: any[]) { if (isInitialMount.current) { isInitialMount.current = false; } else if (!lastDeps.current || dependencies.some((dep, i) => !Object.is(dep, lastDeps[i]))) { - effect(); + return effect(); } lastDeps.current = dependencies; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/@react-spectrum/calendar/test/CalendarBase.test.js b/packages/@react-spectrum/calendar/test/CalendarBase.test.js index 39fa06f1b14..1680a007b86 100644 --- a/packages/@react-spectrum/calendar/test/CalendarBase.test.js +++ b/packages/@react-spectrum/calendar/test/CalendarBase.test.js @@ -759,7 +759,7 @@ describe('CalendarBase', () => { ); let grid = getByRole('grid'); - let selected = getAllByRole('button').find(cell => cell.getAttribute('tabIndex') === '0'); + let selected = getAllByRole('button').find(cell => cell.tagName === 'SPAN' && cell.getAttribute('tabIndex') === '0'); expect(document.activeElement).toBe(selected); await user.keyboard('{ArrowLeft}'); @@ -779,7 +779,7 @@ describe('CalendarBase', () => { fireEvent.blur(grid); fireEvent.focus(grid); - selected = getAllByRole('button').find(cell => cell.getAttribute('tabIndex') === '0'); + selected = getAllByRole('button').find(cell => cell.tagName === 'SPAN' && cell.getAttribute('tabIndex') === '0'); expect(document.activeElement).toBe(selected); await user.keyboard('{ArrowLeft}'); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index abddf4d2174..ebdb76e8f54 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -1708,7 +1708,7 @@ describe('ComboBox', function () { expect(listbox).toBeVisible(); let items = within(listbox).getAllByRole('option'); - fireEvent.mouseDown(items[0]); + await user.pointer({target: items[0], keys: '[MouseLeft>]'}); act(() => { jest.runAllTimers(); }); @@ -1720,7 +1720,7 @@ describe('ComboBox', function () { expect(document.activeElement).toBe(combobox); expect(listbox).toBeVisible(); - fireEvent.mouseUp(items[0]); + await user.pointer({target: items[0], keys: '[/MouseLeft]'}); act(() => { jest.runAllTimers(); }); diff --git a/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js b/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js index 7d4b4301d26..3937bb3880e 100644 --- a/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js @@ -73,7 +73,7 @@ describe('DatePickerBase', function () { let button = getAllByRole('button')[0]; expect(button).toBeVisible(); - expect(button).not.toHaveAttribute('tabindex'); + expect(button).toHaveAttribute('tabindex', '0'); }); it.each` diff --git a/packages/@react-spectrum/list/test/ListView.test.js b/packages/@react-spectrum/list/test/ListView.test.js index 8eb86c93e4c..e96b694f8c6 100644 --- a/packages/@react-spectrum/list/test/ListView.test.js +++ b/packages/@react-spectrum/list/test/ListView.test.js @@ -13,7 +13,7 @@ jest.mock('@react-aria/live-announcer'); jest.mock('@react-aria/utils/src/scrollIntoView'); -import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render as renderComponent, triggerTouch, within} from '@react-spectrum/test-utils-internal'; import {ActionButton} from '@react-spectrum/button'; import {announce} from '@react-aria/live-announcer'; import {FocusExample} from '../stories/ListViewActions.stories'; @@ -746,8 +746,7 @@ describe('ListView', function () { let row = tree.getAllByRole('row')[0]; await user.tab(); expect(row).toHaveAttribute('aria-selected', 'false'); - fireEvent.keyDown(row, {key: ' '}); - fireEvent.keyUp(row, {key: ' '}); + await user.keyboard(' '); checkSelection(onSelectionChange, ['foo']); expect(row).toHaveAttribute('aria-selected', 'true'); @@ -761,8 +760,7 @@ describe('ListView', function () { let row = tree.getAllByRole('row')[0]; await user.tab(); expect(row).toHaveAttribute('aria-selected', 'false'); - fireEvent.keyDown(row, {key: 'Enter'}); - fireEvent.keyUp(row, {key: 'Enter'}); + await user.keyboard('{Enter}'); checkSelection(onSelectionChange, ['foo']); expect(row).toHaveAttribute('aria-selected', 'true'); @@ -850,8 +848,8 @@ describe('ListView', function () { expect(announce).toHaveBeenCalledTimes(1); expect(gridListTester.selectedRows).toHaveLength(1); - fireEvent.keyDown(rows[0], {key: 'a', ctrlKey: true}); - fireEvent.keyUp(rows[0], {key: 'a', ctrlKey: true}); + await user.keyboard('{Control>}a{/Control}'); + act(() => jest.runAllTimers()); checkSelection(onSelectionChange, 'all'); onSelectionChange.mockClear(); expect(announce).toHaveBeenLastCalledWith('All items selected.'); @@ -932,17 +930,13 @@ describe('ListView', function () { fireEvent.pointerUp(rows[1], {pointerType: 'touch'}); onSelectionChange.mockReset(); - fireEvent.pointerDown(rows[2], {pointerType: 'touch'}); - fireEvent.pointerUp(rows[2], {pointerType: 'touch'}); - + triggerTouch(rows[2]); checkSelection(onSelectionChange, ['bar', 'baz']); // Deselect all to exit selection mode - fireEvent.pointerDown(rows[2], {pointerType: 'touch'}); - fireEvent.pointerUp(rows[2], {pointerType: 'touch'}); + triggerTouch(rows[2]); onSelectionChange.mockReset(); - fireEvent.pointerDown(rows[1], {pointerType: 'touch'}); - fireEvent.pointerUp(rows[1], {pointerType: 'touch'}); + triggerTouch(rows[1]); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, []); @@ -1202,9 +1196,7 @@ describe('ListView', function () { let row = tree.getAllByRole('row')[1]; expect(row).toHaveAttribute('aria-selected', 'false'); await user.keyboard('[ControlLeft>]'); - fireEvent.pointerDown(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true}); - fireEvent.pointerUp(getRow(tree, 'Bar'), {pointerType: 'mouse', ctrlKey: true}); - fireEvent.click(getRow(tree, 'Bar'), {ctrlKey: true}); + await user.pointer({target: getRow(tree, 'Bar'), keys: '[MouseLeft]', coords: {width: 1}}); await user.keyboard('[/ControlLeft]'); checkSelection(onSelectionChange, ['bar']); @@ -1246,22 +1238,19 @@ describe('ListView', function () { checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(announce).toHaveBeenLastCalledWith('Bar selected.'); expect(announce).toHaveBeenCalledTimes(2); checkSelection(onSelectionChange, ['bar']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowUp'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowUp'}); + await user.keyboard('{ArrowUp}'); expect(announce).toHaveBeenLastCalledWith('Foo selected.'); expect(announce).toHaveBeenCalledTimes(3); checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.'); expect(announce).toHaveBeenCalledTimes(4); checkSelection(onSelectionChange, ['foo', 'bar']); @@ -1277,15 +1266,13 @@ describe('ListView', function () { checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', shiftKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', shiftKey: true}); + await user.keyboard('{Shift>}{ArrowDown}{/Shift}'); expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.'); expect(announce).toHaveBeenCalledTimes(2); checkSelection(onSelectionChange, ['foo', 'bar']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(announce).toHaveBeenLastCalledWith('Baz selected. 1 item selected.'); checkSelection(onSelectionChange, ['baz']); }); @@ -1300,27 +1287,23 @@ describe('ListView', function () { checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + await user.keyboard('{Control>}{ArrowDown}{/Control}'); expect(announce).toHaveBeenCalledTimes(1); expect(onSelectionChange).not.toHaveBeenCalled(); expect(document.activeElement).toBe(getRow(tree, 'Bar')); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + await user.keyboard('{Control>}{ArrowDown}{/Control}'); expect(announce).toHaveBeenCalledTimes(1); expect(onSelectionChange).not.toHaveBeenCalled(); expect(document.activeElement).toBe(getRow(tree, 'Baz')); - fireEvent.keyDown(document.activeElement, {key: ' ', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: ' ', ctrlKey: true}); + await user.keyboard('{Control>} {/Control}'); expect(announce).toHaveBeenCalledWith('Baz selected. 2 items selected.'); expect(announce).toHaveBeenCalledTimes(2); checkSelection(onSelectionChange, ['foo', 'baz']); onSelectionChange.mockClear(); - fireEvent.keyDown(document.activeElement, {key: ' '}); - fireEvent.keyUp(document.activeElement, {key: ' '}); + await user.keyboard(' '); expect(announce).toHaveBeenCalledWith('Baz selected. 1 item selected.'); expect(announce).toHaveBeenCalledTimes(3); checkSelection(onSelectionChange, ['baz']); @@ -1330,23 +1313,19 @@ describe('ListView', function () { let tree = renderSelectionList({onSelectionChange, selectionStyle: 'highlight', onAction, selectionMode: 'multiple'}); let rows = tree.getAllByRole('row'); - fireEvent.pointerDown(rows[0], {pointerType: 'mouse'}); - fireEvent.pointerUp(rows[0], {pointerType: 'mouse'}); - fireEvent.click(rows[0], {pointerType: 'mouse'}); + await user.pointer({target: rows[0], keys: '[MouseLeft]', coords: {width: 1}}); checkSelection(onSelectionChange, ['foo']); onSelectionChange.mockClear(); expect(announce).toHaveBeenLastCalledWith('Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); - fireEvent.keyDown(rows[0], {key: 'a', ctrlKey: true}); - fireEvent.keyUp(rows[0], {key: 'a', ctrlKey: true}); + await user.keyboard('{Control>}a{/Control}'); checkSelection(onSelectionChange, 'all'); onSelectionChange.mockClear(); expect(announce).toHaveBeenLastCalledWith('All items selected.'); expect(announce).toHaveBeenCalledTimes(2); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + await user.keyboard('{ArrowDown}'); expect(announce).toHaveBeenLastCalledWith('Bar selected. 1 item selected.'); expect(announce).toHaveBeenCalledTimes(3); checkSelection(onSelectionChange, ['bar']); @@ -1386,6 +1365,7 @@ describe('ListView', function () { expect(within(rows[0]).getByRole('checkbox')).toBeTruthy(); fireEvent.pointerUp(rows[0], {pointerType: 'touch'}); + fireEvent.click(rows[0], {detail: 1}); await user.pointer({target: rows[1], keys: '[TouchA]'}); expect(announce).toHaveBeenLastCalledWith('Bar selected. 2 items selected.'); diff --git a/packages/@react-spectrum/list/test/ListViewDnd.test.js b/packages/@react-spectrum/list/test/ListViewDnd.test.js index d81e4fcb480..9d3e8bdfddc 100644 --- a/packages/@react-spectrum/list/test/ListViewDnd.test.js +++ b/packages/@react-spectrum/list/test/ListViewDnd.test.js @@ -418,6 +418,7 @@ describe('ListView', function () { let dataTransfer = new DataTransfer(); fireEvent.pointerDown(cell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + act(() => rows[1].focus()); fireEvent(cell, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); expect(onDragStart).toHaveBeenCalledTimes(1); @@ -469,6 +470,7 @@ describe('ListView', function () { let dataTransfer = new DataTransfer(); fireEvent.pointerDown(cell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + act(() => rows[1].focus()); fireEvent(cell, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); expect(onDragStart).toHaveBeenCalledTimes(1); @@ -2935,6 +2937,7 @@ describe('ListView', function () { expect(draggableRow).toHaveAttribute('aria-selected', 'false'); expect(onSelectionChange).toHaveBeenCalledTimes(0); fireEvent.pointerUp(draggableRow, {pointerType: 'mouse'}); + fireEvent.click(draggableRow, {detail: 1}); expect(draggableRow).toHaveAttribute('aria-selected', 'true'); checkSelection(onSelectionChange, ['a']); }); diff --git a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js index d5d1336adc5..3762768c1b6 100644 --- a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js +++ b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js @@ -59,7 +59,7 @@ describe('style-macro', () => { " `); - expect(js).toMatchInlineSnapshot('" . A-13alit4c A-13alit4ed"'); + expect(js).toMatchInlineSnapshot('" A-13alit4c A-13alit4ed"'); }); it('should support self references', () => { diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index a871a80afa4..4fa4a8d8b16 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -268,7 +268,7 @@ export function createTheme(theme: T): StyleFunction(); for (let [property, propertyRules] of rules) { if (isStatic) { diff --git a/packages/@react-spectrum/table/test/Table.test.js b/packages/@react-spectrum/table/test/Table.test.js index d1e73383944..4d1f5683475 100644 --- a/packages/@react-spectrum/table/test/Table.test.js +++ b/packages/@react-spectrum/table/test/Table.test.js @@ -3183,7 +3183,9 @@ export let tableTests = () => { expect(onAction).not.toHaveBeenCalled(); expect(tree.queryByLabelText('Select All')).not.toBeNull(); - fireEvent.pointerUp(getCell(tree, 'Baz 5'), {pointerType: 'touch'}); + let cell = getCell(tree, 'Baz 5'); + fireEvent.pointerUp(cell, {pointerType: 'touch'}); + fireEvent.click(cell, {detail: 1}); onSelectionChange.mockReset(); act(() => { jest.runAllTimers(); @@ -3231,6 +3233,7 @@ export let tableTests = () => { await user.keyboard('{ArrowUp}'.repeat(5)); onSelectionChange.mockReset(); announce.mockReset(); + onSelectionChange.mockReset(); await user.keyboard('{Enter}'); expect(onSelectionChange).not.toHaveBeenCalled(); expect(announce).not.toHaveBeenCalled(); @@ -3309,28 +3312,24 @@ export let tableTests = () => { announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + await user.keyboard('{Control>}{ArrowDown}{/Control}'); expect(announce).not.toHaveBeenCalled(); expect(onSelectionChange).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(getCell(tree, 'Baz 6').closest('[role="row"]')); + expect(document.activeElement).toBe(getCell(tree, 'Baz 6')); - fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: 'ArrowDown', ctrlKey: true}); + await user.keyboard('{Control>}{ArrowDown}{/Control}'); expect(announce).not.toHaveBeenCalled(); expect(onSelectionChange).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(getCell(tree, 'Baz 7').closest('[role="row"]')); + expect(document.activeElement).toBe(getCell(tree, 'Baz 7')); - fireEvent.keyDown(document.activeElement, {key: ' ', ctrlKey: true}); - fireEvent.keyUp(document.activeElement, {key: ' ', ctrlKey: true}); + await user.keyboard('{Control>} {/Control}'); expect(announce).toHaveBeenCalledWith('Foo 7 selected. 2 items selected.'); expect(announce).toHaveBeenCalledTimes(1); checkSelection(onSelectionChange, ['Foo 5', 'Foo 7']); announce.mockReset(); onSelectionChange.mockReset(); - fireEvent.keyDown(document.activeElement, {key: ' '}); - fireEvent.keyUp(document.activeElement, {key: ' '}); + await user.keyboard(' '); expect(announce).toHaveBeenCalledWith('Foo 7 selected. 1 item selected.'); expect(announce).toHaveBeenCalledTimes(1); checkSelection(onSelectionChange, ['Foo 7']); diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index 0700ba44043..d4be98204c6 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -2839,6 +2839,7 @@ describe('TableView', function () { expect(draggableRow).toHaveAttribute('aria-selected', 'false'); expect(onSelectionChange).toHaveBeenCalledTimes(0); fireEvent.pointerUp(draggableRow, {pointerType: 'mouse'}); + fireEvent.click(draggableRow); expect(draggableRow).toHaveAttribute('aria-selected', 'true'); checkSelection(onSelectionChange, ['a']); }); diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx index 22440fb3020..76fb9c07b67 100644 --- a/packages/@react-spectrum/table/test/TableSizing.test.tsx +++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx @@ -17,7 +17,7 @@ import Add from '@spectrum-icons/workflow/Add'; import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../'; import {ColumnSize} from '@react-types/table'; import {ControllingResize} from '../stories/ControllingResize'; -import {fireEvent, installPointerEvent, pointerMap, simulateDesktop} from '@react-spectrum/test-utils-internal'; +import {fireEvent, installPointerEvent, pointerMap, simulateDesktop, triggerTouch} from '@react-spectrum/test-utils-internal'; import {HidingColumns} from '../stories/HidingColumns'; import {Key} from '@react-types/shared'; import {Provider} from '@react-spectrum/provider'; @@ -978,14 +978,12 @@ describe('TableViewSizing', function () { let header = tree.getAllByRole('columnheader')[0]; let resizableHeader = within(header).getByRole('button'); - fireEvent.pointerDown(resizableHeader, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(resizableHeader, {pointerType: 'touch', pointerId: 1}); + triggerTouch(resizableHeader); act(() => {jest.runAllTimers();}); let resizeMenuItem = tree.getAllByRole('menuitem')[0]; - fireEvent.pointerDown(resizeMenuItem, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(resizeMenuItem, {pointerType: 'touch', pointerId: 1}); + triggerTouch(resizeMenuItem); act(() => {jest.runAllTimers();}); let resizer = tree.getByRole('slider'); diff --git a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx index 4cca024b418..180b3ce1b21 100644 --- a/packages/@react-spectrum/table/test/TreeGridTable.test.tsx +++ b/packages/@react-spectrum/table/test/TreeGridTable.test.tsx @@ -1413,7 +1413,7 @@ describe('TableView with expandable rows', function () { describe('selectionStyle highlight', function () { installPointerEvent(); - it('should toggle selection with mouse', function () { + it('should toggle selection with mouse', async function () { let treegrid = render(); expect(treegrid.queryByLabelText('Select All')).toBeNull(); @@ -1422,8 +1422,7 @@ describe('TableView with expandable rows', function () { let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); checkRowSelection(rows, false); - fireEvent.pointerDown(cell, {pointerType: 'mouse', pointerId: 1}); - fireEvent.pointerUp(cell, {pointerType: 'mouse', pointerId: 1}); + await user.pointer({target: cell, keys: '[MouseLeft]', coords: {width: 1}}); act(() => jest.runAllTimers()); expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 3, Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); @@ -1432,8 +1431,7 @@ describe('TableView with expandable rows', function () { onSelectionChange.mockReset(); cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); - fireEvent.pointerDown(cell, {pointerType: 'mouse', pointerId: 1}); - fireEvent.pointerUp(cell, {pointerType: 'mouse', pointerId: 1}); + await user.pointer({target: cell, keys: '[MouseLeft]', coords: {width: 1}}); act(() => jest.runAllTimers()); expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 1, Foo selected.'); expect(announce).toHaveBeenCalledTimes(2); @@ -1442,7 +1440,7 @@ describe('TableView with expandable rows', function () { checkRowSelection(rows.slice(1), false); }); - it('should toggle selection with touch', function () { + it('should toggle selection with touch', async function () { let treegrid = render(); expect(treegrid.queryByLabelText('Select All')).toBeNull(); @@ -1451,8 +1449,7 @@ describe('TableView with expandable rows', function () { let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); checkRowSelection(rows, false); - fireEvent.pointerDown(cell, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(cell, {pointerType: 'touch', pointerId: 1}); + await user.pointer({target: cell, keys: '[TouchA]', coords: {width: 1}}); act(() => jest.runAllTimers()); expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 3, Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); @@ -1461,8 +1458,7 @@ describe('TableView with expandable rows', function () { onSelectionChange.mockReset(); cell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); - fireEvent.pointerDown(cell, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(cell, {pointerType: 'touch', pointerId: 1}); + await user.pointer({target: cell, keys: '[TouchA]', coords: {width: 1}}); act(() => jest.runAllTimers()); expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 1, Foo selected. 2 items selected.'); expect(announce).toHaveBeenCalledTimes(2); @@ -1478,7 +1474,7 @@ describe('TableView with expandable rows', function () { let firstCell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); let secondCell = getCell(treegrid, 'Row 1, Lvl 1, Foo'); - fireEvent.pointerDown(firstCell, {pointerType: 'touch'}); + await user.pointer({target: firstCell, keys: '[TouchA>]', coords: {width: 1}}); expect(onSelectionChange).not.toHaveBeenCalled(); expect(onAction).not.toHaveBeenCalled(); @@ -1488,22 +1484,19 @@ describe('TableView with expandable rows', function () { checkRowSelection([rows[2]], true); expect(onAction).not.toHaveBeenCalled(); - fireEvent.pointerUp(firstCell, {pointerType: 'touch'}); + await user.pointer({target: firstCell, keys: '[/TouchA]', coords: {width: 1}}); onSelectionChange.mockReset(); - fireEvent.pointerDown(secondCell, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(secondCell, {pointerType: 'touch', pointerId: 1}); + await user.pointer({target: secondCell, keys: '[TouchA]', coords: {width: 1}}); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, ['Row 1 Lvl 1', 'Row 1 Lvl 3']); checkRowSelection([rows[0], rows[2]], true); // Deselect all to exit selection mode - fireEvent.pointerDown(firstCell, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(firstCell, {pointerType: 'touch', pointerId: 1}); + await user.pointer({target: firstCell, keys: '[TouchA]', coords: {width: 1}}); act(() => jest.runAllTimers()); onSelectionChange.mockReset(); - fireEvent.pointerDown(secondCell, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(secondCell, {pointerType: 'touch', pointerId: 1}); + await user.pointer({target: secondCell, keys: '[TouchA]', coords: {width: 1}}); act(() => jest.runAllTimers()); checkSelection(onSelectionChange, []); expect(onAction).not.toHaveBeenCalled(); @@ -1519,15 +1512,14 @@ describe('TableView with expandable rows', function () { let cell = getCell(treegrid, 'Row 1, Lvl 3, Foo'); checkRowSelection(rows, false); - fireEvent.pointerDown(cell, {pointerType: 'mouse', pointerId: 1}); - fireEvent.pointerUp(cell, {pointerType: 'mouse', pointerId: 1}); + await user.pointer({target: cell, keys: '[MouseLeft]', coords: {width: 1}}); act(() => jest.runAllTimers()); expect(announce).toHaveBeenLastCalledWith('Row 1, Lvl 3, Foo selected.'); expect(announce).toHaveBeenCalledTimes(1); checkSelection(onSelectionChange, ['Row 1 Lvl 3']); expect(onAction).not.toHaveBeenCalled(); onSelectionChange.mockReset(); - await user.dblClick(cell); + await user.pointer({target: cell, keys: '[MouseLeft][MouseLeft]', coords: {width: 1}}); act(() => jest.runAllTimers()); expect(announce).toHaveBeenCalledTimes(1); expect(onSelectionChange).not.toHaveBeenCalled(); diff --git a/packages/@react-spectrum/tree/test/TreeView.test.tsx b/packages/@react-spectrum/tree/test/TreeView.test.tsx index 5af771e4069..be7bc2bd8a9 100644 --- a/packages/@react-spectrum/tree/test/TreeView.test.tsx +++ b/packages/@react-spectrum/tree/test/TreeView.test.tsx @@ -515,40 +515,40 @@ describe('Tree', () => { let row = tree.getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); rerender(tree, ); row = tree.getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); }); - it('should not update the press state if the row is not interactive', () => { + it('should not update the press state if the row is not interactive', async () => { let tree = render(); let row = tree.getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).not.toHaveAttribute('data-pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); let expandableRow = tree.getAllByRole('row')[1]; expect(expandableRow).not.toHaveAttribute('data-pressed'); - fireEvent.mouseDown(expandableRow); + await user.pointer({target: expandableRow, keys: '[MouseLeft>]'}); expect(expandableRow).toHaveAttribute('data-pressed', 'true'); - fireEvent.mouseUp(expandableRow); + await user.pointer({target: expandableRow, keys: '[/MouseLeft]'}); expect(expandableRow).not.toHaveAttribute('data-pressed'); // Test a completely inert expandable row @@ -557,9 +557,9 @@ describe('Tree', () => { expect(expandableRow).toHaveAttribute('data-disabled', 'true'); expect(expandableRow).not.toHaveAttribute('data-pressed'); - fireEvent.mouseDown(expandableRow); + await user.pointer({target: expandableRow, keys: '[MouseLeft>]'}); expect(expandableRow).not.toHaveAttribute('data-pressed'); - fireEvent.mouseUp(expandableRow); + await user.pointer({target: expandableRow, keys: '[/MouseLeft]'}); }); it('should support focus', async () => { diff --git a/packages/dev/test-utils/src/events.ts b/packages/dev/test-utils/src/events.ts index d63c19af256..c607dc427bb 100644 --- a/packages/dev/test-utils/src/events.ts +++ b/packages/dev/test-utils/src/events.ts @@ -14,8 +14,9 @@ import {fireEvent} from '@testing-library/react'; // Triggers a "touch" event on an element. export function triggerTouch(element, opts = {}) { - fireEvent.pointerDown(element, {pointerType: 'touch', ...opts}); - fireEvent.pointerUp(element, {pointerType: 'touch', ...opts}); + fireEvent.pointerDown(element, {pointerType: 'touch', pointerId: 1, ...opts}); + fireEvent.pointerUp(element, {pointerType: 'touch', pointerId: 1, ...opts}); + fireEvent.click(element, opts); } // Mocks and prevents the next click's default operation diff --git a/packages/react-aria-components/docs/Autocomplete.mdx b/packages/react-aria-components/docs/Autocomplete.mdx index 82c61436e56..ec907dbbb50 100644 --- a/packages/react-aria-components/docs/Autocomplete.mdx +++ b/packages/react-aria-components/docs/Autocomplete.mdx @@ -105,7 +105,7 @@ function Example() { ## Anatomy -`Autocomplete` is a controller for a child text input, such as a [TextField](TextField.html) or [SearchField](SearchField), and a collection component such as a [Menu](Menu.html) or [ListBox](ListBox.html). It enables the user to filter a list of items, and navigate via the arrow keys while keeping focus on the input. +`Autocomplete` is a controller for a child text input, such as a [TextField](TextField.html) or [SearchField](SearchField.html), and a collection component such as a [Menu](Menu.html) or [ListBox](ListBox.html). It enables the user to filter a list of items, and navigate via the arrow keys while keeping focus on the input. ```tsx import {UNSTABLE_Autocomplete as Autocomplete, SearchField, Menu} from 'react-aria-components'; diff --git a/packages/react-aria-components/docs/Slider.mdx b/packages/react-aria-components/docs/Slider.mdx index 88fefdb6f18..2f84e3efc6e 100644 --- a/packages/react-aria-components/docs/Slider.mdx +++ b/packages/react-aria-components/docs/Slider.mdx @@ -313,7 +313,7 @@ function Example() { ### Custom value scale -By default, slider values are precentages between 0 and 100. A different scale can be used by setting the `minValue` and `maxValue` props. +By default, slider values are percentages between 0 and 100. A different scale can be used by setting the `minValue` and `maxValue` props. ```tsx example fireEvent.mouseLeave(triggerButton); fireEvent.mouseEnter(triggerButton); fireEvent.mouseUp(triggerButton, {detail: 1}); + fireEvent.click(triggerButton); expect(triggerButton).toHaveAttribute('aria-expanded', 'true'); }); diff --git a/packages/react-aria-components/test/Button.test.js b/packages/react-aria-components/test/Button.test.js index cd5acfec747..1d16303fa07 100644 --- a/packages/react-aria-components/test/Button.test.js +++ b/packages/react-aria-components/test/Button.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {Button, ButtonContext, ProgressBar, Text} from '../'; import React, {useState} from 'react'; import userEvent from '@testing-library/user-event'; @@ -103,7 +103,7 @@ describe('Button', () => { expect(button).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let onPress = jest.fn(); let {getByRole} = render(); let button = getByRole('button'); @@ -111,11 +111,11 @@ describe('Button', () => { expect(button).not.toHaveAttribute('data-pressed'); expect(button).not.toHaveClass('pressed'); - fireEvent.mouseDown(button); + await user.pointer({target: button, keys: '[MouseLeft>]'}); expect(button).toHaveAttribute('data-pressed', 'true'); expect(button).toHaveClass('pressed'); - fireEvent.mouseUp(button); + await user.pointer({target: button, keys: '[/MouseLeft]'}); expect(button).not.toHaveAttribute('data-pressed'); expect(button).not.toHaveClass('pressed'); @@ -130,16 +130,16 @@ describe('Button', () => { expect(button).toHaveClass('disabled'); }); - it('should support render props', () => { + it('should support render props', async () => { let {getByRole} = render(); let button = getByRole('button'); expect(button).toHaveTextContent('Test'); - fireEvent.mouseDown(button); + await user.pointer({target: button, keys: '[MouseLeft>]'}); expect(button).toHaveTextContent('Pressed'); - fireEvent.mouseUp(button); + await user.pointer({target: button, keys: '[/MouseLeft]'}); expect(button).toHaveTextContent('Test'); }); diff --git a/packages/react-aria-components/test/Calendar.test.js b/packages/react-aria-components/test/Calendar.test.js index ec2fde00154..09eace257b5 100644 --- a/packages/react-aria-components/test/Calendar.test.js +++ b/packages/react-aria-components/test/Calendar.test.js @@ -200,7 +200,7 @@ describe('Calendar', () => { expect(cell).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getByRole} = renderCalendar({}, {}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let grid = getByRole('grid'); let cell = within(grid).getAllByRole('button')[7]; @@ -208,11 +208,11 @@ describe('Calendar', () => { expect(cell).not.toHaveAttribute('data-pressed'); expect(cell).not.toHaveClass('pressed'); - fireEvent.mouseDown(cell); + await user.pointer({target: cell, keys: '[MouseLeft>]'}); expect(cell).toHaveAttribute('data-pressed', 'true'); expect(cell).toHaveClass('pressed'); - fireEvent.mouseUp(cell); + await user.pointer({target: cell, keys: '[/MouseLeft]'}); expect(cell).not.toHaveAttribute('data-pressed'); expect(cell).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index fabe661b676..6754c5dd5ef 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -11,7 +11,7 @@ */ import {Checkbox, CheckboxContext} from '../'; -import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -122,18 +122,18 @@ describe('Checkbox', () => { expect(onFocusChange).toHaveBeenLastCalledWith(false); }); - it('should support press state', () => { + it('should support press state', async () => { let {getByRole} = render( isPressed ? 'pressed' : ''}>Test); let checkbox = getByRole('checkbox').closest('label'); expect(checkbox).not.toHaveAttribute('data-pressed'); expect(checkbox).not.toHaveClass('pressed'); - fireEvent.mouseDown(checkbox); + await user.pointer({target: checkbox, keys: '[MouseLeft>]'}); expect(checkbox).toHaveAttribute('data-pressed', 'true'); expect(checkbox).toHaveClass('pressed'); - fireEvent.mouseUp(checkbox); + await user.pointer({target: checkbox, keys: '[/MouseLeft]'}); expect(checkbox).not.toHaveAttribute('data-pressed'); expect(checkbox).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index f51f5c3107f..bb072553e80 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -189,34 +189,34 @@ describe('GridList', () => { expect(row).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderGridList({selectionMode: 'multiple'}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let row = getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); expect(row).toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); - it('should not show press state when not interactive', () => { + it('should not show press state when not interactive', async () => { let {getAllByRole} = renderGridList({}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let row = getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/Link.test.js b/packages/react-aria-components/test/Link.test.js index 02b3168c6d8..04bd48763a6 100644 --- a/packages/react-aria-components/test/Link.test.js +++ b/packages/react-aria-components/test/Link.test.js @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {Link, LinkContext, RouterProvider} from '../'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -107,7 +107,7 @@ describe('Link', () => { expect(link).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let onPress = jest.fn(); let {getByRole} = render( isPressed ? 'pressed' : ''} onPress={onPress}>Test); let link = getByRole('link'); @@ -115,11 +115,11 @@ describe('Link', () => { expect(link).not.toHaveAttribute('data-pressed'); expect(link).not.toHaveClass('pressed'); - fireEvent.mouseDown(link); + await user.pointer({target: link, keys: '[MouseLeft>]'}); expect(link).toHaveAttribute('data-pressed', 'true'); expect(link).toHaveClass('pressed'); - fireEvent.mouseUp(link); + await user.pointer({target: link, keys: '[/MouseLeft]'}); expect(link).not.toHaveAttribute('data-pressed'); expect(link).not.toHaveClass('pressed'); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index c2443fb1582..dcab5c3e5c8 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -400,34 +400,34 @@ describe('ListBox', () => { expect(option).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderListbox({selectionMode: 'multiple'}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let option = getAllByRole('option')[0]; expect(option).not.toHaveAttribute('data-pressed'); expect(option).not.toHaveClass('pressed'); - fireEvent.mouseDown(option); + await user.pointer({target: option, keys: '[MouseLeft>]'}); expect(option).toHaveAttribute('data-pressed', 'true'); expect(option).toHaveClass('pressed'); - fireEvent.mouseUp(option); + await user.pointer({target: option, keys: '[/MouseLeft]'}); expect(option).not.toHaveAttribute('data-pressed'); expect(option).not.toHaveClass('pressed'); }); - it('should not show press state when not interactive', () => { + it('should not show press state when not interactive', async () => { let {getAllByRole} = renderListbox({}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let option = getAllByRole('option')[0]; expect(option).not.toHaveAttribute('data-pressed'); expect(option).not.toHaveClass('pressed'); - fireEvent.mouseDown(option); + await user.pointer({target: option, keys: '[MouseLeft>]'}); expect(option).not.toHaveAttribute('data-pressed'); expect(option).not.toHaveClass('pressed'); - fireEvent.mouseUp(option); + await user.pointer({target: option, keys: '[/MouseLeft]'}); expect(option).not.toHaveAttribute('data-pressed'); expect(option).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 226b12cb0ba..4ac8f863ef9 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -301,18 +301,18 @@ describe('Menu', () => { expect(menuitem).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderMenu({}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let menuitem = getAllByRole('menuitem')[0]; expect(menuitem).not.toHaveAttribute('data-pressed'); expect(menuitem).not.toHaveClass('pressed'); - fireEvent.mouseDown(menuitem); + await user.pointer({target: menuitem, keys: '[MouseLeft>]'}); expect(menuitem).toHaveAttribute('data-pressed', 'true'); expect(menuitem).toHaveClass('pressed'); - fireEvent.mouseUp(menuitem); + await user.pointer({target: menuitem, keys: '[/MouseLeft]'}); expect(menuitem).not.toHaveAttribute('data-pressed'); expect(menuitem).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index e74268740bc..bab80b346b3 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Button, Dialog, DialogTrigger, FieldError, Label, Modal, Radio, RadioContext, RadioGroup, RadioGroupContext, Text} from '../'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -166,18 +166,18 @@ describe('RadioGroup', () => { expect(label).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderGroup({}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let radio = getAllByRole('radio')[0].closest('label'); expect(radio).not.toHaveAttribute('data-pressed'); expect(radio).not.toHaveClass('pressed'); - fireEvent.mouseDown(radio); + await user.pointer({target: radio, keys: '[MouseLeft>]'}); expect(radio).toHaveAttribute('data-pressed', 'true'); expect(radio).toHaveClass('pressed'); - fireEvent.mouseUp(radio); + await user.pointer({target: radio, keys: '[/MouseLeft]'}); expect(radio).not.toHaveAttribute('data-pressed'); expect(radio).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/RangeCalendar.test.tsx b/packages/react-aria-components/test/RangeCalendar.test.tsx index 40205915507..8d6f69e6290 100644 --- a/packages/react-aria-components/test/RangeCalendar.test.tsx +++ b/packages/react-aria-components/test/RangeCalendar.test.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Button, CalendarCell, CalendarGrid, CalendarGridBody, CalendarGridHeader, CalendarHeaderCell, Heading, RangeCalendar, RangeCalendarContext} from 'react-aria-components'; import {CalendarDate, getLocalTimeZone, startOfMonth, startOfWeek, today} from '@internationalized/date'; import {DateValue} from '@react-types/calendar'; @@ -220,7 +220,7 @@ describe('RangeCalendar', () => { expect(cell).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getByRole} = renderCalendar({}, {}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let grid = getByRole('grid'); let cell = within(grid).getAllByRole('button')[7]; @@ -228,11 +228,11 @@ describe('RangeCalendar', () => { expect(cell).not.toHaveAttribute('data-pressed'); expect(cell).not.toHaveClass('pressed'); - fireEvent.mouseDown(cell); + await user.pointer({target: cell, keys: '[MouseLeft>]'}); expect(cell).toHaveAttribute('data-pressed', 'true'); expect(cell).toHaveClass('pressed'); - fireEvent.mouseUp(cell); + await user.pointer({target: cell, keys: '[/MouseLeft]'}); expect(cell).not.toHaveAttribute('data-pressed'); expect(cell).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/Switch.test.js b/packages/react-aria-components/test/Switch.test.js index 81f37548f23..987088625a8 100644 --- a/packages/react-aria-components/test/Switch.test.js +++ b/packages/react-aria-components/test/Switch.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import {Switch, SwitchContext} from '../'; import userEvent from '@testing-library/user-event'; @@ -138,18 +138,18 @@ describe('Switch', () => { expect(onFocusChange).toHaveBeenLastCalledWith(false); }); - it('should support press state', () => { + it('should support press state', async () => { let {getByRole} = render( isPressed ? 'pressed' : ''}>Test); let s = getByRole('switch').closest('label'); expect(s).not.toHaveAttribute('data-pressed'); expect(s).not.toHaveClass('pressed'); - fireEvent.mouseDown(s); + await user.pointer({target: s, keys: '[MouseLeft>]'}); expect(s).toHaveAttribute('data-pressed', 'true'); expect(s).toHaveClass('pressed'); - fireEvent.mouseUp(s); + await user.pointer({target: s, keys: '[/MouseLeft]'}); expect(s).not.toHaveAttribute('data-pressed'); expect(s).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 3480cc758b7..a1bcbbf1df9 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -477,7 +477,7 @@ describe('Table', () => { expect(column).toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderTable({ tableProps: {selectionMode: 'multiple'}, rowProps: {className: ({isPressed}) => isPressed ? 'pressed' : ''} @@ -488,16 +488,16 @@ describe('Table', () => { expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); expect(row).toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); - it('should not show press state when not interactive', () => { + it('should not show press state when not interactive', async () => { let {getAllByRole} = renderTable({ rowProps: {className: ({isPressed}) => isPressed ? 'pressed' : ''} }); @@ -506,16 +506,16 @@ describe('Table', () => { expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); - it('should support row actions', () => { + it('should support row actions', async () => { let onRowAction = jest.fn(); let {getAllByRole} = renderTable({ tableProps: {onRowAction}, @@ -527,11 +527,11 @@ describe('Table', () => { expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); expect(row).toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index bc979391568..6b39a181bcd 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -180,18 +180,18 @@ describe('Tabs', () => { expect(tab).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderTabs({}, {}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let tab = getAllByRole('tab')[0]; expect(tab).not.toHaveAttribute('data-pressed'); expect(tab).not.toHaveClass('pressed'); - fireEvent.mouseDown(tab); + await user.pointer({target: tab, keys: '[MouseLeft>]'}); expect(tab).toHaveAttribute('data-pressed', 'true'); expect(tab).toHaveClass('pressed'); - fireEvent.mouseUp(tab); + await user.pointer({target: tab, keys: '[/MouseLeft]'}); expect(tab).not.toHaveAttribute('data-pressed'); expect(tab).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index 6f44b762cbb..7ec163dd5d8 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -176,34 +176,34 @@ describe('TagGroup', () => { expect(row).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let {getAllByRole} = renderTagGroup({selectionMode: 'multiple'}, {}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let row = getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); expect(row).toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); - it('should not show press state when not interactive', () => { + it('should not show press state when not interactive', async () => { let {getAllByRole} = renderTagGroup({}, {}, {className: ({isPressed}) => isPressed ? 'pressed' : ''}); let row = getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); diff --git a/packages/react-aria-components/test/ToggleButton.test.js b/packages/react-aria-components/test/ToggleButton.test.js index b3614d80d4c..a3bc945ae6d 100644 --- a/packages/react-aria-components/test/ToggleButton.test.js +++ b/packages/react-aria-components/test/ToggleButton.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import {ToggleButton, ToggleButtonContext} from '../'; import userEvent from '@testing-library/user-event'; @@ -93,7 +93,7 @@ describe('ToggleButton', () => { expect(button).not.toHaveClass('focus'); }); - it('should support press state', () => { + it('should support press state', async () => { let onPress = jest.fn(); let {getByRole} = render( isPressed ? 'pressed' : ''} onPress={onPress}>Test); let button = getByRole('button'); @@ -101,11 +101,11 @@ describe('ToggleButton', () => { expect(button).not.toHaveAttribute('data-pressed'); expect(button).not.toHaveClass('pressed'); - fireEvent.mouseDown(button); + await user.pointer({target: button, keys: '[MouseLeft>]'}); expect(button).toHaveAttribute('data-pressed', 'true'); expect(button).toHaveClass('pressed'); - fireEvent.mouseUp(button); + await user.pointer({target: button, keys: '[/MouseLeft]'}); expect(button).not.toHaveAttribute('data-pressed'); expect(button).not.toHaveClass('pressed'); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index b3c6df16214..98539d80ee4 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -497,11 +497,11 @@ describe('Tree', () => { expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); expect(row).toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); @@ -510,36 +510,36 @@ describe('Tree', () => { expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).toHaveAttribute('data-pressed', 'true'); expect(row).toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); }); - it('should not update the press state if the row is not interactive', () => { + it('should not update the press state if the row is not interactive', async () => { let {getAllByRole, rerender} = render( isPressed ? 'pressed' : ''}} />); let row = getAllByRole('row')[0]; expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseDown(row); + await user.pointer({target: row, keys: '[MouseLeft>]'}); expect(row).not.toHaveAttribute('data-pressed'); expect(row).not.toHaveClass('pressed'); - fireEvent.mouseUp(row); + await user.pointer({target: row, keys: '[/MouseLeft]'}); let expandableRow = getAllByRole('row')[1]; expect(expandableRow).not.toHaveAttribute('data-pressed'); expect(expandableRow).not.toHaveClass('pressed'); - fireEvent.mouseDown(expandableRow); + await user.pointer({target: expandableRow, keys: '[MouseLeft>]'}); expect(expandableRow).toHaveAttribute('data-pressed', 'true'); expect(expandableRow).toHaveClass('pressed'); - fireEvent.mouseUp(expandableRow); + await user.pointer({target: expandableRow, keys: '[/MouseLeft]'}); expect(expandableRow).not.toHaveAttribute('data-pressed'); expect(expandableRow).not.toHaveClass('pressed'); @@ -550,10 +550,10 @@ describe('Tree', () => { expect(expandableRow).not.toHaveAttribute('data-pressed'); expect(expandableRow).not.toHaveClass('pressed'); - fireEvent.mouseDown(expandableRow); + await user.pointer({target: expandableRow, keys: '[MouseLeft>]'}); expect(expandableRow).not.toHaveAttribute('data-pressed'); expect(expandableRow).not.toHaveClass('pressed'); - fireEvent.mouseUp(expandableRow); + await user.pointer({target: expandableRow, keys: '[/MouseLeft]'}); }); it('should support focus', async () => {