Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add W3C KeyboardEvent.key values support. #10

Closed
wants to merge 9 commits into from
10 changes: 7 additions & 3 deletions README.md
Expand Up @@ -31,7 +31,7 @@ Unless you want absolute flexibility we highly recommend you to use one of the d

### `keyHandler` decorator

The decorator will decorate the given component with a `keyCode` and `keyName`
The decorator will decorate the given component with a `keyValue`, `keyCode` and `keyName`
property.

```jsx
Expand Down Expand Up @@ -60,17 +60,19 @@ The prop types of the `keyHandler` decorator are:

```js
type Props = {
keyValue: ?string,
keyCode: ?number,
keyEventName: ?string,
keyName: ?string,
}
```

* `keyValue` can be any given [W3C keyboard key value](https://www.w3.org/TR/DOM-Level-3-Events-key/)
* `keyCode` can be any given [keyboard code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode)
* `keyEventName` will default to `'keyup'`
* `keyName` can be any given character

You should either pass a `keyCode` or a `keyName`, not both.
You should either pass a `keyValue`, a `keyCode` or a `keyName`, not both.

### `keyToggleHandler` decorator

Expand Down Expand Up @@ -119,19 +121,21 @@ The prop types of the `KeyHandler` component are:

```js
type Props = {
keyValue: ?string,
keyCode: ?number,
keyEventName: string,
keyName: ?string,
onKeyHandle: Function,
};
```

* `keyValue` can be any given [W3C keyboard key value](https://www.w3.org/TR/DOM-Level-3-Events-key/)
* `keyCode` can be any given [keyboard code](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode)
* `keyEventName` will default to `'keyup'`
* `keyName` can be any given character
* `onKeyHandle` is the function that is being called when key code is handled

You should either pass a `keyCode` or a `keyName`, not both.
You should either pass a `keyValue`, a `keyCode` or a `keyName`, not both.

### Form key handling

Expand Down
37 changes: 19 additions & 18 deletions lib/components/key-handler.js
Expand Up @@ -5,13 +5,14 @@ import keyNames from 'keycodes';
import {canUseDOM} from 'exenv';

import {KEYUP} from '../constants';
import {isInput} from '../utils';
import {isInput, keyValues, matchesKeyboardEvent} from '../utils';

/**
* Types.
*/

type Props = {
keyValue: ?string,
keyCode: ?number,
keyEventName: string,
keyName: ?string,
Expand Down Expand Up @@ -56,9 +57,7 @@ export default class KeyHandler extends React.Component {
}

handleKey(event: KeyboardEvent): void {
const keyCode = this.props.keyCode || keyNames(this.props.keyName);

if (event.keyCode !== keyCode) {
if (!matchesKeyboardEvent(event, this.props)) {
return;
}

Expand All @@ -79,12 +78,14 @@ export default class KeyHandler extends React.Component {
*/

type DecoratorProps = {
keyValue: ?string,
keyCode: ?number,
keyName: ?string,
keyEventName: ?string,
}

type State = {
keyValue: ?string,
keyCode: ?number,
keyName: ?string,
};
Expand All @@ -93,10 +94,10 @@ type State = {
* KeyHandler decorators.
*/

export function keyHandler({keyCode, keyName, keyEventName}: DecoratorProps): Function {
export function keyHandler({keyValue, keyCode, keyName, keyEventName}: DecoratorProps): Function {
return (component) => {
return class KeyHandleDecorator extends React.Component {
state: State = { keyCode: null, keyName: null };
state: State = { keyValue: null, keyCode: null, keyName: null };

constructor(props: any) {
super(props);
Expand All @@ -107,30 +108,30 @@ export function keyHandler({keyCode, keyName, keyEventName}: DecoratorProps): Fu
render(): ReactElement {
return (
<div>
<KeyHandler keyCode={keyCode} keyEventName={keyEventName} keyName={keyName} onKeyHandle={this.handleKey} />
<KeyHandler keyValue={keyValue} keyCode={keyCode} keyEventName={keyEventName} keyName={keyName} onKeyHandle={this.handleKey} />

{this.renderDecoratedComponent()}
</div>
);
}

renderDecoratedComponent(): ReactElement {
const {keyCode, keyName} = this.state;
const {keyValue, keyCode, keyName} = this.state;

return React.createElement(component, { ...this.props, keyCode, keyName });
return React.createElement(component, { ...this.props, keyValue, keyCode, keyName });
}

handleKey(event: KeyboardEvent): void {
this.setState({ keyCode: event.keyCode, keyName: keyNames(event.keyCode) });
this.setState({ keyValue: event.key || keyValues(keyNames(event.keyCode)), keyCode: event.keyCode, keyName: keyNames(event.keyCode) });
}
};
};
}

export function keyToggleHandler({keyCode, keyName, keyEventName}: DecoratorProps): Function {
export function keyToggleHandler({keyValue, keyCode, keyName, keyEventName}: DecoratorProps): Function {
return (component) => {
return class KeyHandleDecorator extends React.Component {
state: State = { keyCode: null, keyName: null };
state: State = { keyValue: null, keyCode: null, keyName: null };

constructor(props: any) {
super(props);
Expand All @@ -141,25 +142,25 @@ export function keyToggleHandler({keyCode, keyName, keyEventName}: DecoratorProp
render(): ReactElement {
return (
<div>
<KeyHandler keyCode={keyCode} keyEventName={keyEventName} keyName={keyName} onKeyHandle={this.handleToggleKey} />
<KeyHandler keyValue={keyValue} keyCode={keyCode} keyEventName={keyEventName} keyName={keyName} onKeyHandle={this.handleToggleKey} />

{this.renderDecoratedComponent()}
</div>
);
}

renderDecoratedComponent(): ReactElement {
const {keyCode, keyName} = this.state;
const {keyValue, keyCode, keyName} = this.state;

return React.createElement(component, { ...this.props, keyCode, keyName });
return React.createElement(component, { ...this.props, keyValue, keyCode, keyName });
}

handleToggleKey(event: KeyboardEvent): void {
if (this.state.keyCode === event.keyCode) {
return this.setState({ keyCode: null, keyName: null });
if (!matchesKeyboardEvent(event, this.state)) {
return this.setState({ keyValue: null, keyCode: null, keyName: null });
}

this.setState({ keyCode: event.keyCode, keyName: keyNames(event.keyCode) });
this.setState({ keyValue: event.key || keyValues(keyNames(event.keyCode)), keyCode: event.keyCode, keyName: keyNames(event.keyCode) });
}
};
};
Expand Down
67 changes: 67 additions & 0 deletions lib/utils.js
@@ -1,5 +1,17 @@
/* @flow */

import keyNames from 'keycodes';

/**
* Types.
*/

type Props = {
keyValue: ?string,
keyCode: ?number,
keyName: ?string
};

/**
* Check if `given` element is an input or textarea form element.
*/
Expand All @@ -13,3 +25,58 @@ export function isInput(element: HTMLElement): boolean {

return tagName === 'INPUT' || tagName === 'TEXTAREA';
}

/**
* Maps [keycodes](https://www.npmjs.com/package/keycodes) key names to KeyboardEvent.key values.
*/
export function keyValues(keyName: string): string {
const keys = {
ctrl: 'Control',
control: 'Control',
alt: 'Alt',
option: 'Option',
shift: 'Shift',
windows: 'Meta',
command: 'Meta',
esc: 'Escape',
escape: 'Escape',
backspace: 'Backspace',
tab: 'Tab',
enter: 'Enter',
'return': 'Enter',
space: ' ',
pause: 'Pause',
insert: 'Insert',
'delete': 'Delete',
home: 'Home',
end: 'End',
pageup: 'PageUp',
pagedown: 'PageDown',
left: 'ArrowLeft',
up: 'ArrowUp',
right: 'ArrowRight',
down: 'ArrowDown',
capslock: 'CapsLock',
numlock: 'NumLock',
scrolllock: 'ScrollLock',
};

return keys[keyName] || keyName;
}

/**
* Maps keyCodes to KeyboardEvent.key values.
*/
export function keyValueFromCode(keyCode: number): string {
return keyValues(keyNames(keyCode));
}

/**
* Matches a KeyboardEvent against a given Props type by all its possible values.
*/
export function matchesKeyboardEvent(event: KeyboardEvent, {keyValue, keyCode, keyName}: Props): boolean {
const keyVal = keyValue || (keyCode && keyValueFromCode(keyCode)) || (keyName && keyValues(keyName));
const eventKeyVal = event.key || keyValueFromCode(event.keyCode);

return String(eventKeyVal).toLowerCase() === String(keyVal).toLowerCase();
}
33 changes: 31 additions & 2 deletions test/components/key-handler.test.js
Expand Up @@ -7,13 +7,24 @@ import KeyHandler, {KEYUP, KEYDOWN} from 'components/key-handler';

const M = 77;
const S = 83;
const ARROW_LEFT = 'ArrowLeft';
const ARROW_RIGHT = 'ArrowRight';

describe('KeyHandler', () => {
it('renders nothing', () => {
const el = render(<KeyHandler />);
expect(el).to.be.blank();
});

it('handles key up events when key value match', () => {
const handler = sinon.spy();
mount(<KeyHandler keyValue={ARROW_LEFT} onKeyHandle={handler} />);

triggerKeyEvent(KEYUP, undefined, ARROW_LEFT);

expect(handler.calledOnce).to.equal(true);
});

it('handles key up events when key code match', () => {
const handler = sinon.spy();
mount(<KeyHandler keyCode={M} onKeyHandle={handler} />);
Expand All @@ -32,6 +43,15 @@ describe('KeyHandler', () => {
expect(handler.calledOnce).to.equal(true);
});

it('ignores key up events when no key value match', () => {
const handler = sinon.spy();
mount(<KeyHandler keyValue={ARROW_LEFT} onKeyHandle={handler} />);

triggerKeyEvent(KEYUP, undefined, ARROW_RIGHT);

expect(handler.calledOnce).to.equal(false);
});

it('ignores key up events when no key code match', () => {
const handler = sinon.spy();
mount(<KeyHandler keyCode={S} onKeyHandle={handler} />);
Expand Down Expand Up @@ -67,9 +87,18 @@ describe('KeyHandler', () => {

expect(handler.calledOnce).to.equal(true);
});

it('prioritizes key value over code/name', () => {
const handler = sinon.spy();
mount(<KeyHandler keyCode={M} keyName="m" keyValue={ARROW_LEFT} onKeyHandle={handler} />);

triggerKeyEvent(KEYUP, S, ARROW_LEFT);

expect(handler.calledOnce).to.equal(true);
});
});

function triggerKeyEvent(eventName, keyCode) {
const event = new window.KeyboardEvent(eventName, { keyCode });
function triggerKeyEvent(eventName, keyCode, keyValue = undefined) {
const event = new window.KeyboardEvent(eventName, { keyCode, key: keyValue });
document.dispatchEvent(event);
}