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 itemArrowDown
: Opens the submenu and focus the first itemSpace
,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 ArrowLeft
or 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.