Skip to content

Commit 9691eb2

Browse files
authored
[react-events] Keyboard support for virtual clicks (#16780)
This accounts for all clicks that are natively dispatched following relevant keyboard interactions (e.g., key is "Enter"), as well as programmatic clicks, and screen-reader virtual clicks.
1 parent b8d079b commit 9691eb2

File tree

3 files changed

+137
-27
lines changed

3 files changed

+137
-27
lines changed

packages/react-events/src/dom/Keyboard.js

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@ import type {
1111
ReactDOMResponderEvent,
1212
ReactDOMResponderContext,
1313
} from 'shared/ReactDOMTypes';
14+
import type {ReactEventResponderListener} from 'shared/ReactTypes';
1415

1516
import React from 'react';
1617
import {DiscreteEvent} from 'shared/ReactTypes';
17-
import type {ReactEventResponderListener} from 'shared/ReactTypes';
18+
import {isVirtualClick} from './shared';
1819

19-
type KeyboardEventType = 'keyboard:keydown' | 'keyboard:keyup';
20+
type KeyboardEventType =
21+
| 'keyboard:click'
22+
| 'keyboard:keydown'
23+
| 'keyboard:keyup';
2024

2125
type KeyboardProps = {|
2226
disabled?: boolean,
27+
onClick?: (e: KeyboardEvent) => ?boolean,
2328
onKeyDown?: (e: KeyboardEvent) => ?boolean,
2429
onKeyUp?: (e: KeyboardEvent) => ?boolean,
2530
preventKeys?: PreventKeysArray,
@@ -34,8 +39,8 @@ export type KeyboardEvent = {|
3439
altKey: boolean,
3540
ctrlKey: boolean,
3641
defaultPrevented: boolean,
37-
isComposing: boolean,
38-
key: string,
42+
isComposing?: boolean,
43+
key?: string,
3944
metaKey: boolean,
4045
pointerType: 'keyboard',
4146
shiftKey: boolean,
@@ -120,10 +125,6 @@ const translateToKey = {
120125
'224': 'Meta',
121126
};
122127

123-
function isFunction(obj): boolean {
124-
return typeof obj === 'function';
125-
}
126-
127128
function getEventKey(nativeEvent: Object): string {
128129
const nativeKey = nativeEvent.key;
129130
if (nativeKey) {
@@ -147,21 +148,24 @@ function createKeyboardEvent(
147148
defaultPrevented: boolean,
148149
): KeyboardEvent {
149150
const nativeEvent = (event: any).nativeEvent;
150-
const {altKey, ctrlKey, isComposing, metaKey, shiftKey} = nativeEvent;
151-
152-
return {
151+
const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
152+
let keyboardEvent = {
153153
altKey,
154154
ctrlKey,
155155
defaultPrevented,
156-
isComposing,
157-
key: getEventKey(nativeEvent),
158156
metaKey,
159157
pointerType: 'keyboard',
160158
shiftKey,
161159
target: event.target,
162160
timeStamp: context.getTimeStamp(),
163161
type,
164162
};
163+
if (type !== 'keyboard:click') {
164+
const key = getEventKey(nativeEvent);
165+
const isComposing = nativeEvent.isComposing;
166+
keyboardEvent = context.objectAssign({isComposing, key}, keyboardEvent);
167+
}
168+
return keyboardEvent;
165169
}
166170

167171
function dispatchKeyboardEvent(
@@ -242,7 +246,7 @@ const keyboardResponderImpl = {
242246
}
243247
state.isActive = true;
244248
const onKeyDown = props.onKeyDown;
245-
if (isFunction(onKeyDown)) {
249+
if (onKeyDown != null) {
246250
dispatchKeyboardEvent(
247251
event,
248252
((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
@@ -251,13 +255,25 @@ const keyboardResponderImpl = {
251255
state.defaultPrevented,
252256
);
253257
}
254-
} else if (type === 'click' && state.isActive && state.defaultPrevented) {
255-
// 'click' occurs before 'keyup' and may need native behavior prevented
256-
nativeEvent.preventDefault();
258+
} else if (type === 'click' && isVirtualClick(event)) {
259+
const onClick = props.onClick;
260+
if (onClick != null) {
261+
dispatchKeyboardEvent(
262+
event,
263+
onClick,
264+
context,
265+
'keyboard:click',
266+
state.defaultPrevented,
267+
);
268+
}
269+
if (state.defaultPrevented && !nativeEvent.defaultPrevented) {
270+
// 'click' occurs before 'keyup' and may need native behavior prevented
271+
nativeEvent.preventDefault();
272+
}
257273
} else if (type === 'keyup') {
258274
state.isActive = false;
259275
const onKeyUp = props.onKeyUp;
260-
if (isFunction(onKeyUp)) {
276+
if (onKeyUp != null) {
261277
dispatchKeyboardEvent(
262278
event,
263279
((onKeyUp: any): (e: KeyboardEvent) => ?boolean),

packages/react-events/src/dom/__tests__/Keyboard-test.internal.js

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,21 @@ describe('Keyboard responder', () => {
4141
});
4242

4343
function renderPropagationTest(propagates) {
44+
const onClickInner = jest.fn(() => propagates);
4445
const onKeyDownInner = jest.fn(() => propagates);
45-
const onKeyDownOuter = jest.fn();
4646
const onKeyUpInner = jest.fn(() => propagates);
47+
const onClickOuter = jest.fn();
48+
const onKeyDownOuter = jest.fn();
4749
const onKeyUpOuter = jest.fn();
4850
const ref = React.createRef();
4951
const Component = () => {
5052
const listenerInner = useKeyboard({
53+
onClick: onClickInner,
5154
onKeyDown: onKeyDownInner,
5255
onKeyUp: onKeyUpInner,
5356
});
5457
const listenerOuter = useKeyboard({
58+
onClick: onClickOuter,
5559
onKeyDown: onKeyDownOuter,
5660
onKeyUp: onKeyUpOuter,
5761
});
@@ -63,19 +67,23 @@ describe('Keyboard responder', () => {
6367
};
6468
ReactDOM.render(<Component />, container);
6569
return {
70+
onClickInner,
6671
onKeyDownInner,
67-
onKeyDownOuter,
6872
onKeyUpInner,
73+
onClickOuter,
74+
onKeyDownOuter,
6975
onKeyUpOuter,
7076
ref,
7177
};
7278
}
7379

74-
test('propagates event when a callback returns true', () => {
80+
test('propagates key event when a callback returns true', () => {
7581
const {
82+
onClickInner,
7683
onKeyDownInner,
77-
onKeyDownOuter,
7884
onKeyUpInner,
85+
onClickOuter,
86+
onKeyDownOuter,
7987
onKeyUpOuter,
8088
ref,
8189
} = renderPropagationTest(true);
@@ -86,13 +94,18 @@ describe('Keyboard responder', () => {
8694
target.keyup();
8795
expect(onKeyUpInner).toBeCalled();
8896
expect(onKeyUpOuter).toBeCalled();
97+
target.virtualclick();
98+
expect(onClickInner).toBeCalled();
99+
expect(onClickOuter).toBeCalled();
89100
});
90101

91-
test('does not propagate event when a callback returns false', () => {
102+
test('does not propagate key event when a callback returns false', () => {
92103
const {
104+
onClickInner,
93105
onKeyDownInner,
94-
onKeyDownOuter,
95106
onKeyUpInner,
107+
onClickOuter,
108+
onKeyDownOuter,
96109
onKeyUpOuter,
97110
ref,
98111
} = renderPropagationTest(false);
@@ -103,6 +116,9 @@ describe('Keyboard responder', () => {
103116
target.keyup();
104117
expect(onKeyUpInner).toBeCalled();
105118
expect(onKeyUpOuter).not.toBeCalled();
119+
target.virtualclick();
120+
expect(onClickInner).toBeCalled();
121+
expect(onClickOuter).not.toBeCalled();
106122
});
107123

108124
describe('disabled', () => {
@@ -128,6 +144,64 @@ describe('Keyboard responder', () => {
128144
});
129145
});
130146

147+
describe('onClick', () => {
148+
let onClick, ref;
149+
150+
beforeEach(() => {
151+
onClick = jest.fn();
152+
ref = React.createRef();
153+
const Component = () => {
154+
const listener = useKeyboard({onClick});
155+
return <div ref={ref} listeners={listener} />;
156+
};
157+
ReactDOM.render(<Component />, container);
158+
});
159+
160+
// e.g, "Enter" on link
161+
test('keyboard click is between key events', () => {
162+
const target = createEventTarget(ref.current);
163+
target.keydown({key: 'Enter'});
164+
target.keyup({key: 'Enter'});
165+
target.virtualclick();
166+
expect(onClick).toHaveBeenCalledTimes(1);
167+
expect(onClick).toHaveBeenCalledWith(
168+
expect.objectContaining({
169+
altKey: false,
170+
ctrlKey: false,
171+
defaultPrevented: false,
172+
metaKey: false,
173+
pointerType: 'keyboard',
174+
shiftKey: false,
175+
target: target.node,
176+
timeStamp: expect.any(Number),
177+
type: 'keyboard:click',
178+
}),
179+
);
180+
});
181+
182+
// e.g., "Spacebar" on button
183+
test('keyboard click is after key events', () => {
184+
const target = createEventTarget(ref.current);
185+
target.keydown({key: 'Enter'});
186+
target.keyup({key: 'Enter'});
187+
target.virtualclick();
188+
expect(onClick).toHaveBeenCalledTimes(1);
189+
expect(onClick).toHaveBeenCalledWith(
190+
expect.objectContaining({
191+
altKey: false,
192+
ctrlKey: false,
193+
defaultPrevented: false,
194+
metaKey: false,
195+
pointerType: 'keyboard',
196+
shiftKey: false,
197+
target: target.node,
198+
timeStamp: expect.any(Number),
199+
type: 'keyboard:click',
200+
}),
201+
);
202+
});
203+
});
204+
131205
describe('onKeyDown', () => {
132206
let onKeyDown, ref;
133207

@@ -271,7 +345,7 @@ describe('Keyboard responder', () => {
271345

272346
const target = createEventTarget(ref.current);
273347
target.keydown({key: 'Tab', preventDefault});
274-
target.click({preventDefault: preventDefaultClick});
348+
target.virtualclick({preventDefault: preventDefaultClick});
275349

276350
expect(onKeyDown).toHaveBeenCalledTimes(1);
277351
expect(preventDefault).toBeCalled();
@@ -293,7 +367,10 @@ describe('Keyboard responder', () => {
293367

294368
const target = createEventTarget(ref.current);
295369
target.keydown({key: 'Tab', preventDefault, shiftKey: true});
296-
target.click({preventDefault: preventDefaultClick, shiftKey: true});
370+
target.virtualclick({
371+
preventDefault: preventDefaultClick,
372+
shiftKey: true,
373+
});
297374

298375
expect(onKeyDown).toHaveBeenCalledTimes(1);
299376
expect(preventDefault).toBeCalled();
@@ -316,7 +393,10 @@ describe('Keyboard responder', () => {
316393

317394
const target = createEventTarget(ref.current);
318395
target.keydown({key: 'Tab', preventDefault, shiftKey: false});
319-
target.click({preventDefault: preventDefaultClick, shiftKey: false});
396+
target.virtualclick({
397+
preventDefault: preventDefaultClick,
398+
shiftKey: false,
399+
});
320400

321401
expect(onKeyDown).toHaveBeenCalledTimes(1);
322402
expect(preventDefault).not.toBeCalled();

packages/react-events/src/dom/shared/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,17 @@ export function hasModifierKey(event: ReactDOMResponderEvent): boolean {
7070
altKey === true || ctrlKey === true || metaKey === true || shiftKey === true
7171
);
7272
}
73+
74+
// Keyboards, Assitive Technologies, and element.click() all produce "virtual"
75+
// clicks that do not include coordinates and "detail" is always 0 (where
76+
// pointer clicks are > 0).
77+
export function isVirtualClick(event: ReactDOMResponderEvent): boolean {
78+
const nativeEvent: any = event.nativeEvent;
79+
return (
80+
nativeEvent.detail === 0 &&
81+
nativeEvent.screenX === 0 &&
82+
nativeEvent.screenY === 0 &&
83+
nativeEvent.clientX === 0 &&
84+
nativeEvent.clientY === 0
85+
);
86+
}

0 commit comments

Comments
 (0)