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 WAI-ARIA Authoring Practices.

Related Articles

Live Examples

Before illustrates the problem, After illustrates the solution. Click the header to see it larger in a modal.

before

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>Drink Options</h3>
<ul id="group1" class="radiogroup">
<li tabindex="0" class="radio" checked>Water</li>
<li tabindex="-1" class="radio">Tea</li>
<li tabindex="-1" class="radio">Coffee</li>
<li tabindex="-1" class="radio">Cola</li>
<li tabindex="-1" class="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';

// Define values for keycodes
var VK_LEFT = 37;
var VK_UP = 38;
var VK_RIGHT = 39;
var VK_DOWN = 40;

// 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.keyCode) {
case VK_UP:
case VK_LEFT: {
e.preventDefault();

if (this.selected === 0) {
this.selected = this.buttons.length - 1;
} else {
this.selected--;
}
break;
}

case VK_DOWN:
case VK_RIGHT: {
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) {
var children = e.target.parentNode.children
for (var 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');
};

var group1 = new RadioGroup('#group1');
}());