Web accessibility. Endless tape

There are many articles, posts, guides on how to implement the functionality itself about what an endless feed (Infinity Scroll, Feed) is, and it seems that talking about exactly how to implement it and what it is is not the idea of ​​this article. As well as talking about the pros and cons of this approach. If you still don’t know what kind of engineering miracle this is, you can read here.

Personally, I want to focus on the accessibility of such a feed, and at the end there is an example implementation in simple HTML/CSS/JS with support for alternative controls, but such an example is easy to port to any other framework/library.

Path

When implementing accessible components, there are always a few aspects to consider:

  1. If there is already a native implementation (semantic tag, the element itself) – use it. For example, the most common example given is buttons. If you make buttons using <div>you risk creating a completely inaccessible, crooked interface from a UX point of view. After all, according to W3C guidesyou must also take into account the role, specifying it via an attribute, and proper keyboard control. But, as practice shows, this is often forgotten: in the end we end up with a custom button (made using <div>), which is in no way accessible using Taband even more so, not controlled using the keyboard.

  2. Correct implementation in the absence of a native element. For example, the element for the tree pattern (Tree) is not in HTML, but there is an already outdated one for it, recommended implementation.

  3. The third thorny path. It suggests that there is no consensus in the community on its implementation, as well as developed and recommended management patterns. In this case, it is permissible to create your own. This is exactly what ours is Feed component.

And the third path is thorny in that we undertake an obligation not just to implement, but to create a new product with which our user has not yet interacted, so it is important to give him familiar patterns so that he does not get lost at all.

Problems

Speaking in this section about how it is recommended, I will refer to this guide from W3C.

In the current implementation of the pattern, it is recommended to make each element of the endless tape focusable

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

However, this leads to the following problem: since the feed can take forever to load, we will generally not exit it to the next focusable elements outside the feed itself (eg footer links).

Here we are offered the following solution:

Control + End – transfers to the first focused element after the tape;
Control + Home – respectively, to the first focused element in front of the tape;

The problem here is that:

  1. IN JSunfortunately, there is no way through any full API to access the next or previous focusable element, although discussions were.

  2. Of course, you can find this through a selector (querySelectorand see more about it in HTML Living Standard), but this solution is a bit unstable because we don't take into account the fact that tabindex can be greater than zero (although this is considered an anti-pattern) and this will basically change the focus order, nor that this can affect complexity and performance the decision itself.

  3. When implemented in frameworks, we, as is correct, operate with a large number of components. And with this approach, when adding new functionality, it’s easy to forget which component should be the previous component, losing focus from the ribbon, which leads to incorrect jumping of focused elements.

It is in such controversial patterns that it is possible to provide an alternative implementation, of course, following the general recommendations.

Implementation

Having described the problems inherent in the current recommendation, you can try to implement your own alternative version, but it is important that it is understandable to the user.

If you don’t really want to read the implementation stages, you can go straight to it for example.

First, let's assign tabindex -1 to all elements of the feed, thereby making them unfocusable. We will assign tabindex 0 to the feed element itself, making it focusable.

<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>

This is where HTML ends and begins JS.

Let's create several functions and variables that will help us in our work:

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;
};

Here we get a link to the feed element by writing it to a variable feedand also declare a variable poswhich stores the last position of the element.

Function getFeedElements will get a list of current feed items. Here it’s worth revealing a little that when adding new elements to the feed, you should call this function, assigning its result to the items variable. We'll deal with this a little later.

Function changeActive changes the current active element of the ribbon: we make the old one inaccessible for focus, and the new one – vice versa. After this, we focus using .focus()

Next is a matter of technique, just add handlers

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);
  }
});

We attach a focus handler to feed, which makes focus on the feed itself inaccessible in the future, and also makes the first element active.

We also attach a handler for a key press, where the down arrow will make the next element active, and the up arrow will make the previous one active.

Everything is great, now even with the help of pressing Tab you can switch to the focusable element after the tape, and using Shift + Tab to the ribbon, but there is one small detail: what if the ribbon element also contains a focusable element?

In fact, there is no problem at all. We can bypass internal elements, making them unavailable/focusable.

Let's add a new helper function:

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

It returns all focusable elements, although it does not take into account some other features; you can supplement it in your projects.

Let's add the following functionality to the function for changing the current active element:

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");
  });
  
  // ...
};

Then we add MutationObserverand in the callback function we will react to the addition of elements.

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);

What else can you do? You can add a handler to an element so that when you focus (for example, using the mouse), the element also becomes active.

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

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

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

          changeActive(pos - 1);
        });

      // ...
};

Yes, we have implemented an alternative control model, however, in more “prod” code you will also need additional aria-* attributes, in addition to those in the example (aria-posinset, aria-setsize), here it is better to follow the recommendations (Item WAI-ARIA Roles, States, and Properties).

As an idea for implementation: It is worth considering that your users, when using Screen Reader, may think that there is only one element in the feed. You can add a hint using aria‑label, which will indicate that switching between tape items is done by pressing the down/up arrows;

As promised: final example

Example far from idealHowever, it does offer an alternative approach to implementing an infinite feed that will help avoid the problems that users may encounter. If you have ideas for improvement or any questions, welcome to comment.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *