Navigating a Roving Tabindex
Accessible Apps === Better Apps
As web apps and web technology continue to replace traditional software, we as developers may not have realized what got left behind! A forgotten feature that caught me by surprise is keyboard navigation in lists and tables! Keyboard navigation in a website mainly consists of using TAB
and shift + TAB
to navigate from one focusable element to the next. For example, filling out a form or selecting a link in a navigation bar. However, what happens when you have a lists of 10s or hundreds or links, or a table with links or buttons in 2 dimensions! Adding arrow key navigation can greatly enhance a keyboard user’s experience.
A feature that seemed so intuitive, ironically proved to be harder to find or implement a solution. For the sake of both my own understanding and making the web more accessible, here is a play by play of a potential solution
TL;DR
Take use-roving-tabindex-a11y for a spin or check out the examples!
My goals for this package were:
- Small api
- Easy to use
- Accessible
- Use React/Typescript
What is a roving tab index?
Roving tab index is an arrow key navigation feature added to html with javascript that dynamically changes the tabindex
attribute of an element to shift focus. Normally, the tab key navigates to each focusable element on a page, in order, changing focus as a user would bounce from focusable elements to the next focusable element. Elements are focusable by the tab key by default if they are a
, button
, input
, textarea
, select
, details
, [tabindex]:not([tabindex="-1"])
The arrow keys will only scroll a site.
A roving tab index can happen when a group of focusable elements all have a tabindex="-1"
, except for one child who has a tabindex="0"
. This means that when tabbing through a site, the elements with tabindex="-1"
will be skipped and focus will land on which ever element has tabindex="0"
. An event listener is then added to the parent to intercept the arrow keys and programmatically update the tabindex of the next element to 0 in order for it to be focusable.
Let’s walk through what that would look like!
Roving Tab Index Requirements
Event listener on a parent of a group of focusable elements
- example: a
<ul>
full of<a>
‘s or<button>
‘s
- example: a
- A single child element with a
tabindex="0"
- The remaining children with a
tabindex="-1"
Get the Parent with a Callback Ref
This solution uses React, so we will get a reference to the parent with a ref
... sort of. I ran into an issue where the element that I wanted to attach a ref
was conditionally rendered, making useRef
never update after getting a reference to the parent! In comes, callback refs!
React will call the ref callback with the DOM element when the component mounts, and call it with null when it unmounts. Refs are guaranteed to be up-to-date before
componentDidMount
orcomponentDidUpdate
fires.
This function takes an HTMLElement
as an argument and returns nothing. We will be spending the rest of our time inside of this callback function!
const listRef = React.useCallback<(...args: (HTMLElementWithHTMLElementChildNodes | null)[]) => void>
(list => {
...
(note the custom interface HTMLElementWithHTMLElementChildNodes
this will unlock child attributes later on)
On this list
, we will add an event listener to capture all the keystrokes while something is focused in that parent element.
list.addEventListener('keydown', onKeydown)
...
return () => {
list.removeEventListener('keydown', onKeydown)
}
The parent is now ready for children 🚸
Prepare the Children
In order for this to work, every focusable child needs to have a tabindex="-1"
except for one set to tabindex="0"
- that child will be the first stop as a user is tabbing through.
(note: the HTML
tabindex
attribute is all lowercase, while the React prop tabIndex
is camelCased.)
example:
<a href="#">Link above</a>
<ul ref={listRef}>
<li>
<a tabIndex={0} href="#">Item 1</a>
</li>
<li>
<a tabIndex={-1} href="#">Item 2</a>
</li>
<li>
<a tabIndex={-1} href="#">Item 3</a>
</li>
</ul>
<a href="#">Link below</a>
As a user is tabbing through, they will go from Link above
to Item 1
to Link below
, skipping the remaining items because their tabindex
is -1
. That is good.
Find the Chosen One
Because we have the parent list
, we can get access to all of the childNodes
in order to keep track of where we are. The first thing we need to do is to find the childNode
that has the element with tabindex="0"
, or the focused element.
const listItemWithFocus = Array.from(list.childNodes).find(item => {
return item.querySelector<HTMLElement>('[tabindex]:not([tabindex="-1"])')
})
Note about
Array.from()
: Technically,querySelectorAll
returns aNodeList
and one does not simply iterate over aNodeList
. However, one can cheat by converting thatNodeList
into an array withArray.from()
Type Tangent
If you were typing this into your editor, you may notice typescript being a bit grumpy. I needed to make some custom types in order for everyone to play nice:
type ElementAndTable = HTMLUListElement &
HTMLTableSectionElement &
HTMLTableRowElement
interface HTMLElementWithSiblings extends HTMLElement {
nextSibling: HTMLElement | null
previousSibling: HTMLElement | null
}
interface HTMLElementWithHTMLElementChildNodes extends ElementAndTable {
childNodes: NodeListOf<HTMLElementWithSiblings>
}
This gives us 2 very important bits of intellisense
childNodes
: to usequerySelector
nextSibling
andpreviousSibling
: to navigate to our neighbors.
Keyboard Solo 🎹
We now have enough information to start listening to the arrow keys. Here, we can set up a pretty straight forward switch
statement:
const onKeyDown = (e: KeyboardEvent) => {
const listItemWithFocus = ...
...
switch (e.key) {
case 'ArrowDown': {
verticalNav(listItemWithFocus?.nextSibling)
break
}
case 'ArrowUp': {
verticalNav(listItemWithFocus?.previousSibling)
break
}
case 'ArrowRight': {
horizontalNav(1)
break
}
case 'ArrowLeft': {
horizontalNav(-1)
break
}
}
}
We will use 1 of 2 different functions to determine where to shift our focus. Remember those custom types? They enable us to get intellisense of .nextSibling
and .previousSibling
with our vertical navigation!
Vertical Navigation
Vertical navigation involves keeping track of which child in our list we are actually on. Because the focusable element may be super nested in <p>
or <span>
or some other ungodly mess, we keep track of the child <li>
so we will know which sibling <li>
to move to next.
const verticalNav = (sibling?: HTMLElement | null) => {
const allFocusItems = sibling?.querySelectorAll<HTMLElement>("[tabindex]")
const nextFocusRowItemIndex = clampNumber(
getHorizontalIndex() ?? 0,
0,
allFocusItems?.length ?? 1
)
const nextFocusItem = Array.from(allFocusItems ?? [])[nextFocusRowItemIndex]
changeFocus(nextFocusItem)
}
We haven’t discussed this yet, but it is possible to have several focusable elements inside of our <li>
! Perhaps there is a list of cards where the title is a link to go to another page, but there is also a button to display a menu. Or perhaps a better example, would be a tbody
in an HTML table
! In a row could be a: checkbox, link, button for a menu, or all three!
Before we move on vertically from a row, it is important to note how many focusable elements and what position the current focusable element is. By getting that position, we can use it to directly drop down or pop up to the correct focusable element in the next row.
Horizontal Navigation
Now that we know we might need to move horizontally... how do we do it? We’ll need an array of all of the focusable items, the index of the current focused item, and a direction!
const horizontalNav = (direction: 1 | -1) => {
const allFocusItems = Array.from(
listItemWithFocus?.querySelectorAll<HTMLElement>("[tabindex]") ?? []
)
const nextFocusIndex = clampNumber(
getHorizontalIndex() + direction,
0,
allFocusItems.length - 1
)
const nextFocusItem = allFocusItems[nextFocusIndex]
changeFocus(nextFocusItem)
}
CH-ch-ch-Change Focus
Both horizontal and vertical navigation have the same goal: Change the oldFocusItem
‘s tabindex
back to -1
, change the nextFocusItem
‘s tabindex
to 0
, and programmatically change focus()
const changeFocus = (nextFocusItem?: HTMLElement | null) => {
// Prevent screen from scrolling with arrow keys
e.preventDefault()
if (listItemWithFocus && nextFocusItem) {
const oldFocusItem =
listItemWithFocus.querySelector<HTMLElement>('[tabindex="0"]')
if (oldFocusItem) oldFocusItem.tabIndex = -1
nextFocusItem.tabIndex = 0
nextFocusItem.focus()
}
}
Mouse and Other Users 😡
This is all fine and dandy for keyboard users, but the mouse or a screen reader must also be accounted for! Both of those methods will apply focus to an item when interacted with, which is fine, but our roving tab index won’t update itself... unless we listen for a general focus event and handle our tabindex
there as well:
// Update focus from click or screen reader
const onFocus = (e: FocusEvent) => {
const element = e.target
if (element instanceof HTMLElement) {
const focusedItems = list.querySelectorAll<HTMLElement>(
'[tabindex]:not([tabindex="-1"])'
)
focusedItems.forEach(item => (item.tabIndex = -1))
element.tabIndex = 0
}
}
Bonus... maybe?
Since this custom hook is implementing custom behavior for the keyboard, it might be important to leave a note for those who can’t see what is going on.... I think. I am a sighted internet user and I lack the daily experience of a screen reader user. Therefore, this is the least confident part of the code that is begging for advice! In the interim, this hook also adds hidden instructions for a screen reader informing them that the keyboard is going to behave a little differently for this section.
export const useRovingTabIndex = () => {
const instructionsId = useUUID()
...
// add screen reader instructions
if (list?.parentElement) {
// build instructions
const instructions = document.createElement('span')
instructions.id = instructionsId
instructions.innerText =
'Use the up and down arrow keys to navigate the list and use the left and right arrow keys to navigate inside of the item'
instructions.style.border = 'none'
instructions.style.clip = 'rect(0 0 0 0)'
instructions.style.height = '1px'
instructions.style.margin = '-1px'
instructions.style.overflow = 'hidden'
instructions.style.padding = '0'
instructions.style.position = 'absolute'
instructions.style.top = '20px'
instructions.style.width = '1px'
instructions.setAttribute('data-useRovingTabIndex', 'true')
// If ref is on a a table section
if (list.parentElement instanceof HTMLTableElement) {
const existingCaption = list.parentElement.querySelector('caption')
if (existingCaption) {
const existingInstructions = existingCaption.querySelector(
'[data-useRovingTabIndex]'
)
// if instructions exist, don't append. Just use those instead
if (existingInstructions) {
instructions.id = existingInstructions.id
} else {
existingCaption.append(instructions)
}
} else {
const newCaption = document.createElement('caption')
newCaption.append(instructions)
list.parentElement.prepend(newCaption)
}
} else {
list.parentElement.insertBefore(instructions, list)
}
list.setAttribute(
'aria-describedby',
`${
list.getAttribute('aria-describedby')
? list.getAttribute('aria-describedby') + ' '
: ''
}${instructions.id}`
)
cleanUpFns.push(() => {
list.setAttribute(
'aria-describedby',
`${list
.getAttribute('aria-describedby')
?.replace(instructions.id, '')} ${instructions.id}`
)
instructions.remove()
})
}
...
}
Thank you if you made it this far! Check out the code and please leave an issue if you find a way to make this better for everyone on the web!