Про то, что такое бесконечная лента (Infinity Scroll, Feed) - есть много статей, постов, гайдов по реализации самого функционала, и, кажется, рассказывать про то, как именно ее реализовать и что это такое - не идея этой статьи. Так же, как и рассказывать о плюсах и минусах этого подхода. Если Вы все же не знаете, что это за чудо инженерной мысли - можно ознакомиться тут.

Лично я хочу сосредоточиться на доступности такой ленты, а в конце - пример реализации на простом HTML/CSS/JS с поддержкой альтернативного управления, однако такой пример легко портировать на любой другой фреймворк/библиотеку.

Путь

При реализации доступных компонентов есть всегда несколько аспектов, которые стоит учитывать:

  1. Если есть уже нативная реализация (семантический тег, сам элемент) - используйте его. Например, самый частый пример, который приводят - кнопки. Если вы будете делать кнопки с помощью <div>, вы рискуете создать абсолютно недоступный, кривой, с точки зрения UX, интерфейс. Ведь, согласно гайдам W3C, вы должны учесть еще и роль, указав ее через аттрибут, и правильное управление с помощью клавиатуры. Но, как показывает практика, про это часто забывают: в итоге у нас получается кастомная кнопка (сделанная с помощью <div>), которая никак не доступна с помощью Tab, а уже тем более, не управляемая с помощью клавиатуры.

  2. Правильная реализация при отсутствии нативного элемента. Например, элемента для паттерна дерева (Tree) нет в HTML, но для него есть уже устаявшаяся, рекомендуемая реализация.

  3. Третий тернистый путь. Он предполагает, что в сообществе нет согласованности по его реализации, как и выработанных и рекомендуемых паттернов управления. В таком случае допустимо создавать свои. Именно таким является наш компонент Feed.

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

Проблемы

Говоря в этом разделе про то, как рекомендуется, я буду ссылать на данный гайд от W3C.

В текущей реализации паттерна рекомендуется каждый элемент бесконечной ленты делать фокусируемым

<article tabindex="0">
   Я элемент ленты
</article>

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

Тут же нам предлагают слудющее решение:

Control + End - переносит на первый фокусируемый элемент после ленты;
Control + Home - соответственно на первый фокусируемый элемент перед лентой;

Проблема здесь заключается в том, что:

  1. В JS, к сожалению, нет возможности через какое-либо полноценное API получить доступ к следующему или предыдущему фокусируемому элементу, хотя обсуждения были.

  2. Безусловно, можно находить это через селектор (querySelector, а посмотреть больше про фокусировани в HTML Living Standard), но это решение немного нестабильно, потому что мы не учитываем ни того, что tabindex может быть больше нуля (хотя, это считается анти-паттерном) и это в принципе изменит порядок фокусирования, ни того, что это может сказаться на сложности и производительности самого решения.

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

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

Реализация

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

Если вам не очень хочется читать этапы реализации - можно перейти сразу к примеру.

Для начала, назначим всем элементам ленты tabindex -1, тем самым, сделаем их нефокусируемыми. Самому элементу ленты (feed) назначим tabindex 0, сделав его фокусируемым.

<div tabindex="0" aria-label="Посты" role="feed">
  <article aria-posinset="1" aria-setsize="-1" tabindex="-1">
    Я элемент ленты!
  </article>
  <article aria-posinset="2" aria-setsize="-1" tabindex="-1">
    Я элемент ленты 2!
  </article>
  <article aria-posinset="3" aria-setsize="-1" tabindex="-1">
    Я элемент ленты 3!
  </article>
  <article aria-posinset="4" aria-setsize="-1" tabindex="-1">
    Я элемент ленты 4!
  </article>
  <article aria-posinset="5" aria-setsize="-1" tabindex="-1">
    Я элемент ленты 5!
  </article>
</div>

Здесь пока что HTML заканчивается, и начинается JS.

Создадим несколько функций и переменных, которые помогут нам в работе:

const feed = document.querySelector('[role="feed"]');
let pos = 1;

const getFeedElements = (): NodeListOf<HTMLElement> | null => {
  if (!feed) return null;

  return feed.querySelectorAll("article");
};

let items = getFeedElements();
let active = -1;

const changeActive = (value: number) => {
  if (!items || items.length === 0) return;

  if (items[active]) items[active].setAttribute("tabindex", "-1");

  items[value].setAttribute("tabindex", "0");
  items[value].focus();

  active = value;
};

Здесь мы получаем ссылку на feed элемент, записывая в переменную feed, а также объявляем переменную pos, хранящую последнюю позицию элемента.

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

Функция changeActive меняет текущий активный элемент ленты: старый делаем недоступным для фокуса, а новый - наоборот. После этого фокусируемся с помощью .focus()

Далее - дело техники, просто добавляем обработчики

feed?.addEventListener("focus", () => {
  if (!items || items.length === 0) return;

  if (active === -1) {
    feed.setAttribute("tabindex", "-1");
    changeActive(0);
  }
});

feed?.addEventListener("keydown", (e) => {
  if (!items || items.length === 0) return;

  const { key } = (e as KeyboardEvent) || {};

  if (key === "ArrowDown") {
    if (active !== items.length - 1) changeActive(active + 1);
  }

  if (key === "ArrowUp") {
    if (active !== 0) changeActive(active - 1);
  }
});

Вешаем обработчик фокуса на feed, который делает в дальнейшем недоступным фокус на самой ленте, а также делает активным первый элемент.

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

Все супер, теперь даже с помощью нажатия Tab можно переключиться на фокусируемый элемент после ленты, а с помощью Shift + Tab до ленты, но есть одна маленькая деталь: что, если в элементе ленты будет содержаться тоже фокусируемый элемент?

На самом деле, в этом нет никакой проблемы. Мы можем обходить внутренние элементы, делая их недоступными/доступными для фокуса.

Добавим новую вспомогательную функцию:

const getFocusableElements = (element: Element) => {
  return element.querySelectorAll(
    'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'
  );
};

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

Добавим в функцию изменения текущего активного элемента функционал:

const changeActive = (value: number) => {
  // ...

  if (items[active]) {
    // ...
    getFocusableElements(items[active]).forEach((el) => {
      el.setAttribute("tabindex", "-1");
    });
  }

  // ...
  getFocusableElements(items[value]).forEach((el) => {
    el.setAttribute("tabindex", "0");
  });
  
  // ...
};

После добавляем MutationObserver, а в callback-функции будем реагировать на добавление элементов.

const observerOptions = {
  childList: true,
  subtree: true,
};

const observerCallback = (records: MutationRecord[]) => {
  // Обновляем список элементов feed
  items = getFeedElements();

  for (const record of records) {
    record.addedNodes.forEach((el) => {
      if (el instanceof Element) {
        // Делаем дочерние элементы нефокусируемыми
        getFocusableElements(el).forEach((child) => {
          child.setAttribute("tabindex", "-1");
        });
      }
    });
  }

  // Фокусируемя на активный после добавления
  changeActive(active);
};

const observer = new MutationObserver(observerCallback);
if (feed) observer.observe(feed, observerOptions);

Что еще можно сделать? Можно добавить обработчик на элемент, чтобы при фокусировании (например, с помощью мышки) элемент так же становился активным.

const observerCallback = (records: MutationRecord[]) => {
  // ...

  for (const record of records) {
        // ...

        el.addEventListener("focus", () => {
          if (!el.ariaPosInSet) return;
          const pos = parseInt(el.ariaPosInSet);

          changeActive(pos - 1);
        });

      // ...
};

Да, мы реализовали альтернативную модель управления, однако в более "продовом" коде вам также понадобятся дополнительные aria-* аттрибуты, помимо тех, что есть в примере (aria-posinset, aria-setsize), здесь лучше придерживаться рекомендаций (Пункт WAI-ARIA Roles, States, and Properties).

Как идея для реализации: стоит учесть, что ваши пользователи, при использовании Screen Reader'а могут подумать, что в ленте всего один элемент. Можно добавить подсказку с помощью aria‑label, которая будет говорить о том, что переключение между пунктами ленты происходит с помощью нажатия стрелок вниз/вверх;

Как и обещал: итоговый пример

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

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


  1. Nurked
    08.10.2024 01:12
    +1

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


    1. supercat1337
      08.10.2024 01:12

      Согласен, что гемор тот еще.

      А вот что делать тем, кто чаты пишет? Если при просмотре истории диалога будет пагигация, а не скролл, то у пользователя глаз выпадет от непривычки.