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.
My last development article completed the base requirements for keyboard functionality within the component; attention now shifts to adding some of the last functionality required, closing sublists when the lists holding them now close and determining what happens when a closed component is entered via the keyboard through the Tab and Shift+Tab keys.
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.8.0. Links in the article will take you to the proper file in the tagged GitHub Repository.
Because the code for this release is scattered, line numbers are added to make it easier to locate in the linked GitHub file. Line numbers are also provided for those who would like to follow along with a downloaded copy.
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 Focus and Refinement Support Release along with previous requirements.
Content Links
Introduction
The implementation of keyboard handling left one obvious keyboard issue to fix: an apparent keyboard trap that occurs when focus shifts into the component via a Shift+Tab key combination.
In addition to the keyboard trap, other issues arise that, while not errors, can be refined to meet user expectations. Closing sublists when a parent is closed, or closing open lists when navigating through the top row, will give the component a finished aspect.
This release, while fixing one nagging issue, once again focuses on the internal, foundational aspects necessary to support those requirements and sets up the next release for success, enhancing the component to close cleanly, beginning with additions to the NavigationProvider.
Acceptance Criteria
Focus and Closing Foundation Release
- AC 1 - When Shift+Tab is used to enter the component and the last child on the top row is a button, focus should land on that button
A navigation component is not modal; it does not block interaction with other elements on a page, and exiting can be done using the Tab and Shift+Tab keys on the first and last component elements.
As a reminder, the entire component is always in the document object model, meaning that when Shift+Tab is executed to shift focus into the component, there's a good chance the focus will initially land on a link in an unexpanded sublist. In that case, the focus should shift to its parent on the last row.
For the last component element, if it is contained in an unexpanded sublist, focus should shift to its parent on the last row.
Entering
AC 1 fixes the last remaining keyboard-related issue, which occurs when Shift+Tab moves focus from outside the component to the last focusable element in the navigation component. If the top-row parent is a button and the sublist containing the last focusable element is closed, focus should shift to the top-row parent.
Any disappearance of the focus outline is regarded as an error by a screen-perceiving, keyboard-operating user.
The solution is to shift the focus to the top-row parent when focus is placed on the last element of the component within a closed sublist. This requires another event listener, onFocus.
Navigation Item
const handleFocus = () => {
const linkEl = linkRef.current;
const focusableEl = handleLinkFocus(linkEl);
if (!!focusableEl && focusableEl !== linkEl) {
shiftFocus(focusableEl);
}
};
...
const linkProps = {
...,
onFocus: handleFocus,
onKeyDown: handleKeyDown,
ref: linkRef,
...rest,
};
GitHub (release 0.8.0) - NavigationItem.tsx - Line 79
The handleFocus function calls the useNavigation hook's handleLinkFocus function, which can return a new element to shift focus. The function may also return the current link element. The focus should shift only when the returned element is both defined and different.
UseNavigation
HandleLinkFocus
const handleLinkFocus = ( linkEl) => {
if (_isLastElementInComponent(linkEl)) {
return _handleLastChildFocus(linkEl);
}
};
GitHub (release 0.8.0) - useNavigation.tsx - HandleLinkFocus Line 369
The last element of a component is always a link, so a check is implemented to determine whether the link holding focus is the last component element; if so, the handler for the last child is called, which will either return itself or its topmost ancestor on the top row.
_isLastElementInComponent
const _isLastElementInComponent = (focusedEl) => {
return focusedEl === _getLastElementbyComponent();
};
GitHub (release 0.8.0) - useNavigation.tsx - _isLastElementInComponent Line 153
The question of whether the currently focused element is the last component requires the system to know which element is the last in the component.
_getLastElementInComponent
const _getLastElementInComponent = () => {
let lastEl = lastComponentElement;
if (!lastEl) {
const lastElement = _getLastElementInIndexedList(0);
if (lastElement.type === "button") {
lastEl = _getLastElementByParent( lastElement );
} else {
lastEl = lastElement;
}
setLastComponentElement(lastEl);
}
return lastEl ;
};
GitHub (release 0.8.0) - useNavigation.tsx - _getLastElementByComponent Line 95
Determining the last element of the component involves finding the last focusable element associated with a particular parent, which, in this case, would be the last element in the top row. The _getLastElementByParent function triggers a recursive call, which can be resource-intensive. Since the entire nested list resides in the DOM, the last element will not change, so it makes sense to store it in state on the first call.
If the link is the last element within the component, a specific handler for that condition is called.
_handleLastChildFocus
const _handleLastChildFocus = (focusedEl) => {
const { isSubListOpen } = _getNavigationObjectByListElement(focusedEl);
if (!isSubListOpen) {
return _getLastElementInTopRow(focusedEl);
} else {
return focusedEl;
}
};
GitHub (release 0.8.0) - useNavigation.tsx - _handleLastChildFocus Line 203
The only way focus can land on an item in a sublist that has not been expanded is through the Shift+Tab action, at which point the last element in the top row is returned for the focus shift. The currently focused element is returned when the sublist is opened.
_getLastElementInTopRow
const _getLastElementInTopRow = (focusedEl) => {
const lastTopEl = _getLastElementInIndexedList(0);
if (lastTopEl.type === "button") {
const lastEl = _getLastElementByParent(
lastTopEl as ControllingElementType,
);
/* istanbul ignore else */
if (focusedEl === lastEl) {
return lastTopEl;
}
}
};
GitHub (release 0.8.0) - useNavigation.tsx - _getLastElementInRow Line 116
If the last element on the top row is a button, the recursive function is called to request the last component element associated with the last element in the top row; if the link matches, the last element on the top row is returned, and the issue is fixed.
Retrieving the last element in the top row first requires fetching the last element in that row.
_getLastElementInIndexedList
const _getLastElementInIndexedList = useCallback(
(index) => {
const { storedList } = getNavigationArray()[index];
return storedList[storedList.length - 1];
},
[getNavigationArray],
);
GitHub (release 0.8.0) - useNavigation.tsx - _getLastElementInIndexedList Line 74
And now, focus shifts to the end button when the component is entered through Shift+Tab.
With the last keyboard issue resolved, attention can be turned to closing scenarios.
Closings
What should happen when an open list containing other open sublists is closed? In the current iteration, nothing happens, meaning that when a list is reopened, any previously opened sublists remain visible.
When thinking about navigation, it can be helpful to look at the requirements for menus, whether they be operating systems or browser applications. In either case, when a list is closed, any open lists under it are also closed.
The question is: how can this be accomplished within the current architecture? While a button in the SubNavigation component can close the list it is associated with, how can it reach into another open subnavigation component in its downline and trigger the close?
Storing the closeSubNavigation function in the associated navigation object should do the trick. Each function can be held and passed by reference, and executing the passed function will call the appropriate functions.
Setting up for Success
Functions shouldn't be stored in state; triggering them there can cause re-rendering, which could be detrimental to data stability. Instead, a map object is created within a reference object, which holds the functions associated with the parent element. This map object can inject the appropriate functions into the navigation array objects when requested.
Since all the data used to manage the navigation component is stored in the navigation context provider, updates to that data are required.
_dispatchSubListCloseByParent
const _dispatchSubListCloseByParent =
useRef(new Map());
GitHub (release 0.8.0) - NavigationProvider.tsx - _dispatchSubListCloseByParent Line 46
Unlike updates to state, updates to a ref held within the context provider don't trigger a re-render when the map object changes.
getNavigationArray
const getNavigationArray = useCallback(() => {
return state.navigationArray.map((obj) => ({
...obj,
dispatchSubListClose: _dispatchSubListCloseByParent.current.get(
obj.storedParentEl,
),
}));
}, [state.navigationArray]);
GitHub (release 0.8.0) - NavigationProvider.tsx - getNavigationArray Line 49
The function returning the navigation array is updated to inject the subListClose function as dispatchSubListClose into the array when it is returned. The function isn't part of the state holding the array of navigation objects; it's injected each time the function is called.
registerButtonAsParent
const registerButtonAsParent = (isListOpen, parentEl, dispatchSubListClose) => {
dispatch({ type: "SET_PARENT", parentEl, isListOpen });
_dispatchSubListCloseByParent.current.set(parentEl, dispatchSubListClose);
};
GitHub (release 0.8.0) - NavigationProvider.tsx - registerButtonAsParent
Line 59
RegisterButtonAsParent is also modified, setting the map directly and tying the close function to the parent element.
All that's left to do is to add the close function in the correct useEffect.
SubNavigation
useEffect(() => {
if (buttonRef.current !== null) {
registerItemInCurrentList(buttonRef.current as FocusableElementType);
registerButtonAsParent(
isSubListOpen,
buttonRef.current,
closeSubNavigation,
);
}
}, [buttonRef, closeSubNavigation, isSubListOpen, registerButtonAsParent, registerItemInCurrentList]);
GitHub (release 0.8.0) - SubNavigation.tsx - Line 83
With registration complete, work on actually closing the sublists can begin and will be detailed in the next article.
Summary
With keyboard navigation (finally) completed, attention now turns to refinements; fixing the last entry issue by sending focus to the last button in the top row when the last element in the component receives focus in an unopened list and preparing architecture to support each button closing itself and any open sublists maintained by its children.
Top comments (0)