Comments 24

Shift+Tab как-то странно работает, фокус либо переходит вперёд, как без Shift, либо (дойдя до последнего варианта) моргает и остаётся на том же месте.

Никогда в жизни даже не думал о каких-то там табах при открытых модалах… И честно говоря не хочу думать, мы же не тюрьму проектируем, из которой надо обязательно запретить убежать любому попавшему в неё..

Но вот проблема — у модала обычно дефолтная кнопка "Отмена", а перейти на другую кнопку я могу только мышкой, хотя было бы удобно нажать Tab, а затем Enter.

В крайнем случае, гурманы клавиатурного подхода (каким являюсь в том числе и я сам) всегда имеют возможность увеличить свои возможности с помощью браузерных расширений. Я, к примеру, активно использую Vimium. Прекрасная вещь для тех, кто хочет нажимать на нужные элементы чисто с помощью клавиатуры.


Для всех остальных 98% населения этой планеты вообще будет по барабану, уходит ли ваш фокус куда-то не туда, или не уходит) Сидеть и тратить своё рабочее время на эти 2%, которые свою же проблему могут всё равно без особых проблем решить с помощью браузерных расширений (благо у них и мозгов на это обычно хватает), — я лично не вижу смысла. Лучше потратить это время на другие важные для проекта дела.

Мне нужен клавиатурный подход, мне нужна возможность быстро закрыть модальное окно.


Понятно, если вы делаете сайт для домохозяек, то да — им все равно. А если вы делаете сайт для достаточно продвинутых пользователей интернета, которые привыкли часть задач выполнять клавиатурой, потому что это в 1000 раз быстрее, то такие штуки портят впечатление от сайта.


Отдельный привет всем тем формам ввода, которые не дают мне возможность табать код на сайтах для программистов.

Вообще, для закрытия модального окна обычно достаточно повесить реакцию на нажатие кнопки Escape :) Кстати, название символическое прямо, соответствующее контексту этой беседы)

Да, в целом я понимаю вашу логику и желания. Просто на мой взгляд, это совсем не критично) Очень малое кол-во людей будет пользоваться этим, и многие из них всё равно уже пользуются браузерными расширениями для облегчения работы с клавиатуры. Советую и вам кстати тоже попробовать, возможно тоже войдёте во вкус)

Прочёл дальше этот тред.

Вы немного ошибаетесь, поскольку в статье речь идёт о доступности (это когда человек, к примеру, не видит и пользуется скрин ридером, соответственно, остаётся только клавиатура в его распоряжении), а не о запрете выхода из модалки без тыканья в кнопку табом.

Ну, это конечно да. Если затачивать сайт под таких людей, это уже становится нужным и важным.

Статья несколько сумбурная и трудно читается, плюс смешаны в кучу разные проблемы. Что в общем и понятно, a11y тема большая и сложная. Ради подсматривания идей рекомендую посмотреть на примеры Ext JS, мы по многим граблям уже прошлись. Решения часто не идеальные, но более или менее работают.


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


  • Как не отпускать фокус из диалога с модальной маской
  • Как не сойти с ума, если фокус всё же убежал под маску
  • Как не дать экранным читалкам сфокусировать элементы под маской

Первая проблема решается относительно просто. Вариантов пользовательского взаимодействия здесь может быть несколько: pointer/touch, клавиатура, прямой доступ к элементам через интерфейс экранных читалок и т.п.


Для pointer/touch можно использовать модальную маску: элемент, визуально находящийся "под" диалогом и закрывающий весь остаток экрана. Все клики/прикосновения вне диалога приземляются на этот элемент и игнорируются.


С клавиатурой есть несколько вариантов, самый надёжный это ловушки: невидимые элементы <span tabindex="0" aria-hidden="true">, размещённые по "краям" диалога. Верхняя ловушка должна располагаться в DOM дереве перед первым таббабельным элементом в диалоге, нижняя, соответсвенно, после последнего. На ловушки вешается обработчик события focus, который определяет, в каком направлении движется фокус, и перебрасывает его на первый таббабельный элемент сверху или последний снизу. Когда пользователь нажимает Tab/Shift-tab, фокус из диалога не сбегает даже в строку URL (это требование WAI-ARIA).


C tabIndex может оказаться не так просто, если внутри диалога есть элементы с tabIndex > 0. Для универсальности перед показом диалога лучше найти все эти элементы, выбрать минимальный tabIndex и присвоить его верхней ловушке, а максимальный, соответственно, нижней.


Что касается прямого фокусирования элементов через экранные читалки, то с этим де факто ничего сделать нельзя. Пользователь может в любой момент вызвать у себя список, скажем, заголовков на странице, таблиц и строк, etc, и перейти напрямую к любому элементу. С этим нужно просто смириться и проектировать сайт/приложение с учётом такого поведения. Для смягчения проблемы можно давать подсказки экранным читалкам, но это тоже не всегда срабатывает (см. ниже).


Вторая проблема сложнее и распадается на две части: как не дать фокусу попасть "под" маску, и как быть, если он там всё же оказался.


Первая часть относительно сложна, т.к. пользователь может начать нажимать Tab с тела документа или из строки URL, и первый таббабельный элемент запросто может оказаться "под" маской. Или диалог может открыться без вмешательства пользователя (ошибка соединения с сервером, etc) и текущий сфокусированный элемент окажется под маской, и т.д., вариантов миллион. Для искоренения таких возможностей мы ищем все таббабельные элементы "под" маской и убираем tabIndex/ставим -1, а после снятия маски восстанавливаем всё как было.


Если же каким-то образом фокус попал "под" маску и пользователь нажал Tab/Shift-Tab, то первым таббабельным элементом в документе окажется фокусная ловушка в диалоге, которая должна отработать событие и направить фокус внутрь диалога. Чтобы ловушка отрабатывала максимально гибко, мы проверяем только направление движения фокуса, но не принадлежность элементов — если фокус прилетел извне диалога, то в общем и всё равно.


Третья проблема гораздо интереснее. Практическое решение, которое я недавно нашёл, но ещё не успел применить в фреймворке, заключается в следующем:


а) Основную разметку страницы заключаем в дополнительный <div> без позиционирования, который не влияет на раскладку и нужен только для группировки элементов
б) Модальные элементы, включая маску и диалог, добавляем в смежный контейнер, не входящий в основной <div> — это важно
в) При закрытии экрана модальной маской к основному контейнеру добавляем атрибут aria-busy="true", который удаляется при снятии маски


Таким образом мы даём экранной читалке понять, что содержимое страницы, кроме диалога, должно быть недоступно и фокусировать его не нужно. Важно, чтобы диалог с маской не попали в контейнер aria-busy, т.к. в этом случае некоторые читалки просто игнорируют атрибут — нельзя сделать недоступным текущий сфокусированный элемент.


С обработкой клавиатурных событий внутри диалога должно быть более или менее просто: Enter эквивалентен нажатию кнопки по умолчанию, Esc приводит к отмене и закрытию диалога. Это если у вас кнопки OK и Отмена, а вот если, скажем, Да/Нет, то тут уже не так однозначно. Или если в диалоге есть виджеты, "съедающие" Enter/Esc — надо не забывать останавливать событие, иначе, скажем, Esc на открытом списке комбо-бокса закроет не только список, но и диалог тоже.


Много нюансов, но не страшно сложно и нет ничего такого, что нельзя легко закрыть юнит-тестами. Модальные диалоги это всё-таки не grids. :)

Вы немного не последовательны:
1. Во первых aria-busy придуман не для этого. Основной контент надо именно «прятать», для чего нужен aria-hidden. И это относится исключительно к пункту 3.
2. А вот пункты 2 и 3 различать не надо. Есть два события — focusIn, когда фокус куда-то пришел, и focusOut, когда он откуда-то ушел.
Большинство библиотек агрятся на попытку фокуса покинуть ловушку, те focusOut, где новый элемент будет записан в relatedTarget. При этом ловушку в любом случае можно покинуть уйдя в адресную строку браузера, и на этом все сломается. Выбрался — значит свободен.
Плюс focusOut — его можно(и нужно) вешать на свою ноду.
К сожалению, так как это не очень работает, требуется вешать хэндлер на focusIn глобально на документе. Теперь, когда что-то за пределами ловушки получает фокус — можно будет этот фокус взять и положить обратно.
Ну а в итоге требуется вешать события и туда и туда.

И самое главное — НИЧЕГО кроме операций с фокусом делать не надо. Никакие keyboard/mouse/touch events.

Тоже самое относиться к предложеным фокусным ловушкам по краям основной. Главный поинт в том, что эти ловушки должны быть за пределами, а не внутри. И исключительно чтобы между началом/конца документа и модалом был что-то таббательное.
И в таком случае их вообще можно не включать в состав библиотеки — focus-lock/dom-focus-lock не могут менять верстку.

В общем KISS в полной красе.

п. 1. Почитайте внимательнее спецификацию, там отчётливо не рекомендуется использовать aria-hidden для контента, визуально присутствующего на странице. В случае с модальным диалогом контент присутствует, но должен быть недоступен, а для этого лучше подходит aria-busy.


Однозначного решения для этой проблемы я в спецификации не вижу, а вариант с aria-busy был найден в результате совместного поиска со специалистами по доступности веб-приложений из University of Washington.


п. 2. События focusin и focusout, к счастью, уже работают во всех браузерах — но с очень недавних пор, в Firefox они появились только в мартовской версии. Если вам нужно поддерживать предыдущий LTS, то проблемы будут в полный рост. Вряд ли, но просто учитывайте возможность.


В данном случае использовать события focusin и focusout неоптимально по разным причинам. Во-первых, focusin и focusout стреляют не только при перемещении фокуса на другой элемент, но и при фокусировании/расфокусировании окна самого браузера, и не всегда можно определить, что же именно происходит. Во-вторых, спецификация однозначно утверждает, что (выделение моё):


Like non-modal dialogs, modal dialogs contain their tab sequence. That is, Tab and Shift + Tab do not move focus outside the dialog. However, unlike most non-modal dialogs, modal dialogs do not provide means for moving keyboard focus outside the dialog window without closing the dialog.

Ваш вариант предусматривает реакцию на потерю фокуса диалогом, а это совершенно не эквивалентно предотвращению потери фокуса. В частности, экранные читалки могут, и во многих случаях будут, анонсировать сфокусированный элемент вне диалога, а затем ещё раз анонсировать элемент внутри диалога. Слепым пользователям очень трудно ориентироваться в таких условиях.


Но самая большая проблема с таким подходом возникнет, если у вас есть элементы с tabIndex > 0 где-либо вне диалога. В таком случае порядок табуляции может быть крайне неочевидным для пользователя, и при выходе фокуса за пределы диалога вашему коду нужно будет решать, куда вернуть его обратно в диалог. Тут нужно будет либо запоминать состояние на каждый чих, либо пытаться играть в игры с порядком табуляции, что само по себе весьма нетривиальная задача.


На варианте с ловушками я остановился после нескольких лет экспериментов на кошках с клиентскими приложениями, и это единственный вариант, который закрывает все требования и специальные случаи. Реагировать на события pointer*/touch*/key* с таким подходом не обязательно, модальная маска может быть пассивной, предотвращая фокусирование элементов "под" ней просто за счёт своего положения в DOM и более высокого z-index.

Ну насчет aria-hidden – лично меня спецификация не очень убедила. Точнее он все еще более правильный чем aria-busy, тем более достаточно часто контент за пределами модала и не виден пользователю (или не читабелен).
Пока самое хорошее решение — или inert или blockingElements. Но ни того, ни другого в браузеры не завезли.

С фокусом проблем нет — банально проверено на читалках. Но вообще тест очень простой — после ловушки на очень большом растоянии располагалается еще один фокусируемый элемент. Если фокус перескочет на него — произойдет скрол страницы. Но если вызвать preventDefault — то скрола не будет.
Другая проблема что порядок таббания отличается от перехода по элементам стрелками(VioceOver), что (конечно же) может все сломать и с этим уже бороться бесполезно.

А вот проблема с tabIndex раскиданным по странице с точки зрения порядка обхода — и не проблема вовсе — github.com/theKashey/focus-lock/blob/master/src/focusMerge.js
Ну насчет aria-hidden – лично меня спецификация не очень убедила. Точнее он все еще более правильный чем aria-busy, тем более достаточно часто контент за пределами модала и не виден пользователю (или не читабелен).

Я согласен с тем, что данный момент в спецификации не очень хорошо освещён, поэтому возможны различные интерпретации. Вариант, которым я с вами поделился, был рекомендован специалистами по доступности из University of Washington — это люди, которые за большие деньги консультируют компании навроде Microsoft и Amazon. Как минимум один из этих экспертов сам незряч и постоянно пользуется экранными читалками. Я склонен принимать их рекомендации к руководству, а вы решайте сами.


С фокусом проблем нет — банально проверено на читалках.

Вы проверяли на всех читалках, включая мобильные? На всех возможных настройках? Они очень разные бывают.


Но вообще тест очень простой — после ловушки на очень большом растоянии располагалается еще один фокусируемый элемент. Если фокус перескочет на него — произойдет скрол страницы. Но если вызвать preventDefault — то скрола не будет.

Поясните пожалуйста, каким образом прокрутка документа влияет на порядок анонсирования экранных читалок. Насколько я знаю, это вещи не связанные.


Я понимаю и разделяю ваше желание обойтись малой кровью, поэтому и делюсь проверенными на практике решениями. В случае с модальным диалогом все известные мне проблемы снимаются ловушками фокуса и модальной маской, как описано в первом сообщении. Я просто не стал цитировать все тикеты от клиентов, которые привели к этому результату. :)


Другая проблема что порядок таббания отличается от перехода по элементам стрелками(VioceOver), что (конечно же) может все сломать и с этим уже бороться бесполезно.

Если таббабельные элементы находятся только внутри модального диалога, то слишком уж больших проблем возникнуть не должно при любом разумном раскладе. Вообще tabIndex > 0 это зло, которого надо избегать.


А вот проблема с tabIndex раскиданным по странице с точки зрения порядка обхода — и не проблема вовсе

Это вам сейчас так кажется. А потом начинают лезть специальные случаи, и батарея проверок вырастает в два метра длиной. Не спрашивайте, откуда я всё это знаю. :)

Чувствуется опыт, друг ошибок трудных.
С aria-hidden/busy, насколько я понимаю, история уровня zoom:1 – в общем мы методом тыка проверили как лучше, и лучше вот так. За стандарт немного обидно, но в вебе так везде и всегда.
Чувствуется опыт, друг ошибок трудных.

Он самый.


С aria-hidden/busy, насколько я понимаю, история уровня zoom:1 – в общем мы методом тыка проверили как лучше, и лучше вот так. За стандарт немного обидно, но в вебе так везде и всегда.

Стандарт WAI-ARIA это живой документ, он не далеко идеален, но работа по улучшению идёт постоянно. В версии 1.1 добавили уже много такого, чего страшно не хватало раньше, особенно для работы с табличными виджетами. Надеюсь, что браузеры скоро начнут эти нововведения поддерживать, и жизнь должна облегчиться очень заметно.

Большое спасибо за подробное разъяснение по ловушкам. Буквально сегодня делал кастомный попап, и сразу же воспользовался всеми вашими советами. Работает превосходно.

Рад, что помог. Выше давал ссылку на демо-приложение на базе фреймворка Ext JS, в котором я разрабатывал в т.ч. поддержку доступности, рекомендую заимствовать идеи по полной программе. :) Шишек пришлось набить изрядно, вам этот опыт повторять не обязательно.

Спасибо. Совсем забыл про эту «фичу», когда внутри focus-lock находяться первый элемент с tabIndex=1, который оказывается самым-самым первым на странице.
В общем случае с самого первого или самого последнего можно выйти во внешний мир и/или просто начать не правильно работать.
Пофиксил react версию focus-lock просто добавим элемент с tabIndex=1 за пределами ловушки, и обновил ссылки на примеры в статье — теперь работают секси.
PS: Я вообще не совсем уверен откуда я взял старую ссылку — она использует старую версию библиотек. В общем спасибо за комментарий.
Only those users with full accounts are able to leave comments. Log in, please.