Создание доступного диалога с нуля

Опубликовано: 2022-03-10
Краткий обзор ↬ Диалоги присутствуют в современном дизайне интерфейсов повсюду (хорошо это или плохо), но многие из них недоступны для вспомогательных технологий. В этой статье мы рассмотрим, как создать короткий скрипт для создания доступных диалогов.

Во-первых, не делайте этого дома. Не пишите для этого свои собственные диалоги или библиотеку. Их уже существует множество, которые были протестированы, проверены, использованы и использованы повторно, и вы должны предпочесть их своим собственным. a11y-dialog — один из них, но есть и другие (перечислены в конце этой статьи).

Позвольте мне использовать этот пост как возможность напомнить вам всем об осторожности при использовании диалогов . Решать с их помощью все проблемы с дизайном, особенно на мобильных устройствах, сложно, но часто есть и другие способы решить проблемы с дизайном. Мы склонны быстро использовать диалоги не потому, что они обязательно являются правильным выбором, а потому, что они просты. Они отложили проблемы с размером экрана, обменяв их на переключение контекста, что не всегда является правильным компромиссом. Суть в следующем: подумайте, является ли диалог правильным шаблоном проектирования, прежде чем использовать его.

В этом посте мы собираемся написать небольшую библиотеку JavaScript для создания доступных диалогов с самого начала (по сути воссоздавая a11y-диалог). Цель состоит в том, чтобы понять, что входит в это. Мы не будем слишком много заниматься стилем, только часть JavaScript. Мы будем использовать современный JavaScript для простоты (например, классы и стрелочные функции), но имейте в виду, что этот код может не работать в устаревших браузерах.

  1. Определение API
  2. Создание диалогового окна
  3. Отображение и скрытие
  4. Закрытие с накладкой
  5. Закрытие с побегом
  6. Захват фокуса
  7. Сохранение фокуса
  8. Восстановление фокуса
  9. Дать доступное имя
  10. Обработка пользовательских событий
  11. Убираться
  12. Собери все это вместе
  13. Подведение итогов

Определение API

Во-первых, мы хотим определить, как мы будем использовать наш сценарий диалога. Мы собираемся сделать это максимально простым для начала. Мы даем ему корневой HTML-элемент для нашего диалога, и экземпляр, который мы получаем, имеет .show(..) и .hide(..) .

 class Dialog { constructor(element) {} show() {} hide() {} }

Создание диалога

Допустим, у нас есть следующий HTML:

 <div>This will be a dialog.</div>

И мы создаем наш диалог следующим образом:

 const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)

Есть несколько вещей, которые нам нужно сделать под капотом при создании экземпляра:

  • Скройте его, чтобы он был скрыт по умолчанию ( hidden ).
  • Отметьте его как диалоговое окно для вспомогательных технологий ( role="dialog" ).
  • Сделайте остальную часть страницы неактивной при открытии ( aria-modal="true" ).
 constructor (element) { // Store a reference to the HTML element on the instance so it can be used // across methods. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true) }

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

Показать и скрыть

У нас есть два метода: один для отображения диалогового окна и один для его скрытия. Эти методы мало что сделают (пока), кроме переключения hidden атрибута корневого элемента. Мы также собираемся поддерживать логическое значение экземпляра, чтобы иметь возможность быстро оценить, отображается диалоговое окно или нет. Это пригодится позже.

 show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }

Чтобы диалоговое окно не было видно до того, как JavaScript включится и скроет его, добавив атрибут, может быть интересно добавить hidden диалог непосредственно в HTML с самого начала.

 <div hidden>This will be a dialog.</div>

Закрытие с наложением

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

Другим подходом может быть прослушивание событий щелчка на оверлее (иногда называемом «фоном»). Само наложение может быть таким же простым, как <div> с некоторыми стилями.

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

 <div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>

Теперь мы можем запросить элементы с атрибутом data-dialog-hide внутри диалога и дать им прослушиватель кликов, который скроет диалог.

 constructor (element) { // … rest of the code // Bind our methods so they can be used in event listeners without losing the // reference to the dialog instance this._show = this.show.bind(this) this._hide = this.hide.bind(this) const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) }

Хорошая вещь в том, чтобы иметь что-то довольно общее, как это, заключается в том, что мы можем использовать то же самое для кнопки закрытия диалогового окна.

 <div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Еще после прыжка! Продолжить чтение ниже ↓

Закрытие с побегом

Диалоговое окно должно быть скрыто не только при щелчке за его пределами, но также должно быть скрыто при нажатии Esc . При открытии диалога мы можем привязать прослушиватель клавиатуры к документу и удалить его при закрытии. Таким образом, он прослушивает нажатия клавиш только тогда, когда диалог открыт, а не все время.

 show() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown) } hide() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() }

Ловушка Фокус

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

Идея довольно проста: когда диалог открыт, мы слушаем нажатия Tab . Если нажать Tab на последнем элементе диалога, на который можно сделать фокус, мы программно переместим фокус на первый. Если нажать Shift + Tab на первом фокусируемом элементе диалога, мы переместим его на последний.

Функция может выглядеть так:

 function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } }

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

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

 module.exports = [ 'a[href]:not([tabindex^="-"])', 'area[href]:not([tabindex^="-"])', 'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])', 'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked', 'select:not([disabled]):not([tabindex^="-"])', 'textarea:not([disabled]):not([tabindex^="-"])', 'button:not([disabled]):not([tabindex^="-"])', 'iframe:not([tabindex^="-"])', 'audio[controls]:not([tabindex^="-"])', 'video[controls]:not([tabindex^="-"])', '[contenteditable]:not([tabindex^="-"])', '[tabindex]:not([tabindex^="-"])', ]

И этого достаточно, чтобы получить 99%. Мы можем использовать эти селекторы, чтобы найти все фокусируемые элементы, а затем мы можем проверить каждый из них, чтобы убедиться, что он действительно виден на экране (а не скрыт или что-то в этом роде).

 import focusableSelectors from 'focusable-selectors' function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }

Теперь мы можем обновить наш метод handleKeyDown :

 handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }

Поддержание фокуса

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

Чтобы решить эту проблему, мы можем привязать прослушиватель фокуса к элементу <body> при отображении диалогового окна и переместить фокус на первый элемент в диалоговом окне, на который можно сфокусироваться.

 show () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true) } hide () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.removeEventListener('focus', this._maintainFocus, true) } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } moveFocusIn () { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() }

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

  • Сосредоточьтесь на первом элементе.
    Это то, что мы делаем здесь, так как это упрощается тем фактом, что у нас уже есть функция getFocusableChildren .
  • Сосредоточьтесь на кнопке закрытия.
    Это тоже хорошее решение, особенно если кнопка абсолютно позиционирована относительно диалога. Мы можем удобно сделать это, поместив кнопку закрытия в качестве первого элемента нашего диалога. Если кнопка закрытия находится в потоке содержимого диалогового окна, в самом конце, это может быть проблемой, если в диалоговом окне много содержимого (и, следовательно, его можно прокручивать), так как при открытии содержимое будет прокручиваться до конца.
  • Сосредоточьтесь на самом диалоге .
    Это не очень распространено среди диалоговых библиотек, но это также должно работать (хотя для этого потребуется добавить к нему tabindex="-1" , так как это возможно, поскольку элемент <div> по умолчанию не фокусируется).

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

Восстановление фокуса

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

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

 show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }

При скрытии диалога мы можем переместить фокус обратно на этот элемент. Мы защищаем его условием, чтобы избежать ошибки JavaScript, если элемент каким-то образом больше не существует (или если это был SVG):

 hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }

Предоставление доступного имени

Важно, чтобы у нашего диалогового окна было доступное имя, именно так оно будет отображаться в дереве доступности. Есть несколько способов решить эту проблему, один из которых — определить имя в атрибуте aria-label , но у aria-label есть проблемы.

Другой способ — иметь заголовок внутри нашего диалога (независимо от того, скрыт он или нет) и связать с ним наш диалог с помощью атрибута aria-labelledby . Это может выглядеть так:

 <div hidden aria-labelledby="my-dialog-title"> <div data-dialog-hide></div> <div> <h1>My dialog title</h1> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>

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

Обработка пользовательских событий

Что, если мы хотим отреагировать на открытие диалога? Или закрыто? В настоящее время нет способа сделать это, но добавить небольшую систему событий не должно быть слишком сложно. Нам нужна функция для регистрации событий (назовем ее .on(..) ) и функция для их отмены ( .off(..) ).

 class Dialog { constructor(element) { this.events = { show: [], hide: [] } } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } }

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

 class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }

Убираться

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

 class Dialog { destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } }

Собираем все вместе

 import focusableSelectors from 'focusable-selectors' class Dialog { constructor(element) { this.element = element this.events = { show: [], hide: [] } this._show = this.show.bind(this) this._hide = this.hide.bind(this) this._maintainFocus = this.maintainFocus.bind(this) this._handleKeyDown = this.handleKeyDown.bind(this) element.setAttribute('hidden', true) element.setAttribute('role', 'dialog') element.setAttribute('aria-modal', true) const closers = [...element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) } show() { this.isShown = true this.previouslyFocused = document.activeElement this.element.removeAttribute('hidden') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(event => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('hidden', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(event => event()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) } moveFocusIn() { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } } function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } } function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }

Подведение итогов

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

Если вам нужно использовать диалоги в своем проекте, рассмотрите возможность использования одного из следующих решений (напоминаем, что у нас также есть полный список доступных компонентов):

  • Ванильные реализации JavaScript: a11y-dialog от вашего покорного слуги или aria-modal-dialog от Scott O'Hara.
  • Реализации React: react-a11y-dialog снова с уважением,reach/dialog из фреймворка Reach или @react-aria/dialog из Adobe. Вам может быть интересно это сравнение 3 библиотек.
  • Реализации Vue: vue-a11y-dialog Морица Крогера, a11y-vue-dialog Ренато де Леао.

Вот еще вещи, которые можно было бы добавить, но не ради простоты:

  • Поддержка диалогов оповещений через роль alertdialog . Обратитесь к документации a11y-dialog по диалоговым окнам предупреждений.
  • Блокировка возможности прокрутки при открытом диалоговом окне. Обратитесь к документации a11y-dialog по блокировке прокрутки.
  • Поддержка нативного HTML-элемента <dialog> , потому что он некачественный и непоследовательный. Обратитесь к документации a11y-dialog по элементу диалога и этой части Скотта О'Хара для получения дополнительной информации о том, почему это не стоит усилий.
  • Поддержка вложенных диалогов, потому что это сомнительно. Обратитесь к документации a11y-dialog по вложенным диалогам.
  • Рассмотрение вопроса о закрытии диалогового окна при навигации в браузере. В некоторых случаях может иметь смысл закрыть диалоговое окно при нажатии кнопки «Назад» в браузере.