diff --git a/packages/react-events/src/dom/Keyboard.js b/packages/react-events/src/dom/Keyboard.js index 17da68bc6681d..9ccad91b31d3f 100644 --- a/packages/react-events/src/dom/Keyboard.js +++ b/packages/react-events/src/dom/Keyboard.js @@ -11,15 +11,20 @@ import type { ReactDOMResponderEvent, ReactDOMResponderContext, } from 'shared/ReactDOMTypes'; +import type {ReactEventResponderListener} from 'shared/ReactTypes'; import React from 'react'; import {DiscreteEvent} from 'shared/ReactTypes'; -import type {ReactEventResponderListener} from 'shared/ReactTypes'; +import {isVirtualClick} from './shared'; -type KeyboardEventType = 'keyboard:keydown' | 'keyboard:keyup'; +type KeyboardEventType = + | 'keyboard:click' + | 'keyboard:keydown' + | 'keyboard:keyup'; type KeyboardProps = {| disabled?: boolean, + onClick?: (e: KeyboardEvent) => ?boolean, onKeyDown?: (e: KeyboardEvent) => ?boolean, onKeyUp?: (e: KeyboardEvent) => ?boolean, preventKeys?: PreventKeysArray, @@ -34,8 +39,8 @@ export type KeyboardEvent = {| altKey: boolean, ctrlKey: boolean, defaultPrevented: boolean, - isComposing: boolean, - key: string, + isComposing?: boolean, + key?: string, metaKey: boolean, pointerType: 'keyboard', shiftKey: boolean, @@ -120,10 +125,6 @@ const translateToKey = { '224': 'Meta', }; -function isFunction(obj): boolean { - return typeof obj === 'function'; -} - function getEventKey(nativeEvent: Object): string { const nativeKey = nativeEvent.key; if (nativeKey) { @@ -147,14 +148,11 @@ function createKeyboardEvent( defaultPrevented: boolean, ): KeyboardEvent { const nativeEvent = (event: any).nativeEvent; - const {altKey, ctrlKey, isComposing, metaKey, shiftKey} = nativeEvent; - - return { + const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent; + let keyboardEvent = { altKey, ctrlKey, defaultPrevented, - isComposing, - key: getEventKey(nativeEvent), metaKey, pointerType: 'keyboard', shiftKey, @@ -162,6 +160,12 @@ function createKeyboardEvent( timeStamp: context.getTimeStamp(), type, }; + if (type !== 'keyboard:click') { + const key = getEventKey(nativeEvent); + const isComposing = nativeEvent.isComposing; + keyboardEvent = context.objectAssign({isComposing, key}, keyboardEvent); + } + return keyboardEvent; } function dispatchKeyboardEvent( @@ -242,7 +246,7 @@ const keyboardResponderImpl = { } state.isActive = true; const onKeyDown = props.onKeyDown; - if (isFunction(onKeyDown)) { + if (onKeyDown != null) { dispatchKeyboardEvent( event, ((onKeyDown: any): (e: KeyboardEvent) => ?boolean), @@ -251,13 +255,25 @@ const keyboardResponderImpl = { state.defaultPrevented, ); } - } else if (type === 'click' && state.isActive && state.defaultPrevented) { - // 'click' occurs before 'keyup' and may need native behavior prevented - nativeEvent.preventDefault(); + } else if (type === 'click' && isVirtualClick(event)) { + const onClick = props.onClick; + if (onClick != null) { + dispatchKeyboardEvent( + event, + onClick, + context, + 'keyboard:click', + state.defaultPrevented, + ); + } + if (state.defaultPrevented && !nativeEvent.defaultPrevented) { + // 'click' occurs before 'keyup' and may need native behavior prevented + nativeEvent.preventDefault(); + } } else if (type === 'keyup') { state.isActive = false; const onKeyUp = props.onKeyUp; - if (isFunction(onKeyUp)) { + if (onKeyUp != null) { dispatchKeyboardEvent( event, ((onKeyUp: any): (e: KeyboardEvent) => ?boolean), diff --git a/packages/react-events/src/dom/__tests__/Keyboard-test.internal.js b/packages/react-events/src/dom/__tests__/Keyboard-test.internal.js index 237d35df009a0..e49a57c5d2c91 100644 --- a/packages/react-events/src/dom/__tests__/Keyboard-test.internal.js +++ b/packages/react-events/src/dom/__tests__/Keyboard-test.internal.js @@ -41,17 +41,21 @@ describe('Keyboard responder', () => { }); function renderPropagationTest(propagates) { + const onClickInner = jest.fn(() => propagates); const onKeyDownInner = jest.fn(() => propagates); - const onKeyDownOuter = jest.fn(); const onKeyUpInner = jest.fn(() => propagates); + const onClickOuter = jest.fn(); + const onKeyDownOuter = jest.fn(); const onKeyUpOuter = jest.fn(); const ref = React.createRef(); const Component = () => { const listenerInner = useKeyboard({ + onClick: onClickInner, onKeyDown: onKeyDownInner, onKeyUp: onKeyUpInner, }); const listenerOuter = useKeyboard({ + onClick: onClickOuter, onKeyDown: onKeyDownOuter, onKeyUp: onKeyUpOuter, }); @@ -63,19 +67,23 @@ describe('Keyboard responder', () => { }; ReactDOM.render(, container); return { + onClickInner, onKeyDownInner, - onKeyDownOuter, onKeyUpInner, + onClickOuter, + onKeyDownOuter, onKeyUpOuter, ref, }; } - test('propagates event when a callback returns true', () => { + test('propagates key event when a callback returns true', () => { const { + onClickInner, onKeyDownInner, - onKeyDownOuter, onKeyUpInner, + onClickOuter, + onKeyDownOuter, onKeyUpOuter, ref, } = renderPropagationTest(true); @@ -86,13 +94,18 @@ describe('Keyboard responder', () => { target.keyup(); expect(onKeyUpInner).toBeCalled(); expect(onKeyUpOuter).toBeCalled(); + target.virtualclick(); + expect(onClickInner).toBeCalled(); + expect(onClickOuter).toBeCalled(); }); - test('does not propagate event when a callback returns false', () => { + test('does not propagate key event when a callback returns false', () => { const { + onClickInner, onKeyDownInner, - onKeyDownOuter, onKeyUpInner, + onClickOuter, + onKeyDownOuter, onKeyUpOuter, ref, } = renderPropagationTest(false); @@ -103,6 +116,9 @@ describe('Keyboard responder', () => { target.keyup(); expect(onKeyUpInner).toBeCalled(); expect(onKeyUpOuter).not.toBeCalled(); + target.virtualclick(); + expect(onClickInner).toBeCalled(); + expect(onClickOuter).not.toBeCalled(); }); describe('disabled', () => { @@ -128,6 +144,64 @@ describe('Keyboard responder', () => { }); }); + describe('onClick', () => { + let onClick, ref; + + beforeEach(() => { + onClick = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = useKeyboard({onClick}); + return
; + }; + ReactDOM.render(, container); + }); + + // e.g, "Enter" on link + test('keyboard click is between key events', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.keyup({key: 'Enter'}); + target.virtualclick(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + defaultPrevented: false, + metaKey: false, + pointerType: 'keyboard', + shiftKey: false, + target: target.node, + timeStamp: expect.any(Number), + type: 'keyboard:click', + }), + ); + }); + + // e.g., "Spacebar" on button + test('keyboard click is after key events', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.keyup({key: 'Enter'}); + target.virtualclick(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + defaultPrevented: false, + metaKey: false, + pointerType: 'keyboard', + shiftKey: false, + target: target.node, + timeStamp: expect.any(Number), + type: 'keyboard:click', + }), + ); + }); + }); + describe('onKeyDown', () => { let onKeyDown, ref; @@ -271,7 +345,7 @@ describe('Keyboard responder', () => { const target = createEventTarget(ref.current); target.keydown({key: 'Tab', preventDefault}); - target.click({preventDefault: preventDefaultClick}); + target.virtualclick({preventDefault: preventDefaultClick}); expect(onKeyDown).toHaveBeenCalledTimes(1); expect(preventDefault).toBeCalled(); @@ -293,7 +367,10 @@ describe('Keyboard responder', () => { const target = createEventTarget(ref.current); target.keydown({key: 'Tab', preventDefault, shiftKey: true}); - target.click({preventDefault: preventDefaultClick, shiftKey: true}); + target.virtualclick({ + preventDefault: preventDefaultClick, + shiftKey: true, + }); expect(onKeyDown).toHaveBeenCalledTimes(1); expect(preventDefault).toBeCalled(); @@ -316,7 +393,10 @@ describe('Keyboard responder', () => { const target = createEventTarget(ref.current); target.keydown({key: 'Tab', preventDefault, shiftKey: false}); - target.click({preventDefault: preventDefaultClick, shiftKey: false}); + target.virtualclick({ + preventDefault: preventDefaultClick, + shiftKey: false, + }); expect(onKeyDown).toHaveBeenCalledTimes(1); expect(preventDefault).not.toBeCalled(); diff --git a/packages/react-events/src/dom/shared/index.js b/packages/react-events/src/dom/shared/index.js index 3e56457a7eb24..46202d1ff882d 100644 --- a/packages/react-events/src/dom/shared/index.js +++ b/packages/react-events/src/dom/shared/index.js @@ -70,3 +70,17 @@ export function hasModifierKey(event: ReactDOMResponderEvent): boolean { altKey === true || ctrlKey === true || metaKey === true || shiftKey === true ); } + +// Keyboards, Assitive Technologies, and element.click() all produce "virtual" +// clicks that do not include coordinates and "detail" is always 0 (where +// pointer clicks are > 0). +export function isVirtualClick(event: ReactDOMResponderEvent): boolean { + const nativeEvent: any = event.nativeEvent; + return ( + nativeEvent.detail === 0 && + nativeEvent.screenX === 0 && + nativeEvent.screenY === 0 && + nativeEvent.clientX === 0 && + nativeEvent.clientY === 0 + ); +}