A software engineer focused on writing expressive code for humans (and computers).

June 2, 2019 ·

Adding a keyboard shortcut for global search

Some websites use a global “/” keyboard shortcut to trigger some form of search. For instance GitHub and Heroku focus the global search box and Jira slides out a search panel. I was faced with implementing this recently in a web application and bumped into a few gotchas.

tl;dr

Here’s a slash key binding that avoids the gotchas outlined in the rest of this post:

document.addEventListener("keyup", e => {
  if (e.key !== "/" || e.ctrlKey || e.metaKey) return;
  if (/^(?:input|textarea|select|button)$/i.test(e.target.tagName)) return;

  e.preventDefault();
  document.getElementById("search").focus();
});

Listen for a “/” then focus an element

Let’s start with a simple approach that listens for any keyup event. If the key code is anything other than a slash, return immediately. Otherwise, find the search element by ID and focus on it.

document.addEventListener("keyup", e => {
  if (e.code !== "slash") return;

  e.preventDefault();
  document.getElementById("search").focus();
});

Seems like a simple enough solution, but here be dragons!

It works totally fine… until you realize that anytime the slash key is pressed, the search box is focused. And it turns out you need slashes and question marks to fill out forms with things like dates and questions.

But not while filling out a form

To limit the interaction not to happen while filling out a form, we can check the event.target.tagName to determine which HTML element was focused when the keyup happened. Then return early if it was typed while focused on an input, textarea, select, or button.

document.addEventListener("keyup", e => {
  if (e.code !== "slash") return;
  if (/^(?:input|textarea|select|button)$/i.test(e.target.tagName)) return;

  // …
});

The slash key now works globally and the forms are still usable. But there’s another problem. The slash key is shared with another common character: the question mark.

Or when any modifier keys are pressed

In our current implementation, typing “?” using shift + / triggers focus. While this might be okay since we’re ignoring form inputs, it is unintentionally binding an extra shortcut. Actually, it also binds “÷” since alt + / returns the division symbol.

This brings up the idea of modifier keys: shift, command, alt, and control. Currently our slash listener completely ignores any modifier keys, but we only want it to react when a slash it typed with no modifiers.

Fortunately the Event object makes it easy to determine modifiers with the altKey, ctrlKey, metaKey, and shiftKey boolean properties. We can return early if any of them are true.

document.addEventListener("keyup", e => {
  if (e.code !== "slash") return;
  if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
  if (/^(?:input|textarea|select|button)$/i.test(e.target.tagName)) return;

  // …
});

With all that in place, we are now only binding to the “/” key when not focused on a form element and when no modifiers are pressed. But there’s one other case to consider…

And keep Dvorak happy :)

Not everyone uses the Qwerty keyboard layout. And it turns out the e.code returns the physical key identifier, not the actual value of the key. So if someone with the Dvorak layout were to hit “[”, the physical key assigned to type a slash, it wouldn’t do anything. Instead they would need to hit the key assigned to “z” because that’s the physical slash key.

We can fix this by checking e.key instead of e.code. The key property contains the character output from pressing the key, rather than the physical key identifier. Which also means the guards for shift and alt modifiers are no longer needed because they output different characters than a slash (“?” and “÷”).

And with that, voilà, our finished version:

document.addEventListener("keyup", e => {
  if (e.key !== "/" || e.ctrlKey || e.metaKey) return;
  if (/^(?:input|textarea|select|button)$/i.test(e.target.tagName)) return;

  e.preventDefault();
  document.getElementById("search").focus();
});