Создайте многоразовую блокировку фокуса в React, чтобы улучшить взаимодействие с пользователем и доступность

Создайте многоразовую блокировку фокуса React, чтобы улучшить взаимодействие с пользователем

Меня зовут Джеймс, я веб-разработчик, специализирующийся на специальных возможностях, в компании Tamman Inc, расположенной в Старом городе, Филадельфия. Сегодня я хочу поговорить об управлении фокусом браузера, особенно на сайтах, созданных с использованием библиотеки React.

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

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

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

Запуск нашего компонента

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

import React from 'react'

const FocusLock = ({ children, ...otherProps }) => {

 return (
   <div {...otherProps}>
     { children }
   </div>
 )
}

export default FocusLock

Добавление ссылок

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

import React, { useRef } from 'react'

const FocusLock = ({ children, ...otherProps }) => {
 const rootNode = useRef(null)
 const focusableItems = useRef([])

 return (
   <div {...otherProps} ref={rootNode}>
     { children }
   </div>
 )
}

export default FocusLock

Поиск всех детей, на которых можно сфокусироваться

Мы хотим знать все элементы внутри нашей блокировки фокуса, на которых можно сфокусироваться. Для этого мы собираемся запустить querySelectorAll на нашем rootNode ref, ища часто фокусируемые элементы, такие как кнопки, ссылки, входы и т. Д. Не стесняйтесь обновлять это спросите, если вы столкнетесь с пробелом в том, что выбирается. Затем мы обновим нашу ссылку focusableItems, добавив фокусируемые элементы.

import React, { useEffect, useRef } from 'react'

const FocusLock = ({ isLocked = true, children, ...otherProps }) => {
 const rootNode = useRef(null)
 const focusableItems = useRef([])

 useEffect(() => {
   const updateFocusableItems = () => {
     focusableItems.current = rootNode.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video')
   }
updateFocusableItems()
 }, [rootNode])

 return (
   <div {...otherProps} ref={rootNode}>
     { children }
   </div>
 )
}

export default FocusLock

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

import React, { useEffect, useRef } from 'react'

const FocusLock = ({ isLocked = true, children, ...otherProps }) => {
 const rootNode = useRef(null)
 const focusableItems = useRef([])

 useEffect(() => {
   const updateFocusableItems = () => {
     focusableItems.current = rootNode.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video')
   }

   const observer = new MutationObserver(() => {
     updateFocusableItems()
   })
    updateFocusableItems()
   observer.observe(rootNode.current, { childList: true })
   return () => {
     observer.disconnect()
   }
 }, [rootNode])

 return (
   <div {...otherProps} ref={rootNode}>
     { children }
   </div>
 )
}

export default FocusLock

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

Настройка слушателей клавиатуры

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

Во-первых, давайте добавим к компоненту новое необязательное свойство isLocked. Это свойство по умолчанию будет иметь значение true для экземпляров, когда блокировка фокуса отображается только при необходимости, но если есть ситуация, когда блокировку необходимо отключить, это может быть сделано путем изменения родительским элементом isLocked к ложному.

const FocusLock = ({ isLocked = true, children, …otherProps }) => {

Затем мы собираемся создать еще один эффект, зависящий от focusableItems и isLocked. В этом случае мы настроим прослушиватель keyDown, который запускает наш обратный вызов. Обратный вызов будет делать что-то только в четырех сценариях, когда блокировка задействована.

  • Если focusableItems пуст, мы вернемся из обратного вызова.
  • Если при нажатии табуляции есть только один focusableItem, то мы предотвратим табуляцию по умолчанию, сохраняя табуляцию на том же элементе.
  • Если нажата вкладка, когда последний focusableItem является activeElement, тогда мы устанавливаем фокус на первый focusableItem.
  • Если нажаты tab и shift, когда первый focusableItem является activeElement, тогда мы устанавливаем фокус на последний f ocusableItem.
useEffect(() => {
   const handleKeyPress = event => {
     if (!focusableItems.current) return

     const { keyCode, shiftKey } = event
     const {
       length,
       0: firstItem,
       [length - 1]: lastItem
     } = focusableItems.current

     if (isLocked && keyCode === TAB_KEY ) {
       // If only one item then prevent tabbing when locked
       if ( length === 1) {
         event.preventDefault()
         return
       }

       // If focused on last item then focus on first item when tab is pressed
       if (!shiftKey && document.activeElement === lastItem) {
         event.preventDefault()
         firstItem.focus()
         return
       }

       // If focused on first item then focus on last item when shift + tab is pressed
       if (shiftKey && document.activeElement === firstItem) {
         event.preventDefault()
         lastItem.focus()
         return
       }
     }
   }

   window.addEventListener('keydown', handleKeyPress)
   return () => {
     window.removeEventListener('keydown', handleKeyPress)
   }
 }, [isLocked, focusableItems])

Полный код компонента можно увидеть ниже.

import React, { useEffect, useRef } from 'react'

const TAB_KEY = 9

const FocusLock = ({ isLocked = true, children, ...otherProps }) => {
 const rootNode = useRef(null)
 const focusableItems = useRef([])

 useEffect(() => {
   const updateFocusableItems = () => {
     focusableItems.current = rootNode.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video')
   }

   const observer = new MutationObserver(() => {
     updateFocusableItems()
   })
    updateFocusableItems()
   observer.observe(rootNode.current, { childList: true })
   return () => {
     observer.disconnect()
   }
 }, [rootNode])

 useEffect(() => {
   const handleKeyPress = event => {
     if (!focusableItems.current) return

     const { keyCode, shiftKey } = event
     const {
       length,
       0: firstItem,
       [length - 1]: lastItem
     } = focusableItems.current

     if (isLocked && keyCode === TAB_KEY ) {
       // If only one item then prevent tabbing when locked
       if ( length === 1) {
         event.preventDefault()
         return
       }

       // If focused on last item then focus on first item when tab is pressed
       if (!shiftKey && document.activeElement === lastItem) {
         event.preventDefault()
         firstItem.focus()
         return
       }

       // If focused on first item then focus on last item when shift + tab is pressed
       if (shiftKey && document.activeElement === firstItem) {
         event.preventDefault()
         lastItem.focus()
         return
       }
     }
   }

   window.addEventListener('keydown', handleKeyPress)
   return () => {
     window.removeEventListener('keydown', handleKeyPress)
   }
 }, [isLocked, focusableItems])

 return (
   <div {...otherProps} ref={rootNode}>
     { children }
   </div>
 )
}

export default FocusLock

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

{ showDialog && <FocusLock><Dialog /></FocusLock> }

Или, если вы используете его как часть меню, которое управляется кнопкой-переключателем:

<FocusLock isLocked={isMenuOpen}>
  <button onClick={() => setIsMenuOpen(!isMenuOpen)}>
  { isMenuOpen && <Menu /> }
</FocusLock>

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

Заканчивать

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