I recently started learning VIM and became enchanted by its navigation. I am also a twitter addict, which has excellent shortcuts.
Inspired by these, I added similar navigation to my project. And I will show you how to do this in React in this article.
I created phived.com a year ago with the purpose of being a minimal to-do list that would help me remember things I had to do.
Since then, I have used it everyday. I added some shortcuts:
-
enter
to go to the next task -
shift + enter
to go to the previous task -
cmd + enter
to complete a task
Here is the code for these behaviors. Since it isn't the focus of this article, I won't explain it further.
This mostly kept my hands off the mouse. But another feature changed things...
After using the website routinely, I found myself adding the same tasks every day. I always pushed back the idea of adding more than five tasks at a time, since that would undermine the original purpose of the project.
I ended up adding a /daily page, similar to the original tasks, with a key difference: tasks that you complete can be restored tomorrow: so you don't have to type them everyday!
Now, I wanted to toggle between phived.com
and phived.com/daily
tasks using g + g
, inspired by twitter's shortcuts such as g + h
to go to Home, g + n
to go to Notifications, etc...
I achieved this with the following code. It belongs to the Daily component and redirects us to "/" when the user presses the combination g + g
.
const pressedKeys = useRef("");
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
const inputIsFocused = document.activeElement instanceof HTMLInputElement;
if (event.key !== "g" || inputIsFocused) {
return;
}
pressedKeys.current += "g";
if (pressedKeys.current === "gg") {
window.location.href = "/";
pressedKeys.current = "";
}
};
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
}, []);
Let's dive in! First, a ref will store our key presses:
const keysPressed = useRef("")
Our useEffect has one goal: listen to the keydown event and store them in the ref.
An important caveat is that, if the input is focused (user is editing a task) the redirect shouldn't happen. Imagine you're typing "buy e gg s" as a task, and it suddenly jumps you to another page.
We avoid that by returning early, which we also do if the key pressed isn't g
:
const inputIsFocused = document.activeElement instanceof HTMLInputElement;
if (event.key !== "g" || inputIsFocused) {
return;
}
Now we can add the key press to the ref:
pressedKeys.current += "g";
Check if the combination is g + g
and redirect towards our general tasks (that live on phived.com
, remember). We also empty the ref since it has fulfilled its cycle:
if (pressedKeys.current === "gg") {
window.location.href = "/";
pressedKeys.current = "";
}
We then add (and remove) the eventListener with useEffect, so that it hooks into the component's lifecycle correctly:
window.addEventListener("keydown", handleKeyPress);
return () => {
window.removeEventListener("keydown", handleKeyPress);
};
And voilà! It works as intended. But not quite...
Since we can't redirect while input is focused, when can we redirect? When the focus is somewhere else. Currently, the only way to do that is by clicking another element. And that's not what we're looking for!
We are missing a crucial step, that Twitter implements in its website: pressign ESC
to blur from input.
The following code does this. If there's an active element and the key pressed is ESC
, we blur (remove focus).
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!(document.activeElement instanceof HTMLElement)) {
return;
}
if (e.key === "Escape") {
document.activeElement.blur();
}
};
Now, we just assign the onKeyDown prop to handleKeyDown on the element that surrounds the components where we want this behavior. In my case, that is a div
.
...
return (
<GeneralTasksContextProvider>
<div
onKeyDown={handleKeyDown}
className="flex h-full w-full flex-col items-center justify-center bg-softWhite dark:bg-trueBlack"
>
<HelmetProvider>
<Head />
</HelmetProvider>
<ModeSelectorMobile />
<Header />
<GeneralTasks />
<Message />
<Footer />
</div>
</GeneralTasksContextProvider>
);
...
Now it works: we can toggle between the general and daily tasks by pressing ESC + g + g
- without touching the mouse!
Thanks for reading! If you enjoyed this follow me on twitter and star phived on github.
Top comments (3)
Great article and great feature, man!
I would suggest also adding a debounce in case you decide to add more commands. This way, if the user presses "g" but then decides to send another command, it wouldn't trigger an unwanted command that may fit the sequence.
Adding a debounce would discard the pressed key if the user doesn't type anything else (or an invalid command sequence) in, like, 1s.
Thank you for the feedback! That is a super important point, not only for avoiding unwanted keyboard combinations, but also long pauses between the g keystrokes.
Currently, I can press g, wait for 10 seconds, and press g again and the shortcut will work, which feels clunky at best. I will add the debounce, thanks!
Nice! Maybe using aria-keyshortcuts to enable it for accessibility would improve further the experience