Skip to content

Commit 55aca80

Browse files
authored
Fix onPress when used with onKeyUp on a button (#5554)
* Fix button stuck in pressed state
1 parent cd91f8a commit 55aca80

File tree

5 files changed

+45
-16
lines changed

5 files changed

+45
-16
lines changed

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
// NOTICE file in the root directory of this source tree.
1616
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
1717

18+
import {chain, focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils';
1819
import {disableTextSelection, restoreTextSelection} from './textSelection';
1920
import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents} from '@react-types/shared';
20-
import {focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils';
2121
import {PressResponderContext} from './context';
2222
import {RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react';
2323

@@ -270,8 +270,16 @@ export function usePress(props: PressHookProps): PressResult {
270270
shouldStopPropagation = triggerPressStart(e, 'keyboard');
271271

272272
// Focus may move before the key up event, so register the event on the document
273-
// instead of the same element where the key down event occurred.
274-
addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', onKeyUp, false);
273+
// instead of the same element where the key down event occurred. Make it capturing so that it will trigger
274+
// before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element.
275+
let originalTarget = e.currentTarget;
276+
let pressUp = (e) => {
277+
if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && originalTarget.contains(e.target as Element) && state.target) {
278+
triggerPressUp(createEvent(state.target, e), 'keyboard');
279+
}
280+
};
281+
282+
addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true);
275283
}
276284

277285
if (shouldStopPropagation) {
@@ -292,11 +300,6 @@ export function usePress(props: PressHookProps): PressResult {
292300
state.metaKeyEvents = new Map();
293301
}
294302
},
295-
onKeyUp(e) {
296-
if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && !e.repeat && e.currentTarget.contains(e.target as Element) && state.target) {
297-
triggerPressUp(createEvent(state.target, e), 'keyboard');
298-
}
299-
},
300303
onClick(e) {
301304
if (e && !e.currentTarget.contains(e.target as Element)) {
302305
return;
@@ -338,13 +341,9 @@ export function usePress(props: PressHookProps): PressResult {
338341
}
339342

340343
let target = e.target as Element;
341-
let shouldStopPropagation = triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
344+
triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
342345
removeAllGlobalListeners();
343346

344-
if (shouldStopPropagation) {
345-
e.stopPropagation();
346-
}
347-
348347
// If a link was triggered with a key other than Enter, open the URL ourselves.
349348
// This means the link has a role override, and the default browser behavior
350349
// only applies when using the Enter key.

packages/@react-aria/interactions/test/usePress.test.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1789,7 +1789,6 @@ describe('usePress', function () {
17891789
let el = getByText('test');
17901790
fireEvent.keyDown(el, {key: ' '});
17911791
fireEvent.keyUp(el, {key: ' '});
1792-
17931792
expect(events).toEqual([
17941793
{
17951794
type: 'pressstart',

packages/@react-spectrum/button/stories/Button.stories.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ export default {
3131
onPress: action('press'),
3232
onPressStart: action('pressstart'),
3333
onPressEnd: action('pressend'),
34+
onPressChange: action('presschange'),
3435
onPressUp: action('pressup'),
3536
onFocus: action('focus'),
36-
onBlur: action('blur')
37+
onBlur: action('blur'),
38+
onKeyUp: action('keyup')
3739
},
3840
argTypes: {
3941
onPress: {

packages/@react-spectrum/button/test/Button.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,34 @@ describe('Button', function () {
7878
expect(text).not.toBeNull();
7979
});
8080

81+
it.each`
82+
Name | Component | props
83+
${'ActionButton'} | ${ActionButton}| ${{onPress: onPressSpy, onPressStart: onPressStartSpy, onPressEnd: onPressEndSpy, onPressUp: onPressUpSpy, onPressChange: onPressChangeSpy}}
84+
${'Button'} | ${Button} | ${{onPress: onPressSpy, onPressStart: onPressStartSpy, onPressEnd: onPressEndSpy, onPressUp: onPressUpSpy, onPressChange: onPressChangeSpy}}
85+
${'LogicButton'} | ${LogicButton} | ${{onPress: onPressSpy, onPressStart: onPressStartSpy, onPressEnd: onPressEndSpy, onPressUp: onPressUpSpy, onPressChange: onPressChangeSpy}}
86+
`('$Name keyboard press and key events', async function ({Component, props}) {
87+
let onKeyDownSpy = jest.fn();
88+
let onKeyUpSpy = jest.fn();
89+
let {getByRole} = render(<Component {...props} onKeyDown={onKeyDownSpy} onKeyUp={onKeyUpSpy}>Click Me</Component>);
90+
91+
let button = getByRole('button');
92+
await user.tab();
93+
expect(onKeyUpSpy).toHaveBeenCalledTimes(1);
94+
expect(document.activeElement).toBe(button);
95+
await user.keyboard('{Enter}');
96+
expect(onPressStartSpy).toHaveBeenCalledTimes(1);
97+
expect(onPressSpy).toHaveBeenCalledTimes(1);
98+
expect(onPressEndSpy).toHaveBeenCalledTimes(1);
99+
expect(onPressUpSpy).toHaveBeenCalledTimes(1);
100+
expect(onPressChangeSpy).toHaveBeenCalledTimes(2);
101+
expect(onKeyDownSpy).toHaveBeenCalledTimes(1);
102+
expect(onKeyUpSpy).toHaveBeenCalledTimes(2);
103+
104+
await user.keyboard('A');
105+
expect(onKeyDownSpy).toHaveBeenCalledTimes(2);
106+
expect(onKeyUpSpy).toHaveBeenCalledTimes(3);
107+
});
108+
81109
it.each`
82110
Name | Component
83111
${'ActionButton'} | ${ActionButton}

packages/react-aria-components/stories/ToggleButton.stories.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {action} from '@storybook/addon-actions';
1314
import {classNames} from '@react-spectrum/utils';
1415
import React from 'react';
1516
import styles from '../example/index.css';
@@ -21,6 +22,6 @@ export default {
2122

2223
export const ToggleButtonExample = () => {
2324
return (
24-
<ToggleButton className={classNames(styles, 'toggleButtonExample')} data-testid="toggle-button-example">Toggle</ToggleButton>
25+
<ToggleButton className={classNames(styles, 'toggleButtonExample')} data-testid="toggle-button-example" onKeyUp={action('keyup')} onPress={action('press')}>Toggle</ToggleButton>
2526
);
2627
};

0 commit comments

Comments
 (0)