Building a Reusable Roving Tabindex Strategy in React, Part 1
A collection of
limestone-karst islands at Maya Bay, Thailand.
Sweet! But things are going to become more complex in a bit.
The Roving Tabindex pattern has been around for some time. It's a very useful pattern when it comes to collections of items that are actionable. Without it, each item in the collection is a tab stop, and if the collection is quite large, maybe more than 5 item large, keyboard users might experience frustration moving pass it. Also, it might be easier for keyboard users to just use arrow keys to move between items instead of using Tab or Shift Tab. For me, at least, it's faster and way more intuitive.
Benefits
Before going into the details, I believe it's crucial to explain the benefits, so the purpose of building a Roving Tabindex, or importing one at least, is a great step towards improving your App or Design System's UX and Accessibility. Let's list them:
- Reducing the tab stops from a collection of items.
- Improve navigation between the items with arrow keys, Home, End and possibly character keys.
- After tabbing away from the collection, tabbing back brings you back to the previously focused item.
- Necessary for collections such as nested menus.
- Circular navigation in the collection.
Requirements
The UX benefits are real, so let's create a list of requirements for our tabindex strategy:
- Only one item in the collection should be tabbable at a time.
- the item should have tabindex="0".
- the rest of the items should have tabindex="-1".
- tabindex="0" should follow focus.
- that means that when an item is focused by any means (keyboard, click, programatic), it should be the one receiving tabindex="0".
- Tabbing from and back to the collection should apply focus to the item that
was last focused.
- this is why #2 is crucial.
- Keyboard navigation should work with Arrow Keys, as well as Home and End.
- Navigation orientation should be both horizontal and vertical.
- RTL should also be considered for horizontal navigation.
- Disabled items should be skipped when navigating, but how do we mark a
disabled item?
- "disabled" attribute.
- "aria-disabled" attribute of value "true".
- no "tabindex" value.
- custom implementation.
- Navigation might be circular or not, so we should support both.
- It has to be re-usable.
- We might have many components in our app or design system that need this pattern: lists, menus, tabs, steppers etc.
- If the codebase is mature enough, these components have a lot of custom logic added to them, so integrating our tabindex should not be a refactoring pain.
- Some componenents might need to change focus based on some custom requirement (character key navigation), so our tabindex utility should be aware of that.
There are a lot of things to take into consideration, and this is the reason for me writing about the Roving Tabindex pattern. We are building this utility in MUI in order to bring consistency and correct pattern implementation, and I believe that the lessons we learned as we developed it will be useful for anyone that needs this utility in their project.
Implementation Strategy
It's not a very hard pattern to understand, but given the many requirements, it might be hard to hit the nail exactly right. And I believe it's about time web apps get this pattern 100% right. We need great Accessibility and UX from our apps. Now, with the great help the AI tools bring us, it's a great opportunity for us to understand the pattern and deliver it in our apps.
As we understand something very well before we actually implement it, test driving the implementation is a great way to ensure we are building the right thing. For this reason, I will develop the utility using a TDD strategy, and it's going to be very rewarding to see those tests turning from red to green, as we continue to iterate and add value to our utility. Iterating this way is very helpful both for an Engineer, as well as for an AI agent, or an Engineer using AI. Wins across the board, let's go!
Implementation
Setting Up
In order for this article to be super intuitive, I will actually start by addressing #9 in our requirements list: the utility being reusable. I will pick React to implement the utility, but I'm sure there is going to be an equivalent implementation in Angular or Vue if the strategy is clear enough. Reusable logic in React is best implemented via a custom hook, so we'll do just that.
Create Project
I will set up a new React project with Vite. I will be using TypesScript, by the way. After scaffolding the project, I will create a new GitHub repo and add my new project to that repo. We can do our first commit now.
Testing Example
In our App.tsx file I will go ahead and add a supporting component which will
act as our testing example.
// App.tsx
function App() {
const {getContainerProps, getItemProps} = useRovingTabIndex()
return (
<div {...getContainerProps()} data-testid="container" role="tablist">
<button {...getItemProps(0)} data-testid="button-1" role="tab">
Button 1
</button>
<button {...getItemProps(1)} data-testid="button-2" role="tab">
Button 2
</button>
<button {...getItemProps(2)} data-testid="button-3" role="tab" disabled>
Button 3
</button>
<button {...getItemProps(3)} data-testid="button-4" role="tab">
Button 4
</button>
<button {...getItemProps(4)} data-testid="button-5" role="tab">
Button 5
</button>
</div>
)
}
It's a Tabs component wannabe, implemented using buttons, and also has a disabled button in the middle. Now, the API I chose is clear: Getter Props. Our hook will return a couple of functions, called getter props, which will be called and their object result will be de-structured on both the container and the items.
It's a great way to support re-usability through this pattern, which is
available in Downshift for quite some time, even before hooks were introduced.
Notice that getItemProps is called with the index, so we know which item is
which behind the scenes.
The object that is returned by our getter props contains event handlers and HTML attributes, which are going to make the roving tabindex strategy possible. The consumer shouldn't care what's going on, as long as they call the API correctly.
The API
So, we need to return those 2 getter props from the hook, so we can go ahead and actually create the file for it, along with the API:
// useRovingTabIndex.ts
import * as React from 'react'
type RovingTabIndexOptions = {}
type RovingTabIndexReturn = {
getContainerProps: () => {
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
onFocus: (event: React.FocusEvent<HTMLElement>) => void
}
getItemProps: <T extends HTMLElement = HTMLElement>(
index: number,
) => {
ref: React.Ref<T>
tabIndex: number
}
}
export function useRovingTabIndex(
options: RovingTabIndexOptions,
): RovingTabIndexReturn {
// ToDo: Implement me!
}
So, there we go, our second commit. And we made sure requirement #9 is achieved! We're so on the right track!
Testing Infrastrucutre
I promised we structure the implementation with a TDD strategy, so we'll do one more setup, the unit tests. I'll be using Vitest and React Testing Library, including UserEvent.
I'll be reusing the same testing markup from App.tsx and create test file for
our hook:
// useRovingTabIndex.test.tsx
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {useRovingTabIndex} from './useRovingTabIndex'
function Tabs() {
const {getContainerProps, getItemProps} = useRovingTabIndex()
return (
<div
{...getContainerProps()}
data-testid="container"
tabIndex={-1}
role="tablist"
>
<button {...getItemProps(0)} data-testid="button-1" role="tab">
Button 1
</button>
<button {...getItemProps(1)} data-testid="button-2" role="tab">
Button 2
</button>
<button {...getItemProps(2)} data-testid="button-3" role="tab" disabled>
Button 3
</button>
<button {...getItemProps(3)} data-testid="button-4" role="tab">
Button 4
</button>
<button {...getItemProps(4)} data-testid="button-5" role="tab">
Button 5
</button>
</div>
)
}
function renderTabs() {
const utils = render(<Tabs />)
const user = userEvent.setup()
return {
...utils,
user,
}
}
The same API is used on the markup, and we render it with RTL's render method.
We will abstract this away in renderTabs and also setup the user object for
future interactions.
We can create another commit with setup infrastructure. Now we're truly ready
to begin.
Other Infrastructure Stuff
Up to you, you can setup ESLint, Prettier and anything else that's going to help out your development setup.
Implementing one Tab Stop
This time, we'll address the requirements in order.
Only one item in the collection should be tabbable at a time.
We expect, from the initial render of the Tabs, that only one tab has tabindex="0", while every other tab has tabindex="-1". And we will also consider the initial focusable item to be the first one. This is our test.
// useRovingTabindex.test.tsx
describe('useRovingTabIndex', () => {
it('should render tabs with tabindex attributes', () => {
const {getByTestId} = renderTabs()
expect(getByTestId('button-1')).toHaveAttribute('tabindex', '0')
expect(getByTestId('button-2')).toHaveAttribute('tabindex', '-1')
expect(getByTestId('button-3')).toHaveAttribute('tabindex', '-1')
expect(getByTestId('button-4')).toHaveAttribute('tabindex', '-1')
expect(getByTestId('button-5')).toHaveAttribute('tabindex', '-1')
})
})
Of course, it fails when we use npm t (vitest) to run it. Fixing it is quite
simple, getItemProps just needs to return tabindex="0" only for the focusable
index and "-1" for rest. We will use a state value to keep track of what index
is going to be focusable, and return the tabindex value based on that.
// useRovingTabIndex.ts
export function useRovingTabIndex(
options: RovingTabIndexOptions,
): RovingTabIndexReturn {
const [currentIndex, setCurrentIndex] = React.useState(0)
return {
getContainerProps: () => ({}),
getItemProps: (index: number) => ({
tabIndex: index === currentIndex ? 0 : -1,
}),
}
}
That's it, the test passes. #1 requirement is done. We can also check the live example, if hit Tab, the focus moves to the first button. Then, on the second Tab, the focus moves away from the pace. On Shift Tab, it goes back to the first button. Sweet! But things are going to become more complex in a bit.
Tabindex should follow focus
tabindex="0" should follow focus.
This is requirement #2. And it's a necessary requirement since our hook needs to
know whenever one element receives focus so it updates the currentIndex
accordingly, and, consequently, the tabindex values.
We will add another test, to account for focus change by mouse click, or programatic focus:
// useRovingTabIndex.test.ts
it('should set tabindex to 0 whenever an item is focused', async () => {
const {getByTestId, user} = renderTabs()
await user.click(getByTestId('button-4'))
expect(getByTestId('button-4')).toHaveFocus()
expect(getByTestId('button-4')).toHaveAttribute('tabindex', '0')
expect(getByTestId('button-1')).toHaveAttribute('tabindex', '-1')
fireEvent.focus(getByTestId('button-2'))
expect(getByTestId('button-2')).toHaveAttribute('tabindex', '0')
expect(getByTestId('button-4')).toHaveAttribute('tabindex', '-1')
})
A general way to handle this is by intercepting the focus event from the
container, identify its target, and get the index of that element in order to
perform the state update for the currentIndex.
// useRovingTabIndex.tsx
const getContainerProps = (): React.HTMLAttributes<HTMLElement> => ({
onFocus: event => {
const target = event.target as HTMLElement
const index = Array.from(target.parentElement?.children || []).indexOf(
target,
)
setCurrentIndex(index)
},
})
So, we get the target, and then check its siblings for the position of the
element inside the collection. This implementation will have to do for now, even
though the are some obvious flaws here:
- what if the DOM structure is different, and we have siblings nested 2 or more levels deep, or other DOM related differences?
- what if not all siblings are focusable, and instead we have separators or subheadings in our collection?
We'll get there when we get there, for now it's important we acknowledge the gaps, write them down in our list of requirements, and handle them at the appropriate time. With the above implementation, our new test should pass. And we will add another requirement, #11:
Being able to skip items that should not be focusable: dividers, subheaders, etc.
