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 development article, I covered creating the underlying data structures for supporting keyboard handling between components.
This article discusses implementing the up and down arrow keys to move focus between components.
Note: This article is one of a series demonstrating building 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 builds on the previous one 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.6.0.
While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x, all while using vanilla CSS. Examples use Next.js v16.x, which is not required to run the navigation component.
You can view the requirements for the Up/Down Keyboarding Release along with previous requirements.
This article is long and contains code and explanations. The code for this release is displayed, and the corresponding GitHub source is linked. Use these anchor links to return to the discussions of the individual components. Acceptance criteria are linked to the first component responsible for their implementation.
Content Links
Introduction
Most keyboard handling within components is fairly simple to implement. A key is pressed, and a function is called, which triggers an action. When implementing keyboard handling that requires shifting focus between components with complex conditional criteria, simplicity goes out the window. The fact is, the individual links and buttons within the navigation component have no way of knowing anything about any other links, buttons or lists. To handle shifting focus, the entire set of lists and sublists is duplicated into an array of objects to facilitate smooth keyboard handling.
A demonstration of the implemented keyboard handling is provided.
Acceptance Criteria
DOWN Key
All Focusable Items
- AC 1 - Focus should move to the next focusable item in the same list.
When the currently focused item is a link:
- AC 2 - If the link is the last child in its list and the link's parent is in the top row, shift focus to the parent.
- AC 3 - If the link is the last element in its list and its' parent is the last element in its list, shift focus to the topmost parent in the top row.
- AC 4 - If the focused link is the last child on the top row, focus remains on the link.
- AC 5 - If the focused link is the last child in its list and its parent is not the last element in its list, shift focus to the parent's next sibling.
When the currently focused item is a button.
- AC 6 - If the focused button's associated list is open, shift focus to the list's first child.
- AC 7 - If the focused button's associated list is closed, and it is the last element in its list, shift focus to the button's ancestor in the top row.
UP Key
All Focusable Items
- AC 8 - Focus should move to the previous element in the same list.
- AC 9 - If the focused element is the first child on the top row, focus should remain on the focused element.
- AC 10 - If the previous element in the list is a button and the sublist is open, shift focus to the parent's last child in the open sublist.
Requirements exist for both the Down and Up keys, which differ depending on whether the focused item is a link or a button and on its position in the current list/array. Conditionals in this case can become complex, hard to read and even harder to maintain. The rest of this article goes through the code.
Are you ready?
Preparation
The NavigationProvider stores the navigationArray of objects, each corresponding to a NavigationList component and its children. While code has been implemented to register the individual objects, there's been no way to retrieve the data until now.
NavigationProvider
export function NavigationProvider({...}) {
...
const setListItems = ...
const getNavigationArray = useCallback(() => {
return state.navigationArray;
}, [state.navigationArray]);
...
return (
<NavigationContext.Provider
value={{
getNavigationArray,
...
getNavigationArray,
}}
>
{children}
</NavigationContext.Provider>
);
}
GitHub (release 0.6.0) - NavigationProvider.tsx
A new provider function, getNavigationArray, is created and passed to the navigation hook for use, returning the entire navigation array stored in the state.
Down Arrow Handling
When keyboard handling is the focus, it's important to remember that there are two distinct classes with different expectations as to how a keyboard should work for them. Keyboard handling for someone who depends on a screen reader differs from that required for someone who depends on a screen for perceivability. Use of the arrow keys is restricted in some screen readers to specific situations, and many of them do not allow it outside those situations. Arrow key functionality is mainly used by those who use the screen/keyboard combination.
Because those who will use the arrow keys can view focus changes, the movement of these keys must correspond to common expectations for the keys themselves; a down arrow should move to the next currently visible focusable element on the screen, while an up arrow should shift focus to the previous currently visible focusable element. Keep in mind the movement will change depending on which element currently has focus, as well as its placement index within the navigationObject's storedList.
Links
I want to start by implementing the down arrow functionality on a link.
NavigationItem
export default function NavigationItem({...}){
const { ..., shiftFocus} = useNavigationList();
const { getNextByLink, registerItemInNavigationArray } = useNavigation();
...
const handleKeyDown = (e) => {
const linkEl = linkRef.current as HTMLAnchorElement;
...
HandleCommonKeyDown(...);
let focusableEl;
switch (e.key) {
case Keys.DOWN:
focusableEl = getNextByLink(linkEl);
break;
}
if (focusableEl) {
shiftFocus(focusableEl);
}
};
...
}
GitHub (release 0.6.0) - NavigationItem.tsx Line 100
A new switch is added to handleKeyDown(), which calls the function getNextByLink provided by the useNavigation hook when the down arrow key is pressed. Rather than calling shiftFocus for each call, a mutable variable is redefined based on the key pressed, triggering a cascade that updates the focusableEl variable for the key that was handled.
The shiftFocus function is called only when a focusable element is returned, since there are times when focus should remain on the current element, and returning undefined will make this easier.
useNavigation
The getNextByLink and other variations discussed later can be thought of as controllers. They define the conditions and request the return of specific focusable elements to the keyboard handler. The conditions for returning the next focusable item after a link are specified in the acceptance criteria listed below.
AC 1
Focus should move to the next focusable item in the same list.
AC 2
If the link is the last child in its list and the link's parent is in the top row, shift focus to the parent.
AC 3
If the link is the last element in its list and its parent is the last element in its list, shift focus to the topmost parent in the top row.
AC 4
If the focused link is the last child on the top row, focus remains on the link.
AC 5
If the focused link is the last child in its list and its parent is not the last element in its list, shift focus to the parent's next sibling.
When the acceptance criteria are examined, it's clear that most of the conditional functionality occurs only when the link is the last child in its list. In any other scenario, focus moves to the next sibling.
getNextByLink
const getNextByLink = (linkEl) => {
const { storedParentEl, storedList } = _getNavigationObjectByListElement(linkEl);
// default to next item in list
let focusableEl = _getNextElementInList(linkEl, storedList);
// is Link the last element in the current list and not in the top row?
if (
storedList.indexOf(linkEl) === storedList.length - 1 && storedParentEl !== null
) {
const isLinkLast = _isLastElementInCurrentList(linkEl);
const isParentInTopRow = _isElementInTopRow(storedParentEl);
const isParentLast = _isLastElementInCurrentList(storedParentEl);
if (isParentInTopRow) {
focusableEl = storedParentEl;
} else if (isParentLast && isLinkLast) {
focusableEl = _getTopRowElement(linkEl);
} else {
// focus goes to the parent's next sibling
const parentNavObject =
_getNavigationObjectByListElement(storedParentEl);
const { storedList: parentList } = parentNavObject;
focusableEl = _getNextElementInList(storedParentEl, parentList);
}
}
return focusableEl;
};
GitHub (release 0.6.0) - useNavigation.tsx - getNextByLink - Line 149
Creating a set of conditionals that match each acceptance criterion one-to-one results in an unmitigated mess of unmaintainable, hard-to-debug, and unreadable code. I found it much easier to set a mutable variable and arrange the code so that the most common scenario is called first and sets the variable. Only when other conditions are met would the variable become overwritten. The result is easier to read and to maintain.
In this case, the most common scenario is simply moving to the next item in the list, but before that can happen, the navigation object containing the current link in the stored list must be returned.
Conditions are met as follows:
- AC 1 - let focusableEl = _getNextElementInList(linkEl, storedList);`
- AC 2 - if(isParentInTopRow){focusableEl = storedParentEl}`
- AC 3 - if (isParentLast && isLinkLast) { focusableEl = _getTopRowElement(linkEl);}`
- AC 5 - }else{.. const { storedList: parentList } = parentNavObject; focusableEl = _getNextElementInList(storedParentEl, parentList);`
You'll note that AC 4 isn't in the conditions to be handled. If focus should remain on the currently focusable element, the focusableEl will be undefined.
Outside of the controllers, all functions within the hook query the data store held in the context provider and return either boolean values to answer questions (isLinkLast, isParentInTopRow) or retrieve either a navigation object from the array or a focusable element to return to the calling component.
If your eyes glaze over at lots of code, you can skip down to the discussions around buttons.
_getNavigationObjectByListElement()
Navigation objects, each representing one list in the array, may be retrieved from the controller. In this case, a link may only appear within a storedList array, and so the link requests the object it is a part of:
const _getNavigationObjectByListElement = useCallback(
(focusedEl) => {
return getNavigationArray().find(({ storedList }) =>
storedList.includes(focusedEl),
);
},
[getNavigationArray]);
GitHub (release 0.6.0) - useNavigation.tsx - _getNavigationObjectByListElement - Line 28
Any focusable element, button or link will be exclusively contained in a navigation object's stored list array. Any function that calls the provider's getNavigationArray should be wrapped in a useCallback hook.
_getNextByLink()
// default to next item in list
let focusableEl = _getNextElementInList(linkEl, storedList);
const _getNextElementInList = (focusedEl, currentList) => {
const nextIndex = currentList.indexOf(focusedEl) + 1;
return currentList[nextIndex];
};
GitHub (release 0.6.0) - useNavigation.tsx - _getNextElementInList() - Line 93
Unless a link is the last element in its list, the default requirement is to retrieve the next item. This function mimics the right-arrow key handling in the other context provider, NavigationListProvider, with one exception: regardless of whether the item is at the end of the list, the focus always moves to the next index, which can be outside the list length and so result in an undefined value. This corresponds to AC 4 - If the focused link is the last child on the top row, focus remains on the link. As mentioned earlier, a value of undefined is a valid return value.
getNextByLink()
if (
storedList.indexOf(linkEl) === storedList.length - 1 && storedParentEl !== null
) {
const isLinkLast = _isLastElementInCurrentList(linkEl);
const isParentInTopRow = _isElementInTopRow(storedParentEl);
const isParentLast = _isLastElementInCurrentList(storedParentEl);
GitHub (release 0.6.0) - useNavigation.tsx - getNextByLink - Line 157
Returning to the getNextByLink controller, every other aspect of the cascade requires the item not to be in the top row (storedParentEl !== null) and to be the last child in its list (storedList.indexOf(linkEl) === storedList.length - 1).
When the conditions are met, a series of questions is asked to determine the next steps.
The first question asks if the currently focused element is the last child in its list, while the last question asks the same of the current list's parent.
_isLastElementInCurrentList
const _isLastElementInCurrentList = (focusedEl) => {
const { storedList } = _getNavigationObjectByListElement(focusedEl);
return storedList.indexOf(focusedEl) === storedList.length - 1;
};
GitHub (release 0.6.0) - useNavigation.tsx - _isLastElementInCurrentList - Line 87
_isElementInTopRow
const _isElementInTopRow = ( focusedEl ) => {
return _getIndexInTopRow(focusedEl) >= 0;
};
const _getIndexInTopRow = useCallback((focusedEl) => {
const { storedList } = getNavigationArray()[0];
return storedList.indexOf(focusedEl);
},
[getNavigationArray],
);
GitHub (release 0.6.0) - useNavigation.tsx - _isElementInTopRow - Line 65
The question of whether the passed-in element is part of the top row is answered by calling a function to extract the top-row object (which will always have an index of 0 since it was created as the first element when the navigation context provider was initiated). The top-row array is then checked to see whether the focused element is part of it.
The retrieved answers will help determine the remaining conditional requirements.
getNextByLink()
if (isParentInTopRow) {
focusableEl = storedParentEl;
} else if (isParentLast && isLinkLast) {
focusableEl = _getTopRowElement(linkEl);
} else {
// focus goes to the parent's next sibling
const parentNavObject =
_getNavigationObjectByListElement(storedParentEl);
const { storedList: parentList } = parentNavObject;
focusableEl = _getNextElementInList(storedParentEl, parentList);
}
GitHub (release 0.6.0) - useNavigation.tsx - getNextByLink - Line 165
The rest of the controller conditions depend on whether the parent is in the top row. If it is and the child is the last element in its list, then focus is shifted to the parent on the top row.
If both the parent and link are the last elements in their respective lists, focus shifts to the ancestor button in the top row, which can be found by calling the current element's top-row ancestor.
_getTopRowElement
const _getTopRowElement = (focusedEl) => {
return _getRecursiveTopElementByElement(
focusedEl,
_getNavigationObjectByListElement,
_isElementInTopRow,
);
};
GitHub (release 0.6.0) - useNavigation.tsx _getTopRowElement - Line 71
Depending on an element's placement within the navigation component's sub-lists, finding the top row ancestor may require traversing multiple objects, so a recursive function is needed. Recursion shouldn't be used in the hook, since constant rendering can cause instability. Instead, a recursive function is created outside of the hook and called within. Variables passed through include the element and functions defined within the hook, which are necessary for its use.
_getRecursiveTopElementByElement
export const _getRecursiveTopElementByElement = (
focusableEl,
_getNavigationObjectByListElement,
isElementInTopRow,
) => {
const { storedParentEl } = _getNavigationObjectByListElement(focusableEl);
if (isElementInTopRow(storedParentEl)) {
return storedParentEl;
} else {
return _getRecursiveTopElementByElement(
storedParentEl,
_getNavigationObjectByListElement,
isElementInTopRow,
);
}
};
GitHub (release 0.6.0) - hookFunctions.ts - _getRecursiveTopElementByElement
Recursive functions work well in scenarios with nested sublists. But there are constraints to using them in React when they are a part of the render. Instead of executing the recursion within the hook function itself, data and the required hook functions are passed to a common function residing outside of the component or hook. While they can be defined above the function in which they are called, they can also be stored in a separate file.
The recursive function first asks if the parent of the passed-through element, focusableEl, is in the top row. If so, the parent is returned. If the parent found is not in the top row, it is passed to the same function, and the process repeats until the parent on the top row is found and returned.
getNextByLink()
if (isParentInTopRow) {
…
} else if (isParentLast && isLinkLast) {
…
} else {
// focus goes to the parent's next sibling
const parentNavObject =
_getNavigationObjectByListElement(storedParentEl);
const { storedList: parentList } = parentNavObject;
focusableEl = _getNextElementInList(storedParentEl, parentList);
}
GitHub (release 0.6.0) - useNavigation.tsx - getNextByLink - Line 169
The last conditional happens when a link is the last child in its list, but its parent is not the last element in its own list. In that case, the focus shifts to the parent's next sibling via a call to _getNextElementInList(), passing both the parent and the list it belongs to.
With the down arrow on the link implemented, our focus (pun intended) shifts to the button.
Buttons
While a button can act like a focusable element when it is part of a list array, it also controls its own list. Shifting focus from a button depends on whether its list is open or closed when the down arrow key is pressed.
SubNavigation
export default function SubNavigation({...}){
...
const handleKeyDown = (e) => {
const buttonEl = buttonRef.current;
...
let focusableEl;
switch (e.key) {
case Keys.DOWN:
focusableEl = getNextByButton(buttonEl, isSubListOpen);
break;
}
if (focusableEl) {
shiftFocus(focusableEl);
}
};
...
}
GitHub (release 0.6.0) - SubNavigation.tsx Line 140
Just like the keydown handler for a link, a new controller, getNextByButton, is called, passing both the button element and the visibility of its sublist.
useNavigation
AC 1
Focus should move to the next focusable item in the same list
AC 6
If the focused button's associated list is open, shift focus to the list's first child.
AC 7
If the focused button's associated list is closed, and it is the last element in its list, shift focus to the button's ancestor in the top row.
Both links and buttons share the common criterion that the focus should move to the next focusable item in the same list. As for the rest, if the list associated with the button is open, then pressing the down key should shift focus to the first child in the open sublist. If the sublist is closed and the button is the last element of the entire list, then focus should be shifted to the ancestor on the top row.
GetNextByButton
const getNextByButton = (buttonEl, isSubListOpen) => {
const { storedList: currentList } =
_getNavigationObjectByListElement(buttonEl);
// default to next item in list
let focusableEl = _getNextElementInList(buttonEl, currentList);
if (isSubListOpen) {
const currentNavObject = _getNavigationObjectByParent(buttonEl);
const { storedList: subNavigationList } = currentNavObject;
// move to the sublist's first child
focusableEl = subNavigationList[0];
} else if (currentList.indexOf(buttonEl) === currentList.length - 1) {
// last focusable element and sublist is collapsed. Set to parent;
focusableEl = _getParentByElement(buttonEl) as FocusableElementType;
}
return focusableEl;
};
GitHub (release 0.6.0) - useNavigation.tsx - GetNextByButton - Line 126
Both buttons and links first call _getNextElementInlist. Just as with links, any button finding itself at the end of its list will return undefined.
Everything else depends on whether the sublist controlled by the button is open.
If the sublist is open, focus should shift to the controlled list's first element, contained at index 0.
If you skipped over the code explanations for link handling, you might want to skip down to the discussions around up arrow handling.
To reach into the controlled list to find the first element, an object must be retrieved by the parentElement.
_getNavigationObjectByParent
const _getNavigationObjectByParent = useCallback((parentEl) => {
return getNavigationArray().find(
({ storedParentEl }) => storedParentEl === parentEl,
);
},
[getNavigationArray],
);
GitHub (release 0.6.0) - useNavigation.tsx - _getNavigationObjectByParent - Line 38
The object is returned when the storedParent matches the parent element passed in.
If the button is at the end of a list and its controlled sublist is closed, the topParent is retrieved, as discussed in the getNextByLink controller.
Down arrow handling is complete.
Up Arrow Handling
When using the up arrow, the visible focus should move up smoothly through any visible list items. If the currently focused element is the first child in an open list, then focus should shift onto the controlling button. If the currently focused element's previous sibling is a button, handling depends on the previous sibling's list-open status. If the list is open, focus should shift to the last child in the open list. If it is closed, then focus should shift to the element's previous sibling.
Links
NavigationItem
export default function NavigationItem({...}){
const { ..., shiftFocus} = useNavigationList();
const { getNextByLink, registerItemInNavigationArray } = useNavigation();
...
const handleKeyDown = (e) => {
const linkEl = linkRef.current as HTMLAnchorElement;
...
HandleCommonKeyDown(...);
let focusableEl;
switch (e.key) {
case Keys.UP:
focusableEl = getPreviousByLink(linkEl);
break;
...
}
if (focusableEl) {
shiftFocus(focusableEl);
}
};
...
}
GitHub (release 0.6.0) - NavigationItem.tsx
Another controller, getPreviousByLink, is called in the component when the up arrow key is pressed.
useNavigation
The called controller calls and sets up the conditionals to meet the last three acceptance criteria.
AC 8
Focus should move to the previous element in the same list.
AC 9
If the focused element is the first child on the top row, focus should remain on it.
AC 10
If the previous element in the list is a button and the sublist is open, shift focus to the parent's last child in the open sublist.
const getPreviousByLink = ( linkEl) => {
const isButton = (focusableEl) => {
return focusableEl?.type === "button";
};
let focusableEl = _getPreviousByElement(linkEl);
if (isButton(focusableEl)) {
const { isSubListOpen, storedList } = _getNavigationObjectByParent( focusableEl );
if (isSubListOpen && storedList.indexOf(linkEl) < 0) {
focusableEl = storedList[storedList.length - 1];
}
}
return focusableEl;
};
GitHub (release 0.6.0) - useNavigation.tsx - getPreviousByLink - Line 187
Compared to all the requirements in getNextByLink(), getPreviousByLink() is much simpler. That's because most of the complexity is shared with its button counterpart and is therefore placed in the _getPreviousByElement function. Once an element is returned, it must be checked whether it is a button, since the last acceptance criterion, AC 10, requires this determination.
To determine the element type, a tagname (which is available in any HTML element; in this case, "BUTTON") could be used. A button element also has a "type" attribute that specifies either "submit" or "button," which is what I have chosen to use.
When the focusable element returned by _getPreviousByElement() is a button, and the list it controls is open, then the button's children must be checked to determine whether the link is part of the list. If it isn't, the focus shifts to the last child of the list controlled by the button.
_getPreviousByElement
const _getPreviousByElement = (focusedEl) => {
const { storedList, storedParentEl } =
_getNavigationObjectByListElement(focusedEl);
// default to previous item in list
let focusableEl = _getPreviousElementInList(focusedEl, storedList);
const isInTopRow = _isElementInTopRow(focusedEl);
// not on the top row and first child in its list.
if (!isInTopRow && storedList.indexOf(focusedEl) === 0) {
focusableEl = storedParentEl;
}
if (!isInTopRow || focusedEl !== _getFirstElementInIndexedList(0)) {
return focusableEl;
}
};
GitHub (release 0.6.0) - useNavigation.tsx - _getPreviousByElement - Line 105
In most cases, the focus shifts to the up arrow in the same way, whether it's a button or a link. As with the similar down-arrow functionality, the most common event is to shift focus to the previous sibling of the current list via the _getPreviousElementInList function.
The currently focused element is then checked to determine whether it is on the top row and the first child in its row. The button controlling the current list is only set to the focusable element if the element is not on the top row.
The last call returns the next element to focus on only if the currently focused element is not in the top row. If it is in the top row, the next focusable element is returned only if it isn't the first child in the array.
Two new functions are utilized in this function: _getPreviousElementInList and _getFirstElementInIndexedList.
_getPreviousElementInList
const _getPreviousElementInList = (focusedEl, currentList) => {
const previousIndex = currentList.indexOf(focusedEl) - 1;
return currentList[previousIndex];
};
GitHub (release 0.6.0) - useNavigation.tsx - _getPreviousElementInList - Line 99
Like its _getNextElementInList counterpart, the call returns the element at the previous index in the array. If the element passed through is the first child, the returned element is undefined.
_getFirstElementInIndexedList
const _getFirstElementInIndexedList = useCallback((index) => {
return getNavigationArray()[index].storedList[0];
},
[getNavigationArray],
);
GitHub (release 0.6.0) - useNavigation.tsx - _getFirstElementInIndexedList - Line 57
Like any other query function, calling getNavigationArray directly, _getFirstElementInIndexedList is wrapped in a useCallback hook. Since the top row is always at index 0 and the first element in the stored list is at index 0, the return is straightforward to write.
Buttons
Moving the focus backward from a button is the simplest function, since all of its dependencies have already been written.
SubNavigation
export default function SubNavigation({...}){
...
const handleKeyDown = (e) => {
const buttonEl = buttonRef.current;
...
let focusableEl;
switch (e.key) {
case Keys.Up:
focusableEl = getPreviousByButton(buttonEl);
break;
}
if (focusableEl) {
shiftFocus(focusableEl);
}
};
...
}
GitHub (release 0.6.0) - SubNavigation.tsx - Line 137
For a button, handling the up arrow key only requires passing the button element, not its sublist state.
useNavigation
getPreviousByButton
const getPreviousByButton = ( buttonEl) => {
return _getPreviousByElement(buttonEl);
};
GitHub (release 0.6.0) - useNavigation.tsx - getPreviousByButton - Line 181
The button only depends on the focusable element returned by the shared _getPreviousByElement.
Summary
So far, this layer of progressive enhancements has been the most complex to implement. Keeping track of the conditions necessary for each element type and direction was made far simpler by having each focusable element cascade from the most to the least common. Up and Down keyboard handling has been achieved. You can try it yourself by downloading the release and running the examples.
Unlike the up and down arrow keys, Tab key handling is integral to both screen and screen reader, and I'll be handling that in the next release.
Top comments (0)