Prologue
A while ago, I decided to develop a fully accessible main navigation component in React and write a series of articles documenting the steps it took to create a non-trivial accessible component.
In my last article, the focus was actually on creating the components and adding a transformation utility to convert JavaScript objects into actual navigation. The base navigation component at this stage supports screen reader functionality through structured HTML and WAI-ARIA attributes, the ability to open and close a list through pointers and the Enter and Space keys.
This article, along with its accompanying release, focuses on basic keyboard handling for screen/keyboard users in a single list.
Note: This article is one of a series demonstrating how to build a React navigational component from scratch while considering accessibility through the process. The articles are accompanied by a GitHub repository with releases tied to one or more articles; each building on the previous, until a fully implemented navigation component is complete.
Each release and its associated tag contain fully runnable code for the article. The code discussed in this article is available in the release. and may be downloaded at release 0.4.0.
Examples have been updated in this release, enabling keyboard handling for a single list. Examples include a vertically aligned single list and horizontally aligned components with links and buttons for verifying operability.
While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x. Examples use Next.js 16.x, which is not required to run the navigation component.
You can view the requirements for the Single List Keyboard Release along with previous requirements.
Follow along either by downloading the release and running the examples while examining the codebase, or by activating the link accompanying each code snippet to view the full file on GitHub.
This article is fairly long. It contains code and explanations. The code for this release is displayed, and the corresponding GitHub source is linked. Use these anchor links to move to the items of interest.
Content Links
- Introduction
- Acceptance Criteria
- Holding Data in a Provider
- Registering a Focusable Element
- Keyboard Handler
- Summary
Introduction
This article is the first of three detailing keyboard handling in a universally accessible navigation component; It is also the simplest.
In my experience, many developers seem to expect keyboard handling to be a browser feature, and it's been rare on the contracts I've worked on to see code that handles much custom keyboard input. Keyboard handling works well with native browser elements, but not as well with custom widgets, where developers need to handle code navigation requirements.
While most developers seem to understand that a keyboard is necessary for screen reader users, the fact that many people use a keyboard alongside a screen to navigate isn't always obvious. Users of screen readers have a variety of key combinations they may use to navigate through structures such as headings, links, form elements and tables. Users who navigate the screen with a pointer can simply move their cursor to a specific element and click. Unfortunately, the keyboard flexibility is limited for screen/keyboard users, who are the only ones who must navigate a page linearly. Navigation is not as easy for someone who can see a screen and depends on a keyboard for both input and navigation.
Those who use a screen and keyboard have expectations that developers should not only be aware of, but should strive to match. For those developers who work with a screen, testing how focus moves within a component is actually easy; just use the keyboard. Developers should always ensure a component is fully navigable with the Tab/Shift+Tab key combinations, which are required for both screen and screen reader users, and that any custom keyboard handling for screen users is implemented and works correctly.
To do so, developers need to receive acceptance criteria that detail what happens when a key is used.
When developers don't receive information about keyboard handling requirements when implementing a custom widget, efforts to bring the code into compliance with accessibility standards can run into snags. Keyboard handling can be compromised, and what actually gets implemented might be more focused on the letter of the law than the spirit, as is demonstrated by the following video.
Since the focus of this navigation component is accessibility, the acceptance criteria must specify what happens on each key press.
Acceptance Criteria
Single List Keyboard Handling
- AC 1 - Pressing the HOME Key should take a user to the first item in the current List.
- AC 2 - Pressing the END Key should take a user to the last item in the current list.
- AC 3 - Pressing the LEFT-ARROW key should take a user to the previous item in the current list.
- AC 4 - If focus is already on the first item in the current list, pressing the LEFT-ARROW key should take the user to the last item in the current list.
- AC 5 - Pressing the RIGHT-ARROW Key should take a user to the next item in the current list.
- AC 6 - If focus is already on the last item in the current list, pressing the RIGHT-ARROW Key should take a user to the first item in the current list.
The keyboard support section in the Disclosure Navigation pattern in the APG requires interpretation. The guide references moving buttons to buttons and links to links. Both are focusable elements, and depending on where each lies, navigation can move from a button to a link, or from a link to a button, within a single list.
Hand-eye coordination is key in this instance. We expect the cursor to move in the direction indicated by the arrow keys. When the cursor moves to an unexpected location, it's jarring; if it disappears entirely, the site can appear broken.
Holding Data in a Provider
Navigation in a sublist requires a focusable element, in this case either a button or a link, to be able to determine its sibling in a list and move according to the acceptance criteria.
Each link and button in a list is rendered in its own component, with no real way to know about, much less access, the others.
Data for each focusable item in a particular list can be stored in a context provider, along with functions to register and retrieve the collective data. Since each focusable element is part of a list, the best data structure to hold the information is an array.
NavigationListProvider
Context providers hold data along with functions for retrieving and setting it. Each list works with its own copy of the provider, and the data in its array consists of each individual link or button element within the list items it contains. Each focusable element, whether a button or a link, can only focus on siblings contained in the list it resides in.
export function NavigationListProvider({ children }) {
const [state, dispatch] = useReducer(navigationListReducer, {
items: [],
});
const getCurrentListItems = useCallback(() => {
return state.items;
}, [state.items]);
const registerItemInCurrentList = useCallback((focusableEl) => {
dispatch({ type: "REGISTER_ITEM", item: focusableEl });
}, []);
return (
<NavigationListContext.Provider
value={{
getCurrentListItems,
registerItemInCurrentList,
}}
>
{children}
</NavigationListContext.Provider>
);
}
NavigationListProvider.context = NavigationListContext;
GitHub (release 0.4.0) - NavigationListProvider.tsx
Given the nature of the data, useReducer is a better solution than useState. A reducer still uses the state object but can manage more complex logic. Since state updates to the NavigationListProvider affect multiple components and changes might depend on others, it makes sense to use the provided reducer hook rather than the state hook.
navigationListReducer
export function navigationListReducer(
state,
action,
) {
switch (action.type) {
case "REGISTER_ITEM": {
if (state.items.includes(action.item)) return state;
return { ...state, items: [...state.items, action.item] };
}
default: {
return state;
}
}
}
GitHub (release 0.4.0) - navigationListReducer.ts
Like most reducers, actions are held in a switch, even though there is only one action type, REGISTER_ITEM. When called, a focusable element (action.item) is passed through and checked to determine if it already exists in the array. If not, it is added in.
A context provider is not exposed to public components; instead, a hook is used to execute functionality that can be passed through the registration function and to add additional functionality when necessary.
useNavigationList
export function useNavigationList() {
const navigationListContextObj = use(NavigationListContext);
const { registerItemInCurrentList } =
returnTrueElementOrUndefined(
!!navigationListContextObj,
navigationListContextObj,
);
return {
registerItemInCurrentList,
};
}
A common pattern for a hook associated with a context provider is to load the provider's public functions and either use them in the hook or pass them through to another component. For the moment, the registration function is just passed through, so it may be imported into the navigation components.
Registering a Focusable Element
In order for the array to be used with keyboard handling, the focusable elements must be registered (added) into the data array. The first step is to wrap each list within its own context provider.
NavigationList
export default function NavigationList({...) {
const listProps= {...};
return (
<NavigationListProvider>
<List key={`list-$id`} {...listProps}>
{children}
</List>
</NavigationListProvider>
);
}
GitHub (release 0.4.0) - NavigationList.tsx
With the NavigationListProvider now wrapping the List component, each list item can access any other list item stored in its copy of the data array. It's important to understand that each list maintains its own array of items and cannot access items across lists.
NavigationItem
export default function NavigationItem({...}) {
const {
registerItemInCurrentList,
} = useNavigationList();
const currentPath = ...;
const linkRef = useRef(null);
useEffect(() => {
if (linkRef.current !== null) {
registerItemInCurrentList(linkRef.current);
}
}, [ linkRef, registerItemInCurrentList]);
const listItemProps = { ... };
const linkProps = {
... ,
ref: linkRef,
...rest,
};
return ( ... );
}
GitHub (release 0.4.0) - NavigationItem.tsx
Code that has already been discussed in previous releases is represented by ellipses; only new code is given.
Focusable elements are stored in ref objects, but reading the element through ref.current can lead to unreliable code during renders. Since the elements held in the ref object are just pointers to the DOM elements, it's safe to extract them and store them separately as read-only.
References to elements are not populated right away; the focusable element, in this case a link, needs to be rendered before the information is available, so the useEffect() that registers the link into the currentlistitems array only runs when the ref. current is not null.
SubNavigation
export default function SubNavigation({...}) {
const {
registerItemInCurrentList,
} = useNavigationList();
const buttonRef = useRef(null);
const [isSubListOpen, setIsSubListOpen] = useState(false);
useEffect(() => {
if (buttonRef.current !== null) {
registerItemInCurrentList(buttonRef.current);
}
}, [buttonRef, registerItemInCurrentList]);
const handlePress = () => {
setIsSubListOpen(!isSubListOpen);
};
...
ref: buttonRef,
};
...
}
GitHub (release 0.4.0) - SubNavigation.tsx
The code to register a button is almost identical to the code registering a link. The only difference is that the reference being passed is a button element.
Keyboard Handler
With the data structure set up and items registered, it's finally time to shift the focus to handling the appropriate keys, and attention turns back to the acceptance criteria.
- AC 1 - Pressing the HOME Key should take a user to the first item in the current List.
- AC 2 - Pressing the END Key should take a user to the last item in the current list.
- AC 3 - Pressing the LEFT-ARROW key should take a user to the previous item in the current list.
- AC 4 - If focus is already on the first item in the current list, pressing the LEFT-ARROW key should take the user to the last item in the current list.
- AC 5 - Pressing the RIGHT-ARROW Key should take a user to the next item in the current list.
- AC 6 - If focus is already on the last item in the current list, pressing the RIGHT-ARROW Key should take a user to the first item in the current list.
The only keys being handled now are home, end, and the left and right arrows. If the focus is on the first element in the list and the left arrow key is pressed, then focus should jump to the last element in the list, and the opposite should happen if the focus is on the last element and the right key is pressed. Focus should move to the first element.
useNavigationList
Since the functionality needs to access data stored in the context provider, the functions are created in the hook.
export function useNavigationList() {
const navigationListContextObj = use(NavigationListContext);
const { getCurrentListItems, registerItemInCurrentList } =
returnTrueElementOrUndefined(
!!navigationListContextObj,
navigationListContextObj,
);
const currentListItems = getCurrentListItems();
const _getCurrentIndex = useCallback(
(currentlyFocusedEl) => {
return currentListItems.indexOf(currentlyFocusedEl);
},
[currentListItems],
);
const shiftFocus= ( focusableEl) => {
focusableEl.focus({ preventScroll: true });
};
const setFirstFocus = () => {
shiftFocus(currentListItems[0]);
};
const setLastFocus = () => {
shiftFocus(currentListItems[currentListItems.length - 1]);
};
const setNextFocus = ( currentlyFocusedEl) => {
const newIndex = _getCurrentIndex(currentlyFocusedEl) + 1;
if (newIndex >= currentListItems.length) {
setFirstFocus();
} else {
shiftFocus(currentListItems[newIndex]);
}
};
const setPreviousFocus = ( currentlyFocusedEl) => {
const newIndex = _getCurrentIndex(currentlyFocusedEl) - 1;
if (newIndex < 0) {
setLastFocus();
} else {
shiftFocus(currentListItems[newIndex]);
}
};
return {
registerItemInCurrentList,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus,
shiftFocus,
};
}
GitHub (release 0.4.0) - useNavigationList.tsx
A reminder that the code discussed earlier is represented by ellipses; only the new code is shown.
It's useful to know when a private or public function is used, and I do so by prefacing private functions with an underscore.
To provide a fresh copy of the data to the hook, don't call the data directly; it won't refresh. Instead, a getter is retrieved from the context provider, getCurrentListItems(). You'll note an alias for currentListItems that calls the getter each time. When used this way, the code always returns an updated copy of the array, making it more natural to work with.
A custom function, shiftFocus(), is used instead of implementing focus within each publicly called function to standardize how scrolling is handled. In these cases, scrolling should be prevented when navigation is displayed in a horizontal layout to prevent extraneous shifts when sublists open and close.
The rest of the code is self-explanatory; focus is shifted depending on the key pressed: Home and End, sends focus to the first or last children in the list, while the right or left arrow key sets focus depending on where the currently focused element is; If the focus is at the end of the list when setNextFocus is called, focus then moves to the first element and if the focus is at the beginning of the list when setPreviousFocus is called, focus moves to the end. Otherwise, focus shifts to the respective sibling on either side of the current element.
With the focus functions created, it's time to use them.
NavigationItem
export default function NavigationItem({ ... }) {
const {
registerItemInCurrentList,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus,
} = useNavigationList();
const currentPath = ...;
…
const handleKeyDown = (e) => {
const linkEl = linkRef.current;
switch (e.key) {
case Keys.HOME:
case Keys.END:
case Keys.LEFT:
case Keys.RIGHT:
e.preventDefault();
e.stopPropagation();
break;
}
handleCommonKeyDown(
e,
linkEl,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus,
);
};
…
const linkProps = {
...
onKeyDown: handleKeyDown,
ref: linkRef,
...rest,
};
return (..);
}
The setters from the hook are retrieved, and a handleKeyboard function is created. Rather than setting preventDefault and stopPropagation in each case statement, I use a switch that sets them on all the keys at once. And because both links and buttons handle these keys in the same way, a common keyboard handler is created and called rather than duplicating the code across components.
When calling a function outside the render, everything necessary must be passed in, including all the hook calls.
handleCommonKeyDown
export const handleCommonKeyDown = (
e,
currentlyFocusedEl,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus
) => {
switch (e.key) {
case Keys.HOME:
setFirstFocus();
break;
case Keys.END:
setLastFocus();
break;
case Keys.LEFT:
setPreviousFocus(currentlyFocusedEl);
break;
case Keys.RIGHT:
setNextFocus(currentlyFocusedEl);
break;
}
};
GitHub (release 0.4.0) - handleCommonKeyDown.tsx
The common key handler is quite straightforward; each specific key triggers a function in the hook to decide the next item to shift to.
SubNavigation
export function SubNavigation({...}) {
const {
currentListItems,
parentRef,
registerListItem,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus
} = useNavigationList();
const {
registerListItem,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus} = useNavigationList();
const buttonRef = useRef(null);
...
useEffect(() => {
const currentButtonEl = buttonRef.current;
if (currentButtonEl !== null) {
registerItemInCurrentList(currentButtonEl);
}
} , [buttonEl, buttonRef, isSubListOpen, registerItemInCurrentList]);
const handleKeyDown = (e) => {
const buttonEl = buttonRef.current;
switch (e.key) {
case Keys.HOME:
case Keys.END:
case Keys.LEFT:
case Keys.RIGHT:
e.preventDefault();
break;
}
handleCommonKeyDown(
e,
buttonEl,
setFirstFocus,
setLastFocus,
setNextFocus,
setPreviousFocus
);
...
const buttonProps: ButtonProps = {
...
onKeyDown: handleKeyDown,
onPress: handlePress,
ref: buttonRef,
...
};
...
return ( ...)
}
GitHub (release 0.4.0) - SubNavigation.tsx
Notice how stopPropagation() is not included in the top switch. This is because the button component I use from react-aria-components already calls stopPropagation() itself, so I don't need to set it. This may differ from the button component you are working with. Otherwise, the code for SubNavigation is similar to that already seen in NavigationItem.
Summary
Handling link references to multiple components becomes straightforward when a provider holds the data and the functions that call and manipulate it. A hook passes through necessary provider functions as well as its own to provide keyboard handling within a single list.
With all the code added to support single-list keyboard handling, the question arises: Does it work?
Part of the issue with demonstrating keyboard handling is the inability to show which key is being used as focus moves. It's one of the reasons keyboard accessibility is so hard to demonstrate during sprint demos. I'll be providing videos I make that detail what I am doing and which keys I'm pressing as I guide you through the enhancements.
I'll be back with another article soon, focusing on CSS design and applying a layout to the default horizontal navigation.
Top comments (0)