Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: No longer preventDefault in usePress and allow browser to manage focus #7542

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

devongovett
Copy link
Member

@devongovett devongovett commented Dec 20, 2024

Part of #1720

Fixes #5004, fixes #4302, fixes #6618, closes #7448, fixes #5643, fixes #7480, fixes #5522, fixes #4089, fixes #5833, fixes #6512, fixes #4355, fixes #5940, fixes #1926, fixes #1384, closes #1391

This is a major refactor to usePress to improve compatibility with other libraries, improve the way focus is managed, and fix many issues caused by our current approach. 🎄

This is mainly possible because of a change in Safari 17, which enabled buttons and other native inputs to be focused (both via mouse/touch and keyboard tabbing) only when they have an explicit tabIndex set. WebKit/WebKit#12743 We previously worked around this by calling preventDefault and manually managing focus. However, because preventDefault is not granular this had many unintended side effects.

Thanks to the Safari change, we can now let the browser handle focusing elements on press for us. This changes the order that things occur, so we'll need to test carefully. In particular, the element will no longer be focused before onPressStart because onFocus is fired by the browser after onMouseDown. On touch devices, this will happen after the user lifts their finger. This change should fix these issues:

When preventFocusOnPress is set, we allow the browser to focus, but handle the event at a window-level capturing listener, and immediately revert it and stop propagation to make the event non-observable to child elements.

We also now delay firing onPress until the onClick event instead of firing it during onPointerUp. This fixes many issues:

We will need to evaluate how much of a breaking change this is. Outside usePress itself, the main changes were for tests, which now must actually trigger the click event and not just pointerdown/pointerup. Switching to user-event fixes this for the most part. We also have to deal with some assumptions that focus will occur before onPressStart, such as in useMenuTrigger which now uses preventFocusOnPress and manually focuses its trigger prior to opening the popover so that it can be restored properly. This only happens for mouse interactions.

@rspbot
Copy link

rspbot commented Dec 20, 2024

export function isFocusable(element: HTMLElement) {
return element.matches(FOCUSABLE_ELEMENT_SELECTOR);
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to @react-aria/utils to avoid creating a circular dependency when I import it in @react-aria/interactions.

// 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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't reproduce the original issue anymore. iOS VO uses DOM focus now. Not sure if we need to worry about this elsewhere. If so, it should probably be done e.g. in dialog trigger instead of here so it doesn't affect other things like submitting a form.

@@ -544,18 +536,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.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next step will be to make this branch development only, and possibly get rid of the touch event handling entirely (since most people write their tests using mouse events).

checkSelection(onSelectionChange, ['Foo 10']);
// screen reader automatically handles this one
expect(announce).not.toHaveBeenCalled();
expect(announce).toHaveBeenCalledWith('Foo 10 selected.');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was wrong before. The announcement was not fired because the table never had focus.

@rspbot
Copy link

rspbot commented Dec 21, 2024

@rspbot
Copy link

rspbot commented Dec 21, 2024

## API Changes

@react-aria/focus

/@react-aria/focus:isFocusable

 isFocusable {
-  element: HTMLElement
+  element: Element
   returnVal: undefined
 }

@react-aria/test-utils

/@react-aria/test-utils:triggerLongPress

 triggerLongPress {
   opts: {
     element: HTMLElement
   advanceTimer: (number) => void | Promise<unknown>
-  pointerOpts?: {
-  
+  pointerOpts?: Record<string, any>
 }
-}
   returnVal: undefined
 }

@react-aria/utils

/@react-aria/utils:isFocusable

+isFocusable {
+  element: Element
+  returnVal: undefined
+}

/@react-aria/utils:isTabbable

+isTabbable {
+  element: Element
+  returnVal: undefined
+}

@react-spectrum/test-utils

/@react-spectrum/test-utils:triggerLongPress

 triggerLongPress {
   opts: {
     element: HTMLElement
   advanceTimer: (number) => void | Promise<unknown>
-  pointerOpts?: {
-  
+  pointerOpts?: Record<string, any>
 }
-}
   returnVal: undefined
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment