Интерактивные визуализации на базе D3.js часто строятся вокруг мышиных событий: наведения, кликов, перетаскивания, масштабирования колесом мыши. Однако полноценный интерфейс не может зависеть только от указателя. Навигация с клавиатуры обеспечивает:
При разработке интерактивных графиков необходимо учитывать, что SVG-элементы по умолчанию практически не участвуют в табуляции. D3.js не решает эту проблему автоматически, поэтому управление фокусом, клавишами и состояниями доступности реализуется вручную.
HTML-элементы вроде <button> или
<input> автоматически поддерживают клавиатурную
навигацию. SVG-элементы — нет.
Например:
<circle cx="100" cy="100" r="40"></circle>
Такой элемент нельзя выделить через клавишу Tab.
Для участия SVG-элемента в последовательности фокуса требуется атрибут:
tabindex="0"
В D3.js:
d3.selectAll("circle")
.attr("tabindex", 0);
Теперь окружности становятся доступными для навигации с клавиатуры.
0Добавляет элемент в естественный порядок табуляции:
selection.attr("tabindex", 0);
Элемент будет доступен через Tab.
-1Элемент нельзя выбрать через Tab, но можно сфокусировать
программно:
selection.attr("tabindex", -1);
Используется для:
.attr("tabindex", 5)
Практически не используются в современных интерфейсах, поскольку нарушают естественный порядок навигации.
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 поддерживает стандартные DOM-события клавиатуры:
keydownkeyupkeypress (устаревшее)Основным считается keydown.
d3.selectAll("rect")
.on("keydown", function(event, d) {
console.log(event.key);
});
Объект event содержит:
| Свойство | Назначение |
|---|---|
key |
Название клавиши |
code |
Физический код клавиши |
ctrlKey |
Зажат ли Ctrl |
shiftKey |
Зажат ли Shift |
altKey |
Зажат ли Alt |
.on("keydown", function(event, d) {
if (event.key === "Enter") {
console.log("Выбран элемент");
}
});
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();
Без стилизации пользователь не увидит активный элемент.
rect:focus {
outline: none;
stroke: red;
stroke-width: 3;
}
SVG плохо работает с outline, поэтому чаще
используется:
strokebars
.on("focus", function() {
d3.select(this)
.attr("stroke", "orange")
.attr("stroke-width", 4);
})
.on("blur", function() {
d3.select(this)
.attr("stroke", null);
});
В больших интерфейсах сотни элементов не должны участвовать в табуляции одновременно.
Плохой вариант:
.attr("tabindex", 0)
для каждого элемента.
Лучший подход — только один элемент имеет tabindex=0,
остальные -1.
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();
}
Преимущества:
Интерактивный SVG-элемент должен вести себя как обычная кнопка.
.on("keydown", function(event, d) {
if (event.key === "Enter") {
activate(d);
}
});
.on("keydown", function(event, d) {
if (event.key === " ") {
event.preventDefault();
activate(d);
}
});
SVG-элементам полезно явно назначать роль.
.attr("role", "button")
.attr("role", "list")
.attr("role", "listitem")
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();
Частый сценарий D3.js — интерактивные карты.
Для двумерных интерфейсов используется матрица координат.
if (event.key === "ArrowDown") {
row++;
}
if (event.key === "ArrowUp") {
row--;
}
После вычисления нового индекса выполняется перевод фокуса.
if (event.key === "Escape") {
closeTooltip();
closeMenu();
}
Escape должен:
Подсказки не должны зависеть только от 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);
}
});
При скрытии элементов необходимо исключать их из навигации.
selection
.attr("tabindex", -1)
.attr("aria-hidden", true);
Иначе пользователь сможет переходить к невидимым объектам.
При масштабировании SVG часто возникает проблема:
Решение:
element.scrollIntoView({
block: "nearest",
inline: "nearest"
});
Для SVG-контейнеров иногда требуется дополнительное центрирование через transform.
Перетаскивание мышью недоступно с клавиатуры без дополнительной логики.
.attr("aria-pressed", true)
Используется для toggle-кнопок.
.attr("aria-selected", true)
Используется для выделенных элементов.
.attr("aria-expanded", false)
Для раскрывающихся узлов.
Для tree visualization обычно используются:
| Клавиша | Действие |
|---|---|
| → | раскрыть |
| ← | свернуть |
| ↑ | предыдущий узел |
| ↓ | следующий узел |
| Enter | активировать |
При тысячах SVG-элементов обработчики на каждом узле создают нагрузку.
svg.on("keydown", function(event) {
const target = event.target;
});
Преимущества:
.attr("data-index", (d, i) => i)
Упрощает навигацию и восстановление состояния.
Некоторые клавиши имеют встроенное поведение браузера:
| Клавиша | Стандартное действие |
|---|---|
| Space | прокрутка |
| Arrow | прокрутка |
| Tab | смена фокуса |
| Enter | отправка формы |
При необходимости:
event.preventDefault();
Но злоупотреблять этим нельзя — интерфейс должен оставаться предсказуемым.
Плохой интерфейс может «запереть» пользователя.
Например:
event.preventDefault();
для Tab без альтернативной навигации.
Следствия:
Минимальный сценарий тестирования:
SVG визуально интерактивен, но недоступен с клавиатуры.
Пользователь теряет понимание текущего элемента.
Tooltip и меню недоступны без мыши.
Кнопка не реагирует на клавиатуру.
После обновления данных пользователь вынужден начинать навигацию заново.
Хорошая реализация обычно содержит: