Back to homepage

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 Menubar 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:

<nav>
    <ul>
        <li>
            <a href="/#about">About</a>
        </li>
				
        <li>
            <a href="/#admissions">Admissions</a>
        </li>
				
        <li>
            <a href="/#academics">Academics</a>
        </li>
    </ul>
</nav>

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 aria- 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 Menubar list, and the other is a child MenuItem list item. Together we'll use these to compose a compound <Menubar /> component.

The parent Menubar returns an unordered list element. Since it's the widget's root element, we'll assign it the menubar role. The aria-orientation attribute allows assistive technology to determine the direction of the menu. Finally, let's include a custom data- attribute for targeting and styling later on.

function Menubar({ children, ...props }) {
    const listProps = {
        ...props,
        "aria-orientation": "horizontal",
        "data-menubar-list": "",
        role: "menubar",
    };

    return <ul {...listProps}>{children}</ul>;
};

The second component is the MenuItem. It accepts a single node for its children 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 listitem role by default. By overriding it to none, we completely remove it from the accessibility tree. We then assign the child node the menuitem role by cloning the element and shallow merging the prop.

function MenuItem({ children, ...props }) {
    const listItemProps = {
        ...props,
        "data-menubar-listitem": "",
        role: "none"
    };

    const childProps = {
        "data-menubar-menuitem": "",
        role: "menuitem",
    };

    return (
        <li {...listItemProps}>
            {React.cloneElement(children, childProps)}
        </li>
    );
};

Finally, let's add a matching aria-label to the navigation element.

The current React markup will look something like this:

<nav aria-label="Mythical University">
    <Menubar aria-label="Mythical University">
        <MenuItem>
            <a href="/#about">About</a>
        </MenuItem>
		
        <MenuItem>
            <a href="/#admissions">Admissions</a>
        </MenuItem>
		
        <MenuItem>
            <a href="/#academics">Academics</a>
        </MenuItem>
    </Menubar>
</nav>

Which will compile into the following HTML:

<nav aria-label="Mythical University">
    <ul
        aria-label="Mythical University"
        aria-orientation="horizontal"
        data-menubar-list
        role="menubar"
    >
        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#about" role="menuitem">
                About
            </a>
        </li>
			
        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#admissions" role="menuitem">
                Admissions
            </a>
        </li>
			  
        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#academics" role="menuitem">
                Academics
            </a>
        </li>
    </ul>
</nav>

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 Menubar component needs to be aware of each child MenuItem. We can achieve this by utilizing the React createContext() and useEffect() hooks.

Let's start by creating a new MenubarContext:

export const MenubarContext = React.createContext(null);

The MenubarContext will store a Set of nested MenuItem nodes within a parent Menubar. We contain the Set in a mutable ref object created with the useRef() hook, and store the current value in a variable.

This allows us to manipulate the Set contents without re-rendering the Menubar. Next, we'll memoize an object with the useMemo() hook and assign the menuItems as a property. Finally, we'll pass the object to the value attribute of the MenubarContext.Provider.

function Menubar({ children, ...props }) {
    const menuItems = React.useRef(new Set()).current;
    const value = React.useMemo(() => ({ menuItems }), [menuItems]);
    const listProps = { ... };

    return (
        <MenubarContext.Provider value={value}>
            <ul {...listProps}>
                {children}
            </ul>
        </MenubarContext.Provider>
    );
};

The MenuItem should only ever be a child of a Menubar component. To enforce this, let's throw an error if the useContext() hook cannot find a MenubarContext. This allows us to assert that menuItems exists below the following conditional statement:

const menubarContext = React.useContext(MenubarContext);

if (!menubarContext) {
    throw new Error("MenuItem must be used within a Menubar Context");
}

const { menuItems } = menubarContext;

Let's create an object reference to the MenuItem DOM node with the useRef() hook. Then let's use the useEffect() hook to trigger a side-effect that adds the node to the menuItems Set. We'll also return a cleanup function to remove it from the Set if the MenuItem unmounts.

const { menuItems } = menubarContext;

const menuItemRef = React.useRef(null);

const listItemProps = {
    [ ... ],
    ref: menuItemRef,
};

React.useEffect(() => {
    const menuItemNode = menuItemRef.current;

    if (menuItemNode) {
        menuItems.add(menuItemNode);
    }

    return () => {
        menuItems.delete(menuItemNode);
    };
}, [menuItems]);

return (
    <li {...listItemProps}>
        {React.cloneElement(children, childProps)}
    </li>
);

Roving tab index

We now have a reference to each MenuItem node. With them, we can apply the roving tab index pattern to manage focus within the component. To do that, the Menubar needs to keep track of the current and previously-focused MenuItem. We can do this by storing the indexes of the current and previous nodes in the Menubar's component state.

The current index is a stateful value stored using the React useState() hook. When the Menubar first mounts, the first MenuItem child should have a tab index of 0. Thus, we can assign 0 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 null.

/* https://usehooks.com/usePrevious/ */
const [currentIndex, setCurrentIndex] = React.useState(0);
const previousIndex = usePrevious(currentIndex) ?? null;

function usePrevious(value) {
    const ref = React.useRef();

    React.useEffect(() => {
        ref.current = value;
    }, [value]);

    return ref.current;
}

To apply the roving tab index, the menuItems[currentIndex] node must have a tab index of 0. All other elements in the component's tab sequence should have a tab index of -1. 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 -1
  • The next node's tab index is set to 0
  • The next node receives focus

Let's utilize the React useEffect() 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 MenuItem, not the list item wrapper.

React.useEffect(() => {
    if (currentIndex !== previousIndex) {
        const items = Array.from(menuItems);
        const currentNode = items[currentIndex]?.firstChild;
        const previousNode = items[previousIndex]?.firstChild;
		
        previousNode?.setAttribute("tabindex", "-1");
        currentNode?.setAttribute("tabindex", "0");
        currentNode?.focus();
    }
}, [currentIndex, previousIndex, menuItems]);

We don’t have to add the tab index to each menu item, we can update the MenuItem component to do that for us! We can assume that if the menuItems Set is empty, then the node is the first menu item in the sequence.

Let's add some component state to track whether the MenuItem is the first node in the set. If it is, we can assign its tab index a value of 0 — otherwise, we'll fall back to -1.

const [isFirstChild, setIsFirstChild] = React.useState(false);
const menuItemRef = React.useRef(null);
const { menuItems } = menubarContext;

const listItemProps = {
    [ ... ],
    ref: menuItemRef,
};

const childProps = {
    [ ... ],
    tabIndex: isFirstChild ? "0" : "-1",
};

React.useEffect(() => {
    const menuItemNode = menuItemRef.current;
	
    if (menuItemNode) {
        if (!menuItems.size) {
            setIsFirstChild(true);
        }

        menuItems.add(menuItemNode);
    }

    return () => {
        menuItems.delete(menuItemNode);
    };
}, [menuItems]);

return (
    <li {...listItemProps}>
        {React.cloneElement(children, childProps)}
    </li>;
);

Keyboard controls

Next, we'll use the Menubar's onKeyDown() 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 keyDown event.

// Moves focus to the first item in the menubar.
const first = () => setCurrentIndex(0);

// Moves focus to last item in the menubar.
const last = () => setCurrentIndex(menuItems.size - 1);

// Moves focus to the next item in the menubar. 
// If focus is on the last item, moves focus to the first item.
const next = () => {
    const index = currentIndex === menuItems.size - 1 ? 0 : currentIndex + 1;
    setCurrentIndex(index);
};

// Moves focus to the previous item in the menubar. 
// If focus is on the first item, moves focus to the last item.
const previous = () => {
    const index = currentIndex === 0 ? menuItems.size - 1 : currentIndex - 1;
    setCurrentIndex(index);
};

// Moves focus to next item in the menubar that starts with the character. 
// If none of the items start with the typed character, focus does not move.
const match = (e) => {
    const items = Array.from(menuItems);

    const reorderedItems = [
        ...items.slice(currentIndex),
        ...items.slice(0, currentIndex)
    ];

    const matches = reorderedItems.filter((menuItem) => {
        const { textContent } = menuItem.firstChild;
        const firstLetter = textContent.toLowerCase().charAt(0);
        return e.key === firstLetter;
        });

    if (!matches.length) {
        return;
    }

    const currentNode = items[currentIndex];
    const nextMatch = matches.includes(currentNode) ? matches[1] : matches[0];
    const index = items.findIndex((item) => item === nextMatch);

    setCurrentIndex(index);
};

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 match() helper method.

const keyDown = (e) => {
    e.stopPropagation();

switch (e.code) {
    case "ArrowLeft":
        e.preventDefault();
        previous();
        break;
    case "ArrowRight":
        e.preventDefault();
        next();
        break;
    case "End":
        e.preventDefault();
        last();
        break;
    case "Home":
        e.preventDefault();
        first();
        break;
    default:
        match(e);
        break;
    }
}

const listProps = {
    [ ... ],
    onKeyDown: (e) => {
        keyDown(e);
    },
};

Notice that we are calling e.preventDefault() 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 End 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 e.preventDefault() 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 ctrl + r to refresh the page. If we called e.preventDefault() on the default case, it would ignore the refresh request. It would then pass the r key to the match 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...

<nav aria-label="Mythical University">
    <Menubar aria-label="Mythical University">
        <MenuItem>
            <a href="/#about">About</a>
        </MenuItem>
			
        <MenuItem>
            <a href="/#admissions">Admissions</a>
        </MenuItem>
			
        <MenuItem>
            <a href="/#academics">Academics</a>
        </MenuItem>
    </Menubar>
</nav>

...yet the compiled HTML markup now includes tab indexes on the menu items.

Progress!

<nav aria-label="Mythical University">
    <ul
        aria-label="Mythical University"
        aria-orientation="horizontal"
        data-menubar-list
        role="menubar"
    >
        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#about" role="menuitem" tabindex="0">
                About
            </a>
        </li>

        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#admissions" role="menuitem" tabindex="-1">
                Admissions
            </a>
        </li>

        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#academics" role="menuitem" tabindex="-1">
                Academics
            </a>
        </li>
    </ul>
</nav>

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?

<nav aria-label="Mythical University">
    <Menubar aria-label="Mythical University">
        <MenuItem>
            <a href="/#about">About</a>
        </MenuItem>
			
        <MenuItem>
            <button>Admissions</button>

            <ul>
                <li><a href="/#visit">Visit</a></li>
                <li><a href="/#photo-tour">Photo Tour</a></li>
                <li><a href="/#connect">Connect</a></li>
            </ul>
        </MenuItem>
			
        <MenuItem>
            <a href="/#academics">Academics</a>
        </MenuItem>
    </Menubar>
</nav>

For this, we're going to need to create a second compound component — the <Submenu />. It is composed of three functional components:

  • The Submenu will hold shared logic and component state
  • The Trigger will allow the user to expand the menu
  • The List will display the expanded menu items

The MenubarContext keeps track of menu items within the Menubar. In turn, let's create a SubmenuContext to keep track of menu items nested within a Submenu.

export const SubmenuContext = React.createContext(null);

Let's start by defining the Submenu component. It'll share some similar behaviors and functionality to the Menubar. Alongside the index tracking, it also needs to know if its menu has expanded. We could declare another state variable with useState(). Instead, it makes more sense to merge the logic into a reducer function.

The purpose of the Submenu 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 SubmenuContext.Provider.

const submenuInitialState = {
    currentIndex: null,
    previousIndex: null,
    isExpanded: false,
};

function submenuReducer(state, action) {
    switch (action.type) {
        case "expand":
            return { ...state, isExpanded: true };
        case "collapse":
            return submenuInitialState;
        case "move":
            return {
                ...state,
                isExpanded: true,
                currentIndex: action.index,
                previousIndex: state.currentIndex
            };
        default:
            throw new Error(`${action.type} not recognised`);
    }
}

const Submenu = ({ children }) => 
    const menuItems = React.useRef(new Set()).current;
    const [state, dispatch] = React.useReducer(submenuReducer, submenuInitialState);
    const value = React.useMemo(() => ({ menuItems }), [menuItems]);

    return (
        <SubmenuContext.Provider value={value}>
            {children}
        </SubmenuContext.Provider>
    );
};

Now, let's define the helper methods for navigating the submenu's menu items. These are almost identical to the Menubar helpers. The key difference is they dispatch reducer actions instead of updating the component state directly.

const open = React.useCallback(() => dispatch({ type: "expand" }), []);

const close = React.useCallback(() => dispatch({ type: "collapse" }), []);

const first = React.useCallback() => dispatch({ type: "move", index: 0 }), []);

const last = React.useCallback(() => (
    dispatch({ type: "move", index: menuItems.size - 1 }), [menuItems.size]
));

const move = React.useCallback((index) => dispatch({ type: "move", index }), []);

const value = React.useMemo(() => ({ open, close, first, last, move }),
    [open, close, first, last, move]
);

return (
    <SubmenuContext.Provider value={value}>
        {children}
    </SubmenuContext.Provider>
);

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 Submenu. Note that we store the menuId within a reference object. This is to prevent the uniqueId() function from regenerating the id on every render. Each subcomponent can now retrieve the values from the useContext() hook.

const id = React.useRef(_.uniqueId("submenu--")).current;
const buttonId = `button--${id}`;
const listId = `list--${id}`;

const buttonRef = React.useRef(null);
const listRef = React.useRef(null);

const value = React.useMemo(
    () => ({ buttonId, buttonRef, listId, listRef })
    [buttonId, buttonRef, listId, listRef]
);

Let's now manage focus within the Submenu. 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.

React.useEffect(() => {
    const items = Array.from(menuItems);

    if (currentIndex !== previousIndex) {
        const currentNode = items[currentIndex]?.firstChild;
        currentNode?.focus();
    }
}, [menuItems, currentIndex, previousIndex]);

Submenus do not follow the roving tab index pattern. Instead, the tab index of each menu item within a submenu will always be -1. This requires a small change to the MenuItem component. If a SubmenuContext exists, we can assume the MenuItem is inside a Submenu and apply -1 to its tab index.

const [isFirstChild, setIsFirstChild] = React.useState(false);
const submenuContext = React.useContext(SubmenuContext);

const childProps = {
    [ ... ],
    tabIndex: !submenuContext && isFirstChild ? "0" : "-1",
};

Trigger

With the Submenu defined, let's create the Trigger component. We'll start by retrieving the buttonId and buttonRef from the SubmenuContext. Since a button's default type is submit, it's usually a good idea to override it to button.

Finally, the Trigger should only ever be a child of the Submenu. Like before, let's throw an error if we use it outside of a SubmenuContext.

const Trigger = ({ onKeyDown, ...props }) => {
const context = React.useContext(SubmenuContext);

if (!context) {
    throw new Error("Trigger must be used within a Submenu Context");
}

const { buttonId, buttonRef } = context;

const buttonProps = {
    ...props,
    id: buttonId,
    ref: buttonRef,
    type: "button",  
}

return <button {...buttonProps} />;
};

Next, let's add the appropriate aria- attributes. aria-haspopup='true' will inform assistive technology that the button controls a submenu. To go one step further, we can also add the aria-controls attribute. This informs the screen reader of the exact submenu controlled by the Trigger.

Let's also retrieve the listId and the isExpanded state from the SubmenuContext. We'll assign the listId to aria-controls. Then, all that's left is to assign the isExpanded state to the aria-expanded attribute. Assistive technology is now aware of the menu button controls, and whether they are open or closed.

const { buttonId, buttonRef, listId, isExpanded } = submenuContext;

const buttonProps = {
    ...props,
    "aria-haspopup": true,
    "aria-expanded": isExpanded,
    "aria-controls": listId,
    "data-menubar-submenu-trigger": "",
    id: buttonId,
    ref: buttonRef,
    type: "button",
};

Now, let's add keyboard support to the Trigger. The Trigger will be a sibling of the Menubar menu items. That means it should perform the same keyDown events as the Menubar links. It also requires some additional functionality. Alongside the menu item behavior, the Trigger should:

  • ArrowUp: Open the submenu and focus the last item
  • ArrowDown: Opens the submenu and focus the first item
  • Space, Enter: Open the submenu and focus to the first item

To do this, we'll retrieve some methods from the SubmenuContext and assign them to the relevant e.code. Note that we only want to execute the e.stopPropagation() method on unique events.

Doing so allows all other events to bubble up to the MenuBar. This is what prevents us from having to duplicate the menu item's keydown events.

const { first, last } = submenuContext;

const keyDown = (e) => {
    switch (e.code) {
        case "ArrowUp":
            e.stopPropagation();
            last();
            break;
        case "ArrowDown":
            e.stopPropagation();
            first();
            break;
        case "Enter":
        case "Space":
            e.stopPropagation();
            first();
            break;
        default:
            break;
    }
};

const buttonProps = {
    [ ... ],
    onKeyDown: (e) => {
    onKeyDown?.(e);
        keyDown(e);
    },
};

Let's say a submenu is open when the user presses the ArrowLeftor ArrowRight key. The submenu should close and focus the previous or next Menubar menu item. If the root menu item is also a submenu, it should expand the menu but keep focus on the trigger.

The Trigger 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 keydown methods focus the trigger.

const buttonProps = {
    [ ... ],
    onFocus: (e) => {
        const isFromSubmenu = e.relatedTarget?.getAttribute(
            "data-menubar-submenu-menuitem"
        ) === "";
			
        if (isFromSubmenu) {
            open();
        }
    }
};

List

Now that we have a Trigger, all we need to do is create a submenu List. Like the Trigger, we'll throw an error if the List component is not used within a SubmenuContext.

Let's also define some attributes. First, we'll apply the role='menu' and retrieve the listId from the SubmenuContext. We'll retrieve isExpanded from the context and assign it to the aria-hidden 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 buttonId to the aria-labelledby attribute. Finally, we'll supply the menu's direction to assistive technology with the aria-orientation attribute.

const List = ({ children, ...props }) => {
    const submenuContext = React.useContext(SubmenuContext);

    if (!submenuContext ) {
        throw new Error("List must be used within a Submenu Context");
    }

    const { listId, listRef, isExpanded } = submenuContext;

    const listProps = {
        ...props,
        "aria-hidden": !isExpanded,
        "aria-labelledby": buttonId,
        "aria-orientation": "vertical",
        "data-menubar-submenu-list": "",
        id: listId,
        ref: listRef,
        role: "menu",
    };

    return (
        <ul {...listProps}>
            {children}
        </ul>
    );
};

Now let's add some keydown events specific to the List component. We'll retrieve the appropriate helpers from the SubmenuContext. Again, we only want to stop propagation on events that we do not want to bubble up to the Menubar's keydown event.

const { close, first, last, move } = submenuContext;

const keyDown = (e) => {
    switch (e.code) {
        case "ArrowUp":
            e.stopPropagation();
            e.preventDefault();
            previous();
            break;
        case "ArrowDown":
            e.stopPropagation();
            e.preventDefault();
            next();
            break;
        case "ArrowLeft":
            e.preventDefault();
            close();
            break;
        case "ArrowRight":
            e.preventDefault();
            close();
            break;
        case "Home":
            e.stopPropagation();
            e.preventDefault();
            first();
            break;
        case "End":
            e.stopPropagation();
            e.preventDefault();
            last();
            break;
        case "Enter":
        case "Space":
            close();
            break;
        case "Escape":
            e.stopPropagation();
            e.preventDefault();
            close();
            break;
        case "Tab":
            close();
            break;
        default:
            e.stopPropagation();
            match(e);
            break;
    }
};

const listProps = {
    [ ... ],
    onKeyDown: (e) => {
        e.preventDefault();
        keyDown(e);
    },
};

The MenuItem component will work within a Submenu for the most part. We'll need to make a couple of changes to ensure that both the Menubar and Submenu can make use of the component.

The first change is to ensure that the correct menuItems Set receives the menuItem node. We can assert that a submenu is an ancestor element if the MenuItem can retrieve a SubmenuContext. If it returns a false value, then the Menuitem must belong to the Menubar.

Let's update the error to check for the SubmenuContext. The error should only throw if both contexts do not exist. A MenuItem can now be a child of either a Menubar or a Submenu.

const menubarContext = React.useContext(MenubarContext);
const submenuContext = React.useContext(SubmenuContext);

if (!menubarContext && !submenuContext) {
    throw new Error(
        "MenuItem must be used within either a Menubar or Submenu Context"
    );
}

There is one final change that we need to make to the MenuItem component. Let's revisit the structure of the Submenu.

The MenuItem currently clones its children prop and appends extra props. In the example below, we can see that MenuItem's child is the Submenu component. The Submenu 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.

<Menubar aria-label="Menubar example">
    <MenuItem>
        <SubmenuContext.Provider {...menuItemProps}>
            <Trigger />
            <List />
        </SubmenuContext.Provider>
    </MenuItem> 
</Menubar>

Instead, we would like to append the MenuItem's childProps onto the submenu Trigger. To do so, the MenuItem component will need to check its children'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.

return (
    <li {...listItemProps}>
        { typeof children === "function"
            ? children(childProps)
            : React.cloneElement(children, childProps)
        }
    </li>
);

MenuItem.propTypes = {
    children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
}

That leaves us with this flexible React markup:

<nav aria-label="Mythical University">
    <Menubar aria-label="Mythical University">
        <MenuItem>
            <a href="/#about">About</a>
        </MenuItem> 
		
        <MenuItem>
            {(menuItemProps) => (
                <Submenu>
                    <Trigger {...menuItemProps}>
                        Admissions
                    </Trigger>
	
                    <List>
                        <MenuItem>
                            <a href="/#visit">Visit</a>
                        </MenuItem> 
											
                        <MenuItem>
                            <a href="/#photo-tour">Photo Tour</a>
                        </MenuItem> 

                        <MenuItem>
                            <a href="/#connect">Connect</a>
                        </MenuItem> 
                    </List>
                </Submenu>
            )}
        </MenuItem> 
		
        <MenuItem>
            <a href="/#academics">Academics</a>
        </MenuItem> 
    </Menubar>
</nav>

...which compiles into this beautiful, accessible HTML:

<nav aria-label="Mythical University">
    <ul
        aria-label="Mythical University"
        aria-orientation="horizontal"
        data-menubar-list
        role="menubar"
    >
        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#about" role="menuitem" tabindex="0">
                About
            </a>
        </li>
			
        <li data-menubar-listitem role="none">
            <button
                aria-controls="list--submenu--1"
                aria-expanded="false"
                aria-haspopup="true"
                data-menubar-menuitem
                data-menubar-submenu-trigger
                id="button--submenu--1"
                role="menuitem"
                tabindex="-1"
                type="button"
            >
                Admissions
            </button>
			
            <ul
                aria-hidden="true"
                aria-labelledby="button--submenu--1"
                aria-orientation="vertical"
                data-menubar-submenu-list
                id="list--submenu--1"
                role="menu"
            >
                <li data-menubar-submenu-listitem role="none">
                    <a
                        data-menubar-submenu-menuitem
                        href="/#visit"
                        role="menuitem"
                        tabindex="-1" 
                    >
                        Visit
                    </a>
                </li>

                <li data-menubar-submenu-listitem role="none">
                    <a
                        data-menubar-submenu-menuitem
                        href="/#photo-tour"
                        role="menuitem"
                        tabindex="-1" 
                    >
                        Photo Tour
                    </a>
                </li>

                <li data-menubar-submenu-listitem role="none">
                    <a
                        data-menubar-submenu-menuitem
                        href="/#connect"
                        role="menuitem"
                        tabindex="-1" 
                    >
                        Connect
                    </a>
                </li>
            </ul>
        </li>

        <li data-menubar-listitem role="none">
            <a data-menubar-menuitem href="/#academics" role="menuitem" tabindex="-1">
                Academics
            </a>
        </li>
    </ul>
</nav>

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.