Перейти к содержимому
EN
Масштаб
Плотность
Тема
Акцент

Основания

Поведение

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

Атрибуты
ИмяЗначениеЧто делает
атрибут
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. Своя фраза для скринридера вместо «Copied»
data-failed-label—На .inst-copy. То же для неудачи
data-copiedtrue · falseСтавит и снимает instrument.js. Ответ кнопки копирования, живёт 1,4 с
data-instrumentmanualНа <html>. Модуль не запускается сам — приложение зовёт start() когда готово
data-rovingmanualНа группе. Бегущий 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