It's a (focus) Trap

    Люди которые пишут стандарты — очень хитро устроились. Им достаточно написать как должно все хорошо работать, а дальше уже не их проблемы.

    Примерно так и получилось с тем «как должны работать диалоги», точнее «правильные» с точки зрения a11y модальные диалоги.

    В описание к dialog role на MDN все написано очень просто:

    • The dialog must be properly labeled
    • Keyboard focus must be managed correctly

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



    Modal dialog


    История началась совсем недавно — в рассылке Веб-стандартов попалась мне ссылка на «правильный» WAI-ARIA Dialog. И понеслось.

    Компонент на самом деле хорош:

    • он вешает aria-hidden на страницу, чтобы скрыть контент от screen-readers (работает только в первом примере).
    • он затеняет контент и вырубает скрол странице.
    • контролирует фокус, так чтобы из модала нельзя было табнуться.
    • после закрытия диалога он возвращает фокус на исходную позицию.
    • и добавляет разные aria-специфичные тэги, конечно же.

    Те он делает все что просит MDN и даже больше, так как без первого пункта «выйти» из диалога с активированным screen reader — не составляет никакого труда.

    В общем — must have!



    Focus


    Но вот реализация "focus-management" немного подкачала — ребята реально перехватывают keyboard events(и не только) и эмулируют кнопку tab самостоятельно.

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

    Начнем с сайтов (немного предвзятая выборка):

    • Google Gmail|G+ — идеально. ️
    • Одноклассники — с уходом таба закрывают модал, на который фокус так и не приходит
    • FB — зависит от страницы. В группе/на личной страницы — ничего нет, в момент написания сообщений есть. Никогда не жмакайте Таб(в сафари) на главной — крышу сносит.
    • VK — страница «рандомно» игнорирует таб ️
    • Yandex.Maps — страница полностью игнорирует таб ️
    • Yandex.Music — страница полностью игнорирует таб ️
    • РСЯ — нет focus management.
    • LiveJournal — нет focus management.
    • Мои собственные сайты — нет focus management.
    • Habrahabr — нет ни focus management, модалов
    • Jira/Confluence — идеально. ️

    Вывод простой — у «нормальных» сайтов немного не хватает мозгов, а Яндексу руки оторвать.

    С фреймворками (немного предвзятая выборка) сильно интереснее:


    С фреймворками оказывается тоже совсем плохо. И совсем никто-никто не вешает aria-hidden на остальной контент, чтобы сделать модал на самом деле доступным для людей, которые вынуждены использовать скрин-ридеры.

    Офтопик


    На самом деле я тоже раньше совершенно не заморачивался всей этой фигней, но тут, как на зло, моя дорогая жена решила научиться верстке в HTML Academy где pepelsbey с большой-большой компанией заставили и ее и меня задуматься над вопросом насколько таббабельный у меня сайт.

    Пришлось и науку новую выучить, и проблемму решить с фокусом.

    PS: Вадим рекомендует забить на всю эту aria-hidden с focus-management и воспользоваться html атрибутом inert, который просто «выключит»(врям совсем) все кроме модала и проблем нет будет ни с screen/reader, ни с фокусом.

    Хотя насчет второго не уверен, да и работает он пока не очень, а полифилы просто ужасающие.....

    Focus Lock


    В общем, как говорили на улице Льва Толстого… – а какие же ваши предложения.

    На самом деле проблема очень проста — не смотря на то, что для JS было написано миллионы модулей — модулей для focus management фактически нет.

    • focus-manager — простой focus-manager с простым и ванильным API и отличным примером. Есть пара минусов
    • ToleFocus — какой-то монстр, от которого бежать хочется.
    • react-focus-trap — настолько простой, что возвращает фокус только в начало.
    • Focus manager из AUI, но кто раньше слышал про AUI?
    • focus-trap, он же focus-trap-react который был использован в WAI-ARIA демке в начале статьи. И который по дефолту выключается по Esc и вообще не очень правильно использует DOM-API

    В общем 7 бед = +1 новый велосипед. А точнее настоящий поезд из focus-lock, dom-focus-lock, react-focus-lock и vue-focus-lock — на все случаи жизни.

    Со стороны обертки (react, vue, dom) все очень просто — получить DOM ноду и закрыть в ней фокус. Вся соль именно в focus-lock.

    Причин создания новой библиотеки несколько:

    • К сожалению все решения(кроме focus-trap/lock) совершенно полностью игнорируют tabIndex и становятся полностью неработоспособными если один умный програмист сломает порядок таббания.
      Случай, конечно, немного синтетический, но вполне реальный. К моему большому большому сожалению.
    • Из всех решений (кроме focus-trap/lock и react-focus-trap) можно без проблем табнуться в сафари(JFYI: сафари различает Tab и Opt+Tab). И если фокус единожды покинет ловушку — назад его уже никто не вернет.
    • focus-trap, который так хорошо везде работает, делает это потому что перехватывает и эмулирует Tab, те полностью игнорирует настройки того же Safari пунктом выше.
    • Все решения(кроме focus-lock и BluePrint.js) по входу селектят первый элемент, а не элемент с автофокусом.
      PS: focus-trap ищет элемент с атрибутом initialFocus. С чего бы?

    Так что пришлось сделать очередной велосипед, который временно отвечает чуть большему списку свистелок, чем его ближайшие конкуренты. Или конкретно всем.

    Просто оберни модалы(и не только модалы) в FocusLock — и половина проблем будет решена
    Демо React-focus-lock — codesandbox.io/s/jvl0k6zyk3 (найдите разницу).
    Демо Vue-focus-lock — codesandbox.io/s/l5qlpxqvnq

    <FocusLock>
       <Modal>
         any data
       </Modal>
    </FocusLock>
    

    Но только половина, так как aria-hidden (или inert) вешать прийдется кому-то другому и куда-то в другое место. Но это уже другая история.

    Итого


    Итого не забывай %username%, что модалы — это не только серенький лайтбокс, что не прокликивается мышкой, но и дивный мир клавиатурных упражнений.

    Но самое главное — не забывай что не надо мешать пользователю оперировать с сайтом не только мышкой.

    PS: А еще лучше включить VoiceOver или другой ScreenReader и попробуйте свои сайты на прочность. Будете удивлены.
    Многие вещи, например «ручная клавиатурная навигация» в ЯндексПочте — дефакто не не меняет активный элемент.
    Одного програмиста из Финляндии Яндекс точно потерял как пользователя.

    PPS: Gmail, правда, не так чтобы сильно лучше.
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 23
    • 0

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

      • –2

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

        • +1

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

          • –2

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


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

            • +1

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


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


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

              • 0

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

                • 0

                  Escape вроде обычно тоже работает как отмена, разве нет?

                  • 0

                    Команда отмены чем-то отличается от закрытия модала?

                    • 0

                      Иногда модалы бывают двух-кнопочные, я описывал такой случай.

                      • 0

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

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

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

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

          • +2

            Статья несколько сумбурная и трудно читается, плюс смешаны в кучу разные проблемы. Что в общем и понятно, 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. :)

            • 0
              Вы немного не последовательны:
              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 в полной красе.
              • 0

                п. 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.

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

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

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

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


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

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


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

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


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


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

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


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

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

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

                        Он самый.


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

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

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

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

              • 0
                Но у вас Shift+Tab не зациклен, как Tab, а всегда упирается в первый элемент
                • 0
                  Спасибо. Совсем забыл про эту «фичу», когда внутри focus-lock находяться первый элемент с tabIndex=1, который оказывается самым-самым первым на странице.
                  В общем случае с самого первого или самого последнего можно выйти во внешний мир и/или просто начать не правильно работать.
                  Пофиксил react версию focus-lock просто добавим элемент с tabIndex=1 за пределами ловушки, и обновил ссылки на примеры в статье — теперь работают секси.
                  PS: Я вообще не совсем уверен откуда я взял старую ссылку — она использует старую версию библиотек. В общем спасибо за комментарий.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.