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

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

2. Пока модальное окно открыто, фокус не может покинуть его (это называется «захватом фокуса клавиатуры»).

3. Клавиша Esc закрывает модальное окно.

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

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

Давайте разберем его, проанализировав каждое требование и его нюансы и представив его решение, как с ‹диалогом, так и без него.

Смещение фокуса на модальное открытие

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

Мы могли бы установить атрибут «autofocus» для нужного элемента, но React манипулирует им и обрабатывает фокус по-своему. Судя по моим экспериментам, вы не можете полагаться на «автофокус» при работе с React.

Пришло время управлять фокусом программно.

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

function useFocusOnOpen({ isOpen, containerRef, customRef }) {
  useEffect(() => {
    if (!isOpen) {
      return;
    }

    if (customRef?.current) {
      // if a custom ref was provided it will be the element to focus:
      customRef.current.focus();
    } else if (containerRef.current) {
      // if no custom ref was provided, we'll search for
      // the first focusable element within the modal:
      const firstFocusableElement = getFocusableElements({
        containerElement: containerRef.current,
      })[0];

      if (firstFocusableElement) {
        firstFocusableElement.focus();
      } else {
        // if there are no focusable elements within the modal
        // we'll set the focus to the modal element itself:
        containerRef.current.tabIndex = -1;
        containerRef.current.focus();
      }
    }
  }, [isOpen, containerRef, customRef]);
}

(вы можете найти реализацию getFocusableElements, а также весь код этой статьи здесь, все на TypeScript BTW).

Смещение фокуса на модальное закрытие

Давайте отклонимся от последнего требования.

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

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

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

function useFocusOnClose({ isOpen, customRef }) {
  const openerRef = useRef(null);
 
  useEffect(() => {
    if (isOpen) {
      // save the element that is focused at the time of the opening:
      openerRef.current = document.activeElement;
    } else {
      (customRef ?? openerRef).current?.focus();
    }
  }, [isOpen, customRef]);
}

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

Закрытие модального окна при «Escape»

С этим отлично справится ‹диалог. Однако мы можем легко реализовать это даже без ‹диалога›. Мы просто воспользуемся обработчиком события нажатия клавиши из части 1 и передадим модальную закрывающую функцию, которая будет вызываться при нажатии клавиши Esc:

useKeydownListener({
  containerRef: modalRef,
  listen: isModalOpen,
  keyListenerMap: { Escape: onModalClose },
});

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

Последний, но тем не менее важный. Я оставил его напоследок, потому что он самый сложный.

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

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

Элемент ‹dialog соответствует первой спецификации, тогда как вторая предложена W3C. Можно спорить, какой подход лучше, но, поскольку кажется, что оба являются законными, давайте реализуем их два. Для удобства я буду называть первую ловушкой слабого фокуса, а вторую — ловушкой строгого фокуса.

Примечание: в дополнение к взаимодействию с мышью и клавиатурой нам также необходимо деактивировать контент за пределами модального окна для программ чтения с экрана. Это можно сделать, добавив 'aria-hidden' к содержимому за пределами модального окна, однако, если мы просто добавим 'aria-modal' к модальному элементу, он должен делать свою работу.

Ловушка свободного фокуса

Если вы не используете ‹диалоговое окно›, вы можете запросить в документе все элементы с вкладками за пределами модального окна и задать им tabindex="-1", чтобы сделать их невозможно. Однако лучшим способом может быть деактивация всего контейнера, находящегося за пределами модального окна. Этого можно добиться с помощью атрибута «inert», но вы должны знать, что на момент написания статьи он еще официально не поддерживался Firefox.

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

function useSingleModalInert({ isOpen }) {
  useEffect(() => {
    const rootElement = document.getElementById('root');

    if (isOpen) {
      rootElement?.setAttribute('inert', 'true');
    } else {
      rootElement?.removeAttribute('inert');
    }
  }, [isOpen]);
}

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

Однако, если вам нужно поддерживать существование нескольких открытых модальных окон (например, одно модальное окно открывает второе модальное окно и т. д. — все они открыты, но активен только самый последний), вам необходимо деактивировать модальные окна, которые открыть в фоновом режиме, в дополнение к деактивации корневого элемента. На данный момент, независимо от того, используете ли вы 'inert' для других модальных окон или tabindex="-1" для элементов вне модального окна, процедура довольно проста. такой же:

  1. Запросите DOM для деактивируемых элементов.
  2. Отфильтруйте элементы, которые находятся внутри активного модального окна (или самого модального элемента, в случае ‘inert’).
  3. Установите для каждого элемента атрибут, чтобы деактивировать его.
  4. Сохраните список деактивированных элементов в переменной состояния.
  5. При закрытии модального окна верните атрибут каждого элемента, чтобы повторно активировать его.

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

Строгая ловушка фокусировки

Если вы заинтересованы в строгом захвате фокуса клавиатуры в пользовательском модальном окне или в элементе ‹dialog›, вот код для этого:

function useFocusTrap({ isOpen, containerRef }) {
  useKeydownListener({
    containerRef,
    listen: isOpen,
    keyListenerMap: { Tab: (e) => focusTrap(e, containerRef) },
  });
}
 
function focusTrap(event, containerRef) {
  if (!containerRef.current) {
    return;
  }
 
  const tabbableElements = getFocusableElements({
    containerElement: containerRef.current,
    tabbableOnly: true,
  });
 
  // if there's just one tabbable element or no tabbable elements at all,
  // we simply leave the focus in its place:
  if ([0, 1].includes(tabbableElements.length)) {
    event.preventDefault();
    return;
  }
 
  const firstElement = tabbableElements[0];
  const lastElement = tabbableElements[tabbableElements.length - 1];
 
  const isForwardFromLastElement =
    !event.shiftKey && document.activeElement === lastElement;

  const isBackwardFromFirstElement =
    event.shiftKey && document.activeElement === firstElement;
 
  if (isForwardFromLastElement) {
    event.preventDefault();
    firstElement.focus();
  } else if (isBackwardFromFirstElement) {
    event.preventDefault();
    lastElement.focus();
  }
}

Подведение итогов модальных окончаний

Мы взялись за модальную клавиатуру a11y в стиле «разделяй и властвуй», создав несколько пользовательских крючков, каждый из которых обрабатывает разные аспекты предмета. У этой реализации есть некоторые недостатки, такие как необходимость вызывать некоторые хуки в определенном порядке, но мы получаем преимущества модульности и возможности повторного использования. Modal — не единственный компонент, который требует особого внимания, когда речь идет о клавиатуре, есть много других компонентов, однако они часто имеют общие требования. Мы можем воспользоваться этим и выбрать хуки, которые могут помочь нам с определенным компонентом в стиле смешивания и сопоставления.

Например, средство выбора даты имеет тот же шаблон взаимодействия с клавиатурой, что и модальное окно, поэтому мы могли бы использовать те же хуки для реализации пользовательского средства выбора даты. Компонент меню разделяет требования по смещению фокуса при открытии/закрытии и закрытию всплывающего окна по нажатию "Esc", но не требует ловушки фокуса при нажатии на "Tab". С другой стороны, это требует взаимодействия с клавишами со стрелками. Таким образом, мы можем использовать обработчики, относящиеся к меню, такие как useFocusOnOpen и useFocusOnClose, опуская при этом другие обработчики, такие как useFocusTrap (вы можете видеть как я это реализовал, помимо части со стрелками, в репозитории статьи).

Последние мысли

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

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

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

Я закончу несколькими общими советами, которые, как мне кажется, являются ключом (я не мог устоять…) к успеху, когда дело доходит до реализации клавиатуры a11y:

1. Узнайте, каково ожидаемое поведение шаблона пользовательского интерфейса, который вы реализуете. Хорошим ресурсом, который поможет вам в этом, является Руководство W3C’s ARIA Authoring Practices.

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

3. Приучите себя работать в Интернете с помощью клавиатуры. Это поможет вам раньше заметить ошибки, связанные с a11y, и вы даже можете в конечном итоге предпочесть клавиатуру мыши.

4. Для каждой назначенной функции или пользовательской истории добавьте задачу по внедрению и тестированию клавиатуры a11y.

Дальнейшее чтение

Пока я писал эту статью, Кристиан Диас опубликовал свою собственную серию из двух частей, которая имеет много общего с этой. Хотя Кристиан не относится конкретно к React, он охватывает довольно много тем и представляет свои решения для нескольких клавиатурных задач, связанных с HTML, CSS и JavaScript. Некоторые части наших статей пересекаются, но другие уникальны для каждой из них, поэтому я призываю вас объединить знания из обеих статей, чтобы получить как можно более широкое представление о клавиатуре.