How Tabindex Works

In spite of my first foray into professional web development being over a decade ago, I cannot say I have have ever had to explicitly set tabindex. This became a problem while developing my auto-tab custom element. I naively assumed there would be a method I could use to determine the next focusable element. While there has been at least one discussion about adding such functionality to the spec, nothing has materialized to date. This meant that I would have to roll my own method and, as part of that, get up to speed with the behavior of tabindex. So here's a (likely incomplete) rundown on how tabindex works:

Explicit Is Prioritized

Consider the following example:

<button id="first" type="button">
no tabindex
</button>
<button id="second" type="button" tabindex="2">
tabindex 2
</button>
<button id="third" type="button" tabindex="3">
tabindex 3
</button>
<button id="fourth" type="button">
no tabindex
</button>

You might assume that the first button element would be implicitly assigned a tabindex value of 1, and as such would be first element focused after pressing Tab.

But this is not the case. After pressing Tab, the first element to receive focus will be #second, followed by #third before #first and finally #fourth. If even a single element in a focus chain has the tabindex attribute defined it is prioritized over all other elements. Elements without tabindex are deferred to the end of the chain.

<button id="first" type="button">
no tabindex
</button>
<button id="second" type="button" tabindex="0">
tabindex 0
</button>
<button id="fourth" type="button">
no tabindex
</button>

But this is not true if the tabindex is zero.

A tabindex of zero is generally used to introduce an otherwise unfocusable element into the focus chain without interfering with the order declared in the document.

Less Than Zero

A negative tabindex also denotes a special case.

Elements with a negative tabindex are removed from the focus chain. However, they are still able to be focused pragmatically using HTMLElement's focus method.

Radio Buttons

Without tabindex, the focusable order is grouped by the name attribute of the radio input elements (per form) with the arrow keys being used to select values within the group.

This makes things interesting once we start assigning explicit tabindex values.

<fieldset>
<legend>First</legend>
<label>
<input name="first" value="a" type="radio">
no tabindex
</label>
<label>
<input name="first" value="b" type="radio">
no tabindex
</label>
</fieldset>
<fieldset>
<legend>Second</legend>
<label>
<input name="second" value="a" type="radio" tabindex="1">
tabindex 1
</label>
<label>
<input name="second" value="b" type="radio" tabindex="3" checked>
tabindex 3
</label>
</fieldset>
<fieldset>
<legend>Third</legend>
<label>
<input name="third" value="a" type="radio">
no tabindex
</label>
<label>
<input name="third" value="b" type="radio" tabindex="2">
tabindex 2
</label>
</fieldset>

Which gives us the following result:

Notice that the element with a tabindex of 1 is skipped as the pair of input[name="second"] elements are considered to have a tabindex of 3. Note that this changes to 1 if the value of second becomes a.

Determining Focus Order Programmatically

Putting together what we have learned, we can now attempt to implement a method to determine the next tabbable element. Using Alice Boxhall's solution of using a TreeWalker:

private _walk(): void {
const treeWalker = document.createTreeWalker(
this,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node) =>
(node as HTMLElement).tabIndex >= 0
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP,
},
);

this._elements = [];
for (
let node = treeWalker.nextNode();
node !== null;
node = treeWalker.nextNode()
) {
this._elements.push(node as HTMLElement);
}

// Sort in ascending order, moving all 0s to the end
this._elements.sort((a, b) => {
const aTabIndex = a.tabIndex;
const bTabIndex = b.tabIndex;

if (aTabIndex === 0) {
return 1;
}

if (bTabIndex === 0) {
return -1;
}

return a.tabIndex - b.tabIndex;
});
}

But this does not account for the unique behavior of radio buttons as we discussed before. I chose to handle this as part of a different routine:

private _update(currentElement: HTMLElement): void {
...
// Special case for radio buttons
if (nextElement.tagName === "INPUT") {
const nextInputElement = nextElement as HTMLInputElement;
const type = nextInputElement.type;
const name = nextInputElement.name;

if (type === "radio" && name != null) {
const parentElement =
nextInputElement.closest("form") ?? document.body;
const selectedInputElement = parentElement.querySelector(
`input[type=radio][name=${name}]:checked`,
);

if (selectedInputElement != null) {
this._update(nextInputElement);
return;
}
}
}

nextElement.focus();
...
}