Перейти к содержимому
Плотность
Тема

Основания

Поведение

Кит рисует состояние и объявляет роли. Роль — это обещание: 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

ИмяЗначениеЧто делает
атрибут
rolelistbox · tree · menu · radiogroup · tablistНа контейнере. Только эти пять имеют контракт клавиатуры
aria-orientationhorizontal · verticalОсь стрелок. По умолчанию у списка, дерева и меню вертикальная, у радиогруппы и вкладок горизонтальная
aria-levelНа treeitem. По нему стрелка «назад» находит родителя — вложенность в разметке может быть плоской
aria-expandedtrue · falseНа раскрываемом treeitem. Без него узел считается листом
aria-disabledtrueПункт выпадает из обхода стрелками
data-valueНа пункте группы и на теге. Что придёт в detail события вместо текста
data-copyНа .inst-copy. Копировать это значение, а не текст блока
data-copied-labelНа .inst-copy. Своя фраза для скринридера вместо «Скопировано»
data-failed-labelНа .inst-copy. То же для неудачи
data-copiedtrue · 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