Фокус в виртуализированных списках

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

Управление фокусом с ally.js

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

import * as ally from 'ally.js';

// Найти первый доступный фокусируемый элемент внутри контейнера
const firstFocusable = ally.query.firstTabbable(document.getElementById('list-container'));
firstFocusable.focus();

Поиск фокусируемых элементов

Методы query.firstTabbable, query.lastTabbable и query.tabbable позволяют получить список элементов, доступных для фокуса. В виртуализированном списке важно вызывать их после рендеринга текущего окна элементов, иначе фокус может быть установлен на отсутствующий элемент.

const visibleItems = document.querySelectorAll('.list-item');
const tabbables = ally.query.tabbable(visibleItems);

Установка фокуса на элементы при навигации

Для виртуализированных списков обычная навигация с клавиатуры (стрелки вверх/вниз) требует контроля фокуса вручную. Пример обработки клавиш:

document.addEventListener('keydown', (event) => {
  const current = document.activeElement;
  if (!current.classList.contains('list-item')) return;

  let nextItem;
  if (event.key === 'ArrowDown') {
    nextItem = ally.query.nextTabbable(current, { context: current.parentElement });
  } else if (event.key === 'ArrowUp') {
    nextItem = ally.query.previousTabbable(current, { context: current.parentElement });
  }

  if (nextItem) {
    nextItem.focus();
    event.preventDefault();
  }
});

Ключевой момент: nextTabbable и previousTabbable корректно работают только с элементами, присутствующими в DOM. В виртуализированных списках необходимо предварительно рендерить следующий/предыдущий элемент.

Контроль видимости элемента при фокусе

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

const focused = document.activeElement;
if (!ally.is.focusable(focused)) {
  const firstVisible = ally.query.firstTabbable(document.getElementById('list-container'));
  firstVisible.focus();
}

// Прокрутка к элементу
focused.scrollIntoView({ block: 'nearest' });

Использование scrollIntoView предотвращает ситуации, когда фокус установлен на элемент, который визуально скрыт.

Управление фокусом при удалении и добавлении элементов

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

let focusedIndex = Array.from(document.querySelectorAll('.list-item')).indexOf(document.activeElement);

// обновление виртуального списка
renderVirtualList(items, startIndex, endIndex);

const newItems = document.querySelectorAll('.list-item');
if (newItems[focusedIndex]) {
  newItems[focusedIndex].focus();
} else if (newItems.length > 0) {
  newItems[0].focus();
}

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

Работа с группами элементов и роуминг фокуса

В сложных списках могут быть вложенные фокусируемые элементы (кнопки, ссылки внутри элемента списка). Ally.js поддерживает roving tabindex и управление группами:

const items = document.querySelectorAll('.list-item');
items.forEach((item, index) => {
  item.setAttribute('tabindex', index === 0 ? '0' : '-1');
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
    const currentIndex = Array.from(items).indexOf(document.activeElement);
    const nextIndex = event.key === 'ArrowDown' ? currentIndex + 1 : currentIndex - 1;
    if (items[nextIndex]) {
      items.forEach(el => el.setAttribute('tabindex', '-1'));
      items[nextIndex].setAttribute('tabindex', '0');
      items[nextIndex].focus();
      event.preventDefault();
    }
  }
});

Использование roving tabindex уменьшает количество элементов с tabindex="0" и делает виртуализированные списки более совместимыми с клавиатурной навигацией и скринридерами.

Оптимизация производительности

Ally.js эффективно работает с виртуализированными списками, если:

  • Фокус устанавливается только на реально существующие элементы.
  • Списки рендерятся частями с небольшими окнами видимости.
  • Используется scrollIntoView для видимых элементов.
  • Сохраняется состояние фокуса при обновлениях.

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