use-keyboard-shortcut
Advanced tools
| # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created | ||
| # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages | ||
| name: Package to NPM | ||
| on: | ||
| release: | ||
| types: [created] | ||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - uses: actions/setup-node@v2 | ||
| with: | ||
| node-version: 16 | ||
| - name: Cypress.io | ||
| uses: cypress-io/github-action@v2.9.7 | ||
| publish-npm: | ||
| needs: test | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - uses: actions/setup-node@v2 | ||
| with: | ||
| node-version: 16 | ||
| registry-url: https://registry.npmjs.org/ | ||
| - run: npm ci | ||
| - run: npm publish | ||
| env: | ||
| NODE_AUTH_TOKEN: ${{secrets.npm_token}} |
| # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node | ||
| # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions | ||
| name: Testing | ||
| on: | ||
| push: | ||
| branches: [ develop ] | ||
| pull_request: | ||
| branches: [ develop ] | ||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v2 | ||
| - name: Use Node.js ${{ matrix.node-version }} | ||
| uses: actions/setup-node@v2 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
| cache: 'npm' | ||
| - run: npm ci | ||
| - name: Cypress.io | ||
| uses: cypress-io/github-action@v2.9.7 | ||
| {} |
| import { checkHeldKeysRecursive } from '../../lib/utils' | ||
| describe('checkHeldKeysRecursive', () => { | ||
| it('return early with false if passed key is not in shortcut array', () => { | ||
| // Function expects all inputs to be lowercase normalized | ||
| const shortcutKey = 'a' | ||
| const shortcutArray = ['shift', 'k'] | ||
| // By settings this to null, we make sure to return false before an error is thrown. | ||
| const heldKeysArray = null | ||
| const isKeyPartOfShortcutCheck = checkHeldKeysRecursive(shortcutKey, null, shortcutArray, heldKeysArray) | ||
| expect(isKeyPartOfShortcutCheck).to.equal(false) | ||
| }) | ||
| it('return early with false if held keys array and shortcut array do not match', () => { | ||
| // Function expects all inputs to be lowercase normalized | ||
| const shortcutKey = 'j' | ||
| const shortcutArray = ['shift', 'f', 'j'] | ||
| const heldKeysArray = ['shift'] | ||
| expect(checkHeldKeysRecursive(shortcutKey, null, shortcutArray, heldKeysArray)).to.equal(false) | ||
| }) | ||
| it('return early with true if shortcut key matches first index of shortcuy array', () => { | ||
| // Function expects all inputs to be lowercase normalized | ||
| const shortcutKey = 'f' | ||
| const shortcutArray = ['f'] | ||
| const heldKeysArray = [] | ||
| expect(checkHeldKeysRecursive(shortcutKey, null, shortcutArray, heldKeysArray)).to.equal(true) | ||
| }) | ||
| it('return false if key previous to shortcut key is not being held down', () => { | ||
| // Function expects all inputs to be lowercase normalized | ||
| const shortcutKey = 'e' | ||
| const shortcutArray = ['d', 'a', 'e'] | ||
| const heldKeysArray = ['d'] | ||
| expect(checkHeldKeysRecursive(shortcutKey, null, shortcutArray, heldKeysArray)).to.equal(false) | ||
| }) | ||
| it('return true while recursively checking large shortcut combination', () => { | ||
| // Function expects all inputs to be lowercase normalized | ||
| const shortcutKey = 'e' | ||
| const shortcutArray = ['d', 'a', 'c', 'e'] | ||
| const heldKeysArray = ['d', 'a', 'c'] | ||
| expect(checkHeldKeysRecursive(shortcutKey, null, shortcutArray, heldKeysArray)).to.equal(true) | ||
| }) | ||
| }) |
| /// <reference types="cypress" /> | ||
| // *********************************************************** | ||
| // This example plugins/index.js can be used to load plugins | ||
| // | ||
| // You can change the location of this file or turn off loading | ||
| // the plugins file with the 'pluginsFile' configuration option. | ||
| // | ||
| // You can read more here: | ||
| // https://on.cypress.io/plugins-guide | ||
| // *********************************************************** | ||
| // This function is called when a project is opened or re-opened (e.g. due to | ||
| // the project's config changing) | ||
| /** | ||
| * @type {Cypress.PluginConfig} | ||
| */ | ||
| // eslint-disable-next-line no-unused-vars | ||
| module.exports = (on, config) => { | ||
| // `on` is used to hook into various events Cypress emits | ||
| // `config` is the resolved Cypress config | ||
| } |
| // *********************************************** | ||
| // This example commands.js shows you how to | ||
| // create various custom commands and overwrite | ||
| // existing commands. | ||
| // | ||
| // For more comprehensive examples of custom | ||
| // commands please read more here: | ||
| // https://on.cypress.io/custom-commands | ||
| // *********************************************** | ||
| // | ||
| // | ||
| // -- This is a parent command -- | ||
| // Cypress.Commands.add('login', (email, password) => { ... }) | ||
| // | ||
| // | ||
| // -- This is a child command -- | ||
| // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) | ||
| // | ||
| // | ||
| // -- This is a dual command -- | ||
| // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) | ||
| // | ||
| // | ||
| // -- This will overwrite an existing command -- | ||
| // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) |
| // *********************************************************** | ||
| // This example support/index.js is processed and | ||
| // loaded automatically before your test files. | ||
| // | ||
| // This is a great place to put global configuration and | ||
| // behavior that modifies Cypress. | ||
| // | ||
| // You can change the location of this file or turn off | ||
| // automatically serving support files with the | ||
| // 'supportFile' configuration option. | ||
| // | ||
| // You can read more here: | ||
| // https://on.cypress.io/configuration | ||
| // *********************************************************** | ||
| // Import commands.js using ES2015 syntax: | ||
| import './commands' | ||
| // Alternatively you can use CommonJS syntax: | ||
| // require('./commands') |
+21
| MIT License | ||
| Copyright (c) 2021 Arthur Tyukayev | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+100
-62
@@ -1,23 +0,20 @@ | ||
| import { useEffect, useCallback, useReducer } from "react"; | ||
| import { disabledEventPropagation } from './utils' | ||
| import { useEffect, useCallback, useRef, useMemo } from "react"; | ||
| import { | ||
| overrideSystemHandling, | ||
| checkHeldKeysRecursive, | ||
| uniq_fast | ||
| } from "./utils"; | ||
| const blacklistedTargets = ["INPUT", "TEXTAREA"]; | ||
| const BLACKLISTED_DOM_TARGETS = ["TEXTAREA", "INPUT"]; | ||
| const keysReducer = (state, action) => { | ||
| switch (action.type) { | ||
| case "set-key-down": | ||
| const keydownState = { ...state, [action.key]: true }; | ||
| return keydownState; | ||
| case "set-key-up": | ||
| const keyUpState = { ...state, [action.key]: false }; | ||
| return keyUpState; | ||
| case "reset-keys": | ||
| const resetState = { ...action.data }; | ||
| return resetState; | ||
| default: | ||
| return state; | ||
| } | ||
| const DEFAULT_OPTIONS = { | ||
| overrideSystem: false, | ||
| ignoreInputFields: true | ||
| }; | ||
| const useKeyboardShortcut = (shortcutKeys, callback, options) => { | ||
| const useKeyboardShortcut = ( | ||
| shortcutKeys, | ||
| callback, | ||
| options = DEFAULT_OPTIONS | ||
| ) => { | ||
| if (!Array.isArray(shortcutKeys)) | ||
@@ -38,69 +35,110 @@ throw new Error( | ||
| const { overrideSystem } = options || {} | ||
| const initalKeyMapping = shortcutKeys.reduce((currentKeys, key) => { | ||
| currentKeys[key.toLowerCase()] = false; | ||
| return currentKeys; | ||
| }, {}); | ||
| // Normalizes the shortcut keys a deduplicated array of lowercased keys. | ||
| const shortcutArray = useMemo( | ||
| () => uniq_fast(shortcutKeys).map((key) => String(key).toLowerCase()), | ||
| // While using JSON.stringify() is bad for most larger objects, this shortcut | ||
| // array is fine as it's small, according to the answer below. | ||
| // https://github.com/facebook/react/issues/14476#issuecomment-471199055 | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| [JSON.stringify(shortcutKeys)] | ||
| ); | ||
| // useRef to avoid a constant re-render on keydown and keyup. | ||
| const heldKeys = useRef([]); | ||
| const [keys, setKeys] = useReducer(keysReducer, initalKeyMapping); | ||
| const keydownListener = useCallback( | ||
| assignedKey => keydownEvent => { | ||
| const loweredKey = assignedKey.toLowerCase(); | ||
| if (keydownEvent.repeat) return | ||
| if (blacklistedTargets.includes(keydownEvent.target.tagName)) return; | ||
| if (loweredKey !== keydownEvent.key.toLowerCase()) return; | ||
| if (keys[loweredKey] === undefined) return; | ||
| (keydownEvent) => { | ||
| const loweredKey = String(keydownEvent.key).toLowerCase(); | ||
| if (!(shortcutArray.indexOf(loweredKey) >= 0)) return; | ||
| if (overrideSystem) { | ||
| keydownEvent.preventDefault(); | ||
| disabledEventPropagation(keydownEvent); | ||
| if (keydownEvent.repeat) return; | ||
| // This needs to be checked as soon as possible to avoid | ||
| // all option checks that might prevent default behavior | ||
| // of the key press. | ||
| // | ||
| // I.E If shortcut is "Shift + A", we shouldn't prevent the | ||
| // default browser behavior of Select All Text just because | ||
| // "A" is being observed for our custom behavior shortcut. | ||
| const isHeldKeyCombinationValid = checkHeldKeysRecursive( | ||
| loweredKey, | ||
| null, | ||
| shortcutArray, | ||
| heldKeys.current | ||
| ); | ||
| if (!isHeldKeyCombinationValid) { | ||
| return; | ||
| } | ||
| setKeys({ type: "set-key-down", key: loweredKey }); | ||
| if ( | ||
| options.ignoreInputFields && | ||
| BLACKLISTED_DOM_TARGETS.indexOf(keydownEvent.target.tagName) >= 0 | ||
| ) { | ||
| return; | ||
| } | ||
| if (options.overrideSystem) { | ||
| overrideSystemHandling(keydownEvent); | ||
| } | ||
| heldKeys.current = [...heldKeys.current, loweredKey]; | ||
| if (heldKeys.current.length === shortcutArray.length) { | ||
| callback(shortcutKeys); | ||
| } | ||
| return false; | ||
| }, | ||
| [keys, overrideSystem] | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| [shortcutArray, callback, options.overrideSystem, options.ignoreInputFields] | ||
| ); | ||
| const keyupListener = useCallback( | ||
| assignedKey => keyupEvent => { | ||
| const raisedKey = assignedKey.toLowerCase(); | ||
| (keyupEvent) => { | ||
| const raisedKey = String(keyupEvent.key).toLowerCase(); | ||
| if (!(shortcutArray.indexOf(raisedKey) >= 0)) return; | ||
| if (blacklistedTargets.includes(keyupEvent.target.tagName)) return; | ||
| if (keyupEvent.key.toLowerCase() !== raisedKey) return; | ||
| if (keys[raisedKey] === undefined) return; | ||
| const raisedKeyHeldIndex = heldKeys.current.indexOf(raisedKey); | ||
| if (!(raisedKeyHeldIndex >= 0)) return; | ||
| if (overrideSystem) { | ||
| keyupEvent.preventDefault(); | ||
| disabledEventPropagation(keyupEvent); | ||
| if ( | ||
| options.ignoreInputFields && | ||
| BLACKLISTED_DOM_TARGETS.indexOf(keyupEvent.target.tagName) >= 0 | ||
| ) { | ||
| return; | ||
| } | ||
| if (options.overrideSystem) { | ||
| overrideSystemHandling(keyupEvent); | ||
| } | ||
| setKeys({ type: "set-key-up", key: raisedKey }); | ||
| let newHeldKeys = []; | ||
| let loopIndex; | ||
| for (loopIndex = 0; loopIndex < heldKeys.current.length; ++loopIndex) { | ||
| if (loopIndex !== raisedKeyHeldIndex) { | ||
| newHeldKeys.push(heldKeys.current[loopIndex]); | ||
| } | ||
| } | ||
| heldKeys.current = newHeldKeys; | ||
| return false; | ||
| }, | ||
| [keys, overrideSystem] | ||
| [shortcutArray, options.overrideSystem, options.ignoreInputFields] | ||
| ); | ||
| useEffect(() => { | ||
| if (!Object.values(keys).filter(value => !value).length) { | ||
| callback(keys); | ||
| setKeys({ type: "reset-keys", data: initalKeyMapping }); | ||
| } else { | ||
| setKeys({ type: null }) | ||
| } | ||
| }, [callback, keys]); | ||
| window.addEventListener("keydown", keydownListener); | ||
| window.addEventListener("keyup", keyupListener); | ||
| return () => { | ||
| window.removeEventListener("keydown", keydownListener); | ||
| window.removeEventListener("keyup", keyupListener); | ||
| }; | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [keydownListener, keyupListener, shortcutArray]); | ||
| // Resets the held keys array if the shortcut keys are changed. | ||
| useEffect(() => { | ||
| shortcutKeys.forEach(k => window.addEventListener("keydown", keydownListener(k))); | ||
| return () => shortcutKeys.forEach(k => window.removeEventListener("keydown", keydownListener(k))); | ||
| }, []); | ||
| useEffect(() => { | ||
| shortcutKeys.forEach(k => window.addEventListener("keyup", keyupListener(k))); | ||
| return () => shortcutKeys.forEach(k => window.removeEventListener("keyup", keyupListener(k))); | ||
| }, []); | ||
| heldKeys.current = []; | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [shortcutArray]); | ||
| }; | ||
| export default useKeyboardShortcut; |
+83
-5
@@ -1,9 +0,87 @@ | ||
| export function disabledEventPropagation(e){ | ||
| if(e){ | ||
| if(e.stopPropagation){ | ||
| export const overrideSystemHandling = (e) => { | ||
| if (e) { | ||
| if (e.preventDefault) e.preventDefault(); | ||
| if (e.stopPropagation) { | ||
| e.stopPropagation(); | ||
| } else if(window.event){ | ||
| } else if (window.event) { | ||
| window.event.cancelBubble = true; | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| // Function stolen from this Stack Overflow answer: | ||
| // https: stackoverflow.com/a/9229821 | ||
| export const uniq_fast = (a) => { | ||
| var seen = {}; | ||
| var out = []; | ||
| var len = a.length; | ||
| var j = 0; | ||
| for (var i = 0; i < len; i++) { | ||
| var item = a[i]; | ||
| if (seen[item] !== 1) { | ||
| seen[item] = 1; | ||
| out[j++] = item; | ||
| } | ||
| } | ||
| return out; | ||
| }; | ||
| // The goal for this recursive function is to check to ensure | ||
| // that the keys are held down in the correct order of the shortcut. | ||
| // I.E if the shortcut array is ["Shift", "E", "A"], this function will ensure | ||
| // that "E" is held down before "A", and "Shift" is held down before "E". | ||
| export const checkHeldKeysRecursive = ( | ||
| shortcutKey, | ||
| // Tracks the call interation for the recursive function, | ||
| // based on the previous index; | ||
| shortcutKeyRecursionIndex = 0, | ||
| shortcutArray, | ||
| heldKeysArray | ||
| ) => { | ||
| const shortcutIndexOfKey = shortcutArray.indexOf(shortcutKey); | ||
| const keyPartOfShortCut = shortcutArray.indexOf(shortcutKey) >= 0; | ||
| // Early exit if they key isn't even in the shortcut combination. | ||
| if (!keyPartOfShortCut) return false; | ||
| // While holding down one of the keys, if another is to be let go, the shortcut | ||
| // should be void. Shortcut keys must be held down in a specifc order. | ||
| // This function is always called before a key is added to held keys on keydown, | ||
| // this will ensure that heldKeys only contains the prefixing keys | ||
| const comparisonIndex = Math.max(heldKeysArray.length - 1, 0); | ||
| if ( | ||
| heldKeysArray.length && | ||
| heldKeysArray[comparisonIndex] !== shortcutArray[comparisonIndex] | ||
| ) { | ||
| return false; | ||
| } | ||
| // Early exit for the first held down key in the shortcut, | ||
| // except if this is a recursive call | ||
| if (shortcutIndexOfKey === 0) { | ||
| // If this isn't the first interation of this recursive function, and we're | ||
| // recursively calling this function, we should always be checking the | ||
| // currently held down keys instead of returning true | ||
| if (shortcutKeyRecursionIndex > 0) | ||
| return heldKeysArray.indexOf(shortcutKey) >= 0; | ||
| return true; | ||
| } | ||
| const previousShortcutKeyIndex = shortcutIndexOfKey - 1; | ||
| const previousShortcutKey = shortcutArray[previousShortcutKeyIndex]; | ||
| const previousShortcutKeyHeld = | ||
| heldKeysArray[previousShortcutKeyIndex] === previousShortcutKey; | ||
| // Early exit if the key just before the currently checked shortcut key | ||
| // isn't being held down. | ||
| if (!previousShortcutKeyHeld) return false; | ||
| // Recursively call this function with the previous key as the new shortcut key | ||
| // but the index of the current shortcut key. | ||
| return checkHeldKeysRecursive( | ||
| previousShortcutKey, | ||
| shortcutIndexOfKey, | ||
| shortcutArray, | ||
| heldKeysArray | ||
| ); | ||
| }; |
+7
-3
| { | ||
| "name": "use-keyboard-shortcut", | ||
| "version": "1.0.6", | ||
| "version": "1.1.0", | ||
| "description": "A custom React hook for adding keyboard shortcuts to your application", | ||
@@ -8,3 +8,3 @@ "main": "index.js", | ||
| "scripts": { | ||
| "test": "echo \"Error: no test specified\" && exit 1" | ||
| "test": "cypress open" | ||
| }, | ||
@@ -19,2 +19,6 @@ "repository": { | ||
| }, | ||
| "devDependencies": { | ||
| "cypress": "^9.1.1", | ||
| "wait-on": "^6.0.0" | ||
| }, | ||
| "keywords": [ | ||
@@ -33,2 +37,2 @@ "react", | ||
| "homepage": "https://github.com/arthurtyukayev/use-keyboard-shortcut#readme" | ||
| } | ||
| } |
+12
-8
@@ -25,11 +25,12 @@ ## useKeyboardShortcut | ||
| ### Documentation | ||
| `useKeyboardShortcut(keysArray, callback)` | ||
| ```javascript | ||
| useKeyboardShortcut(shortcutArray, callback, options) | ||
| ``` | ||
| `keysArray` should be an array of `KeyboardEvent.key` strings. A full list of strings can be seen [here](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) | ||
| | Parameter | Type | Description | | ||
| |--------------|-----------|------------| | ||
| | `shortcutArray` | `Array` | Array of `KeyboardEvent.key` strings. A full list of strings can be seen [here](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) | | ||
| | `callback` | `Function` | Function that is called once the keys have been pressed. | | ||
| | `options` | `Object` | Object containing some configuration options. [See options section](https://github.com/arthurtyukayev/use-keyboard-shortcut#options) | | ||
| `callback` should be a function that is called once the keys have been pressed. | ||
| `options` an object containing some configuration options. | ||
| ### Options | ||
@@ -39,5 +40,8 @@ | ||
| `overrideSystem` overrides the default browser behavior for that specific keyboard shortcut | ||
| | Option | Default | Description | | ||
| |--------------|-----------|------------| | ||
| | `overrideSystem` | `false` | Overrides the default browser behavior for that specific keyboard shortcut. | | ||
| | `ignoreInputFields` | `true` | Allows disabling and disabling the keyboard shortcuts when pressed inside of input fields. | | ||
| ## Bugs / Problems | ||
| [Please create an issue](https://github.com/arthurtyukayev/use-keyboard-shortcut/issues/new). |
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
18832
38.49%14
100%352
146.15%0
-100%46
9.52%2
Infinity%1
Infinity%