Welcome to From Scratch, where we build solutions to commonly known problems, from scratch.
In this iteration, let's build a keyboard accessible menu component.
The Problems
Most front-end projects do end up needing a custom tailor-made solution for something like a drop-down or a menu. These solutions may end up looking really fancy and can lead to a unique touch for your app that users appreciate and recognize.
But, they are really not accessible by default. Try to use them with a keyboard or a screen reader and they fall apart, and can lead to your app being rendered completely unusable (forgive the pun 😉).
One of the main problems keyboard users face is that most custom menus do not handle focus very well/at all. Something like the behavior that can be seen below.
Notice how the focus just jumps when tabbing between our custom menu components. How do we select something inside the menu? This is really bad. What's the point of having a custom-made solution that is literally unusable?
The Functionality we want
Now, let's look at how we want our custom menu to behave with keyboard inputs.
- Tab / Shift + Tab should allow us to jump between the whole menus, without the focus going inside a menu.
- ↑ and ↓ should allow us to navigate inside a menu.
- Space should allow us to select/ perform an intended action that the focus is currently on (So, basically a click).
- If we jump from a menu to another and then jump back, our initially selected element should be in focus already.
So, with our requirements compiled, let's see how we would go about making our component accessible.
The Solution
Before we look at our solution, let's first clear our fundamentals. By, default only interactive elements such as <button>
, <a>
, etc are focusable. But, how do we make any element focusable?
The tabindex
attribute
Well, this is how. Setting the tabindex
attribute on an element denotes that it can be focused when tabbing using a keyboard. It takes an integer, which decides the behavior
- 0 means that the element will be focusable in the order of the source document.
- A positive number denotes that the element will be focusable and the order is dependent on the this value itself. So, elements with a
tabindex="2"
will be focused first before elements with atabindex="1"
and so on. - A negative number denotes that the element will not be focusable using the keyboard.
The strategy
First, we have to make sure that the top level element in our custom menu(let's say a div) has a tabindex="0"
on it and the children have a tabindex="-1"
on them.
<div class="menu" tabindex="0">
<a href="https://google.com" target="_blank" tabindex="-1">Link to Google</a>
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
</div>
<div class="menu" tabindex="0">
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
<a href="#" tabindex="-1">Link</a>
</div>
As soon the top level element is focused, We set tabindex="0"
on its first child(by default) and programmatically focus it using element.focus()
.
Then, we can setup an event listener for the keydown
event and handle ↑, ↓ and Space keys in our event handler.
When ↑ is pressed, we set tabindex="-1"
on the currently focused element(say element N), and set tabindex="0"
on the element before it(say element N-1). Now, we programmtically focus the N-1 element, which now becomes our focused element.
Now, we can do the same thing for ↓, just in the opposite direction. So, going from element N to element N+1.
Making it circular
So, we can now move focus up and down using the arrow keys, but, what happens if we hit ↑ when we are on the first element and ↓ when we are on the last element.
To handle this edge case, we first check if the currently selected element is either the first element or the last element. If it is, we can just invert the focus when the key is pressed. So going from last element to first and first to last.
Putting it all together
JavaScript
const menuList = document.querySelectorAll('.menu');
menuList.forEach((menu) => {
const children = Array.from(menu.children);
let current = 0;
const handleKeyDown = (e) => {
if (!['ArrowUp', 'ArrowDown', 'Space'].includes(e.code)) return;
if (e.code === 'Space') {
children[current].click();
return;
}
const selected = children[current];
selected.setAttribute('tabindex', -1);
let next;
if (e.code === 'ArrowDown') {
next = current + 1;
if (current == children.length - 1) {
next = 0;
}
} else if ((e.code = 'ArrowUp')) {
next = current - 1;
if (current == 0) {
next = children.length - 1;
}
}
children[next].setAttribute('tabindex', 0);
children[next].focus();
current = next;
};
menu.addEventListener('focus', (e) => {
if (children.length > 0) {
menu.setAttribute('tabindex', -1);
children[current].setAttribute('tabindex', 0);
children[current].focus();
}
menu.addEventListener('keydown', handleKeyDown);
});
menu.addEventListener('blur', (e) => {
menu.removeEventListener('keydown', handleKeyDown);
});
});