Improved accessible routing in Vue.js (updated)

Caution! This article was published over a year ago, and hasn't been updated since. Situation, software and support of the topic below could have changed in the meantime.

I've written about accessible routing in Vue.js before and tried to sketch an accessible strategy for "page" changes in this particular framework – but frankly, the advice I was giving in said article originated from what I learned from what I read and found looking into other frameworks. It was explicitly not based on user research. Luckily, Marcy Sutton who works for Gatsby.js (and has worked in the past for Deque) did just that in 2019. Marcy and accessibility research company Fable Tech Labs joined forces and conducted a series of tests with people with different disabilities in last summer, eager to learn about their barriers and strategies when confronted with navigation in client-side rendered applications. Additionally she has written an extensive blog post about the process: "What we learned from user testing of accessible client-side routing techniques with Fable Tech Labs". But this project was not set up to be finished after the publication of the blog post. Rather it was a beginning of a discussion, of gathering further feedback and insight on the topic, and this would be reflected in the blog post itself:

The exact implementation(s) we integrate into Gatsby will likely evolve as we try things out and iterate on improvements.

In January 2020 Marcy updated her report on the research and refined their recommendation, linking to an implementation in Gatsby.

Now, this article here aims to show how to implement the improved approach in Vue.js, using vue-router. It has two parts: In the first one I'll try to explain Marcy's research and the recommended approach in which it resulted. The second part is a description of how to build it in Vue.js, what you need to know before building, which challenges wait along the way and how to workaround current accessibility shortcomings of vue-router.

(If you don't want to read the whole article, and skip to the code, I set up a codesandbox instance with this approach, and more recommended accessibility improvements for routing in Vue.js.)

Evolving best practices

The problem

Routing in single page application is a problem especially for screen reader users. While clicking on a link on a server-side rendered page leads to a new page (assuming there is no interception with JavaScript) and the screen reader eventually starting to read out the new page, beginning with its title, the situation for SPAs is different (all following quotes are from Marcy's article):

In client-rendered JavaScript applications [...] traditional HTML page reloads don’t typically occur when a user navigates through the app. Instead, client-side JavaScript handles routing through the app by controlling the browser’s history and mapping client-rendered URLs to each page or view.

The sentence that follows encapsulates the intention of the research altogether:

To ensure these experiences are accessible, developers have to recreate some of the missing browser feedback for users by manually managing focus and making announcements in assistive technology (AT)

The research

How said emulation of browser feedback could look like exactly was subject of the trials. Marcy Sutton built five different prototypes, each including a different approach. Together with Fable she gathered user research from the following user groups:

  • Two screen reader users
  • One user employing screen magnification on a mobile and on a desktop device
  • One member of the test group using Dragon Naturally Speaking (voice control technology)
  • One user interacting with the prototypes with a switch access

The recommendation

A routing strategy had to be found that would work for all of these users, and not cause problems in one form of assistive technology. Where past recommendations were "use ARIA live regions" or "focus a headline" were not ideal (or a barrier) for one user group or the other (live regions are only perceivable for screen reader users, and focusing headlines could lead to irritations in magnified screens), as of now the following state of advice is:

  1. Regardless of accessible routing, it's a good idea to use skip links
  2. Once a user has clicked on a route link, send focus to the outermost application container (in frameworks like React, Angular and Vue that is usually the root element, the starting point of your app). Make sure this container is programmatically focusable with tabindex="-1"
  3. Make sure that the next focusable item after the container is a skip link
  4. Additionally, work with ARIA live regions to make information about the successful route transition and the new page explicit, eg. Navigated to About page.

But keep in mind:

These recommendations are an attempt at weaving multiple perspectives into one usable pattern, with the historical knowledge of where teams run into conflicts over accessibility in design (e.g. turning off visible focus outlines on container elements).

If you are interested in an implementation of this approach in React/Gatsby, look into the sourcecode of a workshop demo app that Marcy built. And if you are interested in more details of its research, progress and intermediate steps, here is once again a link to the research article itself.

Implementing this in Vue.js

Now to the second part, which is the implementation in Vue.js. While React and especially Gatsby have the amazing Reach Router component (which will soon be part of React Router), taking care of the container focus, there is no comparable ready-made component in Vue.js available (yet!), so we have to build this part of the feature ourselves.

Prerequisites

It doesn't hurt to be familiar with the following concepts or components

  • If you have a Mac, and you don't have visual focus indicators enabled (annoyingly, they are disabled by default), follow these instructions.
  • vue-router and a strategy for watching the route change (watching $route).
  • The overall concept of skip links, how to hide them, and how to show them once they are focused.
  • ARIA live regions. For ease of use, I recommend to add the vue-announcer library to your project. Regardless of live region announcements that are related to route changes they are a great tool to communicate changes in state or newly loaded content. So in a perfect world they are already part of your app.

Skip links in vue-router

Skip links are basically internal anchors to jump to different sections within a document. What makes them "bypass links" is that they are only visually perceivable when they are focused.

Unfortunately, just dropping something like <a href="#main">Go to main content</a> into your Vue app template won't work if you are using vue-router (and I assume you do). Vue Router seem to consider the fragment #main as a whole new route (as far as I understand. Here's the corresponding GitHub Issue). In order to make the "classic jump" behaviour work, we have to add two things to our Vue instance, or "outermost" component (often App.vue):

At first we have to add a new method. The function scrollFix accepts the hashbang (in this case, #main) as a parameter:

methods: {
    scrollFix: function(hashbang) {
      window.location.hash = hashbang;
    },
    ...
}

Secondly, add the call of scrollFix in App.vue's mounted method, but with a small delay:

setTimeout(() => this.scrollFix(this.$route.hash), 1);

Now you can add a skip link like this (not using <a> but <router-link>):

<router-link to="#main" class="skiplink"
    @click.native="scrollFix('#main')">
    Skip to content
</router-link>

Thanks you, Steven B. from StackOverflow, for finding and documenting this workaround!

Finally, add usual skip link styling (as in: make the link visually hidden, but un-hide it when receiving focus):

.skiplink {
  position: absolute;
  top: 5px;
  border: 0 none;
  clip: rect(0, 0, 0, 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  white-space: nowrap;
  width: 1px;
}

.skiplink:focus {
  clip: auto;
  height: auto;
  position: fixed;
  width: auto;
}

Focus management

(Update: Marcy recommendation is to put the focus on the skip link, not on the route wrapper. See article update below. Still, I leave my original text unchanged, for clarity)

Next stop: Setting tabindex="-1" on the outermost wrapper element, so it can be focused after route transition. In the template context, it can look like this:

<template>
    <div id="app" tabindex="-1">
        <router-link to="#main" class="skiplink"
            @click.native="scrollFix('#main')">
            Skip to content
        </router-link>
        <header>...</header>
        <nav>...</nav>
        <main id="main">Content</header>
        <footer></footer>
    </div>
</template>

For that, let's establish another function in method that gets called on every route transition (we are still in the App.vue file, so it's this.$el or #app that is focusable, see above):

setRouteWrapperFocus() {
    this.$el.focus();
},

Now we have to set up a watcher for route changes like in the following code block. I put setRouteWrapperFocus in its own method to keep the watcher tidy and readable, because we'll be adding more things to it:

watch: {
    $route: function() {
      // 🚨 $nextTick = DOM updated, route successfully transitioned
      this.$nextTick(function() {
        this.setRouteWrapperFocus();
      });
    }
  },

As a result of all this, after a route change, focus should now be set to #app. When you hit the tab key once again, you should be focusing the skip link, giving you the option to jump to you main content (or where ever your skip link leads to).

Live region

Fable Tech Labs and Marcy Sutton's research not only resulted in a focus management strategy but also in a piece of advice to use live regions in order to announce the newly loaded content after a route transition. If you have never heard of live regions before, here's MDN's explanation, and here's a text covering their importance of accessible web apps.

In order to use them in Vue.js I highly recommend the vue-announcer package. You can install it like this:

$ npm install -D vue-announcer

Once installed, import it and use it as a plugin:

import VueAnnouncer from "vue-announcer";

Vue.use(VueAnnouncer);

From now on we can use this.$announcer anywhere in the app. And we're going to do just that. Let's establish a new method:

setRouteAnnouncement(pagetitle) {
  this.$announcer.set(`Navigated to ${pagetitle}`);
},

But as you see, we need the page title for that to function. If you have set up the title in the particular routes' meta data, we can get it from there. So, at this point the route watcher of App.vue looks like this:

watch: {
    $route: function(to) {
      this.$nextTick(function() {
        // 🚨 note the "to" parameter was added!
        const title = to.meta.title; // New line
        this.setRouteAnnouncement(title); // New line
        this.setRouteWrapperFocus();
      });
    }
  },

If you now use your app with a screen reader you will notice that the focus will be managed and that the new page is announced (You can also visit this demo).

Other things not to miss

But don't stop here – in regards to accessible routing there can be done more. At first, make sure you adjust the document title after successful route transition (read Hidde's article about why this is important). Fortunately, we still got the route's title "in our hands" from the announcement, so it is easy to set the document title:

watch: {
    $route: function(to) {
      this.$nextTick(function() {
        const title = to.meta.title;
        document.title = to.meta.title; // New line
        this.setRouteAnnouncement(title);
        this.setRouteWrapperFocus();
      });
    }
  },

The other issue left is a programmatic indication on your route links, showing to assistive technology which on is currently active. Of course, vue-router has CSS classes to indicate (and style) the current link, but, alas, this is worthless for screen readers. Fortunately there is aria-current (read a primer from Leonie Watson on it). Unfortunately, it is not implemented in vue-router (yet), so we have to come up with a workaround:

setAriaCurrent() {
  this.$nextTick(function() {
    // Find existing 'aria-current's
    const oldCurrents = this.$el.querySelectorAll("[aria-current]");

    // Find the class marker that indicates the current route
    const newCurrents = this.$el.querySelectorAll(
      ".router-link-exact-active"
    );

    oldCurrents &&
      oldCurrents.forEach(current => {
        current.removeAttribute("aria-current");
      });

    newCurrents &&
      newCurrents.forEach(current => {
        current.setAttribute("aria-current", "page");
      });
  });
}

As you might assume, the function for setAriaCurrent has to be called on every route transition, making the "final" watcher look like this:

  watch: {
    $route: function(to) {
      this.$nextTick(function() {
        const title = to.meta.title;
        document.title = to.meta.title;
        this.setRouteAnnouncement(title);
        this.setRouteWrapperFocus();
        this.setAriaCurrent();
      });
    }
  },

Now, that was quite some work, wasn't it? But, according to the latest research, it was a huge step forward in overall (not just screen reader related) accessibility. If you want to try out this code, visit the Vue demo app for this approach and/or look into its code on codesandbox.

In conclusion – thank you Marcy and Fable Tech Labs for your valuable research (and for sharing what you've learned in a Gatsby instance on GitHub! Now it's time for the communities and creators of other frameworks to implement your findings in their respective tools, tutorials and codebase, and I hope I have helped to push the needle a little bit in regards for Vue.js.

P.S.: The related pattern on accessible-app.com will be updated soon!

Update January, 30th 2020

I got the research results wrong in one aspect, because I based my Vue implementation on the workshop code: Marcy pointed out that the recommendation wasn't to put focus on the outermost container after transition, but on the skip link. Focusing the container was meant to be a fallback if there is no skip link present. You can find these tweets below as webmentions for this article.

Forking the original Codesandbox to build a correct Vue implementation of the recommendation: See its source code, see its demo rendering.

That got me thinking whether this isn't irritating for visual users, because they see the skip link after every route transition. Marcy rightfully pointed out that the research was, paraphrazing here, about finding a compromise recommendation and that not every usergroup can be pleased. Further that just focusing the container is rather bad for low vision users with your app in a zoomed-in state. To make things worse, thedamon on GitHub reported:

Focusing the app wrapper may cause some issues. In chrome if you throw focus to an element that's larger than the screen, it will just scroll the bottom into view (And this behaviour seems different in firefox, safari and IE..

That leaves me with a question mark over my head how to move on. Although Marcy also pointed out that a :focus-visible-like detection could remedy some issues (in short: using the browser heuristics determine whether the visitor used a mouse, keyboard or keyboard-like input device), after thinking about it a little longer, that idea still could fall short for low vision users, since they are possibly using pointer devices and still are in need of a present skip link after route change. If you, dear reader, have any idea or input, please reach out!

Update February, 24th 2020

Boris Ponomarenko was so kind to point out the aria-current situation could be solved differently. Currently, I'm manipulating the DOM directly which feels neither elegant nor "Vue-y", but was a more or less desperate dirty hack.

It turned out, since version 3.1.0 vue-router has v-slot support. To quote its documentation:

router-link exposes a low level customization through a scoped slot. This is a more advanced API that primarily targets library authors but can come in handy for developers as well, most of the time in a custom component like a NavLink or other.

As for my routing demo app, I chose not to establish another component but instead customize the <router-link> instances directly. This leads to the following verbose - but more elegant code:

<ul class="nav">
    <li>
      <router-link v-slot="{ href, isExactActive, navigate }" to="/">
        <a
           :href="href"
           :aria-current="isExactActive ? 'page' : false"
           @click="navigate">Home</a>
      </router-link>
    </li>
    <li>
      <router-link v-slot="{ href, isExactActive, navigate }" to="/cat">
        <a
          :href="href"
          :aria-current="isExactActive ? 'page' : false"
          @click="navigate">Cat Ipsum</a>
      </router-link>
    </li>
    <!-- and the other ones -->
</ul>

After that modification we can get rid of the direct DOM manipulation, which is wrapped in the setAriaCurrent method. Here's another codesandbox (hopefully the final one for this article) where I implemented all of the above.