DEV Community

Cover image for Building an Accessible Recursive Menu System
Drew Clements
Drew Clements

Posted on

Building an Accessible Recursive Menu System

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 of aria-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>
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 of aria-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>
...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
    })
  }
}
...
Enter fullscreen mode Exit fullscreen mode

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()
      })
    }
  },
}
...
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

Next, we'll write our handleKeyPress method.

Inside this method, we'll need to do a few things.

  1. Get our nav element by ID const navEl = document.getElementById("Sidebar Navigation");
  2. Get all focusable elements in our nav const focusableElements = navEl.querySelectorAll(["a", "button"]);
  3. Convert the returned nodelist to an array const focusableElementsArr = Array.from(focusableElements);
  4. Get the active element on the page const activeEl = document.activeElement;
  5. Find the index of our active element const activeElIndex = focusableElementsArr.findIndex( (f) => f.id === activeEl.id );
  6. 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;
  },
},
Enter fullscreen mode Exit fullscreen mode

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();
    }
  },
},
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)