All Javascript Keyboard Shortcut Libraries Are Broken

Either subtly (by using key), or not-so-subtly (by using code). There is no way to fix the more subtle variant, and the only solution is to Give Up (on supporting a large subset of keyboard shortcuts).


So for work, I was tasked with making it so users could rebind the keyboard shortcuts used in our app. Not too complicated, I just needed to consolidate all the shortcuts into one place so I can split apart the keyboard shortcut -> action and action -> code that gets run maps, create a definition for a keyboard shortcut, let users to override the first map in settings, and bam. Don’t even need to use any external libraries, the existing code shows the browser-provided information is more than sufficient! Except wait a minute, I feel like I’m forgetting something…

What Is A Keyboard Shortcut?

Ah. Those pesky definitional questions. Others may have different, more complex definitions to account for different, more complex scenarios, but for our purposes in our app, a keyboard shortcut is simple:

A keyboard shortcut is one or more modifier keys being pressed when exactly one non-modifier key gets pressed.

And while I’m at it, I should probably also define what a modifier key is:

A modifier key is any one of the following keys:

  • Shift, aka ⇧
  • Control, aka ⌃
  • Alt, aka Option, aka ⌥
  • Meta, aka Super, aka Windows Key, aka Command, aka ⌘

I’m sure we’ll have to revisit this for Vim mode, whenever a critical number of nerds starts using our platform, but good enough for now.

We choose this definition because it exactly lines up with the fields in KeyboardEvent: altKey, ctrlKey, metaKey, and shiftKey. And then one other field for the non-modifier key. One other field… Wait, there’s more than one??

More Than One Way To Describe A Key

Ignoring all the deprecated ways, there are exactly two fields that describing which non-modifier key was pressed: code (deprecated: keyCode, which) and key (deprecated: keyIdentifier). The linked MDN documentation does a pretty good job at explaining the differences, but i also like to think i am also good at explaining things, so here I go:

  • code is for when you want to write a game, where the position of the keys relative to each other matters.
  • key is for literally everything else. Why? Because otherwise, users on alternative or international layouts would read the incorrect thing when using code.

Great, easy! I’m sure that all the popular libraries will be aware of this and all use key, then :)

Oh No All The Libraries Use code

Let’s stroll down the top “shortcut” packages on NPM, sorted by monthly downloads, shall we?

A list is a bit hard to parse, let’s look at it in a table too:

Package Uses which Uses keyCode Uses code Uses key
react-hotkeys-hook ✅ (bad)
mousetrap
hotkeys-js
tinykeys ✅ (good)
combokeys
react-hot-keys
react-shortcuts
@discourse/itsatrap
v-hotkey
ng-keyboard-shortcuts
@ngneat/hotkeys
shortcuts ✅ (bad)
@sofie-automation/sorensen ✅ (good?)
use-keyboard-shortcuts ✅ (bad)

Holy cow. I knew it was bad but I had no idea it was this dire. Now obviously, “most downloaded packages under a keyword” is not necessarily representative of the ecosystem as a whole. lexical for example, which is not primarily a keyboard shortcut library but includes some anyways, uses key. Still, it boggles the mind how bad the popular purpose-built ones tend to be.

But Wait There’s More

Unfortunately, “just use key” isn’t all sunshine & roses like I may have led you to believe. There’s a reason library authors might shy away from it, even though it’s been supported in all major browsers for almost 8 years (or 5, if you include Edge), and choose to use code or its variants instead: the Shift modifier modifies the value produced by key 🙀

Wait, that’s it? Like sure, maybe upper/lowercase doesn’t always round-trip, and doing Shift+2 will be different from Shift+Meta+2, and all this might change based on the user locale, and oh no yeah that’s a big problem isn’t it. By using key, you might be able to support DVORAK a bit better, but German keyboards will have Shift+2 give you " instead of @ like you’d get on a US layout. As far as I can tell, only the roman alphabet (A-Z) is safe.

Even More Actually

The event that sparked this post was honestly something a bit more silly: It’s the fact that, on MacOS, the Option (⌥) key also modifies keys, even on US QWERTY! Alt+c? Surely you mean Alt+ç 🙂. That is what made me switch my initial prototype to use code instead of key, which led me to discover this incosistency, which led me down the rabbithole & out the other side with this big corkboard full of red string.

What The Good Libraries Have To Do

When searching NPM for “shortcut” libraries sorted by most recently updated, I came across keyboard-i18n, which has an interesting solution to this problem: it uses the experimental Keyboard API (only supported on Chrome) to read the KeyboardLayoutMap, which maps code -> key with no modifiers. From this, you can build a reverse mapping key -> code, which lets you specify your keyboard shortcuts using key, but read them using code, getting you the best of both worlds! @sofie-automation/sorensen also uses this approach afaict.

This is not quite the end of it, however; there are certain keys we might want to bind, like Ctrl+[, that won’t be present on all layouts. keyboard-i18n has some useful maps for certain keys like this that will work on international layouts without them, by mapping to non-roman characters in a similar location.

Unfortunately, this comes with a downside: the Keyboard API is only supported on Chrome. Which maybe isn’t a problem if the rest of your app only supports Chrome! But is still a problem if you want to support Safari1, or that other browser everyone forgets about (Firefox).

What We Cross-Browser Plebians Have To Do

So, if we want to support all major browsers, and all major keyboard layouts, our keyboard shortcut definitions must:

  • Use key
  • Only use roman alphabet characters (A-Z) and arabic numbers (0-9)
  • Use .toLowerCase() or .toUpperCase() to normalize case when checking what character was pressed
  • Only allow Shift as a modifier with alphabet characters
  • Never allow Alt as a modifier

As far as I can tell, there is no general-purpose shortcut library that gets this right. And maybe it’s the state of web search, but I can’t find anyone talking about this either. This lexical PR is about the closest I could get. I’ll probably just copy them, enforcing these rules within my own proprietary framework.

Everything is broken, and everything is fine 🔥

Footnotes

  1. Safari is especially problematic, because it has a very poor track record of adopting new web features in a timely manner. anyways