Виртуализированные списки используются для оптимизации рендеринга больших наборов данных. Вместо того чтобы создавать DOM-элементы для каждого элемента списка, создаются только видимые элементы, а остальные динамически заменяются по мере прокрутки. Такой подход повышает производительность, но приводит к сложностям с управлением фокусом, поскольку элементы появляются и исчезают из DOM. Библиотека 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 для видимых элементов.Такой подход позволяет сочетать высокую производительность виртуализации с полной доступностью для пользователей, управляющих интерфейсом с клавиатуры или вспомогательными технологиями.