├── .npmrc ├── src ├── helpers │ ├── tail.ts │ ├── takeUntilLast.ts │ ├── modifierKeyPressed.ts │ ├── isSameSet.ts │ ├── arraysAreEqual.ts │ ├── mapModifierKeys.ts │ ├── getActiveModifierKeys.ts │ ├── getHotkeysArray.ts │ └── ignoreKeydownEvent.ts ├── __tests__ │ ├── helpers │ │ └── fireKeydownEvent.ts │ └── index.test.tsx ├── vendor │ └── shim-keyboard-event-key │ │ └── index.js ├── stories │ └── index.stories.tsx └── index.ts ├── .husky ├── pre-commit ├── commit-msg └── pre-push ├── commitlint.config.js ├── .eslintignore ├── .travis.yml ├── .storybook └── main.js ├── jest.config.js ├── babel.config.js ├── .gitignore ├── .eslintrc ├── .editorconfig ├── .github ├── workflows │ ├── build.yml │ └── release-and-publish.yml └── actions │ └── build-and-test │ └── action.yml ├── tsconfig.json ├── CHANGELOG.md ├── LICENSE ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /src/helpers/tail.ts: -------------------------------------------------------------------------------- 1 | export default (arr: string[]) => arr[arr.length - 1]; 2 | -------------------------------------------------------------------------------- /src/helpers/takeUntilLast.ts: -------------------------------------------------------------------------------- 1 | export default (arr: string[]) => arr.slice(0, -1); 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn format 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | esm 2 | lib 3 | coverage 4 | babel.config.js 5 | commitlint.config.js 6 | jest.config.js 7 | .storybook 8 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint && yarn format && yarn test 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | script: yarn build && yarn test:cover 5 | after_success: 'yarn coveralls' 6 | -------------------------------------------------------------------------------- /src/helpers/modifierKeyPressed.ts: -------------------------------------------------------------------------------- 1 | export default (event: KeyboardEvent) => 2 | event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; 3 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | framework: "@storybook/react", 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/helpers/fireKeydownEvent.ts: -------------------------------------------------------------------------------- 1 | export default ( 2 | key?: string, 3 | options?: { [key: string]: string | boolean } 4 | ) => { 5 | window.dispatchEvent(new KeyboardEvent("keydown", { key, ...options })); 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src/__tests__/"], 3 | testPathIgnorePatterns: ["/src/__tests__/helpers/", "/node_modules/"], 4 | testEnvironment: "jsdom", 5 | collectCoverageFrom: ["/src/index.ts"], 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current' 8 | } 9 | } 10 | ], 11 | '@babel/preset-react', 12 | '@babel/preset-typescript' 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files 2 | .DS_Store 3 | .DS_Store? 4 | ._* 5 | .Spotlight-V100 6 | .Trashes 7 | ehthumbs.db 8 | Thumbs.db 9 | yarn-error.log 10 | 11 | # Map files 12 | *.map 13 | 14 | # Project dependencies 15 | lib 16 | esm 17 | node_modules 18 | coverage/ 19 | .coveralls.yml 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /src/helpers/isSameSet.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if two arrays contain the same items. 3 | * Items do *not* need to be in the same order. 4 | * 5 | * isSameSet([1, 2, 3], [3, 2, 1]) // true 6 | */ 7 | export default (arr1: string[], arr2: string[]) => 8 | arr1.length === arr2.length && arr1.every((item) => arr2.includes(item)); 9 | -------------------------------------------------------------------------------- /src/helpers/arraysAreEqual.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if two arrays contain the same items. 3 | * Items *must* be in the same order. 4 | * 5 | * arraysEqual([1, 2, 3], [3, 2, 1]) // false 6 | * arraysEqual([1, 2, 3], [1, 2, 3]) // true 7 | */ 8 | export default (arr1: string[], arr2: string[]) => 9 | arr1.length === arr2.length && arr1.every((item, i) => item === arr2[i]); 10 | -------------------------------------------------------------------------------- /src/helpers/mapModifierKeys.ts: -------------------------------------------------------------------------------- 1 | interface ModifierKeyMap { 2 | control: string; 3 | shift: string; 4 | alt: string; 5 | meta: string; 6 | [key: string]: string; 7 | } 8 | 9 | const modifierKeyMap: ModifierKeyMap = { 10 | control: "ctrlKey", 11 | shift: "shiftKey", 12 | alt: "altKey", 13 | meta: "metaKey", 14 | }; 15 | 16 | export default (keys: string[]) => keys.map((k) => modifierKeyMap[k]); 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | build-and-test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | # Checkout the repository first, otherwise the workflow 10 | # won't be able to find the `build-and-test` action. 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Build and Test 15 | uses: ./.github/actions/build-and-test 16 | -------------------------------------------------------------------------------- /src/helpers/getActiveModifierKeys.ts: -------------------------------------------------------------------------------- 1 | export default (event: KeyboardEvent): string[] => { 2 | const modifiers = []; 3 | 4 | if (event.ctrlKey) { 5 | modifiers.push("ctrlKey"); 6 | } 7 | 8 | if (event.shiftKey) { 9 | modifiers.push("shiftKey"); 10 | } 11 | 12 | if (event.altKey) { 13 | modifiers.push("altKey"); 14 | } 15 | 16 | if (event.metaKey) { 17 | modifiers.push("metaKey"); 18 | } 19 | 20 | return modifiers; 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "declaration": true, 8 | "pretty": true, 9 | "rootDir": "src", 10 | "strict": true, 11 | "allowJs": true, 12 | "noUnusedLocals": true, 13 | "noImplicitReturns": true, 14 | "outDir": "lib", 15 | "lib": ["es2018", "dom"], 16 | "skipLibCheck": true 17 | }, 18 | "files": [ 19 | "src/index.ts" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "lib", 24 | "esm" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.0.0](https://github.com/reecelucas/react-use-hotkeys/compare/v1.3.5...v2.0.0) (2022-09-27) 4 | 5 | 6 | ### ⚠ BREAKING CHANGES 7 | 8 | * enable configuration options 9 | 10 | ### Features 11 | 12 | * enable configuration options ([7562fac](https://github.com/reecelucas/react-use-hotkeys/commit/7562fac181b310e81249592c00884bb678acbce0)) 13 | 14 | ## [1.3.5](https://github.com/reecelucas/react-use-hotkeys/compare/v1.3.4...v1.3.5) (2022-09-02) 15 | 16 | 17 | ### Miscellaneous Chores 18 | 19 | * release 1.3.5 ([fdab890](https://github.com/reecelucas/react-use-hotkeys/commit/fdab890cf67ff6eed6ef09fb0edf0580ff5b215e)) 20 | -------------------------------------------------------------------------------- /.github/actions/build-and-test/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Build and Test' 2 | description: 'Lint, build and test the package' 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Setup Node 7 | uses: actions/setup-node@v3 8 | with: 9 | node-version: '16' 10 | registry-url: 'https://registry.npmjs.org' 11 | cache: 'yarn' 12 | 13 | - name: Install 14 | run: yarn install --immutable 15 | shell: bash 16 | 17 | - name: Lint 18 | run: yarn lint 19 | shell: bash 20 | 21 | - name: Build 22 | run: yarn build 23 | shell: bash 24 | 25 | - name: Test 26 | run: yarn test --ci 27 | shell: bash 28 | -------------------------------------------------------------------------------- /src/helpers/getHotkeysArray.ts: -------------------------------------------------------------------------------- 1 | export default (hotkeys: string): string[] => { 2 | const hkeys = hotkeys.toLowerCase(); 3 | 4 | if (hkeys.length === 1) { 5 | // We're dealing with a single key 6 | return [hkeys]; 7 | } 8 | 9 | if (hkeys.includes("+")) { 10 | // We're dealing with a modifier-key combination. The `/\b\+/g` regex 11 | // matches the first `+` at the end of each word, allowing combinations including `+`, 12 | // E.g. `Shift++`. We can't use a negative lookbehind because of lack of support. 13 | return hkeys.replace(/\s+/g, "").split(/\b\+/g); 14 | } 15 | 16 | /** 17 | * We're dealing with a key sequence, so split on spaces. 18 | * If the whitespace character is within quotation marks (" " or ' ') 19 | * it signifies a space key and not a delimeter. 20 | */ 21 | return [...(hkeys.match(/[^\s"']+|"([^"]*)"|'([^']*)'/g) || [])].map((key) => 22 | key.replace(/("|').*?("|')/, " ") 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/vendor/shim-keyboard-event-key/index.js: -------------------------------------------------------------------------------- 1 | // Vendored version of https://github.com/shvaikalesh/shim-keyboard-event-key. 2 | // Necessary to fix an issue with SSR. 3 | (function() { 4 | 'use strict'; 5 | 6 | if (typeof self === 'undefined' || !self.document) return; 7 | 8 | var event = KeyboardEvent.prototype; 9 | var desc = Object.getOwnPropertyDescriptor(event, 'key'); 10 | if (!desc) return; 11 | 12 | var keys = { 13 | Win: 'Meta', 14 | Scroll: 'ScrollLock', 15 | Spacebar: ' ', 16 | 17 | Down: 'ArrowDown', 18 | Left: 'ArrowLeft', 19 | Right: 'ArrowRight', 20 | Up: 'ArrowUp', 21 | 22 | Del: 'Delete', 23 | Apps: 'ContextMenu', 24 | Esc: 'Escape', 25 | 26 | Multiply: '*', 27 | Add: '+', 28 | Subtract: '-', 29 | Decimal: '.', 30 | Divide: '/' 31 | }; 32 | 33 | Object.defineProperty(event, 'key', { 34 | get: function() { 35 | var key = desc.get.call(this); 36 | 37 | return keys.hasOwnProperty(key) ? keys[key] : key; 38 | } 39 | }); 40 | })(); 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Reece Lucas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpers/ignoreKeydownEvent.ts: -------------------------------------------------------------------------------- 1 | import modifierKeyPressed from "./modifierKeyPressed"; 2 | 3 | const ELEMENTS_TO_IGNORE = ["INPUT", "TEXTAREA"] as const; 4 | 5 | export type ElementsToIgnore = typeof ELEMENTS_TO_IGNORE[number]; 6 | 7 | export default ( 8 | event: KeyboardEvent, 9 | enableOnContentEditable?: boolean, 10 | ignoredElementWhitelist?: ElementsToIgnore[] 11 | ) => { 12 | /** 13 | * Chrome autocomplete triggers `keydown` event but `event.key` will be undefined. 14 | * See https://bugs.chromium.org/p/chromium/issues/detail?id=581537. 15 | */ 16 | if (!event.key && !modifierKeyPressed(event)) { 17 | return true; 18 | } 19 | 20 | const target = (event.target || {}) as HTMLElement; 21 | 22 | /** 23 | * Ignore the keydown event if it originates from a `contenteditable` 24 | * element, unless the user has overridden this behaviour. 25 | */ 26 | if (target.isContentEditable && !enableOnContentEditable) { 27 | return true; 28 | } 29 | 30 | /** 31 | * Ignore the keydown event if it originates from one of the 32 | * `ELEMENTS_TO_IGNORE`, unless the user has whitelisted it. 33 | */ 34 | return ( 35 | ELEMENTS_TO_IGNORE.includes(target.nodeName as ElementsToIgnore) && 36 | !ignoredElementWhitelist?.includes(target.nodeName as ElementsToIgnore) 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /.github/workflows/release-and-publish.yml: -------------------------------------------------------------------------------- 1 | name: Release and Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release-please: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # `release-please` automates CHANGELOG generation, 13 | # the creation of GitHub releases, and version bumps, 14 | # using conventional commits. 15 | - name: Release 16 | uses: google-github-actions/release-please-action@v3 17 | id: release 18 | with: 19 | release-type: node 20 | package-name: '@reecelucas/react-use-hotkeys' 21 | 22 | # The logic below handles NPM publication. The `if` statements 23 | # ensure we only publish when a release PR is merged. 24 | 25 | # Checkout the repository first, otherwise the workflow 26 | # won't be able to find the `build-and-test` action. 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | if: ${{ steps.release.outputs.release_created }} 30 | 31 | - name: Build and Test 32 | uses: ./.github/actions/build-and-test 33 | if: ${{ steps.release.outputs.release_created }} 34 | 35 | # `--ignore-scripts` secures the `publish` command from malicious packages: 36 | # https://snyk.io/blog/github-actions-to-securely-publish-npm-packages/ 37 | - name: Publish 38 | run: npm publish --ignore-scripts 39 | env: 40 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 41 | if: ${{ steps.release.outputs.release_created }} 42 | -------------------------------------------------------------------------------- /src/stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import useHotkeys from ".."; 4 | 5 | interface ComponentProps { 6 | hotkey: string; 7 | callback?: (event: KeyboardEvent) => void; 8 | } 9 | 10 | const Component: React.FC = ({ hotkey, callback }) => { 11 | useHotkeys(hotkey, (event) => { 12 | if (typeof callback === "function") { 13 | callback(event); 14 | } else { 15 | alert(`${hotkey} pressed!`); 16 | } 17 | }); 18 | 19 | return
Press {hotkey}
; 20 | }; 21 | 22 | export default { 23 | title: "useHotkeys", 24 | component: Component, 25 | }; 26 | 27 | const Template = (args) => ; 28 | 29 | export const SingleHotkey = Template.bind({}); 30 | 31 | SingleHotkey.args = { 32 | label: "Single hotkey", 33 | hotkey: "Escape", 34 | }; 35 | 36 | export const ModifierCombination = Template.bind({}); 37 | 38 | ModifierCombination.args = { 39 | label: "Modifier Combination", 40 | hotkey: "Meta+Shift+z", 41 | }; 42 | 43 | export const KeySequences = Template.bind({}); 44 | 45 | KeySequences.args = { 46 | label: "Key Sequences", 47 | hotkey: "a b c", 48 | }; 49 | 50 | export const SpaceInSequence = Template.bind({}); 51 | 52 | SpaceInSequence.args = { 53 | label: "Space in Sequence", 54 | hotkey: 'a " " c', 55 | }; 56 | 57 | export const EscapeHatch = Template.bind({}); 58 | 59 | EscapeHatch.args = { 60 | label: "Escape Hatch", 61 | hotkey: "*", 62 | callback: (event) => { 63 | alert(`${event.key} pressed!`); 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@reecelucas/react-use-hotkeys", 3 | "version": "2.0.0", 4 | "description": "React hook to create keyboard shortcuts", 5 | "main": "lib/index.js", 6 | "module": "esm/index.js", 7 | "files": [ 8 | "lib/", 9 | "esm/" 10 | ], 11 | "scripts": { 12 | "start": "start-storybook -p 6006", 13 | "test": "jest", 14 | "test:cover": "jest --coverage", 15 | "coveralls": "cat coverage/lcov.info | node node_modules/.bin/coveralls", 16 | "lint": "eslint \"src/**/*.{ts,tsx}\" && prettier --check \"src/**/*.{ts,tsx}\"", 17 | "format": "prettier --write \"src/**/*.{ts,tsx}\"", 18 | "build:cjs": "tsc", 19 | "build:esm": "tsc -m esNext --outDir esm", 20 | "build": "yarn build:cjs && yarn build:esm", 21 | "prepublishOnly": "yarn lint && yarn test && yarn build", 22 | "prepare": "husky install" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/reecelucas/react-use-hotkeys.git" 27 | }, 28 | "keywords": [ 29 | "react", 30 | "hook", 31 | "react-hooks", 32 | "hotkeys", 33 | "keyboard", 34 | "shortcut", 35 | "react-use", 36 | "use" 37 | ], 38 | "author": "Reece Lucas ", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/reecelucas/react-use-hotkeys/issues" 42 | }, 43 | "homepage": "https://github.com/reecelucas/react-use-hotkeys#readme", 44 | "peerDependencies": { 45 | "react": ">=16.8.0" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.18.13", 49 | "@babel/preset-env": "^7.18.10", 50 | "@babel/preset-react": "^7.18.6", 51 | "@babel/preset-typescript": "^7.18.6", 52 | "@commitlint/cli": "^17.1.2", 53 | "@commitlint/config-conventional": "^17.1.0", 54 | "@storybook/builder-webpack4": "^6.5.10", 55 | "@storybook/manager-webpack4": "^6.5.10", 56 | "@storybook/react": "^6.5.10", 57 | "@testing-library/react": "^13.3.0", 58 | "@types/jest": "^29.0.0", 59 | "@types/react": "^18.0.18", 60 | "@typescript-eslint/eslint-plugin": "^5.36.1", 61 | "@typescript-eslint/parser": "^5.36.1", 62 | "babel-jest": "^29.0.1", 63 | "babel-loader": "^8.2.5", 64 | "coveralls": "^3.1.1", 65 | "eslint": "^8.23.0", 66 | "eslint-config-prettier": "^8.5.0", 67 | "husky": "^8.0.1", 68 | "jest": "^29.0.1", 69 | "jest-environment-jsdom": "^29.0.1", 70 | "prettier": "^2.7.1", 71 | "react": "^18.2.0", 72 | "react-dom": "^18.2.0", 73 | "typescript": "^4.8.2" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from "react"; 2 | import arraysAreEqual from "./helpers/arraysAreEqual"; 3 | import getActiveModifierKeys from "./helpers/getActiveModifierKeys"; 4 | import getHotkeysArray from "./helpers/getHotkeysArray"; 5 | import isSameSet from "./helpers/isSameSet"; 6 | import mapModifierKeys from "./helpers/mapModifierKeys"; 7 | import modifierKeyPressed from "./helpers/modifierKeyPressed"; 8 | import tail from "./helpers/tail"; 9 | import takeUntilLast from "./helpers/takeUntilLast"; 10 | import ignoreKeydownEvent from "./helpers/ignoreKeydownEvent"; 11 | import type { ElementsToIgnore } from "./helpers/ignoreKeydownEvent"; 12 | 13 | import "./vendor/shim-keyboard-event-key"; 14 | 15 | interface SequenceTimers { 16 | [key: number]: number; 17 | } 18 | 19 | interface KeySequences { 20 | [key: number]: string[]; 21 | } 22 | 23 | interface Options { 24 | enabled?: boolean; 25 | enableOnContentEditable?: boolean; 26 | ignoredElementWhitelist?: ElementsToIgnore[]; 27 | eventListenerOptions?: AddEventListenerOptions; 28 | } 29 | 30 | const KEY_SEQUENCE_TIMEOUT = 1000; 31 | const ESCAPE_HATCH_KEY = "*"; 32 | 33 | const useHotkeys = ( 34 | hotkeys: string | string[], 35 | callback: (event: KeyboardEvent) => void, 36 | options?: Options 37 | ) => { 38 | const hotkeysArray: string[][] = useMemo( 39 | () => 40 | Array.isArray(hotkeys) 41 | ? hotkeys.map(getHotkeysArray) 42 | : [getHotkeysArray(hotkeys)], 43 | [hotkeys] 44 | ); 45 | 46 | useEffect(() => { 47 | const keySequences: KeySequences = {}; 48 | const sequenceTimers: SequenceTimers = {}; 49 | 50 | const { 51 | enabled, 52 | enableOnContentEditable, 53 | ignoredElementWhitelist, 54 | eventListenerOptions, 55 | } = options || {}; 56 | 57 | const clearSequenceTimer = (index: number) => { 58 | clearTimeout(sequenceTimers[index]); 59 | }; 60 | 61 | const resetKeySequence = (index: number) => { 62 | clearSequenceTimer(index); 63 | keySequences[index] = []; 64 | }; 65 | 66 | const handleKeySequence = ( 67 | event: KeyboardEvent, 68 | keys: string[], 69 | index: number 70 | ) => { 71 | clearSequenceTimer(index); 72 | 73 | keySequences[index] = keySequences[index] || []; 74 | sequenceTimers[index] = window.setTimeout(() => { 75 | resetKeySequence(index); 76 | }, KEY_SEQUENCE_TIMEOUT); 77 | 78 | const keySequence = keySequences[index]; 79 | keySequence.push(event.key.toLowerCase()); 80 | 81 | if (arraysAreEqual(keySequence, keys)) { 82 | resetKeySequence(index); 83 | callback(event); 84 | } 85 | }; 86 | 87 | const handleModifierCombo = (event: KeyboardEvent, keys: string[]) => { 88 | const actionKey: string = tail(keys); 89 | const modKeys = mapModifierKeys(takeUntilLast(keys)); 90 | const activeModKeys = getActiveModifierKeys(event); 91 | const allModKeysPressed = isSameSet(modKeys, activeModKeys); 92 | 93 | if (allModKeysPressed && event.key.toLowerCase() === actionKey) { 94 | callback(event); 95 | } 96 | }; 97 | 98 | const onKeydown = (event: KeyboardEvent) => { 99 | if ( 100 | ignoreKeydownEvent( 101 | event, 102 | enableOnContentEditable, 103 | ignoredElementWhitelist 104 | ) 105 | ) { 106 | return; 107 | } 108 | 109 | hotkeysArray.forEach((keysArray, i) => { 110 | if (keysArray.length === 1 && keysArray[0] === ESCAPE_HATCH_KEY) { 111 | /** 112 | * Provide escape hatch should the user want to perform 113 | * some custom logic not supported by the API. 114 | */ 115 | callback(event); 116 | return; 117 | } 118 | 119 | // Handle modifier key combos 120 | if (modifierKeyPressed(event)) { 121 | handleModifierCombo(event, keysArray); 122 | return; 123 | } 124 | 125 | // Handle key sequences 126 | if (keysArray.length > 1 && !modifierKeyPressed(event)) { 127 | handleKeySequence(event, keysArray, i); 128 | return; 129 | } 130 | 131 | // Handle the basic case; a single hotkey 132 | if (event.key.toLowerCase() === keysArray[0]) { 133 | callback(event); 134 | } 135 | }); 136 | }; 137 | 138 | if (enabled !== false) { 139 | window.addEventListener("keydown", onKeydown, eventListenerOptions); 140 | } 141 | 142 | return () => { 143 | window.removeEventListener("keydown", onKeydown, eventListenerOptions); 144 | }; 145 | }, [ 146 | hotkeysArray, 147 | callback, 148 | options?.enabled, 149 | options?.enableOnContentEditable, 150 | options?.ignoredElementWhitelist, 151 | options?.eventListenerOptions, 152 | ]); 153 | }; 154 | 155 | export default useHotkeys; 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-use-hotkeys 2 | 3 | React hook for creating simple keyboard shortcuts. 4 | 5 | [![Coverage Status](https://coveralls.io/repos/github/reecelucas/react-use-hotkeys/badge.svg?branch=master)](https://coveralls.io/github/reecelucas/react-use-hotkeys?branch=master) 6 | [![Build Status](https://travis-ci.org/reecelucas/react-use-hotkeys.svg?branch=master)](https://travis-ci.org/reecelucas/react-use-hotkeys) 7 | ![npm bundle size (scoped)](https://img.shields.io/bundlephobia/minzip/@reecelucas/react-use-hotkeys.svg) 8 | ![npm (scoped)](https://img.shields.io/npm/v/@reecelucas/react-use-hotkeys.svg) 9 | ![GitHub](https://img.shields.io/github/license/reecelucas/react-use-hotkeys.svg) 10 | 11 | ## Installation 12 | 13 | ```Bash 14 | npm install @reecelucas/react-use-hotkeys 15 | ``` 16 | 17 | ## Example Usage 18 | 19 | ```ts 20 | import useHotkeys from "@reecelucas/react-use-hotkeys"; 21 | ``` 22 | 23 | All hotkey combinations must use valid `KeyBoardEvent` `"key"` values. A full list can be found on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) and Wes Bos has created a great [interactive lookup](https://keycode.info/). 24 | 25 | ```jsx 26 | // Single keys 27 | useHotkeys("Escape", () => { 28 | console.log("Some action"); 29 | }); 30 | 31 | useHotkeys("F7", () => { 32 | console.log("Some action"); 33 | }); 34 | 35 | // Modifier combinations 36 | useHotkeys("Meta+Shift+z", () => { 37 | console.log("Some action"); 38 | }); 39 | 40 | // Key sequences 41 | useHotkeys("w s d", () => { 42 | console.log("Some action"); 43 | }); 44 | 45 | useHotkeys('w " " d', () => { 46 | // space key in sequence (`w ' ' d` also works) 47 | console.log("Some action"); 48 | }); 49 | 50 | // Multiple key combinations mapped to the same callback 51 | useHotkeys(["Control+z", "Meta+z"], () => { 52 | console.log("Some action"); 53 | }); 54 | 55 | useHotkeys(["a", "Meta+z", "w s d"], () => { 56 | console.log("Some action"); 57 | }); 58 | ``` 59 | 60 | The following patterns are **not** supported: 61 | 62 | ```jsx 63 | // Modifier keys in sequences 64 | useHotkeys("Control i d", () => { 65 | console.log("I won't run!"); 66 | }); 67 | 68 | // Modifier combinations in sequences 69 | useHotkeys("Control+z i d", () => { 70 | console.log("I won't run!"); 71 | }); 72 | ``` 73 | 74 | If you find a use case where the API is too restrictive you can use the escape hatch to perform whatever custom logic you need: 75 | 76 | ```jsx 77 | useHotkeys("*", (event) => { 78 | console.log("I will run on every keydown event"); 79 | 80 | if (customKeyLogic(event)) { 81 | console.log("some action"); 82 | } 83 | }); 84 | ``` 85 | 86 | ## Options 87 | 88 | ### `enabled` 89 | 90 | You can disable the hook by passing `enabled: false`. When disabled the hook will stop listening for `keydown` events: 91 | 92 | ```jsx 93 | useHotkeys( 94 | "Escape", 95 | () => { 96 | console.log("I won't run!"); 97 | }, 98 | { enabled: false } 99 | ); 100 | ``` 101 | 102 | ### `enableOnContentEditable` 103 | 104 | By default, the hook will ignore `keydown` events originating from elements with the `contenteditable` attribute, since this behaviour is normally what you want. If you want to override this behaviour you can pass `enableOnContentEditable: true`: 105 | 106 | ```jsx 107 | useHotkeys( 108 | "Escape", 109 | () => { 110 | console.log("Some action"); 111 | }, 112 | { enableOnContentEditable: true } 113 | ); 114 | ``` 115 | 116 | ### `ignoredElementWhitelist` 117 | 118 | By default, the hook will ignore `keydown` events originating from `INPUT` and `TEXTAREA` elements, since this behaviour is normally what you want. If you want to override this behaviour you can use `ignoredElementWhitelist`: 119 | 120 | ```jsx 121 | useHotkeys( 122 | "Escape", 123 | () => { 124 | console.log("I will now run on input elements"); 125 | }, 126 | { ignoredElementWhitelist: ["INPUT"] } 127 | ); 128 | 129 | useHotkeys( 130 | "Escape", 131 | () => { 132 | console.log("I will now run on input and textarea elements"); 133 | }, 134 | { ignoredElementWhitelist: ["INPUT", "TEXTAREA"] } 135 | ); 136 | ``` 137 | 138 | ### `eventListenerOptions` 139 | 140 | You can pass [`AddEventListenerOptions`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters) if you need to listen for `keydown` events in the capturing phase: 141 | 142 | ```jsx 143 | useHotkeys( 144 | "Escape", 145 | () => { 146 | console.log("I will run in the capturing phase"); 147 | }, 148 | { 149 | eventListenerOptions: { 150 | capture: true, 151 | }, 152 | } 153 | ); 154 | ``` 155 | 156 | ## Call Signature 157 | 158 | ```ts 159 | useHotkeys( 160 | hotkeys: string | string[], 161 | callback: (event: KeyboardEvent) => void, 162 | options?: { 163 | enabled?: boolean; 164 | enableOnContentEditable?: boolean; 165 | ignoredElementWhitelist?: ("INPUT" | "TEXTAREA")[]; 166 | eventListenerOptions?: AddEventListenerOptions; 167 | } 168 | ) => void; 169 | ``` 170 | 171 | ## Tests 172 | 173 | Tests use [Jest](https://jestjs.io/) and [react-testing-library](https://github.com/kentcdodds/react-testing-library). 174 | 175 | ```Bash 176 | git clone git@github.com:reecelucas/react-use-hotkeys.git 177 | cd react-use-hotkeys 178 | yarn 179 | yarn test 180 | ``` 181 | 182 | ## LICENSE 183 | 184 | [MIT](./LICENSE) 185 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { screen, render, fireEvent } from "@testing-library/react"; 3 | import useHotkeys from "../index"; 4 | 5 | import fireKeydownEvent from "./helpers/fireKeydownEvent"; 6 | 7 | interface ComponentProps { 8 | hotkeys: string | string[]; 9 | callback: jest.Mock; 10 | options?: Record; 11 | } 12 | 13 | const Component = (props: ComponentProps) => { 14 | useHotkeys( 15 | props.hotkeys, 16 | (event) => { 17 | props.callback(event); 18 | }, 19 | props.options 20 | ); 21 | 22 | return ( 23 | <> 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | const setup = ( 31 | hotkeys: ComponentProps["hotkeys"], 32 | callback: ComponentProps["callback"], 33 | options?: ComponentProps["options"] 34 | ) => { 35 | return render( 36 | 37 | ); 38 | }; 39 | 40 | describe("useHotkeys: basic", () => { 41 | test("callback should be called in response to the keydown event", () => { 42 | const spy = jest.fn(); 43 | 44 | setup("z", spy); 45 | expect(spy).toHaveBeenCalledTimes(0); 46 | 47 | window.dispatchEvent(new Event("click")); 48 | expect(spy).toHaveBeenCalledTimes(0); 49 | 50 | fireKeydownEvent("z"); 51 | expect(spy).toHaveBeenCalledTimes(1); 52 | }); 53 | 54 | test("callback should not be called if event.key is not defined and no modifer key is pressed", () => { 55 | const spy = jest.fn(); 56 | 57 | setup("*", spy); 58 | 59 | fireKeydownEvent(); 60 | expect(spy).toHaveBeenCalledTimes(0); 61 | 62 | fireKeydownEvent(undefined, { ctrlKey: true }); 63 | expect(spy).toHaveBeenCalledTimes(1); 64 | }); 65 | 66 | test("callback should be called with the KeyboardEvent object", () => { 67 | const spy = jest.fn(); 68 | 69 | setup("z", spy); 70 | const event = new KeyboardEvent("keydown", { key: "z" }); 71 | window.dispatchEvent(event); 72 | expect(spy).toHaveBeenCalledWith(event); 73 | }); 74 | 75 | test("z key should fire when pressing z", () => { 76 | const spy = jest.fn(); 77 | 78 | setup("z", spy); 79 | fireKeydownEvent("z"); 80 | expect(spy).toHaveBeenCalledTimes(1); 81 | }); 82 | 83 | test("z key should not fire when pressing y", () => { 84 | const spy = jest.fn(); 85 | 86 | setup("z", spy); 87 | fireKeydownEvent("y"); 88 | expect(spy).toHaveBeenCalledTimes(0); 89 | }); 90 | 91 | test("space key should fire when pressing space", () => { 92 | const spy = jest.fn(); 93 | 94 | setup(" ", spy); 95 | fireKeydownEvent(" "); 96 | expect(spy).toHaveBeenCalledTimes(1); 97 | }); 98 | 99 | test("hotkeys should not be case sensitive", () => { 100 | const spy1 = jest.fn(); 101 | const spy2 = jest.fn(); 102 | const spy3 = jest.fn(); 103 | 104 | setup("escape", spy1); 105 | fireKeydownEvent("Escape"); 106 | expect(spy1).toHaveBeenCalledTimes(1); 107 | 108 | setup("ConTrol", spy2); 109 | fireKeydownEvent("Control"); 110 | expect(spy2).toHaveBeenCalledTimes(1); 111 | 112 | setup("Z", spy3); 113 | fireKeydownEvent("z"); 114 | expect(spy3).toHaveBeenCalledTimes(1); 115 | }); 116 | }); 117 | 118 | describe("useHotkeys: modifier keys", () => { 119 | test("modifier+key should fire when pressing modifier+key", () => { 120 | const spy1 = jest.fn(); 121 | const spy2 = jest.fn(); 122 | const spy3 = jest.fn(); 123 | const spy4 = jest.fn(); 124 | 125 | setup("Control+z", spy1); 126 | fireKeydownEvent("z", { ctrlKey: true }); 127 | expect(spy1).toHaveBeenCalledTimes(1); 128 | 129 | setup("Shift+z", spy2); 130 | fireKeydownEvent("z", { shiftKey: true }); 131 | expect(spy2).toHaveBeenCalledTimes(1); 132 | 133 | setup("Alt+z", spy3); 134 | fireKeydownEvent("z", { altKey: true }); 135 | expect(spy3).toHaveBeenCalledTimes(1); 136 | 137 | setup("Meta+z", spy4); 138 | fireKeydownEvent("z", { metaKey: true }); 139 | expect(spy4).toHaveBeenCalledTimes(1); 140 | }); 141 | 142 | test("multiple modifier keys should work", () => { 143 | const spy = jest.fn(); 144 | 145 | setup("Control+Shift+Alt+z", spy); 146 | fireKeydownEvent("z", { ctrlKey: true, shiftKey: true, altKey: true }); 147 | expect(spy).toHaveBeenCalledTimes(1); 148 | }); 149 | 150 | test("whitespace between modifier combinations is ignored", () => { 151 | const spy1 = jest.fn(); 152 | const spy2 = jest.fn(); 153 | const spy3 = jest.fn(); 154 | 155 | setup("Control + z", spy1); 156 | fireKeydownEvent("z", { ctrlKey: true }); 157 | expect(spy1).toHaveBeenCalledTimes(1); 158 | 159 | setup(" Meta+ Enter", spy2); 160 | fireKeydownEvent("Enter", { metaKey: true }); 161 | expect(spy2).toHaveBeenCalledTimes(1); 162 | 163 | setup("Shift + z ", spy3); 164 | fireKeydownEvent("z", { shiftKey: true }); 165 | expect(spy3).toHaveBeenCalledTimes(1); 166 | }); 167 | 168 | test("z should not fire when Control+z is pressed", () => { 169 | const spy1 = jest.fn(); 170 | const spy2 = jest.fn(); 171 | 172 | setup("z", spy1); 173 | fireKeydownEvent("z", { ctrlKey: true }); 174 | expect(spy1).toHaveBeenCalledTimes(0); 175 | 176 | setup("Control+z", spy2); 177 | fireKeydownEvent("z", { ctrlKey: true }); 178 | expect(spy2).toHaveBeenCalledTimes(1); 179 | }); 180 | 181 | test("Control+z should not fire when Control+Shift+z is pressed", () => { 182 | const spy1 = jest.fn(); 183 | const spy2 = jest.fn(); 184 | 185 | setup("Control+z", spy1); 186 | fireKeydownEvent("z", { ctrlKey: true, shiftKey: true }); 187 | expect(spy1).toHaveBeenCalledTimes(0); 188 | 189 | setup("Control+z", spy2); 190 | fireKeydownEvent("z", { ctrlKey: true }); 191 | expect(spy2).toHaveBeenCalledTimes(1); 192 | }); 193 | 194 | test("Meta+Shift+z should not fire when Meta+z is pressed", () => { 195 | const spy1 = jest.fn(); 196 | const spy2 = jest.fn(); 197 | 198 | setup("Meta+Shift+z", spy1); 199 | fireKeydownEvent("z", { metaKey: true }); 200 | expect(spy1).toHaveBeenCalledTimes(0); 201 | 202 | setup("Meta+Shift+z", spy2); 203 | fireKeydownEvent("z", { metaKey: true, shiftKey: true }); 204 | expect(spy2).toHaveBeenCalledTimes(1); 205 | }); 206 | 207 | test("The order in which modifier keys are pressed should not matter", () => { 208 | const spy1 = jest.fn(); 209 | const spy2 = jest.fn(); 210 | 211 | setup("Shift+Meta+z", spy1); 212 | fireKeydownEvent("z", { metaKey: true, shiftKey: true }); 213 | expect(spy1).toHaveBeenCalledTimes(1); 214 | 215 | setup("Meta+Shift+z", spy2); 216 | fireKeydownEvent("z", { metaKey: true, shiftKey: true }); 217 | expect(spy2).toHaveBeenCalledTimes(1); 218 | }); 219 | 220 | test("modifier combinations must end with a key", () => { 221 | const spy1 = jest.fn(); 222 | const spy2 = jest.fn(); 223 | 224 | setup("Meta+Shift", spy1); 225 | fireKeydownEvent("", { metaKey: true, shiftKey: true }); 226 | expect(spy1).toHaveBeenCalledTimes(0); 227 | 228 | setup("Meta+Shift+z", spy2); 229 | fireKeydownEvent("z", { metaKey: true, shiftKey: true }); 230 | expect(spy2).toHaveBeenCalledTimes(1); 231 | }); 232 | 233 | test("modifier combinations support the + key", () => { 234 | const spy = jest.fn(); 235 | 236 | setup("Shift++", spy); 237 | fireKeydownEvent("+", { shiftKey: true }); 238 | expect(spy).toHaveBeenCalledTimes(1); 239 | }); 240 | }); 241 | 242 | describe("useHotkeys: key sequences", () => { 243 | test('"g i" should fire when "g i" is pressed', () => { 244 | const spy = jest.fn(); 245 | 246 | setup("g i", spy); 247 | fireKeydownEvent("g"); 248 | fireKeydownEvent("i"); 249 | expect(spy).toHaveBeenCalledTimes(1); 250 | }); 251 | 252 | test('"g i" should not fire when "i g" is pressed', () => { 253 | const spy = jest.fn(); 254 | 255 | setup("g i", spy); 256 | fireKeydownEvent("i"); 257 | fireKeydownEvent("g"); 258 | expect(spy).toHaveBeenCalledTimes(0); 259 | }); 260 | 261 | test("key should not fire when included in sequence", () => { 262 | const spy1 = jest.fn(); 263 | const spy2 = jest.fn(); 264 | const spy3 = jest.fn(); 265 | 266 | setup("g i d", spy1); 267 | fireKeydownEvent("g"); 268 | expect(spy1).toHaveBeenCalledTimes(0); 269 | 270 | setup("g i d", spy2); 271 | fireKeydownEvent("i"); 272 | expect(spy2).toHaveBeenCalledTimes(0); 273 | 274 | setup("g i d", spy3); 275 | fireKeydownEvent("d"); 276 | expect(spy3).toHaveBeenCalledTimes(0); 277 | }); 278 | 279 | test("sequences should be space-separated", () => { 280 | const spy1 = jest.fn(); 281 | const spy2 = jest.fn(); 282 | 283 | setup("gi", spy1); 284 | fireKeydownEvent("g"); 285 | fireKeydownEvent("i"); 286 | expect(spy1).toHaveBeenCalledTimes(0); 287 | 288 | setup("g i", spy2); 289 | fireKeydownEvent("g"); 290 | fireKeydownEvent("i"); 291 | expect(spy2).toHaveBeenCalledTimes(1); 292 | }); 293 | 294 | test("extra whitespace in a sequence should be ignored", () => { 295 | const spy1 = jest.fn(); 296 | const spy2 = jest.fn(); 297 | const spy3 = jest.fn(); 298 | 299 | setup("g i ", spy1); 300 | fireKeydownEvent("g"); 301 | fireKeydownEvent("i"); 302 | expect(spy1).toHaveBeenCalledTimes(1); 303 | 304 | setup(" g i", spy2); 305 | fireKeydownEvent("g"); 306 | fireKeydownEvent("i"); 307 | expect(spy2).toHaveBeenCalledTimes(1); 308 | 309 | setup("g i", spy3); 310 | fireKeydownEvent("g"); 311 | fireKeydownEvent("i"); 312 | expect(spy3).toHaveBeenCalledTimes(1); 313 | }); 314 | 315 | test("space key should be wrapped in quotation marks", () => { 316 | const spy1 = jest.fn(); 317 | const spy2 = jest.fn(); 318 | const spy3 = jest.fn(); 319 | 320 | setup('" " g i', spy1); 321 | fireKeydownEvent(" "); 322 | fireKeydownEvent("g"); 323 | fireKeydownEvent("i"); 324 | expect(spy1).toHaveBeenCalledTimes(1); 325 | 326 | setup(`g ' ' i`, spy2); // tslint:disable-line:quotemark 327 | fireKeydownEvent("g"); 328 | fireKeydownEvent(" "); 329 | fireKeydownEvent("i"); 330 | expect(spy2).toHaveBeenCalledTimes(1); 331 | 332 | setup(" g i", spy3); 333 | fireKeydownEvent(" "); 334 | fireKeydownEvent("g"); 335 | fireKeydownEvent("i"); 336 | expect(spy3).toHaveBeenCalledTimes(0); 337 | }); 338 | 339 | test("sequences should not fire for sub-sequences", () => { 340 | const spy1 = jest.fn(); 341 | const spy2 = jest.fn(); 342 | const spy3 = jest.fn(); 343 | const spy4 = jest.fn(); 344 | 345 | setup("g i d", spy1); 346 | fireKeydownEvent("g"); 347 | fireKeydownEvent("i"); 348 | expect(spy1).toHaveBeenCalledTimes(0); 349 | 350 | setup("g i d", spy2); 351 | fireKeydownEvent("i"); 352 | fireKeydownEvent("d"); 353 | expect(spy2).toHaveBeenCalledTimes(0); 354 | 355 | setup("g i d", spy3); 356 | fireKeydownEvent("g"); 357 | fireKeydownEvent("i"); 358 | fireKeydownEvent("d"); 359 | expect(spy3).toHaveBeenCalledTimes(1); 360 | 361 | setup("h a t", spy4); 362 | fireKeydownEvent("h"); 363 | fireKeydownEvent("e"); 364 | fireKeydownEvent("a"); 365 | fireKeydownEvent("r"); 366 | fireKeydownEvent("t"); 367 | expect(spy4).toHaveBeenCalledTimes(0); 368 | }); 369 | 370 | test("sequences should not support modifier keys or combos", () => { 371 | const spy1 = jest.fn(); 372 | const spy2 = jest.fn(); 373 | const spy3 = jest.fn(); 374 | 375 | setup("Shift i d", spy1); 376 | fireKeydownEvent("Shift", { shiftKey: true }); 377 | fireKeydownEvent("i"); 378 | fireKeydownEvent("d"); 379 | expect(spy1).toHaveBeenCalledTimes(0); 380 | 381 | setup("g Control+z d", spy2); 382 | fireKeydownEvent("g"); 383 | fireKeydownEvent("z", { ctrlKey: true }); 384 | fireKeydownEvent("d"); 385 | expect(spy2).toHaveBeenCalledTimes(0); 386 | 387 | setup("Meta+s i d", spy3); 388 | fireKeydownEvent("s", { metaKey: true }); 389 | fireKeydownEvent("i"); 390 | fireKeydownEvent("d"); 391 | expect(spy3).toHaveBeenCalledTimes(0); 392 | }); 393 | 394 | test("sequence should timeout", () => { 395 | jest.useFakeTimers(); 396 | const spy = jest.fn(); 397 | 398 | setup("g i", spy); 399 | fireKeydownEvent("g"); 400 | 401 | const timer = setTimeout(() => { 402 | clearTimeout(timer); 403 | fireKeydownEvent("i"); 404 | }, 1000); 405 | 406 | jest.runAllTimers(); 407 | expect(spy).toHaveBeenCalledTimes(0); 408 | }); 409 | 410 | test("sequence should not timeout", () => { 411 | jest.useFakeTimers(); 412 | const spy = jest.fn(); 413 | 414 | setup("g i d", spy); 415 | fireKeydownEvent("g"); 416 | 417 | setTimeout(() => { 418 | fireKeydownEvent("i"); 419 | }, 600); 420 | 421 | setTimeout(() => { 422 | fireKeydownEvent("d"); 423 | }, 900); 424 | 425 | jest.runAllTimers(); 426 | expect(spy).toHaveBeenCalledTimes(1); 427 | }); 428 | }); 429 | 430 | describe("useHotkeys: multiple combinations", () => { 431 | test("single keys should work", () => { 432 | const spy = jest.fn(); 433 | 434 | setup(["g", "i", "d"], spy); 435 | 436 | fireKeydownEvent("g"); 437 | expect(spy).toHaveBeenCalledTimes(1); 438 | 439 | fireKeydownEvent("i"); 440 | expect(spy).toHaveBeenCalledTimes(2); 441 | 442 | fireKeydownEvent("d"); 443 | expect(spy).toHaveBeenCalledTimes(3); 444 | }); 445 | 446 | test("modifier combinations should work", () => { 447 | const spy = jest.fn(); 448 | 449 | setup(["Control+z", "Meta+z"], spy); 450 | 451 | fireKeydownEvent("z", { ctrlKey: true }); 452 | expect(spy).toHaveBeenCalledTimes(1); 453 | 454 | fireKeydownEvent("z", { metaKey: true }); 455 | expect(spy).toHaveBeenCalledTimes(2); 456 | }); 457 | 458 | test("key sequences should work", () => { 459 | jest.useFakeTimers(); 460 | const spy = jest.fn(); 461 | 462 | setup(["g i d", "t i f"], spy); 463 | 464 | fireKeydownEvent("g"); 465 | fireKeydownEvent("i"); 466 | fireKeydownEvent("d"); 467 | expect(spy).toHaveBeenCalledTimes(1); 468 | 469 | setTimeout(() => { 470 | // Wait for previous sequence ('g i d') to timeout 471 | fireKeydownEvent("t"); 472 | fireKeydownEvent("i"); 473 | fireKeydownEvent("f"); 474 | }, 1000); 475 | 476 | jest.runAllTimers(); 477 | expect(spy).toHaveBeenCalledTimes(2); 478 | }); 479 | 480 | test("mixed key combinations should work", () => { 481 | jest.useFakeTimers(); 482 | const spy = jest.fn(); 483 | 484 | setup(["g i d", "Control+z", "a"], spy); 485 | 486 | fireKeydownEvent("z", { ctrlKey: true }); 487 | expect(spy).toHaveBeenCalledTimes(1); 488 | 489 | fireKeydownEvent("g"); 490 | fireKeydownEvent("i"); 491 | fireKeydownEvent("d"); 492 | expect(spy).toHaveBeenCalledTimes(2); 493 | 494 | setTimeout(() => { 495 | // Wait for previous sequence ('g i d') to timeout 496 | fireKeydownEvent("a"); 497 | }, 1000); 498 | 499 | jest.runAllTimers(); 500 | expect(spy).toHaveBeenCalledTimes(3); 501 | }); 502 | }); 503 | 504 | describe("useHotkeys: escape hatch", () => { 505 | test("* should fire for all keys", () => { 506 | const spy = jest.fn(); 507 | 508 | setup("*", spy); 509 | fireKeydownEvent("z"); 510 | fireKeydownEvent(" "); 511 | fireKeydownEvent(";"); 512 | fireKeydownEvent("", { ctrlKey: true }); 513 | fireKeydownEvent("z", { metaKey: true }); 514 | expect(spy).toHaveBeenCalledTimes(5); 515 | }); 516 | }); 517 | 518 | /** 519 | * Note: `enableOnContentEditable` is not possible to test in JSDOM 520 | * since it lacks support for the `contenteditable` attribute. 521 | */ 522 | describe("useHotkeys: options", () => { 523 | describe("enabled", () => { 524 | test("callback should not be called when enabled is false", () => { 525 | const spy = jest.fn(); 526 | 527 | setup("z", spy, { enabled: false }); 528 | expect(spy).toHaveBeenCalledTimes(0); 529 | 530 | fireKeydownEvent("z"); 531 | expect(spy).toHaveBeenCalledTimes(0); 532 | }); 533 | 534 | test("callback should be called when enabled is true", () => { 535 | const spy = jest.fn(); 536 | 537 | setup("z", spy, { enabled: true }); 538 | expect(spy).toHaveBeenCalledTimes(0); 539 | 540 | fireKeydownEvent("z"); 541 | expect(spy).toHaveBeenCalledTimes(1); 542 | }); 543 | 544 | test("callback should be called when enabled is not defined", () => { 545 | const spy = jest.fn(); 546 | 547 | setup("z", spy); 548 | expect(spy).toHaveBeenCalledTimes(0); 549 | 550 | fireKeydownEvent("z"); 551 | expect(spy).toHaveBeenCalledTimes(1); 552 | }); 553 | }); 554 | 555 | describe("ignoredElementWhitelist", () => { 556 | test.each(["INPUT", "TEXTAREA"])( 557 | "callback should not be called when keydown event originates from %s element", 558 | (nodeName) => { 559 | const spy = jest.fn(); 560 | 561 | setup("z", spy); 562 | expect(spy).toHaveBeenCalledTimes(0); 563 | 564 | fireEvent.keyDown(screen.getByTestId(nodeName), { key: "z" }); 565 | expect(spy).toHaveBeenCalledTimes(0); 566 | 567 | // Should be called when the event does not originate from a restricted element 568 | fireKeydownEvent("z"); 569 | expect(spy).toHaveBeenCalledTimes(1); 570 | } 571 | ); 572 | 573 | test.each(["INPUT", "TEXTAREA"])( 574 | "callback should be called when keydown event originates from %s element if it's specified in the ignoredElementWhitelist", 575 | (nodeName) => { 576 | const spy = jest.fn(); 577 | 578 | setup("z", spy, { ignoredElementWhitelist: [nodeName] }); 579 | expect(spy).toHaveBeenCalledTimes(0); 580 | 581 | fireEvent.keyDown(screen.getByTestId(nodeName), { key: "z" }); 582 | expect(spy).toHaveBeenCalledTimes(1); 583 | 584 | // Should also be called when event does not originate from a restricted element 585 | fireKeydownEvent("z"); 586 | expect(spy).toHaveBeenCalledTimes(2); 587 | } 588 | ); 589 | }); 590 | }); 591 | --------------------------------------------------------------------------------