Customize roving tabindex behavior using a React hook.
npm i use-rove
Assuming items
is an array of objects (props
) where each item
has a unique key
property.
import React from 'react';
import { useRove } from 'use-rove';
function MyList(props) {
const { items = [] } = props || {};
const keys = items.map(item => item.key);
const getTargetProps = useRove(keys);
return (
<ul>
{ items.map({ key, ...item } =>
<li { ...getTargetProps(key) } { ...item } />)
}
</ul>
);
}
The hook requires the first argument (keys
) to be an array of unique values that are expected to represent each item to be focused in tab order. This can be the id
of each item or the index
in loose usage.
// Not recommended, use unique id values instead.
const keys = items.map((_, index) => index);
const getTargetProps = useRove(keys);
The hook uses this list of keys
to determine which item in the collection to focus.
This is the result of the hook; a function expected to be run for each unique key when rendering each item in the collection to be focused. The function will return the following props:
const {
key, // Applies the given key to the element.
ref, // Stores element reference to use .focus().
onClick, // Click/tap event callback; setting this item as document.activeElement.
onKeyDown, // Keyboard event callback; setting the next item as document.activeElement.
} = getTargetProps(key);
You can also provide the component props into the argument instead as long as the key
prop is included. The props used within the hook will be merged with the ones incoming. In other words, event listeners will fire and refs will be set. The only prop that will not be honored is tabIndex
since this hook needs to control this.
function Item(props) {
// props.key will need to exist here otherwise the return will just be the given props.
return (
<li { ...getTargetProps(props) }/>
)
}
- The hook will handle
Home
andEnd
keys; jumping to the beginning and end of the collection respectedly. - The hook will jump to the child based on the starting character of the text content or
aria-label
of each child.
Use the following pattern to handle disabled items.
function Listbox(props) {
// Assume items is an array of props to build buttons, each with a unique key.
const { items } = props;
// Filter the list of items, looking for non-disabled keys.
const activeKeys = items.filter(({ disabled }) => !disabled).map(({ key }) => key);
// Setup the hook.
const getTargetProps = useRove(activeKeys);
// Render items.
return items.map(item => <button { ...( item.disabled ? { tabIndex: -1, ...item } : getTargetProps(item) ) }/> );
}
The hook does not check if an item is disabled when setting the next item. It is merely selecting the next key in the given list of keys from the initial setup. Setting an item as disabled is fundamentally changing the state of the available items and will require a new list.
In some cases, you might not want to move the DOM focus when traversing the children. Setting focus: false
will avoid shifting the DOM focus. However, the tabIndex
attribute will still cycle accordingly so you may target the element with a focused style.
ul [tabindex="0"] {
/* https://css-tricks.com/copy-the-browsers-native-focus-styles/ */
outline: 5px auto Highlight;
outline: 5px auto -webkit-focus-ring-color;
}
These are options you can pass as an object to the second argument of useRove()
.
Option | Value | Description |
---|---|---|
start |
String |
A key (found in the list of keys ) to set as the first item to receive focus on tab. Defaults to the first key in keys . |
loop |
Boolean |
Determines if the arrow keys can loop around past the ends of the list. Defaults to false . |
rtl |
Boolean |
Determines if the user is expecting to control the focus in a right-to-left language. This will flip the horizontal arrow keys. Defaults to false |
orientation |
'horizontal' 'vertical' 'both' |
Determines which keyboard arrow keys to trigger next focus. Defaults to 'both' |
focus |
Boolean |
Calls .focus() on the DOM element ref. Defaults to true |
const getTargetProps = useRove(keys, {
start: 'my-selected-item', // Start at the item with the key 'my-selected-item'.
loop: true, // Do not stop at the ends when using arrow keys.
rtl: false, // Use left-to-right navigational pattern.
orientation: 'horizontal', // Do not listen for up or down arrow keys.
focus: false, // Will not call `.focus()`, useful for showing a visual appearance of focus.
});
The concept of "roving tabindex" is explained on web.dev by Rob Dodson. Some experiences require navigation to be performed by using the arrow keys instead of the tab key.
Much of this package is modeled after features from roving-ux-react
except it was missing some options. The most important is setting the starting item. Many experiences will have a selected item on load which should be the item that receives first focus in some roving scenarios. I've also taken some features embedded within the original hook (like right-to-left support) and allowed them as options as well.
The source code is not a direct copy of the original. As one example, the way the next item to be focused is determined is unique to this implementation. However, the test suite is nearly identical (except coverage for the new options) and kudos to Dangoo for the testing approach of roving-ux-react
.
I've also noticed that some other roving tabindex solutions avoided the parent container altogether by listening for onKeyDown
on each item. I found this to be a good solution so the hook didn't need to control both the parent and the children. Also many solutions could not handle roving tabindex with a dynamic list (such as one found within a search result panel). This hook is also designed to handle this case as well.
One of the missing pieces you might question is the ability to know which element of the list is active; specifically for the purpose of aria-activedescendant
. Research shows that the element.focus
and roving tabIndex
approach to be better supported than wiring in aria-activedescendant
. Ultimately, the author of the application should know what is active by listening for click or keydown on the item and updating an author-controlled state.
import React, { useState } from 'react';
import { useRove } from 'use-rove';
function MyList(props) {
const { items = [] } = props || {};
const keys = items.map(item => item.key);
const [activeId, setActiveId] = useState(items[0].id);
const getTargetProps = useRove(keys, { start: keys[0] });
return (
<ul aria-activedescendant={ activeId }>
{ items.map({ key, id, ...item } => {
const targetProps = getTargetProps({
key,
onClick: () => setActiveId(id),
onKeyDown: (ev) => ['Enter', 'Space'].includes(ev.key) && setActiveId(id),
});
return (
<li
{ ...item }
{ ...targetProps }
id={ id }/>
);
})
}
</ul>
);
}
Above example keeps track of what the user selects, assuming nothing is set as disabled. The purpose of this project is merely to handle the roving tabindex functionality; not to determine the user selected item after these events.