Tab, Tab, Tab: разговор у монитора о том, почему календарь и таблица ломаются первыми

Допустим, вечер четверга, пустой опенспейс и пулреквест с новым пикером даты. Гоша гордо показывает экран: сетка, подсветка сегодняшнего дня, плавная анимация. Ира берёт мышь и откладывает её на край стола.
Ира: Покажи теперь без этого.
Гоша: Без чего?
Ира: Без мыши. Я просто жму Tab.
Гоша: Ну, фокус попадёт на поле...
Ира: Попал. Жму ещё. И ещё. Гош, я уже ушла в футер, а календарь так и не открылся.
Гоша: Он открывается по клику на иконку.
Ира: А иконка у тебя что?
Гоша: div с role="button".
Ира: Вот. Это и есть первая история на сегодня.
Роль — это объявление, а не поведение
Гоша: Но я же написал роль. Скринридер скажет «кнопка».
Ира: Скажет. Только кнопки там нет. ARIA меняет то, что виджет объявляет, а не то, что он умеет. Фокуса div не получит, на Enter и Space не среагирует. Ты нарисовал табличку «кнопка» на куске стены.
Гоша: Поставлю tabindex="0" и обработчики клавиш.
Ира: Можно, но заметь, что ты сейчас заново пишешь то, что нативная кнопка делает бесплатно. Я бы сначала спросила, нельзя ли просто взять button. И заодно: tabindex с положительным значением не трогай никогда, он ломает порядок обхода всей страницы. Это не лекарство от всего.
Гоша: Ладно, кнопка. Но сам календарь всё равно сложный.
Ира: Сложный, и вот почему. Смотри, сколько в нём всего накручено.
Три виджета в одном пальто
Ира: Календарь — это сразу три вещи. Первое: поле, куда можно вписать дату. Второе: модальный диалог, который открывается над страницей. Третье: сетка дат, внутри которой ходят стрелками. У каждой своя клавиатурная модель, и если сломана хоть одна, ломается весь сценарий целиком.
Гоша: Почему «чаще всего»? Форма с тремя инпутами тоже может быть кривой.
Ира: Может, но у обычного инпута всю работу делает браузер: фокус, ввод, подписи. А в составных виджетах браузер не управляет ничем. Куда пойдёт фокус после стрелки вправо, что будет на Esc, кто его вернёт: всё это пишешь ты. И часто не дописываешь, потому что на мышке оно и так «работает».
Гоша: Тогда давай по частям. Что должно быть у диалога?
Ира: Минимум такой:
<div role="dialog" aria-modal="true" aria-label="Выбор даты">
<!-- заголовок месяца, кнопки, сетка дат -->
</div>
Дальше поведение, которое HTML не напишет за тебя:
- Перед открытием запомнить, где был фокус.
- Отправить фокус внутрь, и лучше на выбранную дату, а если её нет, на сегодняшнюю.
- На Esc закрыть.
- После закрытия вернуть фокус на поле.
Гоша: А если пропустить последний пункт?
Ира: Человек закрыл календарь и оказался в начале страницы. Представь, что тебя вышвырнули из лифта на первый этаж, хотя ты ехал на седьмой. Ещё хуже, если из диалога вообще нельзя выйти с клавиатуры: это ловушка фокуса, и под неё в WCAG есть отдельный критерий, 2.1.2.
Сетка, у которой одна дверь
Гоша: Хорошо, а внутри сетки? Там же сорок кнопок-дат. Это сорок остановок Tab?
Ира: Нет, и это ровно то, что у большинства не получается. Сетка должна быть одной остановкой. Входишь Tab-ом один раз, дальше ходишь стрелками, а следующий Tab выводит тебя наружу. Технически это называют roving tabindex: у одной ячейки tabindex="0", у остальных -1, и атрибут переезжает вслед за фокусом.
Гоша: А какие клавиши вообще положены?
Ира: Для сетки дат: стрелки двигают на день и на неделю, Home и End прыгают в начало и конец недели, Page Up и Page Down листают месяц, с Shift листают год. Enter выбирает дату и закрывает диалог.
Гоша: А если я повешу перехват стрелок на весь документ? Так проще.
Ира: Тогда у человека перестанет скроллиться страница. Глобальный перехват стрелок я видела в обзорах типичных ошибок, и он там не один. Слушай события на самой сетке и не трогай остальное.
Что сказать вслух
Гоша: Хорошо, клавиатура. Но слепой пользователь же не видит, что месяц сменился.
Ира: Поэтому заголовок месяца кладут в aria-live="polite": переключил, и скринридер вслух сообщил «ноябрь». Сегодняшнюю дату помечай aria-current="date", выбранную aria-selected. Это три разных состояния, их нельзя мешать, aria-checked тут вообще ни при чём. Стрелочки «назад» и «вперёд», если они декоративные, прячь через aria-hidden, чтобы они не читались как мусор.
Гоша: А выбранную дату я просто закрашиваю акцентным цветом. Нормально?
Ира: Цвет не должен быть единственным признаком. Добавь рамку, жирность, что угодно. И заметный фокус: если после Tab я не вижу, где я, это нарушение 2.4.7. Отдельно с кнопками: видимая подпись и доступное имя должны совпадать, иначе голосовое управление не найдёт «Применить» (критерий 2.5.3).
Гоша: Понял, календарь превращается в аттракцион. Может, вообще выкинуть его и оставить поле?
Ира: Иногда это честный ответ. Данные тут расходятся, но для далёких дат вроде дня рождения ввод обычно быстрее, чем двадцать раз нажимать «назад». Календарь хорош там, где человеку важен контекст: ближайшие две недели, выходные, разница между числами.
Таблица: скучная, пока её не трогают
Гоша: Ладно, календарь понял. Таблица-то чем провинилась? Это table.
Ира: Пока это table с нормальной структурой, ничем. Первым ребёнком идёт caption, а не устаревший summary. В шапке th со scope="col", в первом столбце th со scope="row". Атрибуты id и headers нужны только для сложных шапок с объединёнными ячейками. Проблемы начинаются, когда в таблице появляются сортировка и выбор строк.
Гоша: У меня клик по заголовку сортирует.
Ира: Клик по чему, по th?
Гоша: По th. Там же onclick.
Ира: Вот так он должен выглядеть:
<th scope="col" aria-sort="ascending">
<button type="button">Дата заказа</button>
</th>
Кнопка внутри th, а aria-sort на самом th, и только у активной колонки. Спорят лишь о том, ставить ли aria-sort="none" всем остальным или просто снять атрибут. Одни проставляют, другие убирают, я убираю.
Гоша: А кликабельные строки? У меня весь tr открывает карточку заказа.
Ира: onclick на tr не делает его ни ссылкой, ни кнопкой. Для скринридера это по-прежнему строка, и никакой интерактивности он не объявит. А role="button" на tr сломает структуру таблицы целиком. Положи ссылку в ячейку с номером заказа, а строку можно оставить кликабельной как бонус для мыши.
Когда таблице правда нужен grid
Гоша: А когда role="grid"? Мне казалось, это просто «продвинутая таблица».
Ира: Здесь граница размыта, и практики спорят. Часть из них считает, что grid лишний везде, кроме таблиц вроде электронных. Если людям нужны стрелки по ячейкам, тогда да: контейнер grid, строки row, ячейки gridcell, шапка columnheader и rowheader, имя через aria-labelledby. И снова одна остановка Tab, тот же roving tabindex, что и в календаре. Home и End в краях строки, Ctrl+Home и Ctrl+End в углах таблицы, Page Up и Page Down листают порцию строк.
Гоша: А если таблица просто читается?
Ира: Тогда не надо grid. Сначала нативное, grid только там, где нужны стрелки. Худшее, что я видела: div с role="grid", внутри которого нет ни строк, ни ячеек, ни имени. Скринридер или молчит, или зачитывает кашу.
Гоша: Ещё у меня таблица широкая, с горизонтальным скроллом.
Ира: Один источник советует выдавать прокручиваемому контейнеру tabindex="0" и имя, иначе до правых колонок с клавиатуры не добраться. Я бы проверила это на своей разметке, но идея понятная: если скроллить может только мышь, часть данных для клавиатуры просто не существует.
Чем проверять
Гоша: Прогоню автотест, и всё.
Ира: Автотест увидит отсутствующую подпись или контраст. Он не скажет, что фокус не вернулся на поле и что Esc не закрывает диалог. Это проверяется только руками: отложи мышь, пройди сценарий клавишей Tab, потом включи живой скринридер. Причём не один: они не одинаковы, и то, что сказал один, другой может прочитать иначе.
Гоша: Тогда завтра пишу себе список.
Ира: Начни с одного пункта. Закрой мышь листом бумаги и пройди свой календарь: вход, выбор даты, закрытие. Если на любом шаге ты потерялся, значит, потерялся и тот, кому клавиатура нужна не по настроению, а по необходимости.
Гоша: А потом?
Ира: А потом заменим этот div на button. С него и начнём.