import { useState, useEffect } 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 ); } /** * Function to handle converting key presses to callback functions * * @param {KeyboardEvent} e Keyboard event * @param {String} keys special keys formatted in a string * @param {Array} chain array of past keys * @param {Object} shortcuts object containing callback functions * * @returns {Array} New chain */ const callShortcut = (e, keys, chain, shortcuts) => { const shortcut = chain && chain.length > 0 ? shortcuts[`${chain.join('~')}~${e.code}`] : 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); return []; } // if we have keys don't add to the chain if (keys || e.key === 'Shift') { return []; } return [...chain, e.code]; }; // Default options to be used if null const defaultOptions = { timeout: 0, // The default is zero as we want no delays between keystrokes by default. }; /** * 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 [storedShortcuts] = useState(shortcuts); const [keyChain, setKeyChain] = useState([]); const [mergedOptions, setMergedOptions] = useState({ ...defaultOptions, ...options, }); // update mergedOptions if options prop changes useEffect(() => { const newOptions = {}; if (typeof options.timeout === 'number') newOptions.timeout = options.timeout; setMergedOptions({ ...defaultOptions, ...newOptions }); }, [options.timeout]); // clear key chain after timeout is reached useEffect(() => { if (keyChain.length <= 0) return; const timeout = window.setTimeout(() => { clearTimeout(timeout); setKeyChain([]); }, mergedOptions.timeout); return () => clearTimeout(timeout); }, [keyChain.length, mergedOptions.timeout]); // set up event listeners useEffect(() => { if (!storedShortcuts || Object.keys(storedShortcuts).length === 0) return; const keyEvent = (e) => { if (e.defaultPrevented) return; const ctrlKeyEntry = e.ctrlKey ? 'ctrl+' : ''; const cmdKeyEntry = e.metaKey ? 'cmd+' : ''; const altKeyEntry = e.altKey ? 'alt+' : ''; const shiftKeyEntry = e.shiftKey ? 'shift+' : ''; // We build the special keys string in an opinionated order to ensure consistency const keys = `${ctrlKeyEntry}${cmdKeyEntry}${altKeyEntry}${shiftKeyEntry}`; // 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; const newChain = callShortcut(e, keys, keyChain, storedShortcuts); // update keychain with latest chain setKeyChain(newChain); }; eventTarget?.addEventListener('keydown', keyEvent); return () => eventTarget?.removeEventListener('keydown', keyEvent); }, [keyChain, storedShortcuts, eventTarget]); } /** * 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') * } * } * * * * * @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.oneOfType([ PropTypes.instanceOf(Element), PropTypes.instanceOf(Window), ]), }; KeyboardShortcuts.defaultProps = { shortcuts: {}, options: {}, eventTarget: window, };