Основания
Поведение
Кит рисует состояние и объявляет роли. Роль — это обещание: 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="src/kit.css">
<script type="module" src="src/kit.js"></script>
Модуль, а не классический скрипт: у него есть именованные экспорты, и на них потом сядут обёртки Svelte и React. Сборки по-прежнему нет.
Устройство
| Роль | Стрелки | Ещё |
|---|---|---|
listbox |
↑ ↓ по строкам | Выделение следует за фокусом |
tree |
↑ ↓ по узлам, → ← раскрытие | ← на свёрнутом уходит к родителю по aria-level |
menu |
↑ ↓ по пунктам | Выделения нет: пункт — действие, а не выбор |
radiogroup |
← → по вариантам | Отметка следует за фокусом |
tablist |
← → по вкладкам | Выбор следует за фокусом |
Home и End — во всех пяти. Перебор идёт по кругу: у очереди задач нет
«конца», после которого некуда деться, и тупик на последней строке ничего не
даёт.
Поведение
Ровно один элемент группы достижим по Tab. В этом весь смысл.
| Как было | Что происходило |
|---|---|
tabindex="0" на каждой строке |
Двести нажатий Tab, чтобы уйти из списка |
Нет tabindex ни на одной |
Список недостижим с клавиатуры вообще — так кит и жил |
| Бегущий | Один Tab внутрь, стрелки внутри, один Tab наружу |
Якорем становится выделенный элемент, а если выделения нет — первый.
Строки, прибывающие во время работы
Слушатели висят на документе, а элементы ищутся в момент нажатия. Для
агентного интерфейса это не оптимизация, а единственный рабочий вариант:
строки очереди, шаги и узлы дерева появляются по одной, и любой init(el)
при старте промахнулся бы мимо всего, что пришло позже.
Единственное, что нельзя сделать лениво, — начальный tabindex: группа без
него не достижима по Tab. За появлением групп следит MutationObserver, а
не человек.
import { refresh } from './src/kit.js';
// Нужно только если наблюдатель отключён — например, в тесте.
refresh(container);
Нарисованные обещания
Тот же закон, применённый к оформлению. Кнопка копирования, крестик снятия,
курсор ew-resize на подписи оси — это обещания, данные картинкой. Кит их
рисует, значит, обязан выполнять.
| Что нарисовано | Что делает кит |
|---|---|
.inst-copy внутри .inst-copyable или .inst-code |
Кладёт текст в буфер, отвечает цветом и объявляет результат |
.inst-tag-remove |
Снимает тег и уводит фокус на соседний |
.inst-num-axis с курсором ew-resize |
Тянет значение числового поля |
<output for> рядом с бегунком |
Держит в нём текущее значение |
| Чекбокс в шапке колонки выбора таблицы | Выбирает все строки, показывает частичный выбор |
| Выбранная вкладка, вариант, строка списка | Переносит aria-selected и aria-checked — и по стрелке, и по щелчку |
Сортировка, загрузка файлов, выбор даты и закрытие баннера сюда не входят: кит там ничего не обещает картинкой, а данные — дело приложения.
JS
Модуль ничего не экспортирует классами и не требует инициализации: он подключается один раз и работает делегированием. Всё, что перечислено ниже, — поверхность на случай, когда этого мало.
Методы
start(root = document) |
Подключить. Вызывается сам при загрузке модуля |
stop(root = document) |
Отключить. Нужно тестам и горячей перезагрузке, а не приложению |
refresh(root = document) |
Пройти поддерево заново: бегущий tabindex, <output> бегунков, «выбрать всё» в таблицах |
toast(options) |
Показать уведомление |
События
Все — CustomEvent, всплывают и отменяемы. preventDefault() означает
«приложение берёт эту работу на себя»: кит останавливается и не трогает
разметку.
| Событие | Где | detail |
|---|---|---|
inst:select |
на пункте группы с ролью | { value } — data-value пункта, иначе его текст |
inst:copy |
на .inst-copy |
{ text } — что уйдёт в буфер |
inst:remove |
на .inst-tag |
{ value } — data-value тега, иначе его текст |
inst:selectall |
на .inst-table |
{ checked } — новое состояние всех строк |
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 |
Своя фраза для скринридера вместо «Скопировано» |
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.
Доступность
| Зачем это вообще | Пять компонентов объявляли ARIA-роли и не выполняли их контракт. Роль без поведения обманывает вспомогательную технологию |
| Отключённые пункты | aria-disabled="true" выбрасывает из обхода стрелками, но оставляет в разметке — в отличие от disabled, который выкидывает из порядка обхода целиком |
| Невидимые пункты | Тоже выбрасываются: фокус на них не ставит ни платформа, ни кит |
| Вложенные группы | Стрелка в родителе не прыгает по чужим пунктам — подменю и группы внутри дерева считаются отдельно |
| Модификаторы | Ctrl, Alt и Cmd со стрелками не перехватываются: это команды платформы |
| Уже обработанное | Событие с defaultPrevented пропускается, поэтому приложение может перехватить клавишу раньше |
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. Своя фраза для скринридера вместо «Скопировано» |
data-failed-label | — | На .inst-copy. То же для неудачи |
data-copied | true · false | Ставит и снимает кит. Ответ кнопки копирования, живёт 1,4 с |
| событие | ||
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