Java, UX, HTML, CSS, WEB-design

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

Краткое описание по статье Создание доступного диалога с нуля

Название: Создание доступного диалога с нуля . Краткое описание: ⭐ Китти Жиро . Дата публикации: 15.01.2022 . Автор: Алишер Валеев .

Для чего создан сайт Novosti-Nedeli.ru

Данный сайт посвящен новостям мира и мира технологий . Также тут вы найдете руководства по различным девайсам.

Сколько лет сайту?

Возраст составляет 3 года


  • Китти Жиродель

  • 0 Комментарии

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

  • 13 минут чтения

  • Браузеры, интерфейсы, JavaScript, специальные возможности

Краткое резюме ↬

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

Во-первых, не делайте этого дома. Не пишите для этого свои собственные диалоги или библиотеку. Их уже существует множество, которые были протестированы, проверены, использованы и использованы повторно, и вы должны предпочесть их своим собственным. 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 id="my-dialog">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 id="my-dialog" hidden>This will be a dialog.</div>

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

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

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

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

<div id="my-dialog" 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 id="my-dialog" 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()
}

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

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

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

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

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> элемент не фокусируется по умолчанию).

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

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

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

При отображении диалога мы можем начать с сохранения ссылки на элемент, находящийся в фокусе (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 id="my-dialog" hidden aria-labelledby="my-dialog-title">
  <div data-dialog-hide></div>
  <div>
    <h1 id="my-dialog-title">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 от Moritz Kröger, a11y-vue-dialog от Renato de Leão.

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

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




Source: https://smashingmagazine.com

Заключение

Вы ознакомились с статьей — Создание доступного диалога с нуля

Пожалуйста оцените статью, и напишите комментарий.

Похожие статьи

Добавить комментарий

Ваш адрес email не будет опубликован.

Кнопка «Наверх»