├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── nodejs.yml │ ├── publish-pages.yml │ └── publish.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── karma.config.cjs ├── package-lock.json ├── package.json ├── pages ├── demo.html ├── hotkey_mapper.html └── index.html ├── rollup.config.js ├── src ├── hotkey.ts ├── index.ts ├── macos-symbol-layer.ts ├── macos-uppercase-layer.ts ├── radix-trie.ts ├── sequence.ts └── utils.ts ├── test ├── .eslintrc.json ├── test-normalize-hotkey.js ├── test-radix-trie.js └── test.js └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "22" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | "postCreateCommand": "npm install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | pages 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["plugin:github/recommended", "plugin:github/browser", "plugin:github/typescript"], 4 | "settings": { 5 | "import/resolver": { 6 | "typescript": { 7 | "extensions": [".ts", ".tsx"] 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [push, pull_request] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 22 15 | cache: npm 16 | - name: npm install, build, and test 17 | run: | 18 | npm install 19 | npm run build 20 | npm test 21 | env: 22 | CI: true 23 | -------------------------------------------------------------------------------- /.github/workflows/publish-pages.yml: -------------------------------------------------------------------------------- 1 | name: Publish Pages site 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: npm 21 | 22 | - run: npm install 23 | 24 | - run: npm run buildSite 25 | 26 | - name: Fix permissions 27 | run: | 28 | chmod -c -R +rX "pages/" | while read line; do 29 | echo "::warning title=Invalid file permissions automatically fixed::$line" 30 | done 31 | 32 | - uses: actions/upload-pages-artifact@v2 33 | with: 34 | path: pages 35 | 36 | deploy: 37 | needs: build 38 | 39 | permissions: 40 | pages: write 41 | id-token: write 42 | 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | 47 | runs-on: ubuntu-latest 48 | steps: 49 | - id: deployment 50 | uses: actions/deploy-pages@v2 51 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | id-token: write 10 | 11 | jobs: 12 | publish-npm: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | registry-url: https://registry.npmjs.org/ 20 | cache: npm 21 | - run: npm ci 22 | - run: npm test 23 | - run: npm version ${TAG_NAME} --git-tag-version=false 24 | env: 25 | TAG_NAME: ${{ github.event.release.tag_name }} 26 | - run: npm whoami; npm --ignore-scripts publish --provenance 27 | env: 28 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | pages/hotkey 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/web-systems-reviewers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2020 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hotkey Behavior 2 | 3 | ```html 4 | 5 | ``` 6 | 7 | Trigger an action on a target element when the hotkey (key or sequence of keys) is pressed 8 | on the keyboard. This triggers a focus event on form fields, or a click event on 9 | other elements. 10 | 11 | The hotkey can be scoped to a form field: 12 | 13 | ```html 14 | 17 | 18 | 19 | ``` 20 | 21 | By default, hotkeys are extracted from a target element's `data-hotkey` 22 | attribute, but this can be overridden by passing the hotkey to the registering 23 | function (`install`) as a parameter. 24 | 25 | ## How is this used on GitHub? 26 | 27 | All shortcuts (for example `g i`, `.`, `Meta+k`) within GitHub use hotkey to declare shortcuts in server side templates. This is used on almost every page on GitHub. 28 | 29 | ## Installation 30 | 31 | ``` 32 | $ npm install @github/hotkey 33 | ``` 34 | 35 | ## Usage 36 | 37 | ### HTML 38 | 39 | ```html 40 | 41 | Next 42 | 43 | Search 44 | 45 | Code 46 | 47 | Help 48 | 49 | Search 50 | ``` 51 | 52 | See [the list of `KeyboardEvent` key values](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) for a list of supported key values. 53 | 54 | ### JS 55 | 56 | ```js 57 | import {install} from '@github/hotkey' 58 | 59 | // Install all the hotkeys on the page 60 | for (const el of document.querySelectorAll('[data-hotkey]')) { 61 | install(el) 62 | } 63 | ``` 64 | 65 | Alternatively, the hotkey(s) can be passed to the `install` function as a parameter e.g.: 66 | 67 | ```js 68 | for (const el of document.querySelectorAll('[data-shortcut]')) { 69 | install(el, el.dataset.shortcut) 70 | } 71 | ``` 72 | 73 | To unregister a hotkey from an element, use `uninstall`: 74 | 75 | ```js 76 | import {uninstall} from '@github/hotkey' 77 | 78 | for (const el of document.querySelectorAll('[data-hotkey]')) { 79 | uninstall(el) 80 | } 81 | ``` 82 | 83 | By default form elements (such as `input`,`textarea`,`select`) or elements with `contenteditable` will call `focus()` when the hotkey is triggered. All other elements trigger a `click()`. All elements, regardless of type, will emit a cancellable `hotkey-fire` event, so you can customize the behaviour, if you so choose: 84 | 85 | ```js 86 | for (const el of document.querySelectorAll('[data-shortcut]')) { 87 | install(el, el.dataset.shortcut) 88 | 89 | if (el.matches('.frobber')) { 90 | el.addEventListener('hotkey-fire', event => { 91 | // ensure the default `focus()`/`click()` is prevented: 92 | event.preventDefault() 93 | 94 | // Use a custom behaviour instead 95 | frobulateFrobber(event.target) 96 | }) 97 | } 98 | } 99 | ``` 100 | 101 | ## Hotkey string format 102 | 103 | 1. Hotkey matches against the `event.key`, and uses standard W3C key names for keys and modifiers as documented in [UI Events KeyboardEvent key Values](https://www.w3.org/TR/uievents-key/). 104 | 2. At minimum a hotkey string must specify one bare key. 105 | 3. Multiple hotkeys (aliases) are separated by a `,`. For example the hotkey `a,b` would activate if the user typed `a` or `b`. 106 | 4. Multiple keys separated by a blank space represent a key sequence. For example the hotkey `g n` would activate when a user types the `g` key followed by the `n` key. 107 | 5. Modifier key combos are separated with a `+` and are prepended to a key in a consistent order as follows: `"Control+Alt+Meta+Shift+KEY"`. 108 | 6. `"Mod"` is a special modifier that localizes to `Meta` on MacOS/iOS, and `Control` on Windows/Linux. 109 | 1. `"Mod+"` can appear in any order in a hotkey string. For example: `"Mod+Alt+Shift+KEY"` 110 | 2. Neither the `Control` or `Meta` modifiers should appear in a hotkey string with `Mod`. 111 | 7. `"Plus"` and `"Space"` are special key names to represent the `+` and ` ` keys respectively, because these symbols cannot be represented in the normal hotkey string syntax. 112 | 8. You can use the comma key `,` as a hotkey, e.g. `a,,` would activate if the user typed `a` or `,`. `Control+,,x` would activate for `Control+,` or `x`. 113 | 9. `"Shift"` should be included if it would be held and the key is uppercase: ie, `Shift+A` not `A` 114 | 1. MacOS outputs lowercase key names when `Meta+Shift` is held (ie, `Meta+Shift+a`). In an attempt to normalize this, `hotkey` will automatically map these key names to uppercase, so the uppercase keys should still be used (ie, `"Meta+Shift+A"` or `"Mod+Shift+A"`). **However**, this normalization only works on US keyboard layouts. 115 | 116 | ### Example 117 | 118 | The following hotkey would match if the user typed the key sequence `a` and then `b`, OR if the user held down the `Control`, `Alt` and `/` keys at the same time. 119 | 120 | ```js 121 | 'a b,Control+Alt+/' 122 | ``` 123 | 124 | 🔬 **Hotkey Mapper** is a tool to help you determine the correct hotkey string for your key combination: 125 | 126 | #### Key-sequence considerations 127 | 128 | Two-key-sequences such as `g c` and `g i` are stored 129 | under the 'g' key in a nested object with 'c' and 'i' keys. 130 | 131 | ``` 132 | mappings = 133 | 'c' : New Issue 134 | 'g' : 135 | 'c' : Code 136 | 'i' : Issues 137 | ``` 138 | 139 | In this example, both `g c` and `c` could be available as hotkeys on the 140 | same page, but `g c` and `g` can't coexist. If the user presses 141 | `g`, the `c` hotkey will be unavailable for 1500 ms while we 142 | wait for either `g c` or `g i`. 143 | 144 | ## Accessibility considerations 145 | 146 | ### Character Key Shortcuts 147 | 148 | Please note that adding this functionality to your site can be a drawback for 149 | certain users. Providing a way in your system to disable hotkeys or remap 150 | them makes sure that those users can still use your site (given that it's 151 | accessible to those users). 152 | 153 | See ["Understanding Success Criterion 2.1.4: Character Key Shortcuts"](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts.html) 154 | for further reading on this topic. 155 | 156 | ### Interactive Elements 157 | 158 | Wherever possible, hotkeys should be add to [interactive and focusable elements](https://html.spec.whatwg.org/#interactive-content). If a static element must be used, please follow the guideline in ["Adding keyboard-accessible actions to static HTML elements"](https://www.w3.org/WAI/WCAG21/Techniques/client-side-script/SCR29.html). 159 | 160 | ## Development 161 | 162 | ``` 163 | npm install 164 | npm test 165 | ``` 166 | 167 | ## License 168 | 169 | Distributed under the MIT license. See LICENSE for details. 170 | -------------------------------------------------------------------------------- /karma.config.cjs: -------------------------------------------------------------------------------- 1 | process.env.CHROME_BIN = require('chromium').path 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | frameworks: ['mocha', 'chai'], 6 | files: [ 7 | {pattern: 'dist/index.js', type: 'module'}, 8 | {pattern: 'test/test*.js', type: 'module'} 9 | ], 10 | reporters: ['mocha'], 11 | port: 9876, 12 | colors: true, 13 | logLevel: config.LOG_INFO, 14 | browsers: ['ChromeHeadlessNoSandbox'], 15 | customLaunchers: { 16 | ChromeHeadlessNoSandbox: { 17 | base: 'ChromeHeadless', 18 | flags: ['--no-sandbox'] 19 | } 20 | }, 21 | autoWatch: false, 22 | singleRun: true, 23 | concurrency: Infinity 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/hotkey", 3 | "version": "2.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "repository": "github/hotkey", 10 | "scripts": { 11 | "build": "tsc && rollup -c", 12 | "lint": "eslint . --ext .js,.ts && tsc --noEmit", 13 | "test": "karma start karma.config.cjs", 14 | "clean": "rm -rf dist", 15 | "prebuild": "npm run clean && mkdir dist", 16 | "pretest": "npm run build && npm run lint", 17 | "prepublishOnly": "npm run test", 18 | "buildSite": "npm run build && mkdir -p pages/hotkey && cp -r dist/* pages/hotkey" 19 | }, 20 | "files": [ 21 | "dist", 22 | "index.d.ts" 23 | ], 24 | "keywords": [], 25 | "license": "MIT", 26 | "prettier": "@github/prettier-config", 27 | "devDependencies": { 28 | "@github/prettier-config": "0.0.4", 29 | "chai": "^4.3.10", 30 | "chromium": "^3.0.3", 31 | "eslint": "^8.52.0", 32 | "eslint-plugin-github": "^4.10.1", 33 | "karma": "^6.4.2", 34 | "karma-chai": "^0.1.0", 35 | "karma-chrome-launcher": "^3.2.0", 36 | "karma-mocha": "^2.0.1", 37 | "karma-mocha-reporter": "^2.2.5", 38 | "mocha": "^10.2.0", 39 | "rollup": "^4.1.4", 40 | "typescript": "^5.6.3", 41 | "eslint-import-resolver-typescript": "^3.6.3" 42 | }, 43 | "eslintIgnore": [ 44 | "dist/" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /pages/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | hotkey | Demo 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 | Press o k click this link 18 | 19 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /pages/hotkey_mapper.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | hotkey | Mapper Tool 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Hotkey Code

14 |

15 | Press a key combination to see the corresponding hotkey string. Quickly press another combination to build a 16 | sequence. 17 |

18 |
19 | 32 | 33 |
34 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Copy to clipboard 44 |
45 |
46 | 47 |
48 | Your user agent: 49 | 50 | ... 51 |
52 |
53 | 54 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | hotkey 8 | 9 | 10 | 11 | 12 |
13 |

@github/hotkey

14 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json' with {type: 'json'} 2 | 3 | export default [ 4 | { 5 | input: 'dist/index.js', 6 | output: [{file: pkg['module'], format: 'es'}] 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /src/hotkey.ts: -------------------------------------------------------------------------------- 1 | import {NormalizedSequenceString} from './sequence.js' 2 | import {macosSymbolLayerKeys} from './macos-symbol-layer.js' 3 | import {macosUppercaseLayerKeys} from './macos-uppercase-layer.js' 4 | 5 | const normalizedHotkeyBrand = Symbol('normalizedHotkey') 6 | 7 | /** 8 | * A hotkey string with modifier keys in standard order. Build one with `eventToHotkeyString` or normalize a string via 9 | * `normalizeHotkey`. 10 | * 11 | * A full list of key names can be found here: 12 | * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values 13 | * 14 | * Examples: 15 | * "s" // Lowercase character for single letters 16 | * "S" // Uppercase character for shift plus a letter 17 | * "1" // Number character 18 | * "?" // Shift plus "/" symbol 19 | * "Enter" // Enter key 20 | * "ArrowUp" // Up arrow 21 | * "Control+s" // Control modifier plus letter 22 | * "Control+Alt+Delete" // Multiple modifiers 23 | */ 24 | export type NormalizedHotkeyString = NormalizedSequenceString & {[normalizedHotkeyBrand]: true} 25 | 26 | const syntheticKeyNames: Record = { 27 | ' ': 'Space', 28 | '+': 'Plus' 29 | } 30 | 31 | /** 32 | * Returns a hotkey character string for keydown and keyup events. 33 | * @example 34 | * document.addEventListener('keydown', function(event) { 35 | * if (eventToHotkeyString(event) === 'h') ... 36 | * }) 37 | */ 38 | export function eventToHotkeyString( 39 | event: KeyboardEvent, 40 | platform: string = navigator.platform 41 | ): NormalizedHotkeyString { 42 | const {ctrlKey, altKey, metaKey, shiftKey, key} = event 43 | const hotkeyString: string[] = [] 44 | const modifiers: boolean[] = [ctrlKey, altKey, metaKey, shiftKey] 45 | 46 | for (const [i, mod] of modifiers.entries()) { 47 | if (mod) hotkeyString.push(modifierKeyNames[i]) 48 | } 49 | 50 | if (!modifierKeyNames.includes(key)) { 51 | // MacOS outputs symbols when `Alt` is held, so we map them back to the key symbol if we can 52 | const altNormalizedKey = 53 | hotkeyString.includes('Alt') && matchApplePlatform.test(platform) ? macosSymbolLayerKeys[key] ?? key : key 54 | 55 | // MacOS outputs lowercase characters when `Command+Shift` is held, so we map them back to uppercase if we can 56 | const shiftNormalizedKey = 57 | hotkeyString.includes('Shift') && matchApplePlatform.test(platform) 58 | ? macosUppercaseLayerKeys[altNormalizedKey] ?? altNormalizedKey 59 | : altNormalizedKey 60 | 61 | // Some symbols can't be used because of hotkey string format, so we replace them with 'synthetic' named keys 62 | const syntheticKey = syntheticKeyNames[shiftNormalizedKey] ?? shiftNormalizedKey 63 | 64 | hotkeyString.push(syntheticKey) 65 | } 66 | 67 | return hotkeyString.join('+') as NormalizedHotkeyString 68 | } 69 | 70 | const modifierKeyNames: string[] = ['Control', 'Alt', 'Meta', 'Shift'] 71 | 72 | /** 73 | * Normalizes a hotkey string before comparing it to the serialized event 74 | * string produced by `eventToHotkeyString`. 75 | * - Replaces the `Mod` modifier with `Meta` on mac, `Control` on other 76 | * platforms. 77 | * - Ensures modifiers are sorted in a consistent order 78 | * @param hotkey a hotkey string 79 | * @param platform NOTE: this param is only intended to be used to mock `navigator.platform` in tests. `window.navigator.platform` is used by default. 80 | * @returns {string} normalized representation of the given hotkey string 81 | */ 82 | export function normalizeHotkey(hotkey: string, platform?: string | undefined): NormalizedHotkeyString { 83 | let result: string 84 | result = localizeMod(hotkey, platform) 85 | result = sortModifiers(result) 86 | return result as NormalizedHotkeyString 87 | } 88 | 89 | const matchApplePlatform = /Mac|iPod|iPhone|iPad/i 90 | 91 | function localizeMod(hotkey: string, platform?: string | undefined): string { 92 | const ssrSafeWindow = typeof window === 'undefined' ? undefined : window 93 | const safePlatform = platform ?? ssrSafeWindow?.navigator.platform ?? '' 94 | 95 | const localModifier = matchApplePlatform.test(safePlatform) ? 'Meta' : 'Control' 96 | return hotkey.replace('Mod', localModifier) 97 | } 98 | 99 | function sortModifiers(hotkey: string): string { 100 | const key = hotkey.split('+').pop() 101 | const modifiers: string[] = [] 102 | for (const modifier of ['Control', 'Alt', 'Meta', 'Shift']) { 103 | if (hotkey.includes(modifier)) { 104 | modifiers.push(modifier) 105 | } 106 | } 107 | if (key) modifiers.push(key) 108 | return modifiers.join('+') 109 | } 110 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Leaf, RadixTrie} from './radix-trie.js' 2 | import {fireDeterminedAction, expandHotkeyToEdges, isFormField} from './utils.js' 3 | import {SequenceTracker} from './sequence.js' 4 | import {eventToHotkeyString} from './hotkey.js' 5 | 6 | export {eventToHotkeyString, normalizeHotkey, NormalizedHotkeyString} from './hotkey.js' 7 | export {SequenceTracker, normalizeSequence, NormalizedSequenceString} from './sequence.js' 8 | export {RadixTrie, Leaf} from './radix-trie.js' 9 | 10 | const hotkeyRadixTrie = new RadixTrie() 11 | const elementsLeaves = new WeakMap>>() 12 | let currentTriePosition: RadixTrie | Leaf = hotkeyRadixTrie 13 | 14 | const sequenceTracker = new SequenceTracker({ 15 | onReset() { 16 | currentTriePosition = hotkeyRadixTrie 17 | } 18 | }) 19 | 20 | function keyDownHandler(event: KeyboardEvent) { 21 | if (event.defaultPrevented) return 22 | if (!(event.target instanceof Node)) return 23 | if (isFormField(event.target)) { 24 | const target = event.target as HTMLElement 25 | if (!target.id) return 26 | if (!target.ownerDocument.querySelector(`[data-hotkey-scope="${target.id}"]`)) return 27 | } 28 | 29 | // If the user presses a hotkey that doesn't exist in the Trie, 30 | // they've pressed a wrong key-combo and we should reset the flow 31 | const newTriePosition = (currentTriePosition as RadixTrie).get(eventToHotkeyString(event)) 32 | if (!newTriePosition) { 33 | sequenceTracker.reset() 34 | return 35 | } 36 | sequenceTracker.registerKeypress(event) 37 | 38 | currentTriePosition = newTriePosition 39 | if (newTriePosition instanceof Leaf) { 40 | const target = event.target as HTMLElement 41 | let shouldFire = false 42 | let elementToFire 43 | const formField = isFormField(target) 44 | 45 | for (let i = newTriePosition.children.length - 1; i >= 0; i -= 1) { 46 | elementToFire = newTriePosition.children[i] 47 | const scope = elementToFire.getAttribute('data-hotkey-scope') 48 | if ((!formField && !scope) || (formField && target.id === scope)) { 49 | shouldFire = true 50 | break 51 | } 52 | } 53 | 54 | if (elementToFire && shouldFire) { 55 | fireDeterminedAction(elementToFire, sequenceTracker.path) 56 | event.preventDefault() 57 | } 58 | 59 | sequenceTracker.reset() 60 | } 61 | } 62 | 63 | export function install(element: HTMLElement, hotkey?: string): void { 64 | // Install the keydown handler if this is the first install 65 | if (Object.keys(hotkeyRadixTrie.children).length === 0) { 66 | document.addEventListener('keydown', keyDownHandler) 67 | } 68 | 69 | const hotkeys = expandHotkeyToEdges(hotkey || element.getAttribute('data-hotkey') || '') 70 | const leaves = hotkeys.map(h => (hotkeyRadixTrie.insert(h) as Leaf).add(element)) 71 | elementsLeaves.set(element, leaves) 72 | } 73 | 74 | export function uninstall(element: HTMLElement): void { 75 | const leaves = elementsLeaves.get(element) 76 | if (leaves && leaves.length) { 77 | for (const leaf of leaves) { 78 | leaf && leaf.delete(element) 79 | } 80 | } 81 | 82 | if (Object.keys(hotkeyRadixTrie.children).length === 0) { 83 | document.removeEventListener('keydown', keyDownHandler) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/macos-symbol-layer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Map of special symbols to the keys that would be pressed (while holding `Option`) to type them on MacOS on an 3 | * English layout. Most of these are standardized across most language layouts, so this won't work 100% in every 4 | * language but it should work most of the time. 5 | */ 6 | export const macosSymbolLayerKeys: Record = { 7 | ['¡']: '1', 8 | ['™']: '2', 9 | ['£']: '3', 10 | ['¢']: '4', 11 | ['∞']: '5', 12 | ['§']: '6', 13 | ['¶']: '7', 14 | ['•']: '8', 15 | ['ª']: '9', 16 | ['º']: '0', 17 | ['–']: '-', 18 | ['≠']: '=', 19 | ['⁄']: '!', 20 | ['€']: '@', 21 | ['‹']: '#', 22 | ['›']: '$', 23 | ['fi']: '%', 24 | ['fl']: '^', 25 | ['‡']: '&', 26 | ['°']: '*', 27 | ['·']: '(', 28 | ['‚']: ')', 29 | ['—']: '_', 30 | ['±']: '+', 31 | ['œ']: 'q', 32 | ['∑']: 'w', 33 | ['®']: 'r', 34 | ['†']: 't', 35 | ['¥']: 'y', 36 | ['ø']: 'o', 37 | ['π']: 'p', 38 | ['“']: '[', 39 | ['‘']: ']', 40 | ['«']: '\\', 41 | ['Œ']: 'Q', 42 | ['„']: 'W', 43 | ['´']: 'E', 44 | ['‰']: 'R', 45 | ['ˇ']: 'T', 46 | ['Á']: 'Y', 47 | ['¨']: 'U', 48 | ['ˆ']: 'I', 49 | ['Ø']: 'O', 50 | ['∏']: 'P', 51 | ['”']: '{', 52 | ['’']: '}', 53 | ['»']: '|', 54 | ['å']: 'a', 55 | ['ß']: 's', 56 | ['∂']: 'd', 57 | ['ƒ']: 'f', 58 | ['©']: 'g', 59 | ['˙']: 'h', 60 | ['∆']: 'j', 61 | ['˚']: 'k', 62 | ['¬']: 'l', 63 | ['…']: ';', 64 | ['æ']: "'", 65 | ['Å']: 'A', 66 | ['Í']: 'S', 67 | ['Î']: 'D', 68 | ['Ï']: 'F', 69 | ['˝']: 'G', 70 | ['Ó']: 'H', 71 | ['Ô']: 'J', 72 | ['']: 'K', 73 | ['Ò']: 'L', 74 | ['Ú']: ':', 75 | ['Æ']: '"', 76 | ['Ω']: 'z', 77 | ['≈']: 'x', 78 | ['ç']: 'c', 79 | ['√']: 'v', 80 | ['∫']: 'b', 81 | ['µ']: 'm', 82 | ['≤']: ',', 83 | ['≥']: '.', 84 | ['÷']: '/', 85 | ['¸']: 'Z', 86 | ['˛']: 'X', 87 | ['Ç']: 'C', 88 | ['◊']: 'V', 89 | ['ı']: 'B', 90 | ['˜']: 'N', 91 | ['Â']: 'M', 92 | ['¯']: '<', 93 | ['˘']: '>', 94 | ['¿']: '?' 95 | } 96 | -------------------------------------------------------------------------------- /src/macos-uppercase-layer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Map of 'uppercase' symbols to the keys that would be pressed (while holding `Shift`) to type them on MacOS on an 3 | * English layout. Most of these are standardized across most language layouts, so this won't work 100% in every 4 | * language but it should work most of the time. 5 | * 6 | */ 7 | export const macosUppercaseLayerKeys: Record = { 8 | ['`']: '~', 9 | ['1']: '!', 10 | ['2']: '@', 11 | ['3']: '#', 12 | ['4']: '$', 13 | ['5']: '%', 14 | ['6']: '^', 15 | ['7']: '&', 16 | ['8']: '*', 17 | ['9']: '(', 18 | ['0']: ')', 19 | ['-']: '_', 20 | ['=']: '+', 21 | ['[']: '{', 22 | [']']: '}', 23 | ['\\']: '|', 24 | [';']: ':', 25 | ["'"]: '"', 26 | [',']: '<', 27 | ['.']: '>', 28 | ['/']: '?', 29 | ['q']: 'Q', 30 | ['w']: 'W', 31 | ['e']: 'E', 32 | ['r']: 'R', 33 | ['t']: 'T', 34 | ['y']: 'Y', 35 | ['u']: 'U', 36 | ['i']: 'I', 37 | ['o']: 'O', 38 | ['p']: 'P', 39 | ['a']: 'A', 40 | ['s']: 'S', 41 | ['d']: 'D', 42 | ['f']: 'F', 43 | ['g']: 'G', 44 | ['h']: 'H', 45 | ['j']: 'J', 46 | ['k']: 'K', 47 | ['l']: 'L', 48 | ['z']: 'Z', 49 | ['x']: 'X', 50 | ['c']: 'C', 51 | ['v']: 'V', 52 | ['b']: 'B', 53 | ['n']: 'N', 54 | ['m']: 'M' 55 | } 56 | -------------------------------------------------------------------------------- /src/radix-trie.ts: -------------------------------------------------------------------------------- 1 | export class Leaf { 2 | parent: RadixTrie 3 | children: T[] = [] 4 | 5 | constructor(trie: RadixTrie) { 6 | this.parent = trie 7 | } 8 | 9 | delete(value: T): boolean { 10 | const index = this.children.indexOf(value) 11 | if (index === -1) return false 12 | this.children = this.children.slice(0, index).concat(this.children.slice(index + 1)) 13 | if (this.children.length === 0) { 14 | this.parent.delete(this) 15 | } 16 | return true 17 | } 18 | 19 | add(value: T): Leaf { 20 | this.children.push(value) 21 | return this 22 | } 23 | } 24 | 25 | export class RadixTrie { 26 | parent: RadixTrie | null = null 27 | children: {[key: string]: RadixTrie | Leaf} = {} 28 | 29 | constructor(trie?: RadixTrie) { 30 | this.parent = trie || null 31 | } 32 | 33 | get(edge: string): RadixTrie | Leaf { 34 | return this.children[edge] 35 | } 36 | 37 | insert(edges: string[]): RadixTrie | Leaf { 38 | // eslint-disable-next-line @typescript-eslint/no-this-alias 39 | let currentNode: RadixTrie | Leaf = this 40 | for (let i = 0; i < edges.length; i += 1) { 41 | const edge = edges[i] 42 | let nextNode: RadixTrie | Leaf | null = currentNode.get(edge) 43 | // If we're at the end of this set of edges: 44 | if (i === edges.length - 1) { 45 | // If this end already exists as a RadixTrie, then hose it and replace with a Leaf: 46 | if (nextNode instanceof RadixTrie) { 47 | currentNode.delete(nextNode) 48 | nextNode = null 49 | } 50 | // If nextNode doesn't exist (or used to be a RadixTrie) then make a Leaf: 51 | if (!nextNode) { 52 | nextNode = new Leaf(currentNode) 53 | currentNode.children[edge] = nextNode 54 | } 55 | return nextNode 56 | // We're not at the end of this set of edges: 57 | } else { 58 | // If we're not at the end, but we've hit a Leaf, replace with a RadixTrie 59 | if (nextNode instanceof Leaf) nextNode = null 60 | if (!nextNode) { 61 | nextNode = new RadixTrie(currentNode) 62 | currentNode.children[edge] = nextNode 63 | } 64 | } 65 | currentNode = nextNode 66 | } 67 | return currentNode 68 | } 69 | 70 | delete(node: RadixTrie | Leaf): boolean { 71 | for (const edge in this.children) { 72 | const currentNode = this.children[edge] 73 | if (currentNode === node) { 74 | const success = delete this.children[edge] 75 | if (Object.keys(this.children).length === 0 && this.parent) { 76 | this.parent.delete(this) 77 | } 78 | return success 79 | } 80 | } 81 | return false 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/sequence.ts: -------------------------------------------------------------------------------- 1 | import {NormalizedHotkeyString, eventToHotkeyString, normalizeHotkey} from './hotkey.js' 2 | 3 | interface SequenceTrackerOptions { 4 | onReset?: () => void 5 | } 6 | 7 | export const SEQUENCE_DELIMITER = ' ' 8 | 9 | const sequenceBrand = Symbol('sequence') 10 | 11 | /** 12 | * Sequence of hotkeys, separated by spaces. For example, `Mod+m g`. Obtain one through the `SequenceTracker` class or 13 | * by normalizing a string with `normalizeSequence`. 14 | */ 15 | export type NormalizedSequenceString = string & {[sequenceBrand]: true} 16 | 17 | export class SequenceTracker { 18 | static readonly CHORD_TIMEOUT = 1500 19 | 20 | private _path: readonly NormalizedHotkeyString[] = [] 21 | private timer: number | null = null 22 | private onReset 23 | 24 | constructor({onReset}: SequenceTrackerOptions = {}) { 25 | this.onReset = onReset 26 | } 27 | 28 | get path(): readonly NormalizedHotkeyString[] { 29 | return this._path 30 | } 31 | 32 | get sequence(): NormalizedSequenceString { 33 | return this._path.join(SEQUENCE_DELIMITER) as NormalizedSequenceString 34 | } 35 | 36 | registerKeypress(event: KeyboardEvent): void { 37 | this._path = [...this._path, eventToHotkeyString(event)] 38 | this.startTimer() 39 | } 40 | 41 | reset(): void { 42 | this.killTimer() 43 | this._path = [] 44 | this.onReset?.() 45 | } 46 | 47 | private killTimer(): void { 48 | if (this.timer != null) { 49 | window.clearTimeout(this.timer) 50 | } 51 | this.timer = null 52 | } 53 | 54 | private startTimer(): void { 55 | this.killTimer() 56 | this.timer = window.setTimeout(() => this.reset(), SequenceTracker.CHORD_TIMEOUT) 57 | } 58 | } 59 | 60 | export function normalizeSequence(sequence: string): NormalizedSequenceString { 61 | return sequence 62 | .split(SEQUENCE_DELIMITER) 63 | .map(h => normalizeHotkey(h)) 64 | .join(SEQUENCE_DELIMITER) as NormalizedSequenceString 65 | } 66 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import {NormalizedHotkeyString, normalizeHotkey} from './hotkey.js' 2 | import {SEQUENCE_DELIMITER} from './sequence.js' 3 | 4 | export function isFormField(element: Node): boolean { 5 | if (!(element instanceof HTMLElement)) { 6 | return false 7 | } 8 | 9 | const name = element.nodeName.toLowerCase() 10 | const type = (element.getAttribute('type') || '').toLowerCase() 11 | return ( 12 | name === 'select' || 13 | name === 'textarea' || 14 | (name === 'input' && 15 | type !== 'submit' && 16 | type !== 'reset' && 17 | type !== 'checkbox' && 18 | type !== 'radio' && 19 | type !== 'file') || 20 | element.isContentEditable 21 | ) 22 | } 23 | 24 | export function fireDeterminedAction(el: HTMLElement, path: readonly NormalizedHotkeyString[]): void { 25 | const delegateEvent = new CustomEvent('hotkey-fire', {cancelable: true, detail: {path}}) 26 | const cancelled = !el.dispatchEvent(delegateEvent) 27 | if (cancelled) return 28 | if (isFormField(el)) { 29 | el.focus() 30 | } else { 31 | el.click() 32 | } 33 | } 34 | 35 | export function expandHotkeyToEdges(hotkey: string): NormalizedHotkeyString[][] { 36 | // NOTE: we can't just split by comma, since comma is a valid hotkey character! 37 | const output = [] 38 | let acc = [''] 39 | let commaIsSeparator = false 40 | for (let i = 0; i < hotkey.length; i++) { 41 | if (commaIsSeparator && hotkey[i] === ',') { 42 | output.push(acc) 43 | acc = [''] 44 | commaIsSeparator = false 45 | continue 46 | } 47 | 48 | if (hotkey[i] === SEQUENCE_DELIMITER) { 49 | // Spaces are used to separate key sequences, so a following comma is 50 | // part of the sequence, not a separator. 51 | acc.push('') 52 | commaIsSeparator = false 53 | continue 54 | } else if (hotkey[i] === '+') { 55 | // If the current character is a +, a following comma is part of the 56 | // shortcut and not a separator. 57 | commaIsSeparator = false 58 | } else { 59 | commaIsSeparator = true 60 | } 61 | 62 | acc[acc.length - 1] += hotkey[i] 63 | } 64 | 65 | output.push(acc) 66 | 67 | // Remove any empty hotkeys/sequences 68 | return output.map(h => h.map(k => normalizeHotkey(k)).filter(k => k !== '')).filter(h => h.length > 0) 69 | } 70 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "github/unescaped-html-literal": "off", 4 | "github/no-inner-html": "off", 5 | "eslint-comments/no-use": "off", 6 | "import/extensions": ["error", "always"] 7 | }, 8 | "env": { 9 | "mocha": true 10 | }, 11 | "globals": { 12 | "assert": true 13 | }, 14 | "extends": "../.eslintrc.json", 15 | "settings": { 16 | "import/resolver": { 17 | "node": { 18 | "extensions": [".js"] 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/test-normalize-hotkey.js: -------------------------------------------------------------------------------- 1 | import {normalizeHotkey} from '../dist/index.js' 2 | 3 | describe('normalizeHotkey', () => { 4 | it('should exist', () => { 5 | assert.isDefined(normalizeHotkey) 6 | }) 7 | 8 | const tests = [ 9 | // Base case control tests 10 | ['a', 'a'], 11 | ['Control+a', 'Control+a'], 12 | ['Meta+a', 'Meta+a'], 13 | ['Control+Meta+a', 'Control+Meta+a'], 14 | // Mod should be localized based on platform 15 | ['Mod+a', 'Control+a', 'win / linux'], 16 | ['Mod+a', 'Meta+a', 'mac'], 17 | ['Mod+a', 'Meta+a', 'iPod'], 18 | ['Mod+a', 'Meta+a', 'iPhone'], 19 | ['Mod+a', 'Meta+a', 'iPad'], 20 | ['Mod+A', 'Control+A', 'win / linux'], 21 | ['Mod+A', 'Meta+A', 'mac'], // TODO: on a mac upper-case keys are lowercased when Meta is pressed 22 | ['Mod+9', 'Control+9', 'win / linux'], 23 | ['Mod+9', 'Meta+9', 'mac'], 24 | ['Mod+)', 'Control+)', 'win / linux'], 25 | ['Mod+)', 'Meta+)', 'mac'], // TODO: on a mac upper-case keys are lowercased when Meta is pressed 26 | ['Mod+Alt+a', 'Control+Alt+a', 'win / linux'], 27 | ['Mod+Alt+a', 'Alt+Meta+a', 'mac'], 28 | // undefined platform doesn't localize and falls back to windows (SSR) 29 | ['Mod+a', 'Control+a', undefined], 30 | // Modifier sorting 31 | ['Shift+Alt+Meta+Control+m', 'Control+Alt+Meta+Shift+m'], 32 | ['Shift+Alt+Mod+m', 'Control+Alt+Shift+m', 'win'] 33 | ] 34 | 35 | for (const [input, expected, platform = 'any platform'] of tests) { 36 | it(`given "${input}", returns "${expected}" on ${platform}`, function (done) { 37 | assert.equal(normalizeHotkey(input, platform), expected) 38 | done() 39 | }) 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /test/test-radix-trie.js: -------------------------------------------------------------------------------- 1 | import {RadixTrie, Leaf} from '../dist/index.js' 2 | 3 | describe('RadixTrie', () => { 4 | describe('insert', () => { 5 | it('adds hotkey to trie in a searchable manner', () => { 6 | const trie = new RadixTrie() 7 | const leaf = trie.insert(['ctrl+p', 'a', 'b']) 8 | 9 | assert(trie.get('ctrl+p'), 'missing `ctrl+p` in trie') 10 | assert(trie.get('ctrl+p').get('a'), 'missing `ctrl+p a` in trie') 11 | assert(trie.get('ctrl+p').get('a').get('b'), 'missing `ctrl+p a b` in trie') 12 | assert.equal(trie.get('ctrl+p').get('a').get('b'), leaf, 'didnt return leaf correctly') 13 | assert.instanceOf(leaf, Leaf, 'leaf isnt a Leaf instance') 14 | }) 15 | 16 | it('adds new hotkey to trie using existing maps', () => { 17 | const trie = new RadixTrie() 18 | const leaf = trie.insert(['ctrl+p', 'a', 'b']) 19 | const otherLeaf = trie.insert(['ctrl+p', 'a', 'c']) 20 | 21 | assert.equal(trie.get('ctrl+p').get('a').get('b'), leaf, 'didnt return `ctrl+p a b` end leaf correctly') 22 | assert.equal(trie.get('ctrl+p').get('a').get('c'), otherLeaf, 'didnt return `ctrl+p a c` end leaf correctly') 23 | assert.notEqual(leaf, otherLeaf, 'leaves are same reference but shouldnt be') 24 | assert.instanceOf(leaf, Leaf, 'leaf isnt a Leaf instance') 25 | assert.instanceOf(otherLeaf, Leaf, 'otherLeaf isnt a Leaf instance') 26 | }) 27 | 28 | it('overrides leaves with new deeper insertions', () => { 29 | const trie = new RadixTrie() 30 | const otherLeaf = trie.insert(['g', 'c', 'e']) 31 | 32 | assert.instanceOf(trie.get('g').get('c'), RadixTrie, 'didnt override `g c` leaf as trie') 33 | assert.equal(trie.get('g').get('c').get('e'), otherLeaf, 'didnt add `g c e` leaf to trie') 34 | }) 35 | }) 36 | 37 | describe('delete', () => { 38 | it('removes self from parents, if empty', () => { 39 | const trie = new RadixTrie() 40 | const leaf = trie.insert(['ctrl+p', 'a', 'b']) 41 | const keyATrie = trie.get('ctrl+p').get('a') 42 | const success = leaf.parent.delete(leaf) 43 | 44 | assert(success, 'delete was unsuccessful') 45 | assert.isUndefined(trie.get('ctrl+p'), 'still has ctrl+p leaf') 46 | assert.isUndefined(keyATrie.get('b'), 'keyAtrie still has b key child') 47 | }) 48 | 49 | it('preserves parents with other tries', () => { 50 | const trie = new RadixTrie() 51 | trie.insert(['ctrl+p', 'a', 'b']) 52 | const otherLeaf = trie.insert(['ctrl+p', 'a', 'c']) 53 | const keyATrie = trie.get('ctrl+p').get('a') 54 | const keyCTrie = keyATrie.get('c') 55 | const success = otherLeaf.parent.delete(otherLeaf) 56 | 57 | assert(success, 'delete was unsuccessful') 58 | assert.equal(keyCTrie.children.length, 0, '`c` trie still has children') 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import {install, uninstall, eventToHotkeyString} from '../dist/index.js' 2 | 3 | let elementsActivated = [] 4 | function clickHandler(event) { 5 | elementsActivated.push(event.target.id) 6 | } 7 | 8 | const setHTML = html => { 9 | document.body.innerHTML = html 10 | 11 | for (const element of document.querySelectorAll('[data-hotkey]')) { 12 | install(element) 13 | } 14 | } 15 | 16 | async function wait(ms) { 17 | return new Promise(function (resolve) { 18 | return setTimeout(resolve, ms) 19 | }) 20 | } 21 | 22 | // Simulate entering a series of keys with `delay` milliseconds in between 23 | // keystrokes. 24 | async function keySequence(keys, delay = 10) { 25 | for (const nextKey of keys.split(' ')) { 26 | document.dispatchEvent(new KeyboardEvent('keydown', {key: nextKey})) 27 | await wait(delay) 28 | } 29 | } 30 | 31 | describe('hotkey', function () { 32 | beforeEach(function () { 33 | document.addEventListener('click', clickHandler) 34 | }) 35 | 36 | afterEach(function () { 37 | document.removeEventListener('click', clickHandler) 38 | for (const element of document.querySelectorAll('[data-hotkey]')) { 39 | uninstall(element) 40 | } 41 | uninstall(document.getElementById('button-without-a-attribute')) 42 | document.body.innerHTML = '' 43 | elementsActivated = [] 44 | }) 45 | 46 | describe('single key support', function () { 47 | it('triggers buttons that have a `data-hotkey` attribute', function () { 48 | setHTML('') 49 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'})) 50 | assert.include(elementsActivated, 'button1') 51 | }) 52 | 53 | it('triggers buttons that get hotkey passed in as second argument', function () { 54 | setHTML('') 55 | install(document.getElementById('button-without-a-attribute'), 'Control+c') 56 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'c', ctrlKey: true})) 57 | assert.include(elementsActivated, 'button-without-a-attribute') 58 | }) 59 | 60 | it("doesn't trigger buttons that don't have a `data-hotkey` attribute", function () { 61 | setHTML('') 62 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'})) 63 | assert.notInclude(elementsActivated, 'button2') 64 | }) 65 | 66 | it("doesn't respond to the hotkey in a button's overridden `data-hotkey` attribute", function () { 67 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b', ctrlKey: true})) 68 | assert.notInclude(elementsActivated, 'button3') 69 | }) 70 | 71 | it("doesn't trigger when user is focused on a input or textfield", function () { 72 | setHTML(` 73 | 74 | `) 75 | document.getElementById('textfield').dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key: 'b'})) 76 | assert.deepEqual(elementsActivated, []) 77 | }) 78 | 79 | it('triggers when user is focused on a file input', function () { 80 | setHTML(` 81 | 82 | `) 83 | document.getElementById('filefield').dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key: 'b'})) 84 | assert.deepEqual(elementsActivated, ['button1']) 85 | }) 86 | 87 | it('handles multiple keys in a hotkey combination', function () { 88 | setHTML('') 89 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'c', ctrlKey: true})) 90 | assert.include(elementsActivated, 'button3') 91 | }) 92 | 93 | it("doesn't trigger elements whose hotkey has been removed", function () { 94 | setHTML('') 95 | uninstall(document.querySelector('#button1')) 96 | document.dispatchEvent(new KeyboardEvent('keydown', {code: 'KeyB', key: 'b'})) 97 | assert.deepEqual(elementsActivated, []) 98 | }) 99 | 100 | it('triggers elements with capitalised key', function () { 101 | setHTML('') 102 | document.dispatchEvent(new KeyboardEvent('keydown', {shiftKey: true, code: 'KeyB', key: 'B'})) 103 | assert.include(elementsActivated, 'button1') 104 | }) 105 | 106 | it('dispatches an event on the element once fired', function () { 107 | setHTML('') 108 | let fired = false 109 | document.querySelector('#button1').addEventListener('hotkey-fire', event => { 110 | fired = true 111 | assert.deepEqual(event.detail.path, ['Shift+B']) 112 | assert.equal(event.cancelable, true) 113 | }) 114 | document.dispatchEvent(new KeyboardEvent('keydown', {shiftKey: true, code: 'KeyB', key: 'B'})) 115 | assert.ok(fired, 'button1 did not receive a hotkey-fire event') 116 | }) 117 | 118 | it('wont trigger action if the hotkey-fire event is cancelled', function () { 119 | setHTML('') 120 | document.querySelector('#button1').addEventListener('hotkey-fire', event => event.preventDefault()) 121 | document.dispatchEvent(new KeyboardEvent('keydown', {shiftKey: true, code: 'KeyB', key: 'B'})) 122 | assert.notInclude(elementsActivated, 'button1') 123 | }) 124 | 125 | it('supports comma as a hotkey', function () { 126 | setHTML('') 127 | document.dispatchEvent(new KeyboardEvent('keydown', {key: ','})) 128 | assert.include(elementsActivated, 'button1') 129 | }) 130 | 131 | it('supports comma + modifier as a hotkey', function () { 132 | setHTML('') 133 | document.dispatchEvent(new KeyboardEvent('keydown', {metaKey: true, key: ','})) 134 | assert.include(elementsActivated, 'button1') 135 | }) 136 | 137 | it('multiple comma aliases', function () { 138 | setHTML('') 139 | document.dispatchEvent(new KeyboardEvent('keydown', {key: ','})) 140 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'x'})) 141 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'y'})) 142 | assert.equal(elementsActivated.length, 3) 143 | }) 144 | 145 | it('complex comma parsing', async function () { 146 | setHTML('') 147 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'c'})) 148 | await keySequence(', a b') 149 | assert.equal(elementsActivated.length, 2) 150 | }) 151 | 152 | it('complex comma parsing II', async function () { 153 | setHTML('') 154 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'c'})) 155 | document.dispatchEvent(new KeyboardEvent('keydown', {key: ','})) 156 | await keySequence('a , b') 157 | assert.equal(elementsActivated.length, 3) 158 | }) 159 | 160 | it('complex comma parsing II', async function () { 161 | setHTML('') 162 | await keySequence(', , , ,') 163 | assert.include(elementsActivated, 'button1') 164 | }) 165 | 166 | it('complex comma parsing II', async function () { 167 | setHTML('') 168 | await keySequence(', ,') 169 | document.dispatchEvent(new KeyboardEvent('keydown', {ctrlKey: true, key: 'x'})) 170 | assert.equal(elementsActivated.length, 2) 171 | }) 172 | }) 173 | 174 | describe('data-hotkey-scope', function () { 175 | it('allows hotkey action from form field', function () { 176 | setHTML(` 177 | 178 | `) 179 | document 180 | .getElementById('textfield') 181 | .dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, metaKey: true, cancelable: true, key: 'b'})) 182 | assert.include(elementsActivated, 'button1') 183 | }) 184 | 185 | it('does nothing if `data-hotkey-scope` is set to non-form field', function () { 186 | setHTML(` 187 | 188 | 198 | `) 199 | document 200 | .getElementById('textfield') 201 | .dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, cancelable: true, key: 'b'})) 202 | assert.deepEqual(elementsActivated, []) 203 | }) 204 | 205 | it('identifies and fires correct element for duplicated hotkeys', function () { 206 | setHTML(` 207 | 208 | 209 | 210 | 211 | 212 | `) 213 | const keyboardEventArgs = {bubbles: true, metaKey: true, cancelable: true, key: 'b'} 214 | 215 | // Scoped hotkeys 216 | document.getElementById('textfield1').dispatchEvent(new KeyboardEvent('keydown', keyboardEventArgs)) 217 | assert.include(elementsActivated, 'button1') 218 | 219 | document.getElementById('textfield2').dispatchEvent(new KeyboardEvent('keydown', keyboardEventArgs)) 220 | assert.include(elementsActivated, 'button2') 221 | 222 | // Non-scoped hotkey 223 | document.dispatchEvent(new KeyboardEvent('keydown', keyboardEventArgs)) 224 | assert.include(elementsActivated, 'button3') 225 | }) 226 | 227 | it('dispatches an event on the element once fired', function () { 228 | setHTML(` 229 | 230 | 231 | 232 | 233 | 234 | `) 235 | let fired = false 236 | document.querySelector('#button1').addEventListener('hotkey-fire', event => { 237 | fired = true 238 | assert.deepEqual(event.detail.path, ['Meta+b']) 239 | assert.equal(event.cancelable, true) 240 | }) 241 | document 242 | .getElementById('textfield1') 243 | .dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, metaKey: true, cancelable: true, key: 'b'})) 244 | assert.ok(fired, 'button1 did not receive a hotkey-fire event') 245 | }) 246 | 247 | it('wont trigger action if the hotkey-fire event is cancelled', function () { 248 | setHTML(` 249 | 250 | 251 | 252 | 253 | 254 | `) 255 | document.querySelector('#button1').addEventListener('hotkey-fire', event => event.preventDefault()) 256 | document 257 | .getElementById('textfield1') 258 | .dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, metaKey: true, cancelable: true, key: 'b'})) 259 | assert.notInclude(elementsActivated, 'button1') 260 | }) 261 | }) 262 | 263 | describe('eventToHotkeyString', function () { 264 | const tests = [ 265 | ['Control+Shift+J', {ctrlKey: true, shiftKey: true, code: 'KeyJ', key: 'J'}], 266 | ['Control+Shift+j', {ctrlKey: true, shiftKey: true, code: 'KeyJ', key: 'j'}], 267 | ['Control+j', {ctrlKey: true, code: 'KeyJ', key: 'j'}], 268 | ['Meta+Shift+p', {key: 'p', metaKey: true, shiftKey: true, code: 'KeyP'}], 269 | ['Meta+Shift+8', {key: '8', metaKey: true, shiftKey: true, code: 'Digit8'}], 270 | ['Control+Shift+7', {key: '7', ctrlKey: true, shiftKey: true, code: 'Digit7'}], 271 | ['Shift+J', {shiftKey: true, code: 'KeyJ', key: 'J'}], 272 | ['/', {key: '/', code: ''}], 273 | ['1', {key: '1', code: 'Digit1'}], 274 | ['Control+Shift+`', {ctrlKey: true, shiftKey: true, key: '`'}], 275 | ['c', {key: 'c', code: 'KeyC'}], 276 | ['Shift+S', {key: 'S', shiftKey: true, code: 'KeyS'}], 277 | ['Shift+!', {key: '!', shiftKey: true, code: 'Digit1'}], 278 | ['Control+Shift', {ctrlKey: true, shiftKey: true, key: 'Shift'}], 279 | ['Control+Shift', {ctrlKey: true, shiftKey: true, key: 'Control'}], 280 | ['Alt+s', {altKey: true, key: 's'}], 281 | ['Alt+s', {altKey: true, key: 'ß'}, 'mac'], 282 | ['Alt+Shift+S', {altKey: true, shiftKey: true, key: 'S'}], 283 | ['Alt+Shift+S', {altKey: true, shiftKey: true, key: 'Í'}, 'mac'], 284 | ['Alt+ArrowLeft', {altKey: true, key: 'ArrowLeft'}], 285 | ['Alt+ArrowLeft', {altKey: true, key: 'ArrowLeft'}, 'mac'], 286 | ['Alt+Shift+ArrowLeft', {altKey: true, shiftKey: true, key: 'ArrowLeft'}], 287 | ['Alt+Shift+ArrowLeft', {altKey: true, shiftKey: true, key: 'ArrowLeft'}, 'mac'], 288 | ['Control+Space', {ctrlKey: true, key: ' '}], 289 | ['Shift+Plus', {shiftKey: true, key: '+'}], 290 | ['Meta+Shift+X', {metaKey: true, shiftKey: true, key: 'x'}, 'mac'], 291 | ['Control+Shift+X', {ctrlKey: true, shiftKey: true, key: 'X'}], 292 | ['Meta+Shift+!', {metaKey: true, shiftKey: true, key: '1'}, 'mac'], 293 | ['Control+Shift+!', {ctrlKey: true, shiftKey: true, key: '!'}] 294 | ] 295 | for (const [expected, keyEvent, platform = 'win / linux'] of tests) { 296 | it(`${JSON.stringify(keyEvent)} => ${expected}`, function (done) { 297 | document.body.addEventListener('keydown', function handler(event) { 298 | document.body.removeEventListener('keydown', handler) 299 | assert.equal(eventToHotkeyString(event, platform), expected) 300 | done() 301 | }) 302 | document.body.dispatchEvent(new KeyboardEvent('keydown', keyEvent)) 303 | }) 304 | } 305 | }) 306 | 307 | describe('hotkey sequence support', function () { 308 | it('supports sequences of 2 keys', async function () { 309 | setHTML('') 310 | await keySequence('b c') 311 | assert.deepEqual(elementsActivated, ['link2']) 312 | }) 313 | 314 | it('finds the longest sequence of keys which maps to something', async function () { 315 | setHTML('') 316 | await keySequence('z b c') 317 | assert.deepEqual(elementsActivated, ['link2']) 318 | }) 319 | 320 | it('supports sequences of 3 keys', async function () { 321 | setHTML('') 322 | await keySequence('d e f') 323 | assert.deepEqual(elementsActivated, ['link3']) 324 | }) 325 | 326 | it('only exact hotkey sequence matches', async function () { 327 | setHTML('') 328 | await keySequence('j z k') 329 | assert.deepEqual(elementsActivated, []) 330 | }) 331 | 332 | it('dispatches an event on the element once fired', async function () { 333 | setHTML('') 334 | let fired = false 335 | document.querySelector('#link3').addEventListener('hotkey-fire', event => { 336 | fired = true 337 | assert.deepEqual(event.detail.path, ['d', 'e', 'f']) 338 | assert.equal(event.cancelable, true) 339 | }) 340 | await keySequence('d e f') 341 | assert.ok(fired, 'link3 did not receive a hotkey-fire event') 342 | }) 343 | 344 | it('supports sequences containing commas', async function () { 345 | setHTML('') 346 | await keySequence('b , c') 347 | assert.deepEqual(elementsActivated, ['link2']) 348 | }) 349 | }) 350 | 351 | describe('misc', function () { 352 | it('sequences time out after 1500 ms', async function () { 353 | setHTML(` 354 | 355 | 356 | `) 357 | 358 | keySequence('h') 359 | await wait(1550) 360 | keySequence('i') 361 | assert.deepEqual(elementsActivated, ['create2']) 362 | }) 363 | 364 | it('multiple hotkeys for the same element', async function () { 365 | setHTML('') 366 | 367 | await keySequence('l') 368 | assert.deepEqual(elementsActivated, ['multiple']) 369 | await keySequence('m n') 370 | assert.deepEqual(elementsActivated, ['multiple', 'multiple']) 371 | }) 372 | 373 | it('with duplicate hotkeys, last element registered wins', async function () { 374 | setHTML(` 375 | 376 | 377 | `) 378 | 379 | await keySequence('c') 380 | assert.deepEqual(elementsActivated, ['duplicate2']) 381 | }) 382 | 383 | it('works with macos meta+shift plane', async () => { 384 | setHTML(``) 385 | 386 | document.dispatchEvent(new KeyboardEvent('keydown', {metaKey: true, shiftKey: true, key: 'p'})) 387 | 388 | await wait(10) 389 | 390 | assert.deepEqual(elementsActivated, ['metashiftplane']) 391 | }) 392 | }) 393 | 394 | describe('elements', function () { 395 | it('can focus form elements that declare data-hotkey for focus', async () => { 396 | let didFocus = false 397 | setHTML('') 398 | document.querySelector('input').focus = function () { 399 | didFocus = true 400 | } 401 | 402 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'})) 403 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'})) 404 | 405 | assert.isTrue(didFocus) 406 | assert.deepEqual(elementsActivated, []) 407 | }) 408 | 409 | it('will activate checkbox input elements that have a hotkey attribute', async () => { 410 | setHTML('') 411 | 412 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'})) 413 | 414 | assert.deepEqual(elementsActivated, ['checkbox']) 415 | }) 416 | 417 | it('will activate radio button input elements that have a hotkey attribute', async () => { 418 | setHTML('') 419 | 420 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'})) 421 | 422 | assert.deepEqual(elementsActivated, ['radio']) 423 | }) 424 | 425 | it('can click a[href] elements that declare data-hotkey for activation', async () => { 426 | setHTML('') 427 | 428 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'})) 429 | document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'})) 430 | 431 | assert.deepEqual(elementsActivated, ['link']) 432 | }) 433 | 434 | it('can click button elements that declare data-hotkey for activation', async () => { 435 | setHTML(' 487 | 488 | ` 489 | 490 | for (const el of document.querySelectorAll('[data-hotkey]')) { 491 | install(el) 492 | } 493 | 494 | assert.deepEqual(registeredAddEventListeners, ['keydown']) 495 | assert.deepEqual(registeredRemoveEventListeners, []) 496 | }) 497 | 498 | it('only one keydown listener is installed', function () { 499 | document.body.innerHTML = ` 500 | 501 | 502 | ` 503 | 504 | for (const el of document.querySelectorAll('[data-hotkey]')) { 505 | install(el) 506 | } 507 | 508 | assert.deepEqual(registeredAddEventListeners, ['keydown']) 509 | assert.deepEqual(registeredRemoveEventListeners, []) 510 | }) 511 | 512 | it('uninstalling the last hotkey removes the keydown handler', function () { 513 | document.body.innerHTML = ` 514 | 515 | 516 | ` 517 | 518 | const button1 = document.querySelector('#button1') 519 | const button2 = document.querySelector('#button2') 520 | 521 | install(button1) 522 | install(button2) 523 | 524 | assert.deepEqual(registeredAddEventListeners, ['keydown']) 525 | assert.deepEqual(registeredRemoveEventListeners, []) 526 | 527 | uninstall(button1) 528 | 529 | assert.deepEqual(registeredAddEventListeners, ['keydown']) 530 | assert.deepEqual(registeredRemoveEventListeners, []) 531 | 532 | uninstall(button2) 533 | 534 | assert.deepEqual(registeredAddEventListeners, ['keydown']) 535 | assert.deepEqual(registeredRemoveEventListeners, ['keydown']) 536 | }) 537 | }) 538 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "moduleResolution": "nodenext", 5 | "target": "es2017", 6 | "strict": true, 7 | "declaration": true, 8 | "outDir": "dist", 9 | "removeComments": true 10 | }, 11 | "files": ["src/index.ts"] 12 | } 13 | --------------------------------------------------------------------------------