Roving tabindex 0.2.0

GitHub

A simple HTML web component that implements the roving tabindex pattern for building accessible menus and grids.

Wrap it around some markup and give it a selector to determine which elements become navigable!

<roving-tabindex selector="button">
    <ul class="menu" role="toolbar" aria-label="toolbar example">
        <li>
            <button>
                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
                    <use href="#cut" />
                </svg>
                <span>Cut</span>
            </button>
        </li>
        <li>
            <button>
                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
                    <use href="#copy" />
                </svg>
                <span>Copy</span>
            </button>
        </li>
        <li>
            <button>
                <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
                    <use href="#paste" />
                </svg>
                <span>Paste</span>
            </button>
        </li>
    </ul>
</roving-tabindex>

Usage

<roving-tabindex> isn't on NPM; instead, you install it by downloading roving-tabindex-0.2.0.js and adding it to your codebase. The default export is a class with a static register method:

<script type="module">
  import RovingTabindex from "/rovingtabindex-0.2.0.js";
  RovingTabIndex.register();
<script/>

By default, it registers the tag name <roving-tabindex>. You can customize it by passing a different string:

<script type="module">
  import RovingTabindex from "/rovingtabindex-0.2.0.js";
  RovingTabIndex.register("other-tagname");
<script/>

Once you've registered the tag, wrap <roving-tabindex> (or whatever name you chose) around the markup for which you want arrow key navigation. Use the selector prop to determine which descendant elements should be included.

<roving-tabindex selector="button, a" direction="horziontal">
    <ul class="menu" role="toolbar" aria-label="toolbar-example">
        <li><button>One</button></li>
        <li><button>Two</button></li>
        <li><button>Three</button></li>
        <li><a href="#">Link</a></li>
    </ul>
</roving-tabindex>

Make sure you set WAI-ARIA-related properties as appropriate. For example, the previous demo implements keyboard navigation described in the toolbar pattern, but <roving-tabindex> doesn't manage the role and aria-label attributes.

Examples

Menu Bar

The simplest use of <roving-tabindex> is to make a one dimensional list of buttons or links navigable with arrow keys.

By default, the left/up and right/down arrow keys move forward and backward in the list, respectively, but you can set the direction attribute to "horizontal" or "vertical" to use only one dimension.

Source
<roving-tabindex selector="button, a" direction="horziontal">
    <ul class="menu" role="toolbar" aria-label="toolbar-example">
        <li><button>One</button></li>
        <li><button>Two</button></li>
        <li><button>Three</button></li>
        <li><a href="#">Link</a></li>
    </ul>
</roving-tabindex>

This works especially well as progressive enhancement on menus that are already keyboard navigable with the tab key. For instance, here's this example without <roving-tabindex>. The arrow keys don't work, but you can still access each menu item by tabbing through.

Spreadsheet

<roving-tabindex> can also be used to navigate in two dimensions by setting the direction attribute to "grid" and setting the columns attribute to the number of columns. Here, it's used to replicate a spreadsheet interface.

Each table cell contains a <label> and a hidden <input>. When the user presses a key other than the keys <roving-tabindex> uses for navigation on a <label>, hide it and show the corresponding <input>. When the user presses Enter or Escape on the <input>, swap it back.

Call stopPropagation on keydown events originating from <input> elements to prevent <roving-tabindex> from changing the focused element while editing text.

A B C D E
1
2
3
4
5
Source
<roving-tabindex selector="label" direction="grid" columns="5">
                    <table id="spreadsheet">
                    <thead>
                    <tr>
                    <th width="0"></th>
                    <th width="20%">A</th>
                    <th width="20%">B</th>
                    <th width="20%">C</th>
                    <th width="20%">D</th>
                    <th width="20%">E</th>
                    </tr>
                    </thead>
                    <tbody>
                    <tr>
                    <th>1</th>
                    <td><label for="a1"></label><input class="hidden" id="a1" /></td>
                    <td><label for="a2"></label><input class="hidden" id="a2" /></td>
                    <td><label for="a3"></label><input class="hidden" id="a3" /></td>
                    <td><label for="a4"></label><input class="hidden" id="a4" /></td>
                    <td><label for="a5"></label><input class="hidden" id="a5" /></td>
                    </tr>
                    <tr>
                    <th>2</th>
                    <td><label for="b1"></label><input class="hidden" id="b1" /></td>
                    <td><label for="b2"></label><input class="hidden" id="b2" /></td>
                    <td><label for="b3"></label><input class="hidden" id="b3" /></td>
                    <td><label for="b4"></label><input class="hidden" id="b4" /></td>
                    <td><label for="b5"></label><input class="hidden" id="b5" /></td>
                    </tr>
                    <tr>
                    <th>3</th>
                    <td><label for="c1"></label><input class="hidden" id="c1" /></td>
                    <td><label for="c2"></label><input class="hidden" id="c2" /></td>
                    <td><label for="c3"></label><input class="hidden" id="c3" /></td>
                    <td><label for="c4"></label><input class="hidden" id="c4" /></td>
                    <td><label for="c5"></label><input class="hidden" id="c5" /></td>
                    </tr>
                    <tr>
                    <th>4</th>
                    <td><label for="d1"></label><input class="hidden" id="d1" /></td>
                    <td><label for="d2"></label><input class="hidden" id="d2" /></td>
                    <td><label for="d3"></label><input class="hidden" id="d3" /></td>
                    <td><label for="d4"></label><input class="hidden" id="d4" /></td>
                    <td><label for="d5"></label><input class="hidden" id="d5" /></td>
                    </tr>
                    <tr>
                    <th>5</th>
                    <td><label for="a1"></label><input class="hidden" id="e1" /></td>
                    <td><label for="a2"></label><input class="hidden" id="e2" /></td>
                    <td><label for="a3"></label><input class="hidden" id="e3" /></td>
                    <td><label for="a4"></label><input class="hidden" id="e4" /></td>
                    <td><label for="a5"></label><input class="hidden" id="e5" /></td>
                    </tr>
                    </tbody>
                    </table>
                    </roving-tabindex>

                    <script>
                    spreadsheet.addEventListener("keydown", evt => {
                    if (evt.target instanceof HTMLLabelElement) {
                    switch (evt.key) {
                    // send arrow keys and home/end through to <roving-tabindex>
                    case "ArrowUp":
                    case "ArrowDown":
                    case "ArrowLeft":
                    case "ArrowRight":
                    case "Home":
                    case "End":
                    break;

                    // for all other keys, swap in the corresponding <input> element
                    default:
                    const input = spreadsheet.querySelector("#" + evt.target.htmlFor);
                    evt.target.classList.add("hidden");
                    input.classList.remove("hidden");
                    input.focus();
                    break;
                    }
                    }

                    if (evt.target instanceof HTMLInputElement) {
                    // stop keydown events on <input> elements from reaching <roving-tabindex>
                    evt.stopPropagation();

                    const label = spreadsheet.querySelector(`[for="${evt.target.id}"]`);
                    switch (evt.key) {
                    case "Enter":
                    label.innerText = evt.target.value;
                    // fallthrough
                    case "Escape":
                    evt.target.classList.add("hidden");
                    label.classList.remove("hidden");
                    label.focus();
                    break;
                    }
                    }
                    });

                    spreadsheet.addEventListener("focusout", evt => {
                    if (evt.target instanceof HTMLInputElement) {
                    const label = spreadsheet.querySelector(`[for="${evt.target.id}"]`);
                    label.innerText = evt.target.value;
                    evt.target.classList.add("hidden");
                    label.classList.remove("hidden");
                    }
                    });
                    </script>

API

There are three main ways to control <roving-tabindex> and the focus of the elements it manages: attributes, methods and events.

Attributes

Attributes are applied to the <roving-tabindex> tag in HTML to control its behavior.

Name Description Default
selector A CSS selector that determines which descendant elements should be navigable.
direction

Determines the behavior of the arrow keys.

  • "both" makes right/down focus the next element and left/up focus the previous.
  • "horizontal" makes right focus the next element and left focus the previous.
  • "vertical" uses up focus the next element and down focus the previous.
  • "grid" makes the arrow keys navigate in two dimensions on a grid. The columns attribute is required for grid navigation.
"both"
columns The number of columns each grid row has. 0
loop Whether to wrap around to the beginning of the selection when moving past the end, and vice versa. false

Methods

<roving-tabindex> provides methods that can be called with a direct reference to its DOM node.

Name Description
focus() Return focus to the most recently focused descendant.

Events

The focus of elements can also be controlled by dispatching custom events, either directly on the <roving-tabindex> element itself or on one of its descendants.

Name Description Detail
rove Move focus by a given number of rows or columns. { rows?: number; cols?: number }