Hey hey! It's me again. The guy that rambles like he knows what he's talking about but is really flying by the seat of his pants!
Today we're going to be building an accessible menu system in Nuxt using recursion! You'll be able to use this pattern in a variety of ways: navigation drop-downs, nested sidebar navigations, and plenty of others.
We'll be building it in the context of a sidebar navigation. Think "multiple sub-level navigation menus" akin to what you would expect to see in the sidebar of a documentation site.
Those can get nested and messy very quickly, but we're going to build two components to handle the whole thing!
Note: The accessible menu portion of this article is based on this article by none other than Heydon Pickering
View the repo here
Let's jump right in!
Setting up your project
We're going to be working in Nuxt, so let's get started with spinning up a new project.
I'm actually working from an existing Nuxt project, but here's the command you'll run.
npx create-nuxt-app ally-tuts
It's going to ask you some questions for initial project setup. My answers are below, but choose as you wish.
- Programming Language: Javascript
- Package Manager: Npm
- UI Framework: None (I know, crazy. Right?)
- Nuxt.js Modules: Axios
- Linting Tools: ESLint
- Testing Framework: None
- Rendering Mode: Universal (SSR / SSG)
- Deployment Target: Static (Static/Jamstack hosting)
- Development Tools: jsconfig.json
Now that we have that complete, let's set up a simple scaffold for our app.
A Quick HTML Scaffold
First thing is to delete the Tutorial.vue and NuxtLogo.vue files in the components/ folder. Next, we'll add a SidebarNav.vue
in our components folder.
From there, we'll create a layouts folder in the root of our project and add a default.vue component. In that file, we're going to import our SidebarNav
component and put it in the template.
Generally, this is where you would set up your header and footer-- and any other global layout level stuff-- but that's out of scope for us so we'll keep it nice and simple.
<!-- ~/layouts/default.vue -->
<template>
<main>
<SidebarNav />
<nuxt />
</main>
</template>
One cool thing to note here, is that we don't have to import our SidebarNav component! Nuxt just makes it available.
And with that, we can move forward!
Building the Top Level
Again, we're building this in the context of a sidebar navigation. With that in mind, our next step is to create SidebarNav.vue
in our components/ folder.
Within that, we'll make our root element an nav
and we'll go ahead and give it an id
of Sidebar Navigation
- which we'll be using later. And then we want to create a ul
element inside of our nav, and that will ultimately be where our different menu options render!
<!-- ~/components/SidebarNav.vue -->
<template>
<nav id="Sidebar Navigation">
<ul>
</ul>
</nav>
</template>
Your markup should look like this.
From there, we're going to move into our script
tag in our SidebarNav.vue
-- and what we're doing here is just dropping in some static data that we'll use to pass to our components that will then build out our navigation menu.
Copy & paste the code below in your SidebarNav.vue
// ~/components/SidebarNav.vue
<script>
export default {
data() {
return {
navigation: [
{
title: "Menu 1",
link: "/",
},
{
title: "Menu 2",
submenus: [
{
title: "Submenu 1",
link: "/",
},
{
title: "Submenu 2",
link: "/",
},
{
title: "Submenu 3",
submenus: [
{
title: "Subsubmenu 1",
link: "/",
},
{
title: "Subsubmenu 2",
link: "/",
},
],
},
],
},
],
};
}
};
</script>
Next, we're going to place a component (that doesn't exist yet, we'll build that next) inside of the ul
, let's call it BaseMenu
.
What we'll do here is v-for
over the items in the navigation
data we just created and we're going to pass each item it loops over into BaseMenu
as a prop.
We're also going to pass in a prop of depth
and we'll set it at zero for this base level. Now, we're not actually going to do anything with the depth
prop- but I've found it makes it tremendously easier to track which component is at which level once you get into the recursion side of things.
It's been super helpful in debugging too. You know there's an issue somewhere you see something with a depth of 1 or higher at your root level.
So, let's add our BaseMenu
in.
// ~/components/SidebarNav.vue
<template>
<nav id="Sidebar Navigation">
<ul>
<BaseMenu
v-for="(menu, index) in navigation"
:menu="menu"
:depth="0"
:key="index"
/>
</ul>
</nav>
</template>
Building the First Recursive Level
The piece we're building next is going to be two things.
First, it's going to be the li
within our ul
that we just built in our SidebarNav.vue
. And secondly, it's going to be the layer that determines whether to render another recursive menu system or just spit out a link.
So, lets' create a BaseMenu.vue
component in our components folder, and lets scaffold out our vue file with the root element being an li
.
Let's also declare the props we know this component will be expecting, based on the work we just did in the SidebarNav
.
We know there are two props coming in, menu
and depth
. menu
is a type of object and we want it to be required. depth
is a number, and we want it to be required as well.
// ~/components/BaseMenu.vue
<template>
<li>
</li>
</template>
<script>
export default {
props: {
menu: {
type: Object,
required: true,
},
depth: {
type: Number,
required: true,
},
},
};
</script>
Let's take a step back for a second and look at what we need this to do next.
We know part two of this is that it needs to decide whether to render another menu system or a link. Knowing that, we know we can use a v-if
.
If we take a look at the data we added in our SidebarNav
component, you can see that there is only ever a submenus
array or a link
- which is a just a string- but there is never both a single menu
object.
We can use that to determine which element to render. If there is a submenus array = give us another menu level, if not = give us a link
.
That could look something like this.
<!-- ~/components/BaseMenu.vue -->
<template>
<li>
<template v-if="menu.submenus">
</template>
<nuxt-link v-else>
</nuxt-link>
</li>
</template>
Looking back at our data again, we can see that if a menu object is a link, then it has two keys: title, and link.
Let's use that to finish building out the link part of our BaseMenu
<!-- ~/components/BaseMenu.vue -->
<template>
<li>
<template v-if="menu.submenus">
</template>
<nuxt-link
v-else
:to="menu.link"
:id="menu.title.toLowerCase().replace(' ', '-')"
>
{{ menu.title }
</nuxt-link>
</li>
</template>
You'll notice I did a little javascript-ing on the ID, it's just lowercasing and replacing spaces with hyphens- this step is completely optional. It's just the pattern I prefer for id's.
Now all that's left is to add a bit that will soon become our actual submenu that get's rendered when necessary.
Let's add a component BaseMenuItem
in our v-if
statement, and we'll pass it the same props that our BaseMenu
component uses- which will be menu (and that's an object) and depth (which is a number).
Your BaseMenu
component should be looking something like this.
// ~/components/BaseMenu.vue
<template>
<li>
<template v-if="menu.submenus">
<BaseMenuItem
:menu="menu"
:depth="depth + 1"
/>
</template>
<nuxt-link
v-else
:id="menu.title.toLowerCase().replace(' ', '-')"
:to="menu.link"
>
{{ menu.title }}
</nuxt-link>
</li>
</template>
<script>
export default {
props: {
menu: {
type: Object,
required: true,
},
depth: {
type: Number,
required: true,
},
},
};
</script>
Now we're ready to build out the BaseMenuItem
component we just added to project.
Building the accessible menu
This is the part of the project that was built based on this tutorial by Heydon Pickering for Smashing Magazine. The write-up originally appeared in his book "Inclusive Components".
Let's outline some things this component needs before we jump into the code.
The Basics
- We need a
button
to show/hide a menu's submenu (we know this because we're building a nested menu system) - We need a
ul
that shows/hides when it's parent button is clicked. - We need a method (or function) to handle the click of parent button
Accessibility needs
Again, if you want a detailed breakdown of everything a11y about this system, I highly suggest reading through Heydon's write-up
- We need the
aria-haspopup
attribute on our parent button. This allows assistive technologies to inform the user that clicking this button will reveal more content. - We need the
aria-expanded
attribute on our parent button. This allows assistive technologies to inform the user whether or not the menu is currently open. - We need the
aria-controls
attribute on our parent button. The intention ofaria-controls
is to help screen reader users navigate from a controlling element to a controlled element. It's only available in JAWS screen readers, but some users may expect it. - Pressing the
esc
key should close the currently focused menu - Opening a menu should focus the first element within it.
This may read as if it's a lot, but it really isn't that much work.
The structure
We can start by laying out the basic structure of our component, and we'll incrementally add functionality and accessibility as we go.
So, we'll start with a basic Vue component that has a button
and a ul
in it. We can also declare the props we know are going to be passed in here- remember that's going to be menu and number, same as our previous component.
We'll also want to set the key of isOpen
in our data
, so we'll have a something to toggle with out button click and we can also use that value to determine when to show our submenu.
At this point, we can deduce that the text in our button will be the title of the menu that's passed into it. Knowing that, we can go ahead and set that up as well.
// ~/components/BaseMenuItem.vue
<template>
<div>
<button>
{{ menu.title }}
</button>
<ul>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
isOpen: false,
};
},
props: {
depth: {
type: Number,
required: true,
},
menu: {
type: Object,
required: true,
},
},
};
</script>
Next we can get started making this menu do stuff. Let's add a click event to our button that calls a toggleMenu
function.
// ~/components/BaseMenuItem.vue
...
<button @click.prevent="toggleMenu">
{{ menu.title }}
</buttton>
...
And in our methods
, we'll create out toggleMenu
function. All it's going to do for now is toggle or isOpen
key to it's opposite value
// ~/components/BaseMenuItem.vue
...
<script>
export default {
...
methods: {
toggleMenu() {
this.isOpen = !this.isOpen;
}
}
}
</script>
...
Now that that is in place, we can add a v-show
to our ul
and dynamically render it based on the button click.
Another thing we'll do is create a computed property that is just going to sanitize an ID we can use for the parent button and submenus.
Drop the text hello
into your ul
and fire the app up with yarn dev
or npm run dev
and you should find two parent items, one of which is a button that reveals hello
when you click it!
So far it's working!
// ~/components/BaseMenuItem.vue
<template>
<div>
<button
:id="menuId"
@click.prevent="toggleMenu(menu)"
>
{{ menu.title }}
</button>
<ul
v-show="isOpen"
:id="submenuId"
>
Hello
</ul>
</div>
</template>
<script>
export default {
data() {
return {
isOpen: false,
}
},
computed: {
menuId() {
return this.menu.title.toLowerCase().replace(' ', '-')
},
submenuId() {
return `${this.menu.title.toLowerCase().replace(' ', '-')}-submenu`
}
},
methods: {
toggleMenu() {
this.isOpen = !this.isOpen
}
}
}
</script>
Your BaseMenuItem
component should be looking like this right now.
Adding Aria Attributes
Revisiting our list from above, there's a few aria attributes we want to add to progressively enhance the experience for our assisted users.
- We need the
aria-haspopup
attribute on our parent button. This allows assistive technologies to inform the user that clicking this button will reveal more content. - We need the
aria-expanded
attribute on our parent button. This allows assistive technologies to inform the user whether or not the menu is currently open. - We need the
aria-controls
attribute on our parent button. The intention ofaria-controls
is to help screen reader users navigate from a controlling element to a controlled element. It's only available in JAWS screen readers, but some users may expect it.
On our button, let's add the aria-haspopup="true"
attribute, and we'll also add :aria-expanded="isOpen.toString()"
as well.
We're adding aria-expanded
as a dynamic attribute and we're setting it to the value of our isOpen
data point and converting it to a string. We're doing this because the attribute would be removed altogether when isOpen
was false, and that's not what we want.
The last aria attribute we'll add to our button is :aria-controls="submenuId"
. This is so any screen readers will know which menu this button controls.
// ~/components/BaseMenuItem.vue
...
<button
:id="menuId"
@click.prevent="toggleMenu(menu)"
aria-haspopup="true"
:aria-expanded="isOpen.toString()"
:aria-controls="submenuId"
>
{{ menu.title }}
</button>
...
Extending Accessibility
There's two more things we need to add to our menu item for it to be complete.
- Pressing the
esc
key should close the currently focused menu - Opening a menu should focus the first element within it.
There's three steps to being able to close the currently focused menu. We need to (1) write a closeMenu
method, (2) add a key listener to our ul
that holds the menu, and (3) and a ref to our button.
So, let's add ref="menuButtonRef"
to our button, and then let's create a closeMenu
method that's going to set this.isOpen = false
and we'll also focus our new button ref with this.$refs.menuButtonRef.focus()
.
Lastly, let's add a key listener to our ul
with @keydown.esc.stop="closeMenu"
.
And that should have your currently focused menu closing! If you want to see something fun, remove the .stop
and close a menu š.
// ~/components/BaseMenuItem.vue
<template>
<div>
<button
:id="menuId"
ref="menuButtonRef"
@click.prevent="toggleMenu(menu)"
aria-haspopup="true"
:aria-expanded="isOpen.toString()"
:aria-controls="submenuId"
>
{{ menu.title }}
</button>
<ul
v-show="isOpen"
:id="submenuId"
@keydown.esc.stop="closeMenu"
>
Hello
</ul>
</div>
</template>
<script>
export default {
data() {
return {
isOpen: false,
}
},
computed: {
menuId() {
return this.menu.title.toLowerCase().replace(' ', '-')
},
submenuId() {
return `${this.menu.title.toLowerCase().replace(' ', '-')}-submenu`
}
},
methods: {
toggleMenu() {
this.isOpen = !this.isOpen
},
closeMenu() {
this.isOpen = false
this.$refs.menuButtonRef?.focus()
}
}
</script>
If it's not working, it may be because we haven't focused any menus when we're opening them. Let's do that now!
Focusing first elements
By default, an accessible menu should focus the first element within it once it's opened.
To do this, we'll need to query for all clickable items within a menu from its ID and then focus the first one.
So, in our toggleMenu
method we want to write an if
statement to check if isOpen
is true or not. If it is, then that's where we want to focus our first item.
One additional step we need to do, is utilize Vue's nextTick- which will allow us to ensure that we're checking the value of isOpen
after it's been updated.
Inside of our nextTick
we'll get our submenu by its ID with const subMenu = document.getElementById(this.submenuId);
and then narrow that down to the first one with const firstItem = subMenu.querySelector("a, button");
.
After that, we just call firstItem?.focus()
and now our menu will auto focus its first item when opened!
// ~/components/BaseMenuItem.vue
...
methods: {
toggleMenu() {
this.isOpen = !this.isOpen
if(this.isOpen) {
this.$nextTick(() => {
const submenu = document.getElementById(this.submenuId)
const firstItem = submenu.querySelector("a, button")
firstItem?.focus()
})
}
}
...
We also want to focus the initial trigger for the menu when it's closed. So we'll write a second if
statement checking for !this.isOpen
and add the same this.$refs.menuButtonRef
that our closeMenu
method has
// ~/components/BaseMenuItem.vue
...
methods: {
toggleMenu() {
this.isOpen = !this.isOpen
if(this.isOpen) {
this.$nextTick(() => {
const submenu = document.getElementById(this.submenuId)
const firstItem = submenu.querySelector("a, button")
firstItem?.focus()
})
}
if(!this.isOpen) {
this.$nextTick(() => {
this.$refs.menuButtonRef?.focus()
})
}
},
}
...
Our menu is fully functioning now!! We're not done just yet, but all our base functionality is now in place!
We're officially done with our BaseMenuItem.vue
component.
Arrow Key Navigation
The last step here is to allow users, assisted and non-assisted, to navigate up and down the menu tree with the arrow keys.
A lot of what we need is already in place, so all we're doing is writing a key event listener on the top level of our menu.
So, jumping back to our SidebarNav.vue
component, let's add a @keydown="handleKeyPress"
to our nav
element.
// ~/components/SidebarNav.vue
...
<nav id="Sidebar Navigation" @keydown="handleKeyPress">
<ul>
<BaseMenu
v-for="(menu, index) in navigation"
:menu="menu"
:key="index"
:depth="0"
/>
</ul>
</nav>
...
Next, we'll write our handleKeyPress
method.
Inside this method, we'll need to do a few things.
- Get our nav element by ID
const navEl = document.getElementById("Sidebar Navigation");
- Get all focusable elements in our nav
const focusableElements = navEl.querySelectorAll(["a", "button"]);
- Convert the returned nodelist to an array
const focusableElementsArr = Array.from(focusableElements);
- Get the active element on the page
const activeEl = document.activeElement;
- Find the index of our active element
const activeElIndex = focusableElementsArr.findIndex( (f) => f.id === activeEl.id );
- Find the last index of our focusable elements
const lastIdx = focusableElementsArr.length - 1;
// ~/components/SidebarNav.vue
methods: {
handleKeyPress(e) {
const navEl = document.getElementById("Sidebar Navigation");
const focusableElements = navEl.querySelectorAll(["a", "button"]);
const focusableElementsArr = Array.from(focusableElements);
const activeEl = document.activeElement;
const activeElIndex = focusableElementsArr.findIndex(
(f) => f.id === activeEl.id
);
const lastIdx = focusableElementsArr.length - 1;
},
},
Next, we'll write two if
statements. One for ArrowUp
and one for ArrowDown
. If our user is on the first element and presses the up key, our first element will retain focus- but if they hit the down key, it will move them down one element.
And the inverse will happen for the last element.
// ~/components/SidebarNav.vue
methods: {
handleKeyPress(e) {
const navEl = document.getElementById("Sidebar Navigation");
const focusableElements = navEl.querySelectorAll(["a", "button"]);
const focusableElementsArr = Array.from(focusableElements);
const activeEl = document.activeElement;
const activeElIndex = focusableElementsArr.findIndex(
(f) => f.id === activeEl.id
);
const lastIdx = focusableElementsArr.length - 1;
if (e.key === "ArrowUp") {
activeElIndex <= 0
? focusableElementsArr[0].focus()
: focusableElementsArr[activeElIndex - 1].focus();
}
if (e.key === "ArrowDown") {
activeElIndex >= lastIdx
? focusableElementsArr[lastIdx].focus()
: focusableElementsArr[activeElIndex + 1].focus();
}
},
},
Now jump over to your browser, open up some menus, and arrow key up and down!
Summary
This walkthrough was bit long-winded, but- as you saw- there are a lot of moving parts to consider when building a system like this.
The good news? The system will work for an indefinite level of menus, provided the design and screen real-estate allow for it. The only limits aren't tied to the recursive system itself.
Another thing to note, the accessibility of it all wasn't difficult or complex. It took very little to take this from a "menu system" to an "accessible menu system", and a lot of base accessibility features are equally as simple to get in place.
Accessibility isn't an enhancement that should be place in the backlog. It's a core fundamental that should be accounted for in scoping, planning, and implementation.
Thank you for making it this far! These a11y write-ups have been huge learning experiences for me and I hope to bring more in 2022.
Disclaimer: This was built with happy path data structures. You may have to write some additional code to get your data structured how you want it. In learning this system, I had to write yet another recursive function that would scaffold a flat chunk of data into the nested levels needed.
Top comments (0)