Roving Focus
Also known as "Roving tabindex", typically reserved for custom UI components that require manual focus management using tabindex.
The Problem
Custom components and complex interfaces do not automatically benefit from built in ARIA roles, states, and properties like existing HTML tags. This leads to users relying on keyboard and screen reader navigation being unable to properly navigate the component.
The Solution
Using native HTML elements is preferred, but there are rare cases where that's not possible. In such cases it's imperative to apply the ARIA roles, states, and properties necessary to allow for seamless interaction regardless of input device. Follow the recommendations for the most relevant pattern or widget found in ARIA Authoring Practices Guide Patterns.
Related Articles
- A11ycasts - Roving tabindex
- WAI-ARIA - Radio Group Example Using Roving tabindex
- WAI-ARIA - Design Patterns and Widgets
Live Examples
Before illustrates the problem, After illustrates the solution. Click the header to see it larger in a modal.
Please note that this Before example contains inaccessible code, use this link to skip to the After Live Examplebefore
after
Code Comparison
Code diff between the before and after examples above to show the changes necessary. To copy the final source click on the 'after' path link before the diff.
source
Comparing /examples/focus/roving-focus/before/index.html to /examples/focus/roving-focus/after/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Accessibility Solutions - Focus - Roving Focus</title>
<link rel="stylesheet" href="../../../presentation.css" />
<link rel="stylesheet" href="roving-focus.css" />
</head>
<body>
<h3 id="drink-options">Drink Options</h3>
<ul
id="group1"
class="radiogroup"
role="radiogroup"
aria-labelledby="drink-options"
>
<li tabindex="0" class="radio" role="radio" checked>Water</li>
<li tabindex="-1" class="radio" role="radio">Tea</li>
<li tabindex="-1" class="radio" role="radio">Coffee</li>
<li tabindex="-1" class="radio" role="radio">Cola</li>
<li tabindex="-1" class="radio" role="radio">Ginger Ale</li>
</ul>
<script src="roving-focus.js"></script>
</body>
</html>
styles
Comparing /examples/focus/roving-focus/before/roving-focus.css to /examples/focus/roving-focus/after/roving-focus.css
.radiogroup {
list-style: none;
}
.radio {
position: relative;
}
.radio::before {
content: '';
display: block;
width: 10px;
height: 10px;
border: 1px solid black;
position: absolute;
left: -18px;
top: 3px;
border-radius: 50%;
}
.radio[checked]::after {
content: '';
display: block;
width: 8px;
height: 8px;
background: red;
position: absolute;
left: -16px;
top: 5px;
border-radius: 50%;
}
javascript
Comparing /examples/focus/roving-focus/before/roving-focus.js to /examples/focus/roving-focus/after/roving-focus.js
(function () {
'use strict';
// Helper function to convert NodeLists to Arrays
function slice(nodes) {
return Array.prototype.slice.call(nodes);
}
function RadioGroup(id) {
this.el = document.querySelector(id);
this.buttons = slice(this.el.querySelectorAll('.radio'));
this.selected = 0;
this.focusedButton = this.buttons[this.selected];
this.el.addEventListener('keydown', this.handleKeyDown.bind(this));
this.el.addEventListener('click', this.handleClick.bind(this));
}
RadioGroup.prototype.handleKeyDown = function (e) {
switch (e.key) {
case 'ArrowUp':
case 'ArrowLeft': {
e.preventDefault();
if (this.selected === 0) {
this.selected = this.buttons.length - 1;
} else {
this.selected--;
}
break;
}
case 'ArrowDown':
case 'ArrowRight': {
e.preventDefault();
if (this.selected === this.buttons.length - 1) {
this.selected = 0;
} else {
this.selected++;
}
break;
}
}
this.changeFocus(this.selected);
};
RadioGroup.prototype.handleClick = function (e) {
const children = e.target.parentNode.children;
for (let i = 0; i < children.length; i++) {
if (e.target === children[i]) break;
}
this.selected = i;
this.changeFocus(this.selected);
};
RadioGroup.prototype.changeFocus = function (idx) {
// Set the old button to tabindex -1
this.focusedButton.tabIndex = -1;
this.focusedButton.removeAttribute('checked');
// Set the new button to tabindex 0 and focus it
this.focusedButton = this.buttons[idx];
this.focusedButton.tabIndex = 0;
this.focusedButton.focus();
this.focusedButton.setAttribute('checked', 'checked');
};
const group1 = new RadioGroup('#group1');
})();