8000 Merge pull request #1 from github/init · JavaScriptExpert/hotkey@1dee2b8 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1dee2b8

Browse files
author
Kristján Oddsson
authored
Merge pull request github#1 from github/init
Init
2 parents f749534 + d60f667 commit 1dee2b8

File tree

9 files changed

+3677
-85
lines changed

9 files changed

+3677
-85
lines changed

package-lock.json

Lines changed: 3375 additions & 81 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@
77
"repository": "github/hotkey",
88
"scripts": {
99
"build": "rollup -c",
10-
"lint": "github-lint && flow check",
11-
"test": "npm run lint"
10+
"lint": "github-lint && flow",
11+
"test": "karma start test/karma.config.js",
12+
"clean": "rm -rf dist",
13+
"prebuild": "npm run clean && npm run lint && mkdir dist",
14+
"pretest": "npm run build",
15+
"prepublishOnly": "npm run build"
1216
},
1317
"files": [
1418
"dist"
@@ -19,12 +23,19 @@
1923
"devDependencies": {
2024
"@babel/core": "^7.2.2",
2125
"@babel/plugin-proposal-class-properties": "^7.2.3",
26+
"@babel/preset-env": "^7.2.3",
2227
"@babel/preset-flow": "^7.0.0",
28+
"chai": "^4.2.0",
2329
"eslint": "^5.12.0",
2430
"eslint-plugin-github": "^1.7.3",
2531
"flow-bin": "^0.90.0",
32+
"karma": "^3.1.4",
33+
"karma-chai": "^0.1.0",
34+
"karma-chrome-launcher": "^2.2.0",
35+
"karma-mocha": "^1.3.0",
36+
"karma-mocha-reporter": "^2.2.5",
37+
"mocha": "^5.2.0",
2638
"rollup": "^1.1.0",
27-
"rollup-plugin-babel": "^4.3.0",
28-
"@babel/preset-env": "^7.2.3"
39+
"rollup-plugin-babel": "^4.3.0"
2940
}
3041
}

src/hotkey.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* @flow strict */
2+
3+
// # Returns a hotkey character string for keydown and keyup events.
4+
//
5+
// A full list of key names can be found here:
6+
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
7+
//
8+
// ## Code Example
9+
//
10+
// ```
11+
// document.addEventListener('keydown', function(event) {
12+
// if (hotkey(event) === 'h') ...
13+
// })
14+
// ```
15+
// ## Hotkey examples
16+
//
17+
// "s" // Lowercase character for single letters
18+
// "S" // Uppercase character for shift plus a letter
19+
// "1" // Number character
20+
// "?" // Shift plus "/" symbol
21+
//
22+
// "Enter" // Enter key
23+
// "ArrowUp" // Up arrow
24+
//
25+
// "Control+s" // Control modifier plus letter
26+
// "Control+Alt+Delete" // Multiple modifiers
27+
//
28+
// Returns key character String or null.
29+
export default function hotkey(event: KeyboardEvent) {
30+
return `${event.ctrlKey ? 'Control+' : ''}${event.altKey ? 'Alt+' : ''}${event.metaKey ? 'Meta+' : ''}${event.key}`
31+
}

src/index.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* @flow strict */
2+
3+
import {Leaf, RadixTrie} from './radix-trie'
4+
import {fireDeterminedAction, expandHotkeyToEdges, isFormField} from './utils'
5+
import hotkey from './hotkey'
6+
7+
const hotkeyRadixTrie = new RadixTrie()
8+
const elementsLeaves = new WeakMap()
9+
10+
let currentTriePosition = hotkeyRadixTrie
11+
let resetTriePositionTimer = null
12+
function resetTriePosition() {
13+
resetTriePositionTimer = null
14+
currentTriePosition = hotkeyRadixTrie
15+
}
16+
17+
document.addEventListener('keydown', (event: KeyboardEvent) => {
18+
if (event.target instanceof Node && isFormField(event.target)) return
19+
20+
if (resetTriePositionTimer != null) {
21+
clearTimeout(resetTriePositionTimer)
22+
}
23+
resetTriePositionTimer = setTimeout(resetTriePosition, 1500)
24+
25+
// If the user presses a hotkey that doesn't exist in the Trie,
26+
// they've pressed a wrong key-combo and we should reset the flow
27+
const newTriePosition = currentTriePosition.get(hotkey(event))
28+
if (!newTriePosition) {
29+
resetTriePosition()
30+
return
31+
}
32+
33+
currentTriePosition = newTriePosition
34+
if (newTriePosition instanceof Leaf) {
35+
fireDeterminedAction(newTriePosition.children[newTriePosition.children.length - 1])
36+
event.preventDefault()
37+
resetTriePosition()
38+
return
39+
}
40+
})
41+
42+
export function install(element: HTMLElement) {
43+
const hotkeys = expandHotkeyToEdges(element.getAttribute('data-hotkey') || '')
44+
const leaves = hotkeys.map(hotkey => hotkeyRadixTrie.insert(hotkey).add(element))
45+
elementsLeaves.set(element, leaves)
46+
}
47+
48+
export function uninstall(element: HTMLElement) {
49+
const leaves = elementsLeaves.get(element)
50+
if (leaves && leaves.length) {
51+
for (const leaf of leaves) {
52+
leaf && leaf.delete(element)
53+
}
54+
}
55+
}

src/radix-trie.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/* @flow */
2+
3+
export class Leaf<T> {
4+
parent: RadixTrie
5+
children: T[] = []
6+
7+
constructor(trie: RadixTrie) {
8+
this.parent = trie
9+
}
10+
11+
delete(value: T) {
12+
const index = this.children.indexOf(value)
13+
if (index === -1) return false
14+
this.children = this.children.slice(0, index).concat(this.children.slice(index + 1))
15+
if (this.children.length === 0) {
16+
this.parent.delete(this)
17+
}
18+
return true
19+
}
20+
21+
add(value: T) {
22+
this.children.push(value)
23+
return this
24+
}
25+
}
26+
27+
export class RadixTrie {
28+
parent: RadixTrie | null = null
29+
children: {[key: string]: RadixTrie | Leaf<*>} = {}
30+
31+
constructor(trie: ?RadixTrie) {
32+
this.parent = trie || null
33+
}
34+
35+
get(edge: string) {
36+
return this.children[edge]
37+
}
38+
39+
insert(edges: string[]) {
40+
let currentNode = this
41+
for (let i = 0; i < edges.length; i += 1) {
42+
const edge = edges[i]
43+
let nextNode = currentNode.get(edge)
44+
// If we're at the end of this set of edges:
45+
if (i === edges.length - 1) {
46+
// If this end already exists as a RadixTrie, then hose it and replace with a Leaf:
47+
if (nextNode instanceof RadixTrie) {
48+
currentNode.delete(nextNode)
49+
nextNode = null
50+
}
51+
// If nextNode doesn't exist (or used to be a RadixTrie) then make a Leaf:
52+
if (!nextNode) {
53+
nextNode = new Leaf(currentNode)
54+
currentNode.children[edge] = nextNode
55+
}
56+
return nextNode
57+
// We're not at the end of this set of edges:
58+
} else {
59+
// If we're not at the end, but we've hit a Leaf, replace with a RadixTrie
60+
if (nextNode instanceof Leaf) nextNode = null
61+
if (!nextNode) {
62+
nextNode = new RadixTrie(currentNode)
63+
currentNode.children[edge] = nextNode
64+
}
65+
}
66+
currentNode = nextNode
67+
}
68+
return currentNode
69+
}
70+
71+
// eslint-disable-next-line flowtype/no-weak-types
72+
delete(node: RadixTrie | Leaf<any>) {
73+
for (const edge in this.children) {
74+
const currentNode = this.children[edge]
75+
if (currentNode === node) {
76+
const success = delete this.children[edge]
77+
if (Object.keys(this.children).length === 0 && this.parent) {
78+
this.parent.delete(this)
79+
}
80+
return success
81+
}
82+
}
83+
return false
84+
}
85+
}

src/utils.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/* @flow strict */
2+
3+
export function isFormField(element: Node): boolean {
4+
if (!(element instanceof HTMLElement)) {
5+
return false
6+
}
7+
8+
const name = element.nodeName.toLowerCase()
9+
const type = (element.getAttribute('type') || '').toLowerCase()
10+
return (
11+
name === 'select' ||
12+
name === 'textarea' ||
13+
(name === 'input' && type !== 'submit' && type !== 'reset') ||
14+
element.isContentEditable
15+
)
16+
}
17+
18+
export function fireDeterminedAction(el: HTMLElement): void {
19+
if (isFormField(el)) {
20+
el.focus()
21+
} else if ((el instanceof HTMLAnchorElement && el.href) || el.tagName === 'BUTTON' || el.tagName === 'SUMMARY') {
22+
el.click()
23+
}
24+
}
25+
26+
export function expandHotkeyToEdges(hotkey: string): string[][] {
27+
return hotkey.split(',').map(edge => edge.split(' '))
28+
}

test/.eslintrc.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"rules": {
3+
"flowtype/require-valid-file-annotation": "off",
4+
"github/unescaped-html-literal": "off",
5+
"eslint-comments/no-use": "off"
6+
},
7+
"env": {
8+
"mocha": true
9+
},
10+
"globals": {
11+
"assert": true
12+
},
13+
"extends": "../.eslintrc.json"
14+
}

test/karma.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = function(config) {
2+
config.set({
3+
frameworks: ['mocha', 'chai'],
4+
files: ['../dist/index.umd.js', 'test.js'],
5+
reporters: ['mocha'],
6+
port: 9876,
7+
colors: true,
8+
logLevel: config.LOG_INFO,
9+
browsers: ['ChromeHeadless'],
10+
autoWatch: false,
11+
singleRun: true,
12+
concurrency: Infinity
13+
})
14+
}

test/test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* global hotkey */
2+
3+
let buttonsClicked = []
4+
function buttonClickHandler(event) {
5+
buttonsClicked.push(event.target.id)
6+
}
7+
8+
describe('hotkey', function() {
9+
beforeEach(function() {
10+
document.body.innerHTML = `
11+
<button id="button1" data-hotkey="b">Button 1</button>
12+
<button id="button2">Button 2</button>
13+
<button id="button3" data-hotkey="Control+b">Button 3</button>
14+
<input id="textfield" />
15+
`
16+
for (const button of document.querySelectorAll('button')) {
17+
button.addEventListener('click', buttonClickHandler)
18+
}
19+
for (const button of document.querySelectorAll('[data-hotkey]')) {
20+
hotkey.install(button)
21+
}
22+
})
23+
24+
afterEach(function() {
25+
for (const button of document.querySelectorAll('button')) {
26+
button.removeEventListener('click', buttonClickHandler)
27+
}
28+
for (const button of document.querySelectorAll('[data-hotkey]')) {
29+
hotkey.uninstall(button)
30+
}
31+
document.body.innerHTML = ''
32+
buttonsClicked = []
33+
})
34+
35+
it('triggers buttons that have `data-hotkey` as a attribute', function() {
36+
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'}))
37+
assert.include(buttonsClicked, 'button1')
38+
})
39+
B731
40+
it("doesn't trigger buttons that don't have `data-hotkey` as a attribute", function() {
41+
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'}))
42+
assert.notInclude(buttonsClicked, 'button2')
43+
})
44+
45+
it("doesn't trigger when user is focused on a form field", function() {
46+
document.getElementById('textfield').dispatchEvent(new KeyboardEvent('keydown', {key: 'b'}))
47+
assert.deepEqual(buttonsClicked, [])
48+
})
49+
50+
it('handles multiple keys in a hotkey combination', function() {
51+
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b', ctrlKey: true}))
52+
assert.include(buttonsClicked, 'button3')
53+
})
54+
55+
it("doesn't trigger elements where the hotkey library has been uninstalled", function() {
56+
hotkey.uninstall(document.querySelector('#button1'))
57+
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'}))
58+
assert.deepEqual(buttonsClicked, [])
59+
})
60+
})

0 commit comments

Comments
 (0)
0