Приложениями пользуются разные люди, и некоторым из них для этого нужны вспомогательные технологии — например, скринридеры (программы экранного доступа). Такие программы могут озвучить контент на странице, если приложение соответствует определённым стандартам. Например, они могут прочесть текст из параграфов и заголовков, списки, альтернативные описания изображений, ссылки, переключатели и другие интерактивные элементы. Таким образом скринридеры обеспечивают доступ незрячих пользователей к контенту и сервисам. 

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

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

Привет! Я Вячеслав Дорогинин, занимаюсь фронтендом WebView клиентского приложения Лавки. В этой статье расскажу, как мы адаптировали приложение для работы с популярными скринридерами, с какими трудностями столкнулись и как их решили.

Стартовая точка

Поначалу мы думали, что для доступности сервиса достаточно расставить соответствующие ARIA-атрибуты в тегах, но в реальности всё оказалось сложнее. Пользователи скринридеров также скроллят страницы и нажимают на визуальные элементы, но основные принципы навигации и восприятия страниц всё же отличаются от привычных. Сложности добавляет и тот факт, что скринридеры не следуют стандартам на 100%, и каждая система ведёт себя по-разному. 

Приложение Лавки с этой точки зрения пока не идеально, и нам предстоит решить множество проблем, но мы накопили богатый опыт, которым я и хочу поделиться. Приложение разработано под WebView-компоненты нативных приложений Go, Маркета и Лавки, поэтому мы адаптировали его для работы с VoiceOver и TalkBack — основными скринридерами мобильных устройств. В дальнейшем будем говорить именно о них. 

Важное замечание: нам неизвестно, пользуется ли человек скринридером. Скажем, в JavaScript нельзя проверить, включены ли вспомогательные технологии, да и выключить скринридер можно в любой момент. Поэтому менять вёрстку приложения для зрячего пользователя на лету не рассматривалось как вариант решения возникавших проблем. Для поддержания доступности мы можем использовать только возможности разметки HTML — она должна быть универсальной и для обычных пользователей, и для тех, кто пользуется скринридером.

Как адаптировать приложение для работы со скринридерами

Проставить теги

Самое быстрое и простое — разметить теги заголовков, кнопок и других визуальных элементов по стандартам семантической вёрстки. То есть заголовки сделать заголовками, а кнопки — кнопками. Это действие наименее трудоёмкое, но при этом значительно улучшает доступность приложения, поэтому на него надо обратить внимание в первую очередь. Но иногда разработчики забывают о подобных мелочах и оборачивают все элементы в <div>.

Поэтому хочу отдельно отметить, что у всех кликабельных элементов должна быть семантика кликабельных элементов. Это значит, что не стоит оборачивать кнопки, ссылки или элементы формы тегами <div> и вешать на них событие onclick. Если обычный пользователь может нажать что-то, внешне похожее на кнопку, хотя по семантике оно таковым не будет, то незрячий пользователь никак не узнает, что с этим элементом можно взаимодействовать.

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

Навести семантический порядок в контенте

Пользователи скринридеров обычно перемещаются между соседними элементами экрана свайпом влево-вправо — им важен порядок следования элементов. Если пользователь оказался на какой-либо сущности, например товаре, то сначала захочет узнать основную информацию о нём (название товара), а затем — второстепенную (цена, масса, состав). 

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

Bottom Sheet с детальной информацией о товаре
Bottom Sheet с детальной информацией о товаре

Чтобы вынести более важную информацию на первый план, расположите её выше в вёрстке. Самый простой способ сделать это — перевернуть блоки с контентом в обратном порядке следования и задать родительскому блоку стиль, например flex-direction: column-reverse;.

Есть и более сложные способы модифицировать структуру контента только для вспомогательных технологий, например, с помощью атрибута aria-owns. Но тут мы вступаем в ту часть спецификации WAI-ARIA (специальной разметки для вспомогательных технологий), которая по-разному поддерживается скринридерами. Например, aria-owns не поддерживается на iOS, поэтому полагаться на этот атрибут пока нельзя. Да и сопровождать проект, в котором заложена модификация структуры контента для вспомогательных технологий обычно сложнее, чем просто поменять общую структуру.

Сделать страницы удобными для навигации

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

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

Каждая категория нашего каталога включает подкатегории, которые состоят из заголовков, информеров и карточек товаров. Карточка каждого товара представляла собой кнопку, по нажатии которой открывался Bottom Sheet с детальной информацией. 

Проблема в том, что внутри кнопки-карточки располагалась ещё одна кнопка — добавления товара в корзину — и заголовок с названием товара. Такая разметка создавала сложности с навигацией по подкатегориям (в неё попадали и названия всех товаров) и кнопкам (перемешивались карточки товаров, кнопки добавления в корзину и информеры). А ещё нам хотелось внедрить механизм навигации только по товарам.

Пример страницы категории
Пример страницы категории

Чтобы решить все перечисленные проблемы, мы:

  • сделали карточку товара обычным блоковым элементом;

  • поверх карточки разместили абсолютно позиционированную и растянутую на всю ширину и высоту карточки ссылку с названием товара, по нажатии которой открывается Bottom Sheet;

  • видимый заголовок товара спрятали от скринридеров с помощью атрибута aria‑hidden.

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

Важный момент: скринридеры не обрабатывают ссылки без атрибута href. VoiceOver работает с такими ссылками как с обычными текстовыми элементами, а TalkBack вообще не добавляет их в дерево доступности. Поэтому мы решили добавить в href якорь #, а при нажатии ссылки отменять её поведение по умолчанию и открывать Bottom Sheet товара. 

Сделать текст доступным

Чтобы текст в приложении удобно воспринимался на слух, исключите блоки информации с визуальным представлением, смысл которой утрачивается при прочтении скринридером. Прежде всего это текст со стилевыми особенностями: разных цветов, размеров, с подчёркиванием, картинками и SVG-элементами. Например, в Лавке это блок с ценой товара и бейджи с кешбэком баллами Плюса за покупку.

Блок с кучей цифр: новой ценой, старой ценой, скидкой и баллами кешбэка
Блок с кучей цифр: новой ценой, старой ценой, скидкой и баллами кешбэка

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

То же самое касается бейджа с кешбэком: простое прочтение числа скринридером вообще не даёт представления о смысле этого блока. Выход тут один: добавляйте к таким блокам альтернативный невидимый текст. Да, есть семантический тег del, через который также можно описать зачёркнутый ценник. Однако пока распространённость и удобство его поддержки в скринридерах на разных платформах оставляет желать лучшего, поэтому полагаться на него в подобных ситуациях не стоит. Более надёжным будет всё-таки явное текстовое пояснение.

К сожалению, VoiceOver не поддерживает атрибут aria‑label для обычных блочных или строчных элементов, но мы нашли обходное решение: в родительский блок с ценой поместили невидимый текстовый элемент со следующими стилями: 

.a11y-text {
  position: absolute;
  height: 1px;
  width: 1px;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
}

Видимый в вёрстке неинформативный текст спрятали за aria‑hidden.

Здесь важно то, какую область занимает такой текст при фокусе скринридера на нём. Несмотря на то что мы явно задали CSS-свойства width, height и clip, ограничивающие ширину и высоту элемента одним пикселем, скринридеры, как правило, игнорируют это ограничение. И если TalkBack ограничивает такой текст родительским блоком (при задании стиля position: relative;), то VoiceOver никак не ограничивает размеры текста, и рамка фокуса совпадёт с размерами текста, как если бы он не был ограничен стилями по высоте и ширине. Раньше ограничение можно было выставить, если добавить родительскому блоку нестандартную роль text, однако с 17-й версии iOS эта роль перестала поддерживаться.

Фокус скринридера на невидимом тексте в блоке цены VoiceOver (слева) и TalkBack (справа)
Фокус скринридера на невидимом тексте в блоке цены VoiceOver (слева) и TalkBack (справа)

Кроме визуального несоответствия, которое сможет сбить с толку зрячего пользователя скринридера, других неудобств такое поведение не доставляет: текст не перехватывает фокус при нажатии соседних элементов, куда попадает рамка фокуса самого текста.

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

Bottom Sheet с условиями доставки
Bottom Sheet с условиями доставки

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

В силу ограничений VoiceOver, описанных в примере с блоком цены товара, мы сгруппировали текст с помощью блоков-накладок с невидимым текстом, а видимые части сгруппированного текста скрыли за aria-hidden.

Осталось только добавить подписи ко всем кнопкам с картинками или SVG-иконками. Если такие кнопки не подписаны, скринридер только произнесёт, что это кнопка, и никак не прокомментирует её действие. 

Правда, VoiceOver умеет распознавать некоторые геометрические фигуры в формате SVG. Например, если у кнопки форма пятиконечной звезды, то этот скринридер произнесёт: «Кнопка-звезда». Но зачастую этой фразы недостаточно, чтобы понять, что кнопка «делает». К тому же распознавание образов основано на работе нейронной сети на стороне устройства пользователя и его трудно контролировать со стороны автора контента. Любое изменение иконки или обновление нейронной модели может привести к другому результату распознавания. К счастью, в случае с кнопками оба скринридера корректно обрабатывают aria-label, поэтому просто задаём текст в этом атрибуте.

Навести порядок в модальных окнах

Большинство сценариев в приложении Лавки связано с работой с модальными окнами, в частности с Bottom Sheet. Посмотреть подробную информацию о товаре, узнать цену доставки в зависимости от стоимости корзины, оценить заказ и оставить чаевые курьеру — для всех этих действий на основной экран вызывается шторка. Её можно убрать свайпом вниз, но пока шторка на экране, пользователь не может взаимодействовать с остальным контентом на странице. 

Эти компоненты оказались совершенно непригодны для пользователей вспомогательных технологий: скринридер не мог убрать их с экрана, но мог перемещать фокус за пределы модальных окон.

Чтобы обеспечить доступность модалок, мы выставили элементу основные атрибуты role (dialog или alertdialog) и aria-modal, добавили aria-labelledby. Если у модального окна нет заголовка, рекомендую добавить текст aria-label. С этими атрибутами оба мобильных скринридера хорошо работают и произнесут, какую именно модалку открыл пользователь.

Дальше нужно предусмотреть выход из диалоговых окон. Если Bottom Sheet закрывается только свайпом вниз, то пользователи скринридеров закрыть его не смогут. Оставлять невидимую кнопку закрытия — плохая практика: пользователю придётся пролистывать весь контент в поисках заветной кнопки, а это очень неудобно, особенно если он захочет сразу закрыть окно. 

К счастью, у скринридеров есть жесты «на один шаг назад» (для VoiceOver — зигзаг двумя пальцами, напоминающий букву Z, для TalkBack — жест одним пальцем вниз и влево), которые генерируют в приложении событие нажатия клавиши Escape. Достаточно реализовать закрытие диалоговых окон таким образом, и механизм в скринридерах заработает корректно и понятно для незрячих пользователей.

Когда мы открываем модалку, весь остальной контент обычно закрывается «паранджой» и становится некликабельным. Для незрячих пользователей тоже нужно скрыть контент за пределами диалогового окна. К сожалению, role="dialog" и aria-modal="true", вопреки описаниям в стандарте, оказалось недостаточно: в реальности TalkBack всё равно продолжал выходить за пределы элемента с такими атрибутами. Ожидаемым образом это пока работает только на десктопных операционных системах.

Различные готовые решения для замыкания фокуса наподобие react-focus-lock для React проблему также не решат: они управляют обычным фокусом приложения, который не совпадает с фокусом скринридера. 

В этом случае мы можем лишь скрыть весь контент за пределами диалогового окна с помощью атрибута aria-hidden. Если у вас открываются несколько диалоговых окон одно поверх другого (например, модалка поверх Bottom Sheet), не забудьте скрыть и контент нижележащих диалоговых окон.

Имейте ввиду, что aria-hidden — это очень серьёзный атрибут, неаккуратное применение которого может практически сломать доступность интерфейса, скрыв что-то не то. Например, скрытие контента с атрибутом aria-hidden="true" применяется ко всем дочерним элементам. Никакие попытки проставить у них aria-hidden="false" уже не восстановят их видимость для вспомогательных технологий. То есть элемент модального диалога не должен быть дочерним по отношению к основному контенту страницы, который временно скрывается при помощи aria-hidden.

Для полной доступности модальных окон после их закрытия осталось обеспечить возвращение фокуса на тот элемент, который был в фокусе перед их открытием. Если явно выставить фокус на элемент из JavaScript, то и фокус скринридера переместится на этот элемент.

Сделать динамическую информацию доступной

Контент многих областей в Лавке (например, стоимость корзины, статус и время доставки) меняется в зависимости от действий пользователя или даже без его участия. Эти изменения отражаются по-разному: например, появляются всплывающие окна или динамически меняется часть текста (скажем, сумма заказа в корзине) , но пользователь скринридера этого не заметит. 

В этом случае я советую придерживаться правила: весь динамический контент, изменение которого заметно зрячему пользователю, должен быть доступен и пользователям скринридеров. В этом нам помогут атрибут aria‑live и роли для изменяющихся областей.

Если добавить элементу роль role="alert" или выставить атрибут aria‑live="assertive", то изменения будут озвучиваться немедленно, а если выбрать role="status" или aria‑live="polite", то контент будет озвучиваться после завершения текущего действия скринридера и появления другой более важной информации. Для большинства случаев нам подошёл второй вариант. Эти атрибуты отлично поддерживают как VoiceOver, так и TalkBack. 

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

Мы поступили так: после удаления такого товара из вёрстки мы на несколько секунд добавляем невидимый текстовый тег с ролью status и текстом Продукт {название} удалён из корзины.

Удалённый из корзины товар пропадёт с экрана через пару секунд, о чём нам надо уведомить пользователя
Удалённый из корзины товар пропадёт с экрана через пару секунд, о чём нам надо уведомить пользователя

Учесть особенности разных скринридеров

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

Сложная вёрстка внутри

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

<span>
	Обычный, <b>Жирный,</b> <i>курсив,</i> <span style=“color: red;”>красный.</span>
</span>

Разработчики TalkBack решили пойти дальше и перенесли этот алгоритм на инпуты и подписи к ним. Если у вашего инпута простая стандартная вёрстка, как в примере ниже, то скринридер произнесёт содержимое тега label только один раз как подпись к радиокнопке: 

<input type="radio" id="input-id" />
<label for="input-id">Подпись к инпуту</label>

 Поведение изменится, если в label поместить какой-нибудь тег, например: 

<label for="input-id">
	<span>Подпись внутри тега</span>
</label>

Тогда TalkBack произнесёт сначала «Радиокнопка подпись внутри тега», а при перемещении к следующему элементу прочтёт опять «Подпись внутри тега», уже как отдельный текстовый элемент, по нажатии которого ничего не произойдёт. Мы проконсультировались с нашими тестировщиками доступности и выяснили, что такое поведение сбивает с толку. В итоге мы добавили в инпут атрибут aria‑labelledby, который указывает на тег-контент внутри label, а сам label при этом скрыли с помощью aria‑hidden. Такой вариант работает корректно для обоих скринридеров. 

<input type="radio" id="input-id" aria-labelledby="content-id" />
<label for="input-id" aria-hidden="true">
	<span id="content-id">Подпись внутри тега</span>
</label>

Фокус на удаляемых элементах

Обращайте внимание на элементы, которые пропадают из вёрстки после нажатия. Ниже я привёл пример: кнопка «Показать ещё» после клика заменяется дополнительным контентом. 

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

Кнопка «Ещё N», исчезающая после нажатияна неё
Кнопка «Ещё N», исчезающая после нажатияна неё

Работа скринридеров со списками

Не забывайте об особенностях работы различных скринридеров со списками. В теории достаточно использовать семантическую вёрстку <ul> – <li>, чтобы скринридер идентифицировал список и озвучивал его. Но на практике VoiceOver не воспринимает списки, если тегу списка не продублировать соответствующую роль: 

<ul role="list">
	<li>Первый</li>
	<li>Второй</li>
</ul>

Озвучивание группы элементов формы

В процессе работы над доступностью Лавки мы обнаружили странное поведение скринридеров с группами чекбоксов и радиокнопок. В следующем примере TalkBack работает некорректно:

<div>
	<input type="radio" id="input-1-1" name="group-1" />
	<label for="input-1-1">Группа 1, радио 1</label> 

	<input type="radio" id="input-1-2" name="group-1" />
	<label for=“input-1-2”>Группа 1, радио 2</label>

	<input type="radio" id="input-1-3" name="group-1" />
	<label for="input-1-3">Группа 1, радио 3</label>
</div>

<div>
	<input type="radio" id="input-2-1" name="group-2" />
	<label for="input-2-1">Группа 2, радио 1</label>

	<input type="radio" id="input-2-2" name="group-2" />
	<label for="input-2-2">Группа 2, радио 2</label>
</div>

Для радиокнопок из group-1 скринридер корректно назовёт число инпутов в группе и порядок конкретной кнопки в группе «параметр группы — N из трёх». Однако для второй группы алгоритм ломается: нумерация продолжится так, как будто все инпуты принадлежат к одной группе, размер группы при этом назовётся корректно. То есть для радиокнопки с id="input-2-1" скринридер озвучит «Параметр группы — четыре из двух». Чтобы избежать такого поведения, отдельные группы инпутов надо оборачивать в тег <fieldset>.


Мы устранили большинство проблем доступности в приложении Лавки, но это только полдела. Гораздо важнее поддерживать достигнутый уровень доступности, потому что новые фичи без учёта A11Y могут сломать всё, что было сделано до этого. Чтобы этого избежать, придерживайтесь правил: 

  1. При проектировании новых фич сразу закладывайте всё необходимое для доступности и во фронтенд, и в бэкенд. Например, любую графическую информацию с сервера сопровождайте альтернативным текстом, который будет зачитывать скринридер. Этот текст нужно спроектировать в ответах API, в интерфейсах админки (если картинки задаются там) и в базах данных. Лучше всего заложить дополнительную работу на стадии проектирования фичи, чем после её релиза планировать доделки и синхронизировать эту работу между командами фронтенда и бэкенда.

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

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

  4. Не думайте о доступности как о чём-то отдельном. Бо́льшая часть вещей для доступности — это либо универсальное решение с учётом семантики, либо пояснение общей логики интерфейса несколькими дополнительными атрибутами. Если один раз в этом разобраться и прочувствовать, то существенная часть требований доступности в будущем будет реализовываться уже на интуитивном уровне. Вспомогательные технологии (вроде тех же скринридеров) — это всего лишь ещё один парсер вашей вёрстки, просто несколько более внимательный к деталям.

Комментарии (4)


  1. WeirdDark
    28.03.2024 11:39

    Спасибо за статью, полезно!


  1. adrozhzhov
    28.03.2024 11:39

    Ok.

    Начнём с примера с огурцами.

    По картинкам видно, что это пупырчатые, а не гладкие огурцы. И те и те бывают среднеплодными. Как такая важная информация о продукте, которой нет в описании, но которая поставляется зрячим с помощью картинок, будет донесена до пользователей скрин-ридеров? С помидорами все намного лучше. Сорта указаны.

    Ну и ещё один вопрос по скриншотам-примерам

    Название категории "Продукты и не только" это ведь такая специальная изящная фига в кармане в сторону WCAG?


  1. Nyptus
    28.03.2024 11:39

    Дорогой яндекс!
    Есть две оффтопик просьбы:

    1. Добавьте, пожалуйста, в календарь возможность уведомления о событии через "Алису" (сейчас есть только "в приложении, по e-mail, в смс, через calDAV")

    2. Добавьте, пожалуйста, в Дзене блок форматирования для вставки кода.

    Спасибо!


    1. YAZART
      28.03.2024 11:39

      Дзен же теперь VK