If you select one of these options, it will be stored in your web browser and automatically applied to other pages on this website.

Transcript: How I broke my skip links, then made them (hopefully) bulletproof

This is the text transcript of the video How I broke my skip links, then made them (hopefully) bulletproof.

Go to the video page to watch the video, or read the transcript that follows. Transcripts are made verbaitim, with relevant code snippets which were visible included, and tiny corrections may be made for readability.

Transcript

My website has skip links. Skip links are a useful tool for anyone who uses their computer mostly using the keyboard. They're a good way to meet WCAG criterion “bypass blocks”, which is number 2.4.1. This criterion requires us to provide some mechanism for skipping blocks of content that appear on many pages.

The simplest example of such a block is the page header and the associated navigation elements. But these can also be useful for things like long lists of filters for search results or inline navigation, like a big list of subcategories.

The idea is simple. We provide a link that a keyboard user can reach that transports the user within the page. Instead of the user having to press tab dozens of times every time they open a new page, they follow one link and get straight to the content in one jump.

This mechanism screen reader users quite well too, while a sighted user's eyes can just jump over the header or whatever the repeated elements are straight to the thing that they're interested in. Screen reader users are experiencing narrowly one element at a time. They have other options for navigating the page, of course, but a skip link can be a very helpful one.

The vast majority of skip links on the web are hidden until you focus on them. This means if you use the mouse, you might never see them. This makes sense since they're not especially useful if you use the mouse.

It's not compulsory to hide them, but it's such a common pattern that I believe it's what a lot of users who do like using skip links have come to expect.

Now onto today's story. During a minor redesign, I removed the skip links from my website and a copy and paste error. Since the website is a hobby, a personal project, I didn't notice what I'd done since. I mostly use the mouse. There were no skip links on the site for a couple of months before I figured out what I'd done, and that got me thinking about how I can prevent this type of error from happening again.

What can I do to automatically test that the skip links are present on the page and working correctly with every change? So to do this I'll use Playwright. Playwright is widely used open source tool that supports a lot of different browsers.

Let's think through some of the different things we want to check to see if the skip links are working correctly. First, we want to know that it's not visible on page load. Might be easiest to do this with a screenshot comparison. When the page loads, and focus isn't on the link yet, then the skip link shouldn't be visible. We can use expect toHaveScreenshot to make a screenshot and check that indeed, the skip link is not yet visible.

await expect(page.locator("header")).toHaveScreenshot();

But we can focus on it, so it is in the accessibility tree. This is a good place to use Playwright's so-called “ARIA snapshots”. This converts the accessibility tree information into YAML so if the link disappears, this test will fail. The nice thing about Playwrights toMatchAriaSnapshot method is that we can give it a partial bit of the accessibility tree, even though our header contains a lot more than just this link.

We're able to just hardcode the link with the accessible name, “skip to main content”, and if that's present in the header, then the expectation is met and the test passes.

await expect(page.locator("header")).toMatchAriaSnapshot(`
  - link "Skip to main content"
  `);

Next, let's check what happens when we press the tab key. Here we can use the lower level keyboard methods that Playwright provides. Once the page is loaded, we press the tab key once and expect the link to be visible.

await page.keyboard.press("Tab");

Another screenshot here can check that it is in fact visible.

await expect(page.locator("header")).toHaveScreenshot();

So now we know the skip link is not visible on page. Load is in the accessibility tree, even though it's invisible and it appears the first time it receives focus when the user is moving using the tab key. Now we need to check if the link actually works.

Following the link causes the element which has focus to change from the skip link to the main element of the page. We can use the activeElement property in JavaScript to tell us what is currently in focus. This needs to be called inside of the page, not inside our test script, but Playwright provides us an interface to evaluate arbitrary JavaScript inside the page. So we can check that the text content of the thing that has keyboard focus at this time is indeed, “skip to main content”.

expect(
  await page.evaluate(() => {
    return document.activeElement?.textContent;
  }),
).toEqual("Skip to main content");

Now we can press the enter key. Now it's the main element that has focus.

await page.keyboard.press("Enter");

Here we can't use text content because the main element has too much text content. So let's use local name which should be main on the main element.

expect(
  await page.evaluate(() => {
    return document.activeElement?.localName;
  }),
).toEqual("main");

So now I am testing all the behaviors and functionality of the skip link on every build. If I accidentally break something and any of these things stops working, the test will fail and I will know right away.

As a bonus for anyone reasing the transcript, here's all of the code together:

import { test, expect } from "@playwright/test";

test("has working skip links", async ({ page }) => {
  await page.goto("/");

  await expect(page.locator("header")).toHaveScreenshot();

  await expect(page.locator("header")).toMatchAriaSnapshot(`
    - link "Skip to main content"
    `);

  await page.keyboard.press("Tab");

  await expect(page.locator("header")).toHaveScreenshot();

  expect(
    await page.evaluate(() => {
      return document.activeElement?.textContent;
    }),
  ).toEqual("Skip to main content");

  await page.keyboard.press("Enter");

  expect(
    await page.evaluate(() => {
      return document.activeElement?.localName;
    }),
  ).toEqual("main");
});