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();
...
}