Skip to content

Commit 2a930b7

Browse files
committed
[fix] Change logic of PressResponder for keyboards
This patch removes the keyboard `onClick` shim from `unstable_createElement` and adds it to the `PressResponder`. Previously, the logic in `createElement` was calling `onClick` during `onKeyDown`. This could result in React state update warnings if the host element was no longer in the DOM during `keyup`. Fix #2051
1 parent 6d7a1ea commit 2a930b7

File tree

5 files changed

+101
-165
lines changed

5 files changed

+101
-165
lines changed

packages/react-native-web/src/exports/Pressable/__tests__/__snapshots__/index-test.js.snap

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,14 @@ exports[`components/Pressable hover interaction 3`] = `
6262
/>
6363
`;
6464

65-
exports[`components/Pressable press interaction 1`] = `
65+
exports[`components/Pressable press interaction (keyboard) 1`] = `
6666
<div
6767
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73"
6868
tabindex="0"
6969
/>
7070
`;
7171

72-
exports[`components/Pressable press interaction 2`] = `
72+
exports[`components/Pressable press interaction (keyboard) 2`] = `
7373
<div
7474
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73"
7575
style="outline: press-ring;"
@@ -81,7 +81,28 @@ exports[`components/Pressable press interaction 2`] = `
8181
</div>
8282
`;
8383

84-
exports[`components/Pressable press interaction 3`] = `
84+
exports[`components/Pressable press interaction (keyboard) 3`] = `null`;
85+
86+
exports[`components/Pressable press interaction (pointer) 1`] = `
87+
<div
88+
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73"
89+
tabindex="0"
90+
/>
91+
`;
92+
93+
exports[`components/Pressable press interaction (pointer) 2`] = `
94+
<div
95+
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73"
96+
style="outline: press-ring;"
97+
tabindex="0"
98+
>
99+
<div
100+
data-testid="press-content"
101+
/>
102+
</div>
103+
`;
104+
105+
exports[`components/Pressable press interaction (pointer) 3`] = `
85106
<div
86107
class="css-view-1dbjc4n r-cursor-1loqt21 r-touchAction-1otgn73"
87108
style=""

packages/react-native-web/src/exports/Pressable/__tests__/index-test.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ describe('components/Pressable', () => {
121121
expect(container.firstChild).toMatchSnapshot();
122122
});
123123

124-
test('press interaction', () => {
124+
test('press interaction (pointer)', () => {
125125
let container;
126126
const onContextMenu = jest.fn();
127127
const onPress = jest.fn();
@@ -162,6 +162,50 @@ describe('components/Pressable', () => {
162162
expect(onContextMenu).toBeCalled();
163163
});
164164

165+
test('press interaction (keyboard)', () => {
166+
let container;
167+
const onPress = jest.fn();
168+
const onPressIn = jest.fn();
169+
const onPressOut = jest.fn();
170+
const ref = React.createRef();
171+
172+
function TestCase() {
173+
const [shown, setShown] = React.useState(true);
174+
return shown ? (
175+
<Pressable
176+
children={({ pressed }) => (pressed ? <div data-testid="press-content" /> : null)}
177+
onPress={(e) => {
178+
onPress(e);
179+
setShown(false);
180+
}}
181+
onPressIn={onPressIn}
182+
onPressOut={onPressOut}
183+
ref={ref}
184+
style={({ pressed }) => [pressed && { outline: 'press-ring' }]}
185+
/>
186+
) : null;
187+
}
188+
189+
act(() => {
190+
({ container } = render(<TestCase />));
191+
});
192+
const target = createEventTarget(ref.current);
193+
expect(container.firstChild).toMatchSnapshot();
194+
act(() => {
195+
target.keydown({ key: 'Enter' });
196+
jest.runAllTimers();
197+
});
198+
expect(onPressIn).toBeCalled();
199+
expect(container.firstChild).toMatchSnapshot();
200+
act(() => {
201+
target.keyup({ key: 'Enter' });
202+
jest.runAllTimers();
203+
});
204+
expect(onPressOut).toBeCalled();
205+
expect(onPress).toBeCalled();
206+
expect(container.firstChild).toMatchSnapshot();
207+
});
208+
165209
describe('prop "ref"', () => {
166210
test('value is set', () => {
167211
const ref = jest.fn();

packages/react-native-web/src/modules/createDOMProps/__tests__/index-test.js

Lines changed: 0 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -95,117 +95,6 @@ describe('modules/createDOMProps', () => {
9595
});
9696
});
9797

98-
describe('prop "onClick"', () => {
99-
const callsOnClick = (component, accessibilityRole, accessibilityDisabled = false) => {
100-
const onClick = jest.fn();
101-
const event = { stopPropagation: jest.fn() };
102-
const finalProps = createDOMProps(component, {
103-
accessibilityRole,
104-
accessibilityDisabled,
105-
onClick
106-
});
107-
finalProps.onClick(event);
108-
return onClick.mock.calls.length === 1;
109-
};
110-
111-
test('is called for various roles', () => {
112-
expect(callsOnClick('div', 'link')).toBe(true);
113-
expect(callsOnClick('div', 'button')).toBe(true);
114-
expect(callsOnClick('div', 'textbox')).toBe(true);
115-
expect(callsOnClick('div', 'bogus')).toBe(true);
116-
expect(callsOnClick('a')).toBe(true);
117-
expect(callsOnClick('button')).toBe(true);
118-
expect(callsOnClick('input')).toBe(true);
119-
expect(callsOnClick('select')).toBe(true);
120-
expect(callsOnClick('textarea')).toBe(true);
121-
expect(callsOnClick('h1')).toBe(true);
122-
});
123-
124-
test('is not called when disabled is true', () => {
125-
expect(callsOnClick('div', 'link', true)).toBe(false);
126-
expect(callsOnClick('div', 'button', true)).toBe(false);
127-
expect(callsOnClick('a', undefined, true)).toBe(false);
128-
expect(callsOnClick('button', undefined, true)).toBe(false);
129-
expect(callsOnClick('input', undefined, true)).toBe(false);
130-
expect(callsOnClick('select', undefined, true)).toBe(false);
131-
expect(callsOnClick('textarea', undefined, true)).toBe(false);
132-
133-
expect(callsOnClick('div', 'textbox', true)).toBe(true);
134-
expect(callsOnClick('div', 'bogus', true)).toBe(true);
135-
expect(callsOnClick('h1', undefined, true)).toBe(true);
136-
});
137-
});
138-
139-
describe('prop "onKeyDown"', () => {
140-
const callsOnClick = (key) => (component, accessibilityRole, accessibilityDisabled = false) => {
141-
const onClick = jest.fn();
142-
const onKeyDown = jest.fn();
143-
const event = { key, preventDefault: jest.fn() };
144-
const finalProps = createDOMProps(component, {
145-
accessibilityRole,
146-
accessibilityDisabled,
147-
onClick,
148-
onKeyDown
149-
});
150-
finalProps.onKeyDown(event);
151-
// The original onKeyDown should always be called
152-
expect(onKeyDown).toHaveBeenCalled();
153-
return onClick.mock.calls.length === 1;
154-
};
155-
156-
const respondsToEnter = callsOnClick('Enter');
157-
const respondsToSpace = callsOnClick(' ');
158-
159-
test('does not emulate "onClick" when disabled', () => {
160-
expect(respondsToEnter('div', 'link', true)).toBe(false);
161-
expect(respondsToEnter('div', 'button', true)).toBe(false);
162-
expect(respondsToEnter('div', 'textbox', true)).toBe(false);
163-
expect(respondsToEnter('div', 'bogus', true)).toBe(false);
164-
});
165-
166-
test('does not emulate "onClick" for native elements', () => {
167-
expect(respondsToEnter('a')).toBe(false);
168-
expect(respondsToEnter('button')).toBe(false);
169-
expect(respondsToEnter('input')).toBe(false);
170-
expect(respondsToEnter('select')).toBe(false);
171-
expect(respondsToEnter('textarea')).toBe(false);
172-
expect(respondsToEnter('h1')).toBe(false);
173-
expect(respondsToEnter('div', 'link')).toBe(false);
174-
175-
expect(respondsToSpace('a')).toBe(false);
176-
expect(respondsToSpace('button')).toBe(false);
177-
expect(respondsToSpace('input')).toBe(false);
178-
expect(respondsToSpace('select')).toBe(false);
179-
expect(respondsToSpace('textarea')).toBe(false);
180-
expect(respondsToSpace('h1')).toBe(false);
181-
expect(respondsToSpace('div', 'link')).toBe(false);
182-
});
183-
184-
test('emulates "onClick" for "Enter" for certain roles', () => {
185-
expect(respondsToEnter('div', 'button')).toBe(true);
186-
expect(respondsToEnter('div', 'textbox')).toBe(false);
187-
expect(respondsToEnter('div', 'bogus')).toBe(false);
188-
});
189-
190-
test('emulates "onClick" for "Enter" for items marked accessible', () => {
191-
const onClick = jest.fn();
192-
const event = { key: 'Enter', preventDefault: jest.fn() };
193-
const finalProps = createDOMProps('div', {
194-
accessibilityRole: 'article',
195-
focusable: true,
196-
onClick
197-
});
198-
finalProps.onKeyDown(event);
199-
expect(onClick).toHaveBeenCalled();
200-
});
201-
202-
test('emulates "onClick" for "Space" for certain roles', () => {
203-
expect(respondsToSpace('div', 'button')).toBe(true);
204-
expect(respondsToSpace('div', 'textbox')).toBe(false);
205-
expect(respondsToSpace('div', 'bogus')).toBe(false);
206-
});
207-
});
208-
20998
test('prop "accessibilityLabel" becomes "aria-label"', () => {
21099
const accessibilityLabel = 'accessibilityLabel';
211100
const props = createProps({ accessibilityLabel });

packages/react-native-web/src/modules/createDOMProps/index.js

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,6 @@ const createDOMProps = (elementType, props) => {
136136

137137
const role = AccessibilityUtil.propsToAriaRole(props);
138138

139-
const isNativeInteractiveElement =
140-
role === 'link' ||
141-
elementType === 'a' ||
142-
elementType === 'button' ||
143-
elementType === 'input' ||
144-
elementType === 'select' ||
145-
elementType === 'textarea' ||
146-
domProps.contentEditable != null;
147-
148139
// DEPRECATED
149140
if (accessibilityState != null) {
150141
for (const prop in accessibilityState) {
@@ -417,42 +408,6 @@ const createDOMProps = (elementType, props) => {
417408
domProps['data-testid'] = testID;
418409
}
419410

420-
// Keyboard accessibility
421-
// Button-like roles should trigger 'onClick' if SPACE key is pressed.
422-
// Button-like roles should not trigger 'onClick' if they are disabled.
423-
if (isNativeInteractiveElement || role === 'button' || (_focusable === true && !disabled)) {
424-
const onClick = domProps.onClick;
425-
if (onClick != null) {
426-
if (disabled) {
427-
// Prevent click propagating if the element is disabled. See #1757
428-
domProps.onClick = function (e) {
429-
e.stopPropagation();
430-
};
431-
} else if (!isNativeInteractiveElement) {
432-
// For native elements that are focusable but don't dispatch 'click' events
433-
// for keyboards.
434-
const onKeyDown = domProps.onKeyDown;
435-
domProps.onKeyDown = function (e) {
436-
const { key, repeat } = e;
437-
const isSpacebarKey = key === ' ' || key === 'Spacebar';
438-
const isButtonRole = role === 'button';
439-
if (onKeyDown != null) {
440-
onKeyDown(e);
441-
}
442-
if (!repeat && key === 'Enter') {
443-
onClick(e);
444-
} else if (isSpacebarKey && isButtonRole) {
445-
if (!repeat) {
446-
onClick(e);
447-
}
448-
// Prevent spacebar scrolling the window
449-
e.preventDefault();
450-
}
451-
};
452-
}
453-
}
454-
}
455-
456411
return domProps;
457412
};
458413

packages/react-native-web/src/modules/usePressEvents/PressResponder.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,11 @@ const isTerminalSignal = (signal) =>
130130
signal === RESPONDER_TERMINATED || signal === RESPONDER_RELEASE;
131131

132132
const isValidKeyPress = (event) => {
133-
const key = event.key;
134-
const target = event.currentTarget;
133+
const { key, target } = event;
135134
const role = target.getAttribute('role');
136135
const isSpacebar = key === ' ' || key === 'Spacebar';
137-
return !event.repeat && (key === 'Enter' || (isSpacebar && role === 'button'));
136+
137+
return key === 'Enter' || (isSpacebar && role === 'button');
138138
};
139139

140140
const DEFAULT_LONG_PRESS_DELAY_MS = 450; // 500 - 50
@@ -298,9 +298,27 @@ export default class PressResponder {
298298
};
299299

300300
const keyupHandler = (event: KeyboardEvent) => {
301-
if (this._touchState !== NOT_RESPONDER) {
301+
const { onPress } = this._config;
302+
const { target } = event;
303+
304+
if (this._touchState !== NOT_RESPONDER && isValidKeyPress(event)) {
302305
end(event);
303306
document.removeEventListener('keyup', keyupHandler);
307+
308+
const role = target.getAttribute('role');
309+
const elementType = target.tagName.toLowerCase();
310+
311+
const isNativeInteractiveElement =
312+
role === 'link' ||
313+
elementType === 'a' ||
314+
elementType === 'button' ||
315+
elementType === 'input' ||
316+
elementType === 'select' ||
317+
elementType === 'textarea';
318+
319+
if (onPress != null && !isNativeInteractiveElement) {
320+
onPress(event);
321+
}
304322
}
305323
};
306324

@@ -317,13 +335,22 @@ export default class PressResponder {
317335
},
318336

319337
onKeyDown: (event) => {
320-
if (isValidKeyPress(event)) {
338+
const { disabled } = this._config;
339+
const { key, target } = event;
340+
if (!disabled && isValidKeyPress(event)) {
321341
if (this._touchState === NOT_RESPONDER) {
322342
start(event, false);
323343
// Listen to 'keyup' on document to account for situations where
324344
// focus is moved to another element during 'keydown'.
325345
document.addEventListener('keyup', keyupHandler);
326346
}
347+
const role = target.getAttribute('role');
348+
const isSpacebarKey = key === ' ' || key === 'Spacebar';
349+
const isButtonRole = role === 'button' || role === 'menuitem';
350+
if (isSpacebarKey && isButtonRole) {
351+
// Prevent spacebar scrolling the window
352+
event.preventDefault();
353+
}
327354
event.stopPropagation();
328355
}
329356
},

0 commit comments

Comments
 (0)