use-keyboard-shortcut
Advanced tools
Comparing version 1.0.6 to 1.1.0
@@ -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; |
@@ -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 | ||
); | ||
}; |
{ | ||
"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" | ||
} | ||
} |
@@ -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). |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
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
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Native code
Supply chain riskContains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.
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
18832
14
352
0
46
2