Skip to content
RU
Scale
Density
Theme
Accent

Foundations

Behaviour

CSS declares the roles, instrument.js carries out their keyboard contract. A role="listbox" tells assistive technology that the arrows work — without the script that statement is false.

The file deals with the keyboard and with attributes only; it does not touch the styling.

The example below is live: enter it with Tab and press an arrow. One Tab for the whole group, arrows after that; Home and End work, and the traversal wraps around. There is not one handler in the markup.

The component
Markup
<div class="inst-segmented" role="radiogroup" aria-label="Density">
  <button type="button" role="radio" aria-checked="true" tabindex="0">Compact</button>
  <button type="button" role="radio" aria-checked="false" tabindex="-1">Default</button>
  <button type="button" role="radio" aria-checked="false" tabindex="-1">Comfortable</button>
</div>

<div class="inst-tabs" role="tablist" aria-label="Sections">
  <button class="inst-tab" type="button" role="tab" aria-selected="true" tabindex="0">Active</button>
  <button class="inst-tab" type="button" role="tab" aria-selected="false" tabindex="-1">History</button>
  <button class="inst-tab" type="button" role="tab" aria-selected="false" tabindex="-1">Schedule</button>
</div>

Contract

<link rel="stylesheet" href="instrument.min.css">
<script type="module" src="instrument.js"></script>

A module rather than a classic script: it has named exports. No build step is required for that.

Accessibility

Disabled items aria-disabled="true" keeps an item in the arrow traversal and in the roving tabindex but removes the action: neither a click nor Enter selects it or calls the application. Somebody moving by arrows has to learn that the action exists and why it is unavailable now — an item thrown out is indistinguishable from one that never was. disabled is a different matter: the platform removes it from the traversal itself
Invisible items Thrown out too: neither the platform nor instrument.js puts focus on them
Nested groups An arrow in a parent does not jump across somebody else's items — submenus and groups inside a tree are counted separately
Modifiers Ctrl, Alt and Cmd with arrows are not intercepted: those are commands of the platform
Already handled An event with defaultPrevented is skipped, so an application can intercept a key earlier

Anatomy

Role Arrows And also
listbox ↑ ↓ over rows The selection follows the focus
tree ↑ ↓ over nodes, → ← expansion ← on a collapsed node goes to the parent by aria-level
menu ↑ ↓ over items There is no selection: an item is an action rather than a choice
radiogroup ← → over options The check follows the focus
tablist ← → over tabs The selection follows the focus

Home and End are in all five. The traversal wraps around: a task queue has no "end" past which there is nowhere to go, and a dead end on the last row gives nothing.

Behavior

A roving tabindex: exactly one element of a group is reachable by Tab — one Tab in, arrows inside, one Tab out.

The anchor is the selected element, and if there is no selection, the first one.

Rows arriving while the page works

The listeners hang on the document, and the elements are found at the moment of the keypress. Queue rows, steps and tree nodes appear one at a time, and an init(el) at start-up would miss everything that came later.

The one thing that cannot be done lazily is the initial tabindex: a group without it is not reachable by Tab. What watches for groups appearing is a MutationObserver rather than a human. Every root has an observer of its own: start() on a second root does not take the observer away from the first, and stop() removes exactly the one it started.

A group appearing is not always an edit to the markup. A popover and a <details> open by an attribute, and an observer does not wake for that, so the module also listens for toggle: menu items get their roving tabindex at the moment of opening rather than at the moment focus first lands in them.

import { refresh } from '@keshon/instrument/js';

// Needed only when the observer is off — in a test, for instance.
refresh(container);

Who owns the markup

Everything the module writes it writes through a cancellable event: preventDefault leaves the markup untouched, and the focus goes where it was going anyway — the application refused to lead the state, it did not forbid a person to move.

There is one exception, the roving tabindex. An event will not do for it: it is laid out from refresh(), that is on every batch of changes to the document, and the answer there is always the same. So the ownership is declared rather than decided each time.

Three knobs, and all three exist for one thing: so that an attribute never has two owners.

Knob Where What it does
data-instrument="manual" on <html> The module does not start itself. Read before the first subscription: by the time of the import it is too late to cancel
start(root, { observe: false }) in code Do not raise a MutationObserver. A framework knows about its own commit anyway, and a second observer means a full walk of the document on every render
data-roving="manual" on a group The tabindex inside it is led by the application. The arrows, Home, End and the selection keep working

Without these three words everything stays as it was: a <script type="module"> and a side-effect import start the module themselves.

<html data-instrument="manual">
import { start, refresh } from '@keshon/instrument/js';

start(document, { observe: false });
// and refresh() after every render

The lines a person hears

The module speaks four phrases — the name of the notification region and three responses to actions. The defaults are English: the kit travels in a package, and the language of a screen is chosen by the application rather than by the library. All four are gathered in one place and change together:

import { strings } from '@keshon/instrument/js';

strings.copied = 'Copié';

The attributes data-copied-label and data-failed-label still win: they have another job — to say something of their own for one button rather than to change the language of the screen.

Drawn controls

The copy button, the removal cross, the ew-resize cursor on the label of an axis are controls declared by the styling. Their work is done by the same module.

What is drawn What instrument.js does
.inst-copy inside .inst-copyable or .inst-code Puts the text in the clipboard, answers with colour and announces the result
.inst-tag-remove Removes the tag and moves the focus to its neighbour
.inst-num-axis with an ew-resize cursor Drags the value of a number field
An <output for> beside a slider Keeps the current value in it
The checkbox in the head of a selection column of a table Selects every row, shows a partial selection
A selected tab, option, list row Carries aria-selected and aria-checked over — on an arrow and on a click alike
A role="listbox" inside .inst-cascader ← and → move to the column beside. Scoped by CLASS rather than by role: a listbox anywhere else answers those keys as it always did

Sorting, uploading files, picking a date and closing a banner are not here: those actions work with data, that is, they stay on the application's side.

JS

The module exports nothing as classes and needs no initialisation: it is included once and works by delegation. Everything listed below is the surface for when that is not enough.

Methods

start(root = document) Attach. Called by itself when the module loads. There may be several roots — each with an observer of its own
stop(root = document) Detach. Wanted by tests and hot reloading rather than by an application
refresh(root = document) Walk a subtree again: the roving tabindex, the <output> of sliders, "select all" in tables
toast(options) Show a notification

Events

All of them are CustomEvent, they bubble and they are cancellable. preventDefault() means the application takes the work on itself: instrument.js stops and does not touch the markup.

Event Where detail
inst:select on an item of a group with a role { value } — the item's data-value, otherwise its text
inst:copy on .inst-copy { text } — what will go to the clipboard
inst:remove on .inst-tag { value } — the tag's data-value, otherwise its text
inst:selectall on .inst-table { checked } — the new state of every row
inst:expand on a node of a tree { open } — the new state of aria-expanded
document.addEventListener('inst:remove', (e) => {
  e.preventDefault();            // the tag will stay in the markup
  store.dropTag(e.detail.value); // a re-render from the data will do it
});

A cancelled inst:select leaves aria-selected and aria-checked as they were, but the focus still moves: the application refused to lead the state itself, it did not forbid a person to move.

Dragging an axis and the slider start no events of their own: they change the <input> and send the native input and change. A framework sees them with no glue.

Options

The settings are attributes of the markup rather than a configuration object: the library has no instances to attach one to.

Attribute Where What it does
data-copy on .inst-copy Copy this rather than the text of the block
data-copied-label on .inst-copy A phrase of your own for a screen reader instead of «Copied»
data-failed-label on .inst-copy The same for a failure
data-value on .inst-tag and on an item of a group What arrives in detail instead of the text
step, min, max on an input[type=number] The step and the bounds of dragging an axis

Rules

It does not change the styling. Not one assignment to style, not one class of styling: the module sets attributes from the markup contract — tabindex, aria-selected, aria-checked, aria-expanded — and how they look is decided by CSS. So an application can override the look without touching the behaviour.

It is not obligatory. Without it everything that does not need a keyboard works: expansion on <details>, the popover on the Popover API, the modal on <dialog>, validation on :user-invalid.

API

Attributes
NameValueWhat it does
attribute
rolelistbox · tree · menu · radiogroup · tablistOn the container. Only these five have a keyboard contract
aria-orientationhorizontal · verticalThe axis of the arrows. By default vertical on a list, a tree and a menu, horizontal on a radio group and tabs
aria-level—On a treeitem. The back arrow finds the parent by it — the nesting in the markup may be flat
aria-expandedtrue · falseOn an expandable treeitem. Without it the node counts as a leaf
aria-disabledtrueThe item stays in the arrow traversal but does not act
data-value—On an item of a group and on a tag. What arrives in the event's detail instead of the text
data-copy—On .inst-copy. Copy this value rather than the text of the block
data-copied-label—On .inst-copy. A phrase of your own for a screen reader instead of «Copied»
data-failed-label—On .inst-copy. The same for a failure
data-copiedtrue · falseSet and removed by instrument.js. The response of the copy button, it lives 1.4 s
data-instrumentmanualOn <html>. The module does not start itself — the application calls start() when it is ready
data-rovingmanualOn a group. The roving tabindex inside it is led by the application; the arrows and the selection work as before
event
inst:select—An item of a group was selected. detail is { value }. Cancelling leaves the markup untouched
inst:copy—The copy button was pressed. detail is { text }
inst:remove—A tag is being removed. detail is { value }
inst:selectall—The checkbox in a table head was toggled. detail is { checked }

What is not there yet

  • typeahead, selection by first letters. In a list of two hundred rows it is wanted, and APG recommends it;
  • multiple selection with Shift+arrows in a listbox;
  • closing a tooltip on Escape: as it stands it does not meet criterion 1.4.13, because it can be neither hovered nor dismissed.

Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument