</>404 Dev Found

· frontend · 8 мин чтения

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 не напишет за тебя:

  1. Перед открытием запомнить, где был фокус.
  2. Отправить фокус внутрь, и лучше на выбранную дату, а если её нет, на сегодняшнюю.
  3. На Esc закрыть.
  4. После закрытия вернуть фокус на поле.

Гоша: А если пропустить последний пункт?

Ира: Человек закрыл календарь и оказался в начале страницы. Представь, что тебя вышвырнули из лифта на первый этаж, хотя ты ехал на седьмой. Ещё хуже, если из диалога вообще нельзя выйти с клавиатуры: это ловушка фокуса, и под неё в WCAG есть отдельный критерий, 2.1.2.

Сетка, у которой одна дверь

Гоша: Хорошо, а внутри сетки? Там же сорок кнопок-дат. Это сорок остановок Tab?

Ира: Нет, и это ровно то, что у большинства не получается. Сетка должна быть одной остановкой. Входишь Tab-ом один раз, дальше ходишь стрелками, а следующий Tab выводит тебя наружу. Технически это называют roving tabindex: у одной ячейки tabindex="0", у остальных -1, и атрибут переезжает вслед за фокусом.

Как ездит tabindex по сетке
Как ездит tabindex по сетке

Гоша: А какие клавиши вообще положены?

Ира: Для сетки дат: стрелки двигают на день и на неделю, 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. С него и начнём.