Основания
Поведение
CSS объявляет роли, instrument.js выполняет их клавиатурный контракт.
role="listbox" сообщает вспомогательной технологии, что стрелки работают, —
без скрипта это утверждение неверно.
Файл занимается только клавиатурой и атрибутами; оформление он не трогает.
Пример ниже — живой: войдите в него Tab и нажмите стрелку. Один Tab на всю
группу, дальше стрелки; Home и End работают, перебор идёт по кругу. В
разметке при этом нет ни одного обработчика.
Разметка
<div class="inst-segmented" role="radiogroup" aria-label="Плотность">
<button type="button" role="radio" aria-checked="true" tabindex="0">Плотно</button>
<button type="button" role="radio" aria-checked="false" tabindex="-1">Обычно</button>
<button type="button" role="radio" aria-checked="false" tabindex="-1">Свободно</button>
</div>
<div class="inst-tabs" role="tablist" aria-label="Разделы">
<button class="inst-tab" type="button" role="tab" aria-selected="true" tabindex="0">Активные</button>
<button class="inst-tab" type="button" role="tab" aria-selected="false" tabindex="-1">История</button>
<button class="inst-tab" type="button" role="tab" aria-selected="false" tabindex="-1">Расписание</button>
</div>
Контракт
<link rel="stylesheet" href="instrument.min.css">
<script type="module" src="instrument.js"></script>
Модуль, а не классический скрипт: у него есть именованные экспорты. Сборка при этом не требуется.
Доступность
| Отключённые пункты | aria-disabled="true" оставляет пункт в обходе стрелками и в бегущем tabindex, но снимает действие: ни щелчок, ни Enter не выбирают его и не зовут приложение. Человек, идущий стрелками, обязан узнать, что действие существует и почему сейчас недоступно — выброшенный пункт неотличим от отсутствующего. disabled — другое дело: его убирает из обхода сама платформа |
| Невидимые пункты | Тоже выбрасываются: фокус на них не ставит ни платформа, ни instrument.js |
| Вложенные группы | Стрелка в родителе не прыгает по чужим пунктам — подменю и группы внутри дерева считаются отдельно |
| Модификаторы | Ctrl, Alt и Cmd со стрелками не перехватываются: это команды платформы |
| Уже обработанное | Событие с defaultPrevented пропускается, поэтому приложение может перехватить клавишу раньше |
Устройство
| Роль | Стрелки | Ещё |
|---|---|---|
listbox |
↑ ↓ по строкам | Выделение следует за фокусом |
tree |
↑ ↓ по узлам, → ← раскрытие | ← на свёрнутом уходит к родителю по aria-level |
menu |
↑ ↓ по пунктам | Выделения нет: пункт — действие, а не выбор |
radiogroup |
← → по вариантам | Отметка следует за фокусом |
tablist |
← → по вкладкам | Выбор следует за фокусом |
Home и End — во всех пяти. Перебор идёт по кругу: у очереди задач нет
«конца», после которого некуда деться, и тупик на последней строке ничего не
даёт.
Поведение
Бегущий tabindex: ровно один элемент группы достижим по Tab — один Tab
внутрь, стрелки внутри, один Tab наружу.
Якорем становится выделенный элемент, а если выделения нет — первый.
Строки, прибывающие во время работы
Слушатели висят на документе, а элементы ищутся в момент нажатия. Строки
очереди, шаги и узлы дерева появляются по одной, и init(el) при старте
промахнулся бы мимо всего, что пришло позже.
Единственное, что нельзя сделать лениво, — начальный tabindex: группа без
него не достижима по Tab. За появлением групп следит MutationObserver, а
не человек. Наблюдатель у каждого корня свой: start() на втором корне не
отбирает наблюдателя у первого, и stop() снимает ровно того, кого завёл.
Появление группы — не всегда правка разметки. Поповер и <details>
открываются атрибутом, и наблюдатель на такое не просыпается, поэтому модуль
слушает ещё и toggle: пункты меню получают бегущий tabindex в момент
открытия, а не в момент, когда в них впервые попал фокус.
import { refresh } from '@keshon/instrument/js';
// Нужно только если наблюдатель отключён — например, в тесте.
refresh(container);
Кто владеет разметкой
Всё, что модуль пишет, он пишет через отменяемое событие: preventDefault
оставляет разметку нетронутой, а фокус всё равно уходит куда шёл — приложение
отказалось вести состояние, а не запретило человеку перемещаться.
Одно исключение — бегущий tabindex. Событие ему не годится: его расстановка
идёт из refresh(), то есть на каждую пачку изменений документа, и ответ там
всегда один и тот же. Поэтому владение объявляется, а не решается каждый раз.
Три ручки, и все три существуют ради одного: чтобы у атрибута не оказалось двух хозяев.
| Ручка | Где | Что делает |
|---|---|---|
data-instrument="manual" |
на <html> |
Модуль не запускается сам. Читается до первой подписки: к моменту импорта отменять уже поздно |
start(root, { observe: false }) |
в коде | Не поднимать MutationObserver. Фреймворк и так знает про свой коммит, а второй наблюдатель означает полный обход документа на каждый рендер |
data-roving="manual" |
на группе | tabindex внутри неё ведёт приложение. Стрелки, Home, End и выбор продолжают работать |
Без этих трёх слов всё остаётся как было: <script type="module"> и побочный
импорт запускают модуль сами.
<html data-instrument="manual">
import { start, refresh } from '@keshon/instrument/js';
start(document, { observe: false });
// и refresh() после каждой отрисовки
Строки, которые слышит человек
Модуль произносит четыре фразы — имя области уведомлений и три ответа действий. Умолчания английские: кит уезжает в пакет, и язык экрана выбирает приложение, а не библиотека. Все четыре собраны в одном месте и меняются целиком:
import { strings } from '@keshon/instrument/js';
strings.copied = 'Скопировано';
Атрибуты data-copied-label и data-failed-label по-прежнему выигрывают: у
них другая работа — сказать своё для одной кнопки, а не сменить язык экрана.
Нарисованные элементы управления
Кнопка копирования, крестик снятия, курсор ew-resize на подписи оси — это
элементы управления, объявленные оформлением. Их работу выполняет тот же
модуль.
| Что нарисовано | Что делает instrument.js |
|---|---|
.inst-copy внутри .inst-copyable или .inst-code |
Кладёт текст в буфер, отвечает цветом и объявляет результат |
.inst-tag-remove |
Снимает тег и уводит фокус на соседний |
.inst-num-axis с курсором ew-resize |
Тянет значение числового поля |
<output for> рядом с бегунком |
Держит в нём текущее значение |
| Чекбокс в шапке колонки выбора таблицы | Выбирает все строки, показывает частичный выбор |
| Выбранная вкладка, вариант, строка списка | Переносит aria-selected и aria-checked — и по стрелке, и по щелчку |
role="listbox" внутри .inst-cascader |
← и → уводят в соседнюю колонку. Область — КЛАСС, а не роль: листбокс в любом другом месте отвечает на эти клавиши как отвечал |
Сортировка, загрузка файлов, выбор даты и закрытие баннера сюда не входят: эти действия работают с данными, то есть остаются на стороне приложения.
JS
Модуль ничего не экспортирует классами и не требует инициализации: он подключается один раз и работает делегированием. Всё, что перечислено ниже, — поверхность на случай, когда этого мало.
Методы
start(root = document) |
Подключить. Вызывается сам при загрузке модуля. Корней может быть несколько — у каждого свой наблюдатель |
stop(root = document) |
Отключить. Нужно тестам и горячей перезагрузке, а не приложению |
refresh(root = document) |
Пройти поддерево заново: бегущий tabindex, <output> бегунков, «выбрать всё» в таблицах |
toast(options) |
Показать уведомление |
События
Все — CustomEvent, всплывают и отменяемы. preventDefault() означает,
что работу берёт на себя приложение: instrument.js останавливается и не трогает
разметку.
| Событие | Где | detail |
|---|---|---|
inst:select |
на пункте группы с ролью | { value } — data-value пункта, иначе его текст |
inst:copy |
на .inst-copy |
{ text } — что уйдёт в буфер |
inst:remove |
на .inst-tag |
{ value } — data-value тега, иначе его текст |
inst:selectall |
на .inst-table |
{ checked } — новое состояние всех строк |
inst:expand |
на узле дерева | { open } — новое состояние aria-expanded |
document.addEventListener('inst:remove', (e) => {
e.preventDefault(); // тег останется в разметке
store.dropTag(e.detail.value); // это сделает перерисовка по данным
});
Отменённый inst:select оставляет aria-selected и aria-checked как были,
но фокус всё равно уходит: приложение отказалось вести состояние само, а не
запретило человеку перемещаться.
Перетаскивание оси и бегунок своих событий не заводят: они меняют
<input> и шлют нативные input и change. Фреймворк видит их без клея.
Опции
Настройки — атрибуты разметки, а не объект конфигурации: у библиотеки нет экземпляров, к которым его можно было бы приложить.
| Атрибут | Где | Что делает |
|---|---|---|
data-copy |
на .inst-copy |
Копировать это, а не текст блока |
data-copied-label |
на .inst-copy |
Своя фраза для скринридера вместо «Copied» |
data-failed-label |
на .inst-copy |
То же для неудачи |
data-value |
на .inst-tag и на пункте группы |
Что придёт в detail вместо текста |
step, min, max |
на input[type=number] |
Шаг и границы перетаскивания оси |
Правила
Не меняет оформление. Ни одного присвоения style, ни одного класса
оформления: модуль ставит атрибуты из разметочного контракта — tabindex,
aria-selected, aria-checked, aria-expanded, — а как они выглядят, решает
CSS. Приложение поэтому может переопределить вид, не трогая поведение.
Не обязателен. Без него работает всё, что не требует клавиатуры:
раскрытие на <details>, поповер на
Popover API, модалка на <dialog>,
валидация на :user-invalid.
API
| Имя | Значение | Что делает |
|---|---|---|
| атрибут | ||
role | listbox · tree · menu · radiogroup · tablist | На контейнере. Только эти пять имеют контракт клавиатуры |
aria-orientation | horizontal · vertical | Ось стрелок. По умолчанию у списка, дерева и меню вертикальная, у радиогруппы и вкладок горизонтальная |
aria-level | — | На treeitem. По нему стрелка «назад» находит родителя — вложенность в разметке может быть плоской |
aria-expanded | true · false | На раскрываемом treeitem. Без него узел считается листом |
aria-disabled | true | Пункт остаётся в обходе стрелками, но не действует |
data-value | — | На пункте группы и на теге. Что придёт в detail события вместо текста |
data-copy | — | На .inst-copy. Копировать это значение, а не текст блока |
data-copied-label | — | На .inst-copy. Своя фраза для скринридера вместо «Copied» |
data-failed-label | — | На .inst-copy. То же для неудачи |
data-copied | true · false | Ставит и снимает instrument.js. Ответ кнопки копирования, живёт 1,4 с |
data-instrument | manual | На <html>. Модуль не запускается сам — приложение зовёт start() когда готово |
data-roving | manual | На группе. Бегущий tabindex внутри неё ведёт приложение; стрелки и выбор работают по-прежнему |
| событие | ||
inst:select | — | Выбран пункт группы. detail — { value }. Отмена оставляет разметку нетронутой |
inst:copy | — | Нажата кнопка копирования. detail — { text } |
inst:remove | — | Снимается тег. detail — { value } |
inst:selectall | — | Переключён чекбокс шапки таблицы. detail — { checked } |
Чего пока нет
- набора по первым буквам (typeahead). В списке на двести строк он нужен, и APG его рекомендует;
- множественного выделения
Shift+стрелки вlistbox; - закрытия тултипа по
Escape: сейчас он не проходит критерий 1.4.13, потому что его нельзя ни навести, ни закрыть.
Почему кит устроен именно так — конституция · Открытый код под MIT, github.com/keshon/instrument