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.
a11y and JS - A Seemingly Unconventional Romance
Lindsey Kopacz ・ Jan 28 '19
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
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
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
. 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
, these are our 'buttons' for controlling the whole 'element', we must give each tab the tabindex
of 0, also each tab must have an aria-control
attribute that is the ID of the corresponding panel. Lastly, there's an 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
also we need the 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.
<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.
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.
.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.
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.
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
const someID = new TabController('#some_ID');
Bringing it all together
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!
🦄❤🦄🦄🧠❤🦄
Top comments (18)
Great article! My one nitpick, is overriding the left/right keys to cycle the tabs going to be annoying for some users? For instance, Voice Over on Mac uses left/right to cycle through all the page content, so a user might reasonably expect that the open tab would stay selected until they explicitly changed it. Having said that, I haven't done testing with blind users to find out what their preferences are.
Once you tab past the
tablist
the left and right key start working as normal.It's actually part of the aria spec to do so, which is why I included it. But different users may feel differently about it.
w3.org/TR/wai-aria-practices/examp...
Do you think I should include a link to this in my keyboard support section?
EDIT: I've added a reference to the w3 page now
That makes a lot of sense actually. There are so many websites with poor accessibility, I often wonder how that has impacted user expectations and if they differ from the standards.
Yeah, it was incredibly weird for me too.
I'd expect them to maybe change the focused tab, but not actually activate it?
How do I read the tab title without activating it?
You can use the tab key to move only the focus, which will read the tab title and tell you whether it's active or not. Then you can press either space or return to activate that tab.
Sorry, I was also referring to the examples at w3.org.
The one you linked as These keyboard requirements can be found here was Example of Tabs with Automatic Activation, but there was also Example of Tabs with Manual Activation where indeed you need to press
Space
orReturn
.However, in both of those, the whole tab bar is a single
Tab
target, as in "you focus the whole thing and the nextTab
press takes you out of it entirely".Sort of like if it was a slider
input
or something of the sort.Using roles instead of classes as the CSS/JS selector to not forget to apply the role AND not have an extra class is a really nice touch, bravo.
Did you arrive at that yourself or see it somewhere before?
I think it's relatively common practice these days, I didn't explicitly learn it from someone/something but I may have picked it up somewhere 🙂
Guess I rarely if ever see the source of accessible sites. 😨
Unfortunately, accessibility has become an afterthought for lots of people but I think, hope really, that that is really starting to change 😀.
Yeah, I'm sure it's on the way up, and at a historical high actually.
The only time pages were more parseable than now would be when they were truly formatted like documents and table layouts didn't take off yet.
But then there were neither accessibility tools to make use of it, nor semantically meaningful HTML5 attributes, nor additional attributes for accessibility.
So yeah, I don't agree with the "accessibility has become an afterthought" statement. It is still an afterthought for many end developers, such as small businesses, but frameworks and component libraries are doing an unprecedented good job of making it easy and even automatic to be accessible.
I understand this, but I feel the tabs should not be tabbable in the beginning, only the tab which is active should be tabbable, the other tabs should be toggled by using arrow keys. Only the active tab should be tabbable, and the tabpanel associated with it should be tabbable.
'role' tabs all of them should have tabindex -1 other than the one which is active.
The other tabpanel can have tabindex 0 but they should be hidden, and upon user interaction to the tab they should be visible.
Reference: w3.org/TR/wai-aria-practices/examp...
I'm using 3 tabs for a conference agenda, when toggled will show the agenda for a specific day of the week. The agenda for each day can be quite long and in all of the examples I see it just shows one paragraph. After I select a tab I don't seem to be able to use the down arrow to scroll down the page. Is this a bad use of tabs?
I am also inspired from @lkopacz . She writes wonderfull article 😍 so that I am also write about how to create accessible tablists . Altough I write the article in Turkish. You can find that here;
Interesting post, thank you! My only followup question: my interpretation of the w3 keyboard support guidelines is that if a tab in the tablist is in focus, the tab key should move focus to the tab panel itself, instead of moving the focus to the next tab in the tablist. What do you think?
Side note -- how many times can I use 'tab' in a single sentence? 😅
Great post Andrew, thank you!
There is an error in the keyboard requirements section. The goal of Home key and End key are inverted. Home key is for the first tab and End key is for the last tab.
Fixed