Allow keyboard shortcuts (#10713)

* Create keyboardShortcuts.jsx

Add handler for keyboard shortcuts

* Match github's hotkey style

Makes objects clearer.

* Update keyboardShortcuts.jsx

* Allow single character shortcuts

update to allow single character shortcuts/hotkeys

* Add component documentation

Add inline documentation for the component.

* Fix example typo

* Move keyboardShortcuts to a hook

* Fixed useKeyboardShortcuts example

* Rename keyboardShortcuts.jsx to useKeyboardShortcuts.jsx

* Rebuild

* Move isFormField outside render cycle

Move the isFormField function outside of the render cycle.

* Prevent event listener on empty object

Don't add an event listener if the shortcuts property is an empty object or is null

* Add tests for useKeyboardShortcuts

Initial pass for adding tests to useKeyboardShortcuts

* Missed unmount

Missed a whole word...

* Fix typos

* Convert function type

* Add new test cases.

* Missing brackets

* Typo

* Update useKeyboardShortcuts.test.jsx

* Move event listener to document

* Update tests

* Use testing-library/user-event

* Correct fireEvent

* Update keyDown

* Slight tidy

* Change order, just in case

* Swap to render

* Make function async

* Rerun travis

* Add rerender

* Fix render function

* check event.key for fallback
This commit is contained in:
Andrew Bone 2020-10-22 12:45:42 +01:00 committed by GitHub
parent 4caad99584
commit b7dfb2495a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 211 additions and 0 deletions

View file

@ -0,0 +1,107 @@
import { h } from 'preact';
import { renderHook } from '@testing-library/preact-hooks';
import { fireEvent, render } from '@testing-library/preact';
import { KeyboardShortcuts, useKeyboardShortcuts } from '../useKeyboardShortcuts.jsx';
describe('Keyboard shortcuts for components', () => {
describe('useKeyboardShortcuts', () => {
it('should fire a function when keydown is detected', () => {
const shortcut = {
KeyK: jest.fn()
};
renderHook(() =>
useKeyboardShortcuts(shortcut, document),
);
fireEvent.keyDown(document, { code: "KeyK" });
expect(shortcut.KeyK).toHaveBeenCalledTimes(1);
});
it('should not add event listener if shortcut object is empty', () => {
HTMLDocument.prototype.addEventListener = jest.fn();
renderHook(() =>
useKeyboardShortcuts({}, document),
);
expect(HTMLDocument.prototype.addEventListener).not.toHaveBeenCalled();
});
it('should add event listener to window', () => {
HTMLDocument.prototype.addEventListener = jest.fn();
renderHook(() =>
useKeyboardShortcuts({
KeyK: null
}, document),
);
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1);
});
it('should not fire a function when keydown is detected in element', () => {
const shortcut = {
KeyK: jest.fn()
};
const eventTarget = document.createElement('textarea') // eventTarget set since the default is window
renderHook(() =>
useKeyboardShortcuts(shortcut, document),
eventTarget,
);
fireEvent.keyDown(eventTarget, { code: "KeyK" });
expect(shortcut.KeyK).not.toHaveBeenCalled();
});
it('should remove event listener when the hook is unmounted', () => {
HTMLDocument.prototype.addEventListener = jest.fn();
HTMLDocument.prototype.removeEventListener = jest.fn();
const { unmount } = renderHook(() =>
useKeyboardShortcuts({ KeyK: null }, document),
);
unmount();
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1);
expect(HTMLDocument.prototype.removeEventListener).toHaveBeenCalledTimes(1);
});
});
describe('<KeyboardShortcuts />', () => {
it('should not add event listener if shortcut object is empty', async () => {
HTMLDocument.prototype.addEventListener = jest.fn();
render(
<KeyboardShortcuts eventTarget={document} />,
);
expect(HTMLDocument.prototype.addEventListener).not.toHaveBeenCalled();
});
it('should add event listener to window', async () => {
HTMLDocument.prototype.addEventListener = jest.fn();
render(
<KeyboardShortcuts eventTarget={document} shortcuts={{ KeyK: null }} />,
);
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1);
});
it('should remove event listener when the hook is unmounted', async () => {
HTMLDocument.prototype.addEventListener = jest.fn();
HTMLDocument.prototype.removeEventListener = jest.fn();
const { unmount } = render(
<KeyboardShortcuts eventTarget={document} shortcuts={{ KeyK: null }} />,
);
unmount();
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1);
expect(HTMLDocument.prototype.removeEventListener).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,104 @@
import { useEffect } from "preact/hooks";
import PropTypes from 'prop-types';
import { h } from 'preact';
/**
* Checker that return true if element is a form element
*
* @param {node} element to be checked
*
* @returns {boolean} isFormField
*/
function isFormField(element) {
if ((element instanceof HTMLElement) === false) return false;
const name = element.nodeName.toLowerCase();
const type = (element.getAttribute("type") || "").toLowerCase();
return (
name === "select" ||
name === "textarea" ||
(name === "input" && ["submit", "reset", "checkbox", "radio"].indexOf(type) < 0) ||
element.isContentEditable
);
};
/**
* hook that can be added to a component to listen
* for keyboard presses
*
* @example
* const shortcuts = {
* "ctrl+alt+KeyG": (e) => {
* e.preventDefault();
* alert("Control Alt G has been pressed");
* },
* "?": (e) => {
* setIsHelpVisible(true);
* }
* }
*
* useKeyboardShortcuts(shortcuts, someElementOrWindowObject);
*
* @param {object} shortcuts List of keyboard shortcuts/event
* @param {EventTarget} [eventTarget=window] An event target.
*
*/
export function useKeyboardShortcuts(shortcuts, eventTarget = window) {
useEffect(() => {
if (!shortcuts || Object.keys(shortcuts).length === 0) return;
const keyEvent = (e) => {
if (e.defaultPrevented) return;
// Get special keys
const keys = `${e.ctrlKey || e.metaKey ? "ctrl+" : ""}${e.altKey ? "alt+" : ""}${e.shiftKey ? "shift+" : ""}`;
// If no special keys are pressed and focus is inside a field return
if (e.target instanceof Node && isFormField(e.target) && !keys) return;
const shortcut = shortcuts[`${keys}${e.code}`] || shortcuts[`${keys}${e.key.toLowerCase()}`];
if (shortcut) shortcut(e);
};
eventTarget.addEventListener("keydown", keyEvent);
return () => {
eventTarget.removeEventListener("keydown", keyEvent);
};
}, [shortcuts, eventTarget]);
}
/**
* compoent that can be added to a component to listen
* for keyboard presses using the useKeyboardShortcuts hook
*
* @example
* const shortcuts = {
* "ctrl+alt+KeyG": (e) => {
* e.preventDefault();
* alert("Control Alt G has been pressed")
* }
* }
*
* <KeyboardShortcuts shortcuts={shortcuts} />
* <KeyboardShortcuts shortcuts={shortcuts} eventTarget={ref.current} />
*
* @param {object} shortcuts List of keyboard shortcuts/event
* @param {EventTarget} [eventTarget=window] An event target.
*
*/
export function KeyboardShortcuts({ shortcuts, eventTarget }) {
useKeyboardShortcuts(shortcuts, eventTarget);
return null;
}
KeyboardShortcuts.propTypes = {
shortcuts: PropTypes.object.isRequired,
eventTarget: PropTypes.instanceOf(Element)
}
KeyboardShortcuts.defaultProps = {
shortcuts: {},
eventTarget: window
}