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

Интерактивные визуализации на базе D3.js часто строятся вокруг мышиных событий: наведения, кликов, перетаскивания, масштабирования колесом мыши. Однако полноценный интерфейс не может зависеть только от указателя. Навигация с клавиатуры обеспечивает:

  • доступность для пользователей с ограничениями моторики;
  • совместимость со скринридерами;
  • удобство работы без мыши;
  • соответствие требованиям WCAG;
  • предсказуемое взаимодействие внутри сложных SVG-интерфейсов.

При разработке интерактивных графиков необходимо учитывать, что SVG-элементы по умолчанию практически не участвуют в табуляции. D3.js не решает эту проблему автоматически, поэтому управление фокусом, клавишами и состояниями доступности реализуется вручную.


Особенности SVG и фокуса

HTML-элементы вроде <button> или <input> автоматически поддерживают клавиатурную навигацию. SVG-элементы — нет.

Например:

<circle cx="100" cy="100" r="40"></circle>

Такой элемент нельзя выделить через клавишу Tab.

Для участия SVG-элемента в последовательности фокуса требуется атрибут:

tabindex="0"

В D3.js:

d3.selectAll("circle")
  .attr("tabindex", 0);

Теперь окружности становятся доступными для навигации с клавиатуры.


Атрибут tabindex

Значение 0

Добавляет элемент в естественный порядок табуляции:

selection.attr("tabindex", 0);

Элемент будет доступен через Tab.


Значение -1

Элемент нельзя выбрать через Tab, но можно сфокусировать программно:

selection.attr("tabindex", -1);

Используется для:

  • управления фокусом вручную;
  • всплывающих окон;
  • внутренних элементов сложных компонентов.

Положительные значения tabindex

.attr("tabindex", 5)

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


Добавление фокуса к SVG-элементам

Фокусируемые столбцы диаграммы

const svg = d3.select("svg");

svg.selectAll("rect")
  .data(data)
  .enter()
  .append("rect")
  .attr("x", (d, i) => i * 60)
  .attr("y", d => 300 - d.value)
  .attr("width", 40)
  .attr("height", d => d.value)
  .attr("tabindex", 0);

Теперь каждый столбец можно выбирать клавишей Tab.


События клавиатуры в D3.js

D3.js поддерживает стандартные DOM-события клавиатуры:

  • keydown
  • keyup
  • keypress (устаревшее)

Основным считается keydown.


Обработка keydown

d3.selectAll("rect")
  .on("keydown", function(event, d) {
    console.log(event.key);
  });

Объект event содержит:

Свойство Назначение
key Название клавиши
code Физический код клавиши
ctrlKey Зажат ли Ctrl
shiftKey Зажат ли Shift
altKey Зажат ли Alt

Проверка клавиш

Enter

.on("keydown", function(event, d) {
  if (event.key === "Enter") {
    console.log("Выбран элемент");
  }
});

Space

if (event.key === " ") {
  event.preventDefault();
}

Пробел часто вызывает прокрутку страницы, поэтому требуется preventDefault().


Стрелки

if (event.key === "ArrowRight") {
  console.log("Следующий элемент");
}

Навигация между элементами стрелками

Пример переключения между столбцами

const bars = d3.selectAll("rect");

bars.on("keydown", function(event, d) {

  const current = bars.nodes().indexOf(this);

  if (event.key === "ArrowRight") {

    const next = (current + 1) % bars.size();

    bars.nodes()[next].focus();
  }

  if (event.key === "ArrowLeft") {

    const prev =
      (current - 1 + bars.size()) % bars.size();

    bars.nodes()[prev].focus();
  }
});

Результат:

  • стрелка вправо переводит фокус дальше;
  • стрелка влево — назад;
  • переход за пределы массива зацикливается.

Программный фокус

Любой DOM-элемент с поддержкой фокуса может получать его через:

element.focus();

В D3.js:

d3.select(this).node().focus();

Визуальное отображение фокуса

Без стилизации пользователь не увидит активный элемент.

CSS для SVG

rect:focus {
  outline: none;
  stroke: red;
  stroke-width: 3;
}

SVG плохо работает с outline, поэтому чаще используется:

  • stroke
  • изменение цвета;
  • фильтры;
  • свечение.

Подсветка активного элемента

bars
  .on("focus", function() {

    d3.select(this)
      .attr("stroke", "orange")
      .attr("stroke-width", 4);
  })

  .on("blur", function() {

    d3.select(this)
      .attr("stroke", null);
  });

Роуминг-фокус (Roving Tabindex)

В больших интерфейсах сотни элементов не должны участвовать в табуляции одновременно.

Плохой вариант:

.attr("tabindex", 0)

для каждого элемента.

Лучший подход — только один элемент имеет tabindex=0, остальные -1.


Реализация roving tabindex

bars.attr("tabindex", -1);

d3.select(bars.nodes()[0])
  .attr("tabindex", 0);

При перемещении:

function moveFocus(currentIndex, nextIndex) {

  d3.select(bars.nodes()[currentIndex])
    .attr("tabindex", -1);

  d3.select(bars.nodes()[nextIndex])
    .attr("tabindex", 0)
    .node()
    .focus();
}

Преимущества:

  • меньше элементов в Tab-цикле;
  • лучше доступность;
  • быстрее навигация;
  • поведение соответствует ARIA-паттернам.

Работа с Enter и Space как с кнопкой

Интерактивный SVG-элемент должен вести себя как обычная кнопка.

Обработка Enter

.on("keydown", function(event, d) {

  if (event.key === "Enter") {
    activate(d);
  }
});

Обработка пробела

.on("keydown", function(event, d) {

  if (event.key === " ") {

    event.preventDefault();

    activate(d);
  }
});

Добавление role

SVG-элементам полезно явно назначать роль.

Кнопка

.attr("role", "button")

Список

.attr("role", "list")

Элемент списка

.attr("role", "listitem")

aria-label для графических элементов

SVG не содержит текстового описания по умолчанию.

.attr("aria-label", d => `Значение ${d.value}`);

Скринридер сможет озвучить:

Значение 42


Полноценный пример интерактивной диаграммы

const data = [
  { value: 120 },
  { value: 200 },
  { value: 150 },
  { value: 90 }
];

const svg = d3.select("svg");

const bars = svg.selectAll("rect")
  .data(data)
  .enter()
  .append("rect")
  .attr("x", (d, i) => i * 70)
  .attr("y", d => 300 - d.value)
  .attr("width", 50)
  .attr("height", d => d.value)
  .attr("fill", "steelblue")
  .attr("role", "button")
  .attr("tabindex", (d, i) => i === 0 ? 0 : -1)
  .attr("aria-label", d => `Столбец ${d.value}`);

bars

  .on("focus", function() {

    d3.select(this)
      .attr("stroke", "orange")
      .attr("stroke-width", 4);
  })

  .on("blur", function() {

    d3.select(this)
      .attr("stroke", null);
  })

  .on("keydown", function(event, d) {

    const nodes = bars.nodes();

    const current =
      nodes.indexOf(this);

    if (event.key === "ArrowRight") {

      const next =
        (current + 1) % nodes.length;

      d3.select(nodes[current])
        .attr("tabindex", -1);

      d3.select(nodes[next])
        .attr("tabindex", 0)
        .node()
        .focus();
    }

    if (event.key === "ArrowLeft") {

      const prev =
        (current - 1 + nodes.length)
        % nodes.length;

      d3.select(nodes[current])
        .attr("tabindex", -1);

      d3.select(nodes[prev])
        .attr("tabindex", 0)
        .node()
        .focus();
    }

    if (
      event.key === "Enter" ||
      event.key === " "
    ) {

      event.preventDefault();

      console.log("Активирован:", d);
    }
  });

Управление фокусом после обновления данных

D3.js часто пересоздаёт элементы после обновления данных.

Проблема:

  • фокус может потеряться;
  • пользователь «выпадает» из интерфейса.

Сохранение текущего элемента

Перед обновлением:

const active =
  document.activeElement;

После обновления:

active.focus();

Но если элемент удалён, потребуется логика восстановления.


Восстановление по идентификатору

const activeId =
  document.activeElement.dataset.id;

После перерисовки:

d3.select(`[data-id="${activeId}"]`)
  .node()
  ?.focus();

Навигация в SVG-карте

Частый сценарий D3.js — интерактивные карты.

Подход

  • Tab — вход в карту;
  • стрелки — перемещение между регионами;
  • Enter — выбор;
  • Escape — выход.

Сетка навигации

Для двумерных интерфейсов используется матрица координат.

if (event.key === "ArrowDown") {
  row++;
}

if (event.key === "ArrowUp") {
  row--;
}

После вычисления нового индекса выполняется перевод фокуса.


Escape для закрытия режимов

if (event.key === "Escape") {

  closeTooltip();

  closeMenu();
}

Escape должен:

  • закрывать всплывающие элементы;
  • возвращать фокус;
  • завершать режимы взаимодействия.

Управление tooltip с клавиатуры

Подсказки не должны зависеть только от hover.

Неправильно

.on("mouseover", showTooltip)

Правильно

.on("mouseover", showTooltip)
.on("focus", showTooltip)
.on("mouseout", hideTooltip)
.on("blur", hideTooltip)

Синхронизация мыши и клавиатуры

Хороший интерфейс использует одинаковую логику:

function activateNode(node) {
  // действие
}

selection
  .on("click", activateNode)
  .on("keydown", function(event, d) {

    if (event.key === "Enter") {
      activateNode.call(this, event, d);
    }
  });

Управление tabindex при фильтрации

При скрытии элементов необходимо исключать их из навигации.

selection
  .attr("tabindex", -1)
  .attr("aria-hidden", true);

Иначе пользователь сможет переходить к невидимым объектам.


Навигация внутри zoom/pan интерфейсов

При масштабировании SVG часто возникает проблема:

  • элемент находится вне видимой области;
  • фокус есть, но пользователь его не видит.

Решение:

element.scrollIntoView({
  block: "nearest",
  inline: "nearest"
});

Для SVG-контейнеров иногда требуется дополнительное центрирование через transform.


Фокус и drag-and-drop

Перетаскивание мышью недоступно с клавиатуры без дополнительной логики.

Альтернативный подход

  • Enter — переход в режим перемещения;
  • стрелки — изменение позиции;
  • Enter — подтверждение;
  • Escape — отмена.

Управление состоянием через aria-атрибуты

aria-pressed

.attr("aria-pressed", true)

Используется для toggle-кнопок.


aria-selected

.attr("aria-selected", true)

Используется для выделенных элементов.


aria-expanded

.attr("aria-expanded", false)

Для раскрывающихся узлов.


Навигация по древовидным структурам

Для tree visualization обычно используются:

Клавиша Действие
раскрыть
свернуть
предыдущий узел
следующий узел
Enter активировать

Производительность клавиатурной навигации

При тысячах SVG-элементов обработчики на каждом узле создают нагрузку.


Делегирование событий

svg.on("keydown", function(event) {

  const target = event.target;

});

Преимущества:

  • меньше обработчиков;
  • лучше производительность;
  • проще управление.

Использование data-* атрибутов

.attr("data-index", (d, i) => i)

Упрощает навигацию и восстановление состояния.


Предотвращение конфликтов клавиш

Некоторые клавиши имеют встроенное поведение браузера:

Клавиша Стандартное действие
Space прокрутка
Arrow прокрутка
Tab смена фокуса
Enter отправка формы

При необходимости:

event.preventDefault();

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


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

Плохой интерфейс может «запереть» пользователя.

Например:

event.preventDefault();

для Tab без альтернативной навигации.

Следствия:

  • невозможность покинуть компонент;
  • проблемы со скринридерами;
  • нарушение WCAG.

Проверка клавиатурной доступности

Минимальный сценарий тестирования:

  1. Полностью отключить мышь.
  2. Проверить достижимость всех интерактивных элементов.
  3. Проверить видимость фокуса.
  4. Проверить Escape.
  5. Проверить Enter и Space.
  6. Проверить последовательность Tab.
  7. Проверить работу скринридера.

Частые ошибки

Отсутствие tabindex

SVG визуально интерактивен, но недоступен с клавиатуры.


Отсутствие focus-стилей

Пользователь теряет понимание текущего элемента.


Навигация только через hover

Tooltip и меню недоступны без мыши.


Использование только click

Кнопка не реагирует на клавиатуру.


Потеря фокуса после render

После обновления данных пользователь вынужден начинать навигацию заново.


Практические рекомендации

Для графиков

  • стрелки — перемещение между точками;
  • Enter — подробности;
  • Home/End — первый и последний элемент.

Для карт

  • стрелки — соседние регионы;
  • Enter — выбор региона;
  • Escape — выход.

Для иерархий

  • стрелки вправо/влево — раскрытие;
  • вверх/вниз — соседние узлы.

Для сложных dashboard

  • roving tabindex;
  • единый обработчик клавиатуры;
  • aria-описания;
  • синхронизация мыши и клавиатуры.

Архитектура доступного D3-интерфейса

Хорошая реализация обычно содержит:

  1. Управление фокусом.
  2. Управление tabindex.
  3. ARIA-атрибуты.
  4. Видимые focus-состояния.
  5. Клавиатурные эквиваленты мыши.
  6. Восстановление состояния после обновлений.
  7. Предсказуемые горячие клавиши.
  8. Escape-механизмы.
  9. Поддержку скринридеров.
  10. Минимизацию лишних Tab-остановок.