Last week I watched Pedro Duarte's excellent "So You Think You Can Build A Dropdown" talk at Next.js Conf. It inspired me to write up an accessible component of my own that I recently worked on β the menubar widget.
I have a real interest in accessibility, particularly in frontend web development. Of all the patterns that I've researched to date, the menubar was the most complex. Reach, Radix, and React Aria all provide flexible and accessible React components.
Yet, I struggled to find any library that provided a menubar component out of the box. Given the complexity and lack of material, I thought I'd share my discoveries with the community.
Introduction
This article will explain how I created an accessible
Loading...
component with React. The aim was to create a component that adhered to the WAI-ARIA
design pattern for a menubar widget.
For brevity, the article will focus on a horizontal menubar with a single submenu. It also assumes you are comfortable with React hooks and the compound component pattern. I've included the solution as a Code Sandbox link below.
Useful Links
The Menubar
We'll kick off with the requirements. The Mythical University has requested an accessible site navigation for their website.
To get started, we'll group a collection of hyperlinks in an unordered list. We'll also wrap the list in a navigation section.
The HTML might look something like this:
Loading...
At first glance, the markup looks comprehensive, but how accessible is it for those reliant on assistive technologies? Additionally, can the user navigate the menubar with the expected keyboard controls?
Although we have provided semantic HTML, the current iteration is not considered accessible. The markup is missing critical
Loading...
roles that give context to both the links and the widget itself. Poor keyboard support also means the user is only able to tab through the list of links.
Let's improve both of these areas.
We'll start by creating two functional components. One is a parent
Loading...
list, and the other is a child
Loading...
list item. Together we'll use these to compose a compound
Loading...
component.
The parent
Loading...
returns an unordered list element. Since it's the widget's root element, we'll assign it the
Loading...
role. The
Loading...
attribute allows assistive technology to determine the direction of the menu. Finally, let's include a custom
Loading...
attribute for targeting and styling later on.
Loading...
The second component is the
Loading...
. It accepts a single node for its
Loading...
prop and returns the node wrapped in a list item element.
Assistive technology should only announce the child node. A list item element has the
Loading...
role by default. By overriding it to
Loading...
, we completely remove it from the accessibility tree. We then assign the child node the
Loading...
role by
cloning the element and shallow merging the prop.
Loading...
Finally, let's add a matching
Loading...
to the navigation element.
The current React markup will look something like this:
Loading...
Which will compile into the following HTML:
Loading...
So far we've improved the menubar for those using assistive technology, but what about those who are reliant on keyboard controls? For them to navigate the list of menu items, the
Loading...
component needs to be aware of each child
Loading...
. We can achieve this by utilizing the React
Loading...
and
Loading...
hooks.
Let's start by creating a new
Loading...
:
Loading...
The
Loading...
will store a
Set of nested
Loading...
nodes within a parent
Loading...
. We contain the
Loading...
in a mutable ref object created with the
Loading...
hook, and store the
Loading...
value in a variable.
This allows us to manipulate the
Loading...
contents without re-rendering the
Loading...
. Next, we'll memoize an object with the
Loading...
hook and assign the
Loading...
as a property. Finally, we'll pass the object to the value attribute of the
Loading...
.
Loading...
The
Loading...
should only ever be a child of a
Loading...
component. To enforce this, let's throw an error if the
Loading...
hook cannot find a
Loading...
. This allows us to assert that
Loading...
exists below the following conditional statement:
Loading...
Let's create an object reference to the
Loading...
DOM node with the
Loading...
hook. Then let's use the
Loading...
hook to trigger a side-effect that adds the node to the
Loading...
Loading...
. We'll also return a cleanup function to remove it from the
Loading...
if the
Loading...
unmounts.
Loading...
Roving tab index
We now have a reference to each
Loading...
node. With them, we can apply the
roving tab index pattern to manage focus within the component. To do that, the
Loading...
needs to keep track of the current and previously-focused
Loading...
. We can do this by storing the indexes of the current and previous nodes in the
Loading...
's component state.
The current index is a stateful value stored using the React
Loading...
hook. When the Menubar first mounts, the first
Loading...
child should have a tab index of
Loading...
. Thus, we can assign
Loading...
as the default state for the current index.
We can use a custom hook to track the previous index. The hook accepts the current index as a function parameter. If the hook does not return a value, we can assume that one does not exist and fall back to
Loading...
.
Loading...
To apply the roving tab index, the
Loading...
node must have a tab index of
Loading...
. All other elements in the component's tab sequence should have a tab index of
Loading...
. Whenever the user navigates from one menu item to another, the following should occur:
- The current node should blur and its tab index should set to
Loading...
- The next node's tab index is set to
Loading...
- The next node receives focus
Let's utilize the React
Loading...
hook for this. We'll pass the current and previous indexes as effect dependencies. Whenever either index changes, the effect will update all appropriate indexes. Note that we are applying the tab index attribute to the first child of the
Loading...
, not the list item wrapper.
Loading...
We donβt have to add the tab index to each menu item, we can update the
Loading...
component to do that for us! We can assume that if the
Loading...
Loading...
is empty, then the node is the first menu item in the sequence.
Let's add some component state to track whether the
Loading...
is the first node in the set. If it is, we can assign its tab index a value of
Loading...
β otherwise, we'll fall back to
Loading...
.
Loading...
Keyboard controls
Next, we'll use the
Loading...
's
Loading...
event to update the current index based on the user's keypress. There are five primary methods that a user can navigate through the menu items. They can:
- Return to the previous item
- Advance to the next
- Jump to the first
- Skip to the last
- Move to the next match
Let's encapsulate that logic into some helper methods that we can pass to the
Loading...
event.
Loading...
With the helper methods defined, we can assign them to the appropriate key codes. We'll check to see if the keypress matches any keys associated with movement; if it doesnβt, we'll default to the
Loading...
helper method.
Loading...
Notice that we are calling
Loading...
on most of the helper methods. This is to suppress any default browser behavior as the user interacts with the menubar. For example, by default, the
Loading...
key scrolls the user to the bottom of the page.
Let's say we did not prevent the default behavior; the scroll position would jump to the bottom of the page any time the user tried to skip to the final menu item!
We mustn't call
Loading...
on the default case. If we did, it would ignore any default browser behavior not captured by a switch case. This could lead to undesired behavior. An example would be if a menu item within the menubar had focus and the user pressed
Loading...
to refresh the page. If we called
Loading...
on the default case, it would ignore the refresh request. It would then pass the
Loading...
key to the
Loading...
helper method.
We now have a fully-accessible Menubar widget for a collection of navigation links! Each menu item provides rich contextual information to assistive technology. It also allows those reliant on keyboard support to navigate the list of links as they would expect.
The component API hasn't changed from the previous example...
Loading...
...yet the compiled HTML markup now includes tab indexes on the menu items.
Progress!
Loading...
The Submenu
The previous example is great for a single collection of links, but what if we replaced one of them with a dropdown that revealed a secondary set of navigation links?
Loading...
For this, we're going to need to create a second compound component β the
Loading...
. It is composed of three functional components:
- The
Loading...
will hold shared logic and component state
- The
Loading...
will allow the user to expand the menu
- The
Loading...
will display the expanded menu items
The
Loading...
keeps track of menu items within the
Loading...
. In turn, let's create a
Loading...
to keep track of menu items nested within a
Loading...
.
Loading...
Let's start by defining the
Loading...
component. It'll share some similar behaviors and functionality to the
Loading...
. Alongside the index tracking, it also needs to know if its menu has expanded. We could declare another state variable with
Loading...
. Instead, it makes more sense to merge the logic into a reducer function.
The purpose of the
Loading...
parent component is to hold the compound component state. It is also responsible for distributing shared logic to its sub-components. We assign the logic to a memoized object, after which that object is then passed to the value attribute of a
Loading...
.
Loading...
Now, let's define the helper methods for navigating the submenu's menu items. These are almost identical to the
Loading...
helpers. The key difference is they dispatch reducer actions instead of updating the component state directly.
Loading...
Some functional requirements need the subcomponents to have knowledge of their sibling. We can achieve this by defining ids and references for each subcomponent in the
Loading...
. Note that we store the
Loading...
within a reference object. This is to prevent the
Loading...
function from regenerating the id on every render. Each subcomponent can now retrieve the values from the
Loading...
hook.
Loading...
Let's now manage focus within the
Loading...
. We'll start by adding another side effect. This one will focus the first child of the current index if the tracked indexes do not match. Whenever we update the current index, we focus the first child of the new current node.
Loading...
Submenus do not follow the roving tab index pattern. Instead, the tab index of each menu item within a submenu will always be
Loading...
. This requires a small change to the
Loading...
component. If a
Loading...
exists, we can assume the
Loading...
is inside a
Loading...
and apply
Loading...
to its tab index.
Loading...
Trigger
With the
Loading...
defined, let's create the
Loading...
component. We'll start by retrieving the
Loading...
and
Loading...
from the
Loading...
. Since a button's default type is
Loading...
, it's usually a good idea to override it to
Loading...
.
Finally, the
Loading...
should only ever be a child of the
Loading...
. Like before, let's throw an error if we use it outside of a
Loading...
.
Loading...
Next, let's add the appropriate
Loading...
attributes.
Loading...
will inform assistive technology that the button controls a submenu. To go one step further, we can also add the
Loading...
attribute. This informs the screen reader of the exact submenu controlled by the
Loading...
.
Let's also retrieve the
Loading...
and the
Loading...
state from the
Loading...
. We'll assign the
Loading...
to
Loading...
. Then, all that's left is to assign the
Loading...
state to the
Loading...
attribute. Assistive technology is now aware of the menu button controls, and whether they are open or closed.
Loading...
Now, let's add keyboard support to the
Loading...
. The
Loading...
will be a sibling of the Menubar menu items. That means it should perform the same
Loading...
events as the Menubar links. It also requires some additional functionality. Alongside the menu item behavior, the Trigger should:
Loading...
: Open the submenu and focus the last item
Loading...
: Opens the submenu and focus the first item
Loading...
, Loading...
: Open the submenu and focus to the first item
To do this, we'll retrieve some methods from the
Loading...
and assign them to the relevant
Loading...
. Note that we only want to execute the
Loading...
method on unique events.
Doing so allows all other events to bubble up to the
Loading...
. This is what prevents us from having to duplicate the menu item's
Loading...
events.
Loading...
Let's say a submenu is open when the user presses the
Loading...
or
Loading...
key. The submenu should close and focus the previous or next
Loading...
menu item. If the root menu item is also a submenu, it should expand the menu but keep focus on the trigger.
The
Loading...
achieves this by checking to see if the event originated from a submenu menu item. This ensures that the menu does not expand when other
Loading...
methods focus the trigger.
Loading...
List
Now that we have a
Loading...
, all we need to do is create a submenu
Loading...
. Like the
Loading...
, we'll throw an error if the
Loading...
component is not used within a
Loading...
.
Let's also define some attributes. First, we'll apply the
Loading...
and retrieve the
Loading...
from the
Loading...
. We'll retrieve
Loading...
from the context and assign it to the
Loading...
attribute. This will hide the List from the accessibility tree if the menu is not expanded.
Next, let's label the menu by assigning the
Loading...
to the
Loading...
attribute. Finally, we'll supply the menu's direction to assistive technology with the
Loading...
attribute.
Loading...
Now let's add some
Loading...
events specific to the
Loading...
component. We'll retrieve the appropriate helpers from the
Loading...
. Again, we only want to stop propagation on events that we do not want to bubble up to the
Loading...
's
Loading...
event.
Loading...
The
Loading...
component will work within a
Loading...
for the most part. We'll need to make a couple of changes to ensure that both the
Loading...
and
Loading...
can make use of the component.
The first change is to ensure that the correct
Loading...
Loading...
receives the
Loading...
node. We can assert that a submenu is an ancestor element if the
Loading...
can retrieve a
Loading...
. If it returns a false value, then the
Loading...
must belong to the Menubar.
Let's update the error to check for the
Loading...
. The error should only throw if both contexts do not exist. A
Loading...
can now be a child of either a
Loading...
or a
Loading...
.
Loading...
There is one final change that we need to make to the
Loading...
component. Let's revisit the structure of the
Loading...
.
The
Loading...
currently clones its
Loading...
prop and appends extra props. In the example below, we can see that
Loading...
's child is the
Loading...
component. The
Loading...
returns a context provider as its parent element. The provider returns nothing from render, and so the props are not attached to any DOM node.
Loading...
Instead, we would like to append the
Loading...
's
Loading...
onto the submenu
Loading...
. To do so, the
Loading...
component will need to check its
Loading...
's type.
If the type is a node, then we clone it and append the props. If the type is a function, then we instead provide the props as an argument in the function signature.
This allows us the flexibility of choosing which element should receive the props and additionally retains the convenience of appending the props onto the child by default.
Loading...
That leaves us with this flexible React markup:
Loading...
...which compiles into this beautiful, accessible HTML:
Loading...
Now, all that's left is to add extra logic for mouse pointer events, nested submenus, and a full suite of unit tests!
Unfortunately, we'll consider these features out of scope for this article and they would warrant a follow-up post to cover. I've included all the extra logic and the unit tests in the Code Sandbox demo at the top of the page.
Special thanks to Jenna Smith for her invaluable contributions to the initial API design.