Building an Accessible Menubar Component Using React

  • Publish Date
  • Reading Time
    18 minutes
  • Tags
    • react
    • a11y
Tweet

    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...

    Enjoying the article?

    Support the content

    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.

    Related Articles