[react-events] Keyboard calls preventDefault on 'click' events (#16779)

Make sure to call preventDefault for any 'click' events that follow a 'keydown'
event that matches 'preventKeys'
This commit is contained in:
Nicolas Gallagher
2019-09-13 13:19:18 -07:00
committed by GitHub
parent 0c0b30b8c7
commit 87eaa90ef8
4 changed files with 249 additions and 235 deletions
+34 -12
View File
@@ -16,26 +16,32 @@ import React from 'react';
import {DiscreteEvent} from 'shared/ReactTypes';
import type {ReactEventResponderListener} from 'shared/ReactTypes';
type KeyboardEventType = 'keydown' | 'keyup';
type KeyboardEventType = 'keyboard:keydown' | 'keyboard:keyup';
type KeyboardProps = {
type KeyboardProps = {|
disabled?: boolean,
onKeyDown?: (e: KeyboardEvent) => ?boolean,
onKeyUp?: (e: KeyboardEvent) => ?boolean,
preventKeys?: PreventKeysArray,
};
|};
type KeyboardState = {|
defaultPrevented: boolean,
isActive: boolean,
|};
export type KeyboardEvent = {|
altKey: boolean,
ctrlKey: boolean,
defaultPrevented: boolean,
isComposing: boolean,
key: string,
metaKey: boolean,
pointerType: 'keyboard',
shiftKey: boolean,
target: Element | Document,
type: KeyboardEventType,
timeStamp: number,
defaultPrevented: boolean,
|};
type ModifiersObject = {|
@@ -48,7 +54,7 @@ type ModifiersObject = {|
type PreventKeysArray = Array<string | Array<string | ModifiersObject>>;
const isArray = Array.isArray;
const targetEventTypes = ['keydown_active', 'keyup'];
const targetEventTypes = ['click_active', 'keydown_active', 'keyup'];
const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
/**
@@ -150,6 +156,7 @@ function createKeyboardEvent(
isComposing,
key: getEventKey(nativeEvent),
metaKey,
pointerType: 'keyboard',
shiftKey,
target: event.target,
timeStamp: context.getTimeStamp(),
@@ -182,10 +189,17 @@ function dispatchKeyboardEvent(
const keyboardResponderImpl = {
targetEventTypes,
getInitialState(): KeyboardState {
return {
defaultPrevented: false,
isActive: false,
};
},
onEvent(
event: ReactDOMResponderEvent,
context: ReactDOMResponderContext,
props: KeyboardProps,
state: KeyboardState,
): void {
const {type} = event;
const nativeEvent: any = event.nativeEvent;
@@ -193,10 +207,12 @@ const keyboardResponderImpl = {
if (props.disabled) {
return;
}
let defaultPrevented = nativeEvent.defaultPrevented === true;
if (type === 'keydown') {
state.defaultPrevented = nativeEvent.defaultPrevented === true;
const preventKeys = ((props.preventKeys: any): PreventKeysArray);
if (!defaultPrevented && isArray(preventKeys)) {
if (!state.defaultPrevented && isArray(preventKeys)) {
preventKeyLoop: for (let i = 0; i < preventKeys.length; i++) {
const preventKey = preventKeys[i];
let key = preventKey;
@@ -216,32 +232,38 @@ const keyboardResponderImpl = {
}
}
}
if (key === getEventKey(nativeEvent)) {
defaultPrevented = true;
state.defaultPrevented = true;
nativeEvent.preventDefault();
break;
}
}
}
state.isActive = true;
const onKeyDown = props.onKeyDown;
if (isFunction(onKeyDown)) {
dispatchKeyboardEvent(
event,
((onKeyDown: any): (e: KeyboardEvent) => ?boolean),
context,
'keydown',
defaultPrevented,
'keyboard:keydown',
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 === 'keyup') {
state.isActive = false;
const onKeyUp = props.onKeyUp;
if (isFunction(onKeyUp)) {
dispatchKeyboardEvent(
event,
((onKeyUp: any): (e: KeyboardEvent) => ?boolean),
context,
'keyup',
defaultPrevented,
'keyboard:keyup',
state.defaultPrevented,
);
}
}
@@ -25,7 +25,7 @@ function initializeModules(hasPointerEvents) {
useKeyboard = require('react-events/keyboard').useKeyboard;
}
describe('Keyboard event responder', () => {
describe('Keyboard responder', () => {
let container;
beforeEach(() => {
@@ -40,6 +40,71 @@ describe('Keyboard event responder', () => {
container = null;
});
function renderPropagationTest(propagates) {
const onKeyDownInner = jest.fn(() => propagates);
const onKeyDownOuter = jest.fn();
const onKeyUpInner = jest.fn(() => propagates);
const onKeyUpOuter = jest.fn();
const ref = React.createRef();
const Component = () => {
const listenerInner = useKeyboard({
onKeyDown: onKeyDownInner,
onKeyUp: onKeyUpInner,
});
const listenerOuter = useKeyboard({
onKeyDown: onKeyDownOuter,
onKeyUp: onKeyUpOuter,
});
return (
<div listeners={listenerOuter}>
<div ref={ref} listeners={listenerInner} />
</div>
);
};
ReactDOM.render(<Component />, container);
return {
onKeyDownInner,
onKeyDownOuter,
onKeyUpInner,
onKeyUpOuter,
ref,
};
}
test('propagates event when a callback returns true', () => {
const {
onKeyDownInner,
onKeyDownOuter,
onKeyUpInner,
onKeyUpOuter,
ref,
} = renderPropagationTest(true);
const target = createEventTarget(ref.current);
target.keydown();
expect(onKeyDownInner).toBeCalled();
expect(onKeyDownOuter).toBeCalled();
target.keyup();
expect(onKeyUpInner).toBeCalled();
expect(onKeyUpOuter).toBeCalled();
});
test('does not propagate event when a callback returns false', () => {
const {
onKeyDownInner,
onKeyDownOuter,
onKeyUpInner,
onKeyUpOuter,
ref,
} = renderPropagationTest(false);
const target = createEventTarget(ref.current);
target.keydown();
expect(onKeyDownInner).toBeCalled();
expect(onKeyDownOuter).not.toBeCalled();
target.keyup();
expect(onKeyUpInner).toBeCalled();
expect(onKeyUpOuter).not.toBeCalled();
});
describe('disabled', () => {
let onKeyDown, onKeyUp, ref;
@@ -48,17 +113,13 @@ describe('Keyboard event responder', () => {
onKeyUp = jest.fn();
ref = React.createRef();
const Component = () => {
const listener = useKeyboard({
disabled: true,
onKeyDown,
onKeyUp,
});
const listener = useKeyboard({disabled: true, onKeyDown, onKeyUp});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
});
it('prevents custom events being dispatched', () => {
test('does not call callbacks', () => {
const target = createEventTarget(ref.current);
target.keydown();
target.keyup();
@@ -74,265 +135,200 @@ describe('Keyboard event responder', () => {
onKeyDown = jest.fn();
ref = React.createRef();
const Component = () => {
const listener = useKeyboard({
onKeyDown,
});
const listener = useKeyboard({onKeyDown});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
});
it('is called after "keydown" event', () => {
test('key down', () => {
const target = createEventTarget(ref.current);
target.keydown({key: 'Q'});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({key: 'Q', type: 'keydown'}),
expect.objectContaining({
altKey: false,
ctrlKey: false,
defaultPrevented: false,
isComposing: false,
key: 'Q',
metaKey: false,
pointerType: 'keyboard',
shiftKey: false,
target: target.node,
timeStamp: expect.any(Number),
type: 'keyboard:keydown',
}),
);
});
});
describe('preventKeys', () => {
it('onKeyDown is default prevented', () => {
const onKeyDown = jest.fn();
const ref = React.createRef();
const Component = () => {
const listener = useKeyboard({
onKeyDown,
preventKeys: ['Tab'],
});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
const preventDefault = jest.fn();
test('modified key down', () => {
const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
target.keydown({
key: 'Q',
altKey: true,
ctrlKey: true,
shiftKey: true,
metaKey: true,
});
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Tab',
type: 'keydown',
defaultPrevented: true,
}),
);
});
it('onKeyDown is default prevented (falsy modifier keys)', () => {
let onKeyDown = jest.fn();
let ref = React.createRef();
let Component = () => {
const listener = useKeyboard({
onKeyDown,
preventKeys: [['Tab', {metaKey: false}]],
});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
let preventDefault = jest.fn();
let target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, metaKey: true});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).not.toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Tab',
type: 'keydown',
altKey: true,
ctrlKey: true,
defaultPrevented: false,
}),
);
onKeyDown = jest.fn();
ref = React.createRef();
Component = () => {
const listener = useKeyboard({
onKeyDown,
preventKeys: [['Tab', {metaKey: true}]],
});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
preventDefault = jest.fn();
target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, metaKey: false});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).not.toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Tab',
type: 'keydown',
defaultPrevented: false,
}),
);
});
it('onKeyDown is default prevented (truthy modifier keys)', () => {
let onKeyDown = jest.fn();
let ref = React.createRef();
let Component = () => {
const listener = useKeyboard({
onKeyDown,
preventKeys: [['Tab', {metaKey: true}]],
});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
let preventDefault = jest.fn();
let target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, metaKey: true});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Tab',
type: 'keydown',
defaultPrevented: true,
}),
);
onKeyDown = jest.fn();
ref = React.createRef();
Component = () => {
const listener = useKeyboard({
onKeyDown,
preventKeys: [['Tab', {metaKey: false}]],
});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
preventDefault = jest.fn();
target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, metaKey: false});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
key: 'Tab',
type: 'keydown',
defaultPrevented: true,
isComposing: false,
key: 'Q',
metaKey: true,
pointerType: 'keyboard',
shiftKey: true,
target: target.node,
timeStamp: expect.any(Number),
type: 'keyboard:keydown',
}),
);
});
});
describe('onKeyUp', () => {
let onKeyDown, onKeyUp, ref;
let onKeyUp, ref;
beforeEach(() => {
onKeyDown = jest.fn();
onKeyUp = jest.fn();
ref = React.createRef();
const Component = () => {
const listener = useKeyboard({
onKeyDown,
onKeyUp,
});
const listener = useKeyboard({onKeyUp});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
});
it('is called after "keydown" event', () => {
test('key up', () => {
const target = createEventTarget(ref.current);
target.keydown({key: 'Q'});
target.keyup({key: 'Q'});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({key: 'Q', type: 'keydown'}),
);
expect(onKeyUp).toHaveBeenCalledTimes(1);
expect(onKeyUp).toHaveBeenCalledWith(
expect.objectContaining({key: 'Q', type: 'keyup'}),
expect.objectContaining({
altKey: false,
ctrlKey: false,
defaultPrevented: false,
isComposing: false,
key: 'Q',
metaKey: false,
pointerType: 'keyboard',
shiftKey: false,
target: target.node,
timeStamp: expect.any(Number),
type: 'keyboard:keyup',
}),
);
});
test('modified key up', () => {
const target = createEventTarget(ref.current);
target.keydown({key: 'Q'});
target.keyup({
key: 'Q',
altKey: true,
ctrlKey: true,
shiftKey: true,
metaKey: true,
});
expect(onKeyUp).toHaveBeenCalledWith(
expect.objectContaining({
altKey: true,
ctrlKey: true,
defaultPrevented: false,
isComposing: false,
key: 'Q',
metaKey: true,
pointerType: 'keyboard',
shiftKey: true,
target: target.node,
timeStamp: expect.any(Number),
type: 'keyboard:keyup',
}),
);
});
});
describe('correctly handles responder propagation', () => {
describe('onKeyDown', () => {
let onKeyDownInner, onKeyDownOuter, ref;
describe('preventKeys', () => {
function render(props) {
const ref = React.createRef();
const Component = () => {
const listener = useKeyboard(props);
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
return ref;
}
function renderPropagationTest(propagates) {
onKeyDownInner = jest.fn(() => propagates);
onKeyDownOuter = jest.fn();
ref = React.createRef();
const Component = () => {
const listenerInner = useKeyboard({
onKeyDown: onKeyDownInner,
});
const listenerOuter = useKeyboard({
onKeyDown: onKeyDownOuter,
});
return (
<div listeners={listenerOuter}>
<div ref={ref} listeners={listenerInner} />
</div>
);
};
ReactDOM.render(<Component />, container);
}
test('key config matches', () => {
const onKeyDown = jest.fn();
const preventDefault = jest.fn();
const preventDefaultClick = jest.fn();
const ref = render({onKeyDown, preventKeys: ['Tab']});
it('propagates when cb returns true', () => {
renderPropagationTest(true);
const target = createEventTarget(ref.current);
target.keydown();
expect(onKeyDownInner).toBeCalled();
expect(onKeyDownOuter).toBeCalled();
});
const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault});
target.click({preventDefault: preventDefaultClick});
it('does not propagate when cb returns false', () => {
renderPropagationTest(false);
const target = createEventTarget(ref.current);
target.keydown();
expect(onKeyDownInner).toBeCalled();
expect(onKeyDownOuter).not.toBeCalled();
});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
expect(preventDefaultClick).toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
defaultPrevented: true,
key: 'Tab',
type: 'keyboard:keydown',
}),
);
});
describe('onKeyUp', () => {
let onKeyUpInner, onKeyUpOuter, ref;
test('key config matches (modifier keys)', () => {
const onKeyDown = jest.fn();
const preventDefault = jest.fn();
const preventDefaultClick = jest.fn();
const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]});
function renderPropagationTest(propagates) {
onKeyUpInner = jest.fn(() => propagates);
onKeyUpOuter = jest.fn();
ref = React.createRef();
const Component = () => {
const listenerInner = useKeyboard({
onKeyUp: onKeyUpInner,
});
const listenerOuter = useKeyboard({
onKeyUp: onKeyUpOuter,
});
return (
<div listeners={listenerOuter}>
<div ref={ref} listeners={listenerInner} />
</div>
);
};
ReactDOM.render(<Component />, container);
}
const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, shiftKey: true});
target.click({preventDefault: preventDefaultClick, shiftKey: true});
it('propagates when cb returns true', () => {
renderPropagationTest(true);
const target = createEventTarget(ref.current);
target.keydown();
target.keyup();
expect(onKeyUpInner).toBeCalled();
expect(onKeyUpOuter).toBeCalled();
});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).toBeCalled();
expect(preventDefaultClick).toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
defaultPrevented: true,
key: 'Tab',
shiftKey: true,
type: 'keyboard:keydown',
}),
);
});
it('does not propagate when cb returns false', () => {
renderPropagationTest(false);
const target = createEventTarget(ref.current);
target.keydown();
target.keyup();
expect(onKeyUpInner).toBeCalled();
expect(onKeyUpOuter).not.toBeCalled();
});
test('key config does not match (modifier keys)', () => {
const onKeyDown = jest.fn();
const preventDefault = jest.fn();
const preventDefaultClick = jest.fn();
const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]});
const target = createEventTarget(ref.current);
target.keydown({key: 'Tab', preventDefault, shiftKey: false});
target.click({preventDefault: preventDefaultClick, shiftKey: false});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(preventDefault).not.toBeCalled();
expect(preventDefaultClick).not.toBeCalled();
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({
defaultPrevented: false,
key: 'Tab',
shiftKey: false,
type: 'keyboard:keydown',
}),
);
});
});
});
@@ -137,6 +137,7 @@ function createKeyboardEvent(
{
altKey = false,
ctrlKey = false,
isComposing = false,
key = '',
metaKey = false,
preventDefault = emptyFunction,
@@ -151,6 +152,7 @@ function createKeyboardEvent(
getModifierState(keyArg) {
createGetModifierState(keyArg, modifierState);
},
isComposing,
key,
metaKey,
preventDefault,
@@ -220,9 +220,6 @@ describe('ReactScope', () => {
let target = createEventTarget(ref.current);
target.keydown({key: 'Q'});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({key: 'Q', type: 'keydown'}),
);
onKeyDown = jest.fn();
Component = () => {
@@ -242,9 +239,6 @@ describe('ReactScope', () => {
target = createEventTarget(ref.current);
target.keydown({key: 'Q'});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onKeyDown).toHaveBeenCalledWith(
expect.objectContaining({key: 'Q', type: 'keydown'}),
);
});
});