Accessibility first: tabs
Tags: a11y, css, javascript, webdev
Published: 2019-01-29 13:14:33

I've decided to pick up this series for another element. I was inspired by @lkopacz's post on accessibility and javascript, it's worth a read, to make something that required javascript but keep it accessible.

{% link https://dev.to/lkopacz/a11y-and-js---a-seemingly-unconventional-romance-24i0 %}

I settled on making a form of tabbed navigation, it loosely follows the material design spec. Our finished product will look a little something like this

{% jsfiddle https://jsfiddle.net/link2twenty/v2d1htzx result %}

## Requirements

For us to call our tabs accessible we need to be able to interact with them using the keyboard as well as the mouse we also can't assume our user is sighted.

### Keyboard:
 - **Tab key**, we must be able to use the tab to move focus along the tabs
 - **Return key**, we must be able to press return when a tab is focused to move to it
 - **Space key**, the space key should act like the return key
 - **Home key**, we must select the first tab in the tablist
 - **End key**, we must select the final tab in the tablist
 - **Arrow keys**, we must be able to move to the next or previous tab when pressing the right or left key but only when the focus is within our `tablist`

_These keyboard requirements can be found [here](https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html#kbd_label)_

### Mouse:
 - **Clicking** on a tab should set that tab as active
 - **Hovering** should give some indication of the target

### Non-sighted:
 - Relies on **keyboard support**
 - Must work with a **screen reader**

I believe this is all we need, though if I'm wrong please tell me, I also believe the example above meets each item on our checklist. So let's move on.

## Markup

I have a `<div>` that contains the entire tab 'element' it needs an ID so we can find it with the javascript coming later and the `tab-container` class so we can style it with our CSS.

Now we have some roles, roles tell the browser how each element should be treated, we have a `<ul>` with the role [`tablist`](https://www.w3.org/TR/wai-aria-1.1/#tablist). This lets our browser know that we're listing some tabs, it means when the screen reader looks at the tabs it can say "tab one of two selected".

Next, we have an `<li>` with the role [`tab`](https://www.w3.org/TR/wai-aria-1.1/#tab), these are our 'buttons' for controlling the whole 'element', we must give each tab the [`tabindex`](https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex) of 0, also each tab must have an [`aria-control`](https://www.w3.org/TR/wai-aria-1.1/#aria-controls) attribute that is the ID of the corresponding panel. Lastly, there's an [`aria-selected`](https://www.w3.org/TR/wai-aria-1.1/#aria-selected) which contains true or false depending on whether or not the tab is the active/selected tab.

Finally, let's look at the `<main>` content we have a `<div>` for each panel they need the role [`tabpanel`](https://www.w3.org/TR/wai-aria-1.1/#tabpanel) also we need the [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.1/#aria-expanded) attribute that is true or false depending on whether the panel is active/expanded or not. The ID attribute is required and corresponds to the `aria-control` attribute of the `<li>` elements.

```html
<div id="some_ID" class="tab-container">
  <ul role="tablist">
    <li role="tab" aria-controls="some_ID_1" tabindex="0" aria-selected="true">Tab 1</li>
    <li role="tab" aria-controls="some_ID_2" tabindex="0" aria-selected="false">Tab 2</li>
  </ul>
  <main>
    <div id="some_ID_1" role="tabpanel" aria-expanded="true">
      <p>
        content for 1
      </p>
    </div>
    <div id="some_ID_2" role="tabpanel" aria-expanded="false">
      <p>
        content for 2
      </p>
    </div>
  </main>
</div>
```

Here's the markup from the example.

{% jsfiddle https://jsfiddle.net/link2twenty/d3kc2qgv/ html result %}

## Styles

I won't go into too much detail on these styles as they're personal preference but I'll point out a couple of things. 

Beyond the class `.tab-container` I try to use the role as the selector, this means if I miss a selector it will be obvious but it also makes the code cleaner. 

I have a hover effect but not a focus effect, I think the outline you get inherently with `tabindex` should be sufficient, again feel free to call me out if you disagree. 

```css
.tab-container {
  overflow: hidden;
  background: #fff;
}

.tab-container [role=tablist] {
  display: flex;
  margin: 0;
  padding: 0;
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}

.tab-container [role=tab] {
  position: relative;
  list-style: none;
  text-align: center;
  cursor: pointer;
  padding: 14px;
  flex-grow: 1;
  color: #444;
}

.tab-container [role=tab]:hover {
  background: #eee;
}

.tab-container [role=tab][aria-selected=true] {
  color: #000;
}

.tab-container [role=tab][aria-selected=true]::after {
  content: "";
  position: absolute;
  width: 100%;
  height: 4px;
  background: #f44336;
  left: 0;
  bottom: 0;
}

.tab-container main {
  padding: 0 1em;
  position: relative;
}

.tab-container main [role=tabpanel] {
  display: none;
}

.tab-container main [role=tabpanel][aria-expanded=true] {
  display: block;
}
```
Let's add the styles to our example.

{% jsfiddle https://jsfiddle.net/link2twenty/hm32w5n0/ css result %}

## The JavaScript

Here we go, I'm going to add some javascript. This means the tabs will no longer be accessible, right? Of course not, let's take a look.

Again, I won't go into too much detail as, really, this is just a bunch of event listeners. You may be wondering why I used a class, it's because I like them, you don't have to use a class I just enjoy using them.

I'm using the same selector style as I did with the CSS, it just makes sense to me. I only have one public function and all that does is change the `aria-selected` and `aria-expanded` attributes. Our CSS handles all the style changes.

```javascript
class TabController {
  constructor(container) {
    this.container = document.querySelector(container);
    this.tablist = this.container.querySelector('[role=tablist]');
    this.tabs = this.container.querySelectorAll('[role=tab]');
    this.tabpanels = this.container.querySelectorAll('[role=tabpanel]');
    this.activeTab = this.container.querySelector('[role=tab][aria-selected=true]');

    this._addEventListeners();
  }

  // Private function to set event listeners
  _addEventListeners() {
    for (let tab of this.tabs) {
      tab.addEventListener('click', e => {
        e.preventDefault();
        this.setActiveTab(tab.getAttribute('aria-controls'));
      });
      tab.addEventListener('keyup', e => {
        if (e.keyCode == 13 || e.keyCode == 32) { // return or space
          e.preventDefault();
          this.setActiveTab(tab.getAttribute('aria-controls'));
        }
      })
    }
    this.tablist.addEventListener('keyup', e => {
      switch (e.keyCode) {
        case 35: // end key
          e.preventDefault();
          this.setActiveTab(this.tabs[this.tabs.length - 1].getAttribute('aria-controls'));
          break;
        case 36: // home key
          e.preventDefault();
          this.setActiveTab(this.tabs[0].getAttribute('aria-controls'));
          break;
        case 37: // left arrow
          e.preventDefault();
          let previous = [...this.tabs].indexOf(this.activeTab) - 1;
          previous = previous >= 0 ? previous : this.tabs.length - 1;
          this.setActiveTab(this.tabs[previous].getAttribute('aria-controls'));
          break;
        case 39: // right arrow
          e.preventDefault();
          let next = [...this.tabs].indexOf(this.activeTab) + 1;
          next = next < this.tabs.length ? next : 0
          this.setActiveTab(this.tabs[next].getAttribute('aria-controls'));
          break;
      }
    })
  }

  // Public function to set the tab by id
  // This can be called by the developer too.
  setActiveTab(id) {
    for (let tab of this.tabs) {
      if (tab.getAttribute('aria-controls') == id) {
        tab.setAttribute('aria-selected', "true");
        tab.focus();
        this.activeTab = tab;
      } else {
        tab.setAttribute('aria-selected', "false");
      }
    }
    for (let tabpanel of this.tabpanels) {
      if (tabpanel.getAttribute('id') == id) {
        tabpanel.setAttribute('aria-expanded', "true");
      } else {
        tabpanel.setAttribute('aria-expanded', "false");
      }
    }
  }
}
```

Then we can instantiate an instance of our tab navigation like so

```javascript
const someID = new TabController('#some_ID');
```

Bringing it all together

{% jsfiddle https://jsfiddle.net/link2twenty/v2d1htzx js result %}

## Signing off

I hope you enjoyed this little post and feel free to use these techniques, or the whole thing, on any of your sites. I'm really interested to hear of any methods you might have to do this without JavaScript, I think it could be done with a radio group but I'm not gonna attempt it now.

Thank you for reading!
🦄❤🦄🦄🧠❤🦄