Follow

Follow

Navigating a Roving Tabindex

Greg McKelvey's photo
Greg McKelvey
·Jul 19, 2022·

8 min read

Table of contents

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
  • 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 or componentDidUpdate 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 a NodeList and one does not simply iterate over a NodeList. However, one can cheat by converting that NodeList into an array with Array.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 use querySelector
  • nextSibling and previousSibling : 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!

 
Share this