Add chaining to shortcuts (#11113)
Co-authored-by: Nick Taylor <nick@iamdeveloper.com>
This commit is contained in:
parent
756acebaed
commit
75bec9b705
3 changed files with 213 additions and 109 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { h } from 'preact';
|
||||
import { renderHook } from '@testing-library/preact-hooks';
|
||||
import { fireEvent, render } from '@testing-library/preact';
|
||||
import { KeyboardShortcuts, useKeyboardShortcuts } from '../useKeyboardShortcuts.jsx';
|
||||
import { KeyboardShortcuts, useKeyboardShortcuts } from '../useKeyboardShortcuts';
|
||||
|
||||
describe('Keyboard shortcuts for components', () => {
|
||||
describe('useKeyboardShortcuts', () => {
|
||||
|
|
@ -18,6 +18,40 @@ describe('Keyboard shortcuts for components', () => {
|
|||
expect(shortcut.KeyK).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fire a function when chained keydown is detected', () => {
|
||||
const shortcut = {
|
||||
"KeyA~KeyB": jest.fn()
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useKeyboardShortcuts(shortcut, document),
|
||||
);
|
||||
fireEvent.keyDown(document, { code: "KeyA" });
|
||||
fireEvent.keyDown(document, { code: "KeyB" });
|
||||
|
||||
expect(shortcut["KeyA~KeyB"]).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not fire a function when chained keydown is missed, timeout should only be 0ms', async () => {
|
||||
const shortcut = {
|
||||
"KeyA~KeyB": jest.fn()
|
||||
};
|
||||
|
||||
const timeout = 0;
|
||||
|
||||
renderHook(() =>
|
||||
useKeyboardShortcuts(shortcut, document, { timeout }),
|
||||
);
|
||||
fireEvent.keyDown(document, { code: "KeyA" });
|
||||
|
||||
await new Promise(resolve => setTimeout(() => {
|
||||
fireEvent.keyDown(document, { code: "KeyB" });
|
||||
resolve();
|
||||
}, 25));
|
||||
|
||||
expect(shortcut["KeyA~KeyB"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add event listener if shortcut object is empty', () => {
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
|
||||
|
|
@ -30,11 +64,13 @@ describe('Keyboard shortcuts for components', () => {
|
|||
|
||||
it('should add event listener to window', () => {
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
|
||||
const shortcut = {
|
||||
KeyK: null
|
||||
}
|
||||
|
||||
renderHook(() =>
|
||||
useKeyboardShortcuts({
|
||||
KeyK: null
|
||||
}, document),
|
||||
useKeyboardShortcuts(shortcut, document),
|
||||
);
|
||||
|
||||
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -58,9 +94,13 @@ describe('Keyboard shortcuts for components', () => {
|
|||
it('should remove event listener when the hook is unmounted', () => {
|
||||
HTMLDocument.prototype.addEventListener = jest.fn();
|
||||
HTMLDocument.prototype.removeEventListener = jest.fn();
|
||||
|
||||
const shortcut = {
|
||||
KeyK: null
|
||||
}
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useKeyboardShortcuts({ KeyK: null }, document),
|
||||
useKeyboardShortcuts(shortcut, document),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
|
|
|||
168
app/javascript/shared/components/useKeyboardShortcuts.js
Normal file
168
app/javascript/shared/components/useKeyboardShortcuts.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { useState, useEffect, useCallback } from "preact/hooks";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* 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" && type !== 'submit' && type !== 'reset' && type !== 'checkbox' && type !== 'radio') ||
|
||||
element.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
// Default options to be used if null
|
||||
const defaultOptions = {
|
||||
timeout: 500
|
||||
};
|
||||
|
||||
/**
|
||||
* 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");
|
||||
* },
|
||||
* "KeyG~KeyH": (e) => {
|
||||
* e.preventDefault();
|
||||
* alert("G has been pressed quickly followed by H");
|
||||
* },
|
||||
* "?": (e) => {
|
||||
* setIsHelpVisible(true);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* useKeyboardShortcuts(shortcuts, someElementOrWindowObject, {timeout: 1500});
|
||||
*
|
||||
* @param {object} shortcuts List of keyboard shortcuts/event
|
||||
* @param {EventTarget} [eventTarget=window] An event target.
|
||||
* @param {object} [options = {}] An object for extra options
|
||||
*
|
||||
*/
|
||||
export function useKeyboardShortcuts( shortcuts, eventTarget = window, options = {} ) {
|
||||
const [keyChain, setKeyChain] = useState([]);
|
||||
const [keyChainQueue, setKeyChainQueue] = useState(null);
|
||||
const [mergedOptions, setMergedOptions] = useState({ ...defaultOptions, ...options });
|
||||
|
||||
// Work out the correct shortcut for the key press
|
||||
const callShortcut = useCallback((e, keys) => {
|
||||
let shortcut;
|
||||
if (keyChain.length > 0) {
|
||||
shortcut = shortcuts[`${keyChain.join("~")}~${e.code}`];
|
||||
} else {
|
||||
shortcut = shortcuts[`${keys}${e.code}`] || shortcuts[`${keys}${e.key.toLowerCase()}`];
|
||||
}
|
||||
|
||||
// if a valid shortcut is found call it and reset the chain
|
||||
if (shortcut) {
|
||||
shortcut(e);
|
||||
setKeyChain([]);
|
||||
}
|
||||
}, [shortcuts, keyChain]);
|
||||
|
||||
// update mergedOptions if options prop changes
|
||||
useEffect(() => {
|
||||
const newOptions = {};
|
||||
if (typeof options.timeout === "number") newOptions.timeout = options.timeout;
|
||||
setMergedOptions({ ...defaultOptions, ...newOptions });
|
||||
}, [options.timeout]);
|
||||
|
||||
// Set up key chains
|
||||
useEffect(() => {
|
||||
if (!keyChainQueue && keyChain.length === 0) return;
|
||||
let timeout;
|
||||
|
||||
timeout = window.setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
setKeyChain([]);
|
||||
}, mergedOptions.timeout);
|
||||
|
||||
if (keyChainQueue) {
|
||||
setKeyChain([...keyChain, keyChainQueue]);
|
||||
setKeyChainQueue(null);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [keyChain, keyChainQueue, mergedOptions.timeout]);
|
||||
|
||||
// set up event listeners
|
||||
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.ctrlKey || e.metaKey || e.altKey) && e.shiftKey ? "shift+" : ""}`;
|
||||
|
||||
// If no special keys, except shift, are pressed and focus is inside a field return
|
||||
if (e.target instanceof Node && isFormField(e.target) && !keys) return;
|
||||
|
||||
// If a special key is pressed reset the key chain else add to the chain
|
||||
if (keys) {
|
||||
setKeyChain([]);
|
||||
} else {
|
||||
setKeyChainQueue(e.code);
|
||||
}
|
||||
|
||||
callShortcut(e, keys);
|
||||
};
|
||||
|
||||
eventTarget.addEventListener("keydown", keyEvent);
|
||||
|
||||
return () => eventTarget.removeEventListener("keydown", keyEvent);
|
||||
}, [shortcuts, eventTarget, callShortcut]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A component 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.
|
||||
* @param {object} [options = {}] An object for extra options
|
||||
*
|
||||
*/
|
||||
export function KeyboardShortcuts({ shortcuts, eventTarget, options }) {
|
||||
useKeyboardShortcuts(shortcuts, eventTarget, options);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
KeyboardShortcuts.propTypes = {
|
||||
shortcuts: PropTypes.object.isRequired,
|
||||
options: PropTypes.shape({
|
||||
timeout: PropTypes.number
|
||||
}),
|
||||
eventTarget: PropTypes.instanceOf(Element)
|
||||
};
|
||||
|
||||
KeyboardShortcuts.defaultProps = {
|
||||
shortcuts: {},
|
||||
options: {},
|
||||
eventTarget: window
|
||||
};
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue