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, I demonstrated how structural HTML and Nested CSS go hand in hand, allowing for readable, maintainable stylesheets as work progressed on the beginnings of a styled navigation component suitable for desktop.
This article and its release focus on setting up for future success by adding the underlying data handling needed to support shifting focus between lists and components.
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.5.1.
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 Parent Provider 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
There comes a point in developing a non-trivial component when handling data becomes a prime consideration, and this time has arrived in the navigation component.
Whether a link or a button, a focusable item residing in a list item is unaware of its siblings or anything about the list it is not part of. One list has no way of knowing what another list contains.
Keyboard handling for a single list was implemented using a context provider to hold an array of focusable elements associated with that list. It meant the current element holding focus could send a request to the hook associated with the context provider to shift focus from itself to another element in the same list without knowing anything else.
Thinking ahead to the functionality required to move between components using the up and down arrow keys, along with Tab, requires each element to be able to traverse a series of lists to find the correct item to shift focus or execute other situations, such as where to place focus when a user closes the component using the keyboard.
Whether a link or a button, each focusable item resides in an array held by the NavigationListProvider. A SubNavigation component holds the button that controls the sublist along the sublist itself, meaning each list can be associated with a parent and know whether its sublist is open.
A tree data structure where the button serves as the intermediary is the logical step, so the following acceptance criteria were determined.
Acceptance Criteria
- AC-1 - Every focusable element should have access to the button controlling its list.
- AC-2 - A navigation context provider should hold an array of navigation objects.
- AC-3 - Each focusable element should register itself in the appropriate navigation object within the array.
- AC-4 - Each subnavigation button should register itself in its own navigation object as the controlling element.
At the end, the object array should resolve to the following shape:
[
{
parentEl: null,
isSublistOpen: true,
subList: [Community, Tales, Reference, About]
},
{
parentEl: Community,
isSublistOpen: false,
subList: [Musings, Forum]
},
{
parentEl: Tales
isSublistOpen: false,
subList: [Search, All Stories, All Commentary, Find Your Next Story]
},
{
parentEl: Search,
isSubListOpen: false,
subList: [Basic Search, Advanced Search]
},
{
parentEl: Find Your Next Story,
isSublistOpen: false,
subList: [By Storyteller, By Era]
},
{
parentEl: Reference,
isSubListOpen: false,
subList: [Appendices, Characters, Glossary]
},
{
parentEl: About,
isSublistOpen: false,
subList: [ About the Site, Contact Us, Privacy Policy, Accessibility, Donate,]
}
]
Note that every button is listed twice, once in a sublist and as a parent element. The first list will always be the first object in the array.
Creating the underlying data structure and the functionality to register elements within it is the focus of this release.
Who is my Parent?
AC-1 - Every focusable element should have access to the button controlling its list.
As the current architecture stands, a button can control a list but has no information about its items. At the same time, no item within a list currently has any way of finding or focusing on anything outside its own list. The solution is for each item to know its parent and to access a data structure that returns the next focusable item.
Since the NavigationList component already has access to its parent and a provider, it should be fairly simple to extend it to hold a reference to its parent, which will be a button for any list held in the Subnavigation component, or null when the NavigationListProvider is initialized from the NavigationList component.
NavigationListProvider
export function NavigationListProvider({ children, value }) {
const { parentRef } = value;
const [state, dispatch] = useReducer(navigationListReducer, {
parentRef: parentRef,
items: [],
});
const getCurrentListItems = ...;
const getParentEl = useCallback(() => {
return state.parentRef.current;
}, [state.parentRef]);
const registerItemInCurrentList = ...;
return (
<NavigationListContext.Provider
value={{
getCurrentListItems,
getParentEl,
registerItemInCurrentList,
}}
>
{children}
</NavigationListContext.Provider>
);
}
GitHub (release 0.5.1) - NavigationListProvider.tsx
The parent reference joins the empty items array within the reducer's state. There's no need to dispatch a call to the reducer; simply returning the current property from the parentRef held in state is enough.
useNavigationList
export function useNavigationList() {
const navigationListContextObj = use(NavigationListContext);
const { getCurrentListItems, getParentEl, registerItemInCurrentList } =
returnTrueElementOrUndefined(
!!navigationListContextObj,
navigationListContextObj,
);
const currentListItems = getCurrentListItems();
const parentEl = getParentEl();
...
return {
currentListItems,
parentEl,
...
};
}
GitHub (release 0.5.1) - useNavigationList.tsx
The getParentEl function is passed through to the navigation list hook, which masks the get() as a variable, as a nicety and passes it through.
To guarantee data is fresh, provider functions must be called to retrieve any data held by the provider. Passing the data itself to the hook will never refresh it, and while that might be fine for a parent reference that never changes, other information retrieved needs to be up to date, so getParentEl is created for consistency.
NavigationList
export default function NavigationList({
...
parentRef,
...rest
}){
const listContext = {
parentRef: parentRef,
};
const listProps = ...'
return (
<NavigationListProvider value={listContext}>
<List key={`list-$id`} {...listProps}>
{children}
</List>
</NavigationListProvider>
);
}
GitHub (release 0.5.1) - Navigation.tsx
All that's necessary is to pass the parentRef into any NavigationList and send it to the context provider.
With a parentRef now available to any NavigationItem or SubNavigation component, I can now begin development on a new context provider to hold an array of objects, each representing a different list.
Setting up the Navigation Provider
AC-2 - A navigation context provider should hold an array of navigation objects.
A navigation component is composed of one or more lists, so an array of objects is appropriate. Each object should hold the parent element and another array containing the items in the specific list. Knowing whether a list is opened or closed is going to come in handy, so a navigation object's shape can be stated as an object:
navigationObject = {
storedParentEl, /* parentRef.current */
storedList, /* [] - an array of Focusable Elements (links and buttons) held in the list controlled by the parent */
ssSubListOpen /* boolean - reflects the current state of the stored list's visibility. */
}
One object for each list held in the navigation component will be stored in the array. Each list will have access to its parent, and each item within the storedList will be able to ascertain the list's visible state.
It's time to insert a new context provider into the component.
NavigationProvider
A top-level context provider that wraps all the nested components that make up the lists displayed in the navigation component is required to hold all the returned data. When it's complete, the entire data structure for every link and navigation button will be stored in a reducer.
export function NavigationProvider({ children, value }) {
const { data } = value;
const navigationObject = {
storedParentEl: data.storedParentEl,
isSubListOpen: data.isSubListOpen,
storedList: [],
};
const [state, dispatch] = useReducer(navigationReducer, {
NavigationArray: [navigationObject],
});
const setIsListOpen = useCallback((isListOpen, parentEl) => {
dispatch({ type: "SET_IS_LIST_OPEN", parentEl, isListOpen });
}, []);
const setListItems = useCallback((navigationList, parentEl) => {
dispatch({
type: "SET_LIST_ITEMS",
parentEl,
navigationList: navigationList,
});
}, []);
const registerButtonAsParent = (isListOpen, parentEl) => {
dispatch({ type: "SET_PARENT", parentEl, isListOpen });
};
const registerItemInNavigationArray = (navigationList, parentEl) => {
setListItems(navigationList, parentEl);
};
return (
<NavigationContext.Provider
value={{
registerButtonAsParent,
registerItemInNavigationArray,
setIsListOpen,
setListItems,
}}
>
{children}
</NavigationContext.Provider>
);
}
Since updates are interdependent and registration occurs across different components, a reducer is used again to enable more granular state management. By using a reducer, each dispatch is applied to the latest state, actions can be processed in order, and all transitions are immutable.
navigationReducer
export function navigationReducer(
state,
action,
){
switch (action.type) {
case "SET_PARENT": {
const exists = state.navigationArray.some(
(obj) => obj.storedParentEl === action.parentEl,
);
if (exists) return state;
const navObj= {
storedParentEl: action.parentEl,
isSubListOpen: action.isListOpen,
storedList: [],
};
return {
...state,
navigationArray: [...state.navigationArray, navObj],
};
}
case "SET_IS_LIST_OPEN": {
const index = state.navigationArray.findIndex(
(obj) => obj.storedParentEl === action.parentEl,
);
const current = state.navigationArray[index];
if (current.isSubListOpen === action.isListOpen) return state;
const next = state.navigationArray.slice();
next[index] = { ...current, isSubListOpen: action.isListOpen };
return { ...state, navigationArray: next };
}
case "SET_LIST_ITEMS": {
const index = state.navigationArray.findIndex(
(obj) => obj.storedParentEl === action.parentEl,
);
const current = state.navigationArray[index];
const currentList = returnArray(current.storedList);
const nextList = action.navigationList;
if (arraysEqual(currentList, nextList)) return state;
const next = state.navigationArray.slice();
next[index] = { ...current, storedList: nextList };
return { ...state, navigationArray: next };
}
default: {
return state;
}
}
}
GitHub (release 0.5.1) - navigationReducer.ts
Reducers are typically handled as switch statements, based on their action types. Unlike setters derived from useState, reducers can rely on specific conditions to determine whether to update the reducer state they are responsible for.
SET_PARENT - checks for the existence of the passed-in parent element within the navigation array. If it doesn't exist, a new navigation object is created with the parent, an empty sublist, and whether the sublist is visible, and it is stored in the array.
SET_IS_LIST_OPEN - updates the boolean value on the object where the parent element matches the passed-through parent, only if the value has changed.
SET_LIST_ITEMS - updates the storedList within the object whose parent element matches the passed-through parent, only when the stored list doesn't match the list being passed through.
useNavigation
export function useNavigation() {
const navigationContextObj = use(NavigationContext);
const {
registerItemInNavigationArray,
registerButtonAsParent,
setIsListOpen,
setListItems,
} = returnTrueElementOrUndefined(
!!navigationContextObj,
navigationContextObj,
);
return {
registerItemInNavigationArray,
registerButtonAsParent,
setIsListOpen,
setListItems,
};
}
GitHub (release 0.5.1) - useNavigation.ts
The public functions are returned from the NavigationProvider and passed through by a new hook, useNavigation.
Navigation
The context provider needs to be established within the navigation component.
export default function Navigation({ isOpen = true, ...}){
const parentRef = useRef(null);
const navigationProps = {...};
const navigationContextProps = {
data: {
isSubListOpen: isOpen,
storedParentEl: null,
storedList: [],
},
};
const navigationListProps: NavigationListProps = {
...,
parentRef,
};
return (
<>
<NavigationProvider value={navigationContextProps}>
<nav {...navigationProps}>
<NavigationList {...navigationListProps}>{children}</NavigationList>
</nav>
</NavigationProvider>
</>
);
}
GitHub (release 0.5.1) - Navigation.tsx.
When initializing the first object to be stored in the array, not much is known. There are no items in the list, and the parent element for the top row will always be null. All that is really known is whether the list is open, which, for a horizontally aligned, uncontrolled component, is always true.
A parentRef is provided to be passed through to the NavigationList component, and an initial object is formed, which will always be the first object in the array. The top-level list will never have a controlling element, and the storedList is always sent in as an empty array to be filled during the registration process. Every other list is rendered in the SubNavigation component, and so has a valid parent element: the button that controls it.
With the provider in place, it's time to register the information.
NavigationItem
AC-3 - Each focusable element should register itself in the appropriate navigation object within the array.
export default function NavigationItem({...}) {
const {
currentListItems,
parentEl,
...
} = useNavigationList();
const { registerItemInNavigationArray } = useNavigation();
const currentPath = ...;
const linkRef = useRef(null);
const prevCurrentListItems = usePrevious(currentListItems);
useEffect(() => {
if (linkRef.current !== null) {
registerItemInCurrentList(linkRef.current);
}
}, [linkRef, registerItemInCurrentList]);
useEffect(() => {
const prevList = prevCurrentListItems || [];
if (
linkRef.current !== null &&
currentListItems.length > 0 &&
!arraysEqual(currentListItems, prevList)
) {
registerItemInNavigationArray(currentListItems, parentEl);
}
}, [
currentListItems,
linkRef,
parentEl,
prevCurrentListItems,
registerItemInNavigationArray,
]);
...
return (
<ListItem key={id} {...listItemProps}>
<Link {...linkProps}>{label}</Link>
</ListItem>
);
}
GitHub (release 0.5.1) - NavigationItem.tsx
Within the NavigationItem component, a new useEffect hook is added that passes the currentItemsList and the parent element retrieved from the useNavigationList hook into the navigationArray held by the NavigationProvider.
Effectively, this useEffect should run the registration function only once under very specific conditions. The issue is that the first time this hook is called, the linkRef will return null because the link element hasn't yet been initialized and needs to run after initial rendering. Which means it will run when the dependency array changes. I can leverage React's batch updates and send over the list of focusable elements maintained by the navigation list context provider rather than continuously updating it.
A usePrevious hook retrieves the previous makeup of the currentListItems array, which is the single list array stored in the NavigationListProvider. The function that registers the item into the navigation array is only run when the link element is known and no longer null, and the list actually contains a non-empty array that does not match the previous list.
The same registration needs to be added to the SubNavigation component to register the button into the navigation array as a focusable element.
Subnavigation
AC-4 - Each subnavigation button should register itself in its own navigation object as the controlling element.
export default function SubNavigation({...}) {
const {
currentListItems,
parentEl,
...
} = useNavigationList();
const {
registerButtonAsParent,
registerItemInNavigationArray,
setIsListOpen,
} = useNavigation();
const buttonRef = useRef(null);
const [buttonEl, setButtonEl] = useState(null);
...
const closeSubNavigation = useCallback(() => {
setIsListOpen(false, buttonEl);
setIsSubListOpen(false);
}, [buttonEl, setIsListOpen, setIsSubListOpen]);
const openSubNavigation = useCallback(() => {
setIsListOpen(true, buttonEl);
setIsSubListOpen(true);
}, [buttonEl, setIsListOpen, setIsSubListOpen]);
useEffect(() => {
if (buttonRef.current !== null) {
registerItemInCurrentList(buttonRef.current);
registerButtonAsParent(isSubListOpen, buttonRef.current);
}
}, [
buttonRef,
isSubListOpen,
registerButtonAsParent,
registerItemInCurrentList,
]);
useEffect(() => {
const prevList = prevCurrentListItems || [];
if (
buttonRef.current !== null &&
currentListItems.length > 0 &&
!arraysEqual(currentListItems, prevList)
) {
registerItemInNavigationArray(currentListItems, parentEl);
}
}, [
currentListItems,
parentEl,
prevCurrentListItems,
registerItemInNavigationArray,
]);
...
const handlePress = () => {
if (isSubListOpen) {
closeSubNavigation();
} else {
openSubNavigation();
}
};
...
const navigationListProps: NavigationListProps = {
...
parentRef: buttonRef,
...
};
return ( ... );
}
GitHub (release 0.5.1) - Subnavigation.tsx
The handlePress function is refactored to call a function based on the current sublist's visibility, rather than toggling its open state. Both the sublist visibility held in the component's state and the call to update the object in the NavigationProvider's reducer are set to true or false in the same function.
When the button element has been rendered, not only is it registered in the current list, but the button is registered as a parent within the navigation array; and finally, when the button element meets the same conditions as the link did earlier, it registers itself into the correct navigation array as a focusable element along with the parent it retrieves from the NavigationListProvider.
Summary
Consistent data registration and updating of each list's open state has been the priority of this release. There isn't anything visible on the screen, but a console.log(state.navigationArray) placed in the registerItemInNavigationArray function will show the completed array.
With the scaffolding complete, attention in the next release can turn to making those up and down arrow keys actually work. In the meantime, I'll be turning back to styling the navigation component for a vertical layout in the next article.
Top comments (0)