chartjs-plugin-a11y-legend
Version:
Chart.js plugin to provide keyboard navigation to legends
148 lines (146 loc) • 5.64 kB
JavaScript
const chartStates = new Map();
class ChartLegendManager {
hitBoxes = [];
focusBoxMargin;
focusBox;
chart;
canvas;
constructor(chart, startingMargin = 0) {
this.focusBoxMargin = startingMargin;
this.chart = chart;
this.canvas = chart.canvas;
this.focusBox = this._generateFocusBox();
this.chart.canvas.insertAdjacentElement("afterend", this.focusBox);
}
suppressFocusBox = () => {
this.focusBox.setAttribute("tabIndex", "-1");
};
reviveFocusBox = () => {
this.focusBox.setAttribute("tabIndex", "0");
};
_generateFocusBox = () => {
const focusBox = document.createElement("div");
focusBox.setAttribute("tabIndex", "0");
focusBox.setAttribute("data-legend-index", "0");
focusBox.setAttribute("role", "option");
focusBox.style.position = "absolute";
const hideFocusBox = () => {
focusBox.style.left = "-1000px";
};
const activateFocusBox = (e) => {
const index = Number(focusBox.getAttribute("data-legend-index"));
if (["pie", "doughnut"].includes(this.chart.config.type)) {
this.chart.toggleDataVisibility(index);
const isVisible = this.chart.getDataVisibility(index);
focusBox.setAttribute("aria-label", isVisible ? "Selected" : "Not selected");
}
else {
if (this.chart.isDatasetVisible(index)) {
this.chart.hide(index);
focusBox.setAttribute("aria-label", "Not selected");
}
else {
this.chart.show(index);
focusBox.setAttribute("aria-label", "Selected");
}
}
this.chart.update();
e.preventDefault();
e.stopPropagation();
};
const keyboardNavigation = (e) => {
const index = Number(focusBox.getAttribute("data-legend-index"));
const maxIndex = this.hitBoxes.length - 1;
if (e.key === "ArrowRight") {
e.preventDefault();
e.stopPropagation();
if (index >= maxIndex) {
return;
}
focusBox.setAttribute("data-legend-index", String(index + 1));
moveFocusBox();
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
if (index <= 0) {
return;
}
focusBox.setAttribute("data-legend-index", String(index - 1));
moveFocusBox();
return;
}
if (e.key === " " || e.key === "Enter") {
activateFocusBox(e);
return;
}
};
const moveFocusBox = () => {
const index = Number(focusBox.getAttribute("data-legend-index"));
if (isNaN(index)) {
return;
}
const useOffset = this.canvas.offsetParent !== null && !["BODY", "HTML"].includes(this.canvas.offsetParent.nodeName);
const bbox = this.canvas.getBoundingClientRect();
const adjustment = useOffset ? this.canvas.offsetParent.getBoundingClientRect() : { x: 0 - window.scrollX, y: 0 - window.scrollY };
const { left, top, width, height, text, hidden } = this.hitBoxes[index];
focusBox.style.left = `${bbox.x - adjustment.x + left - this.focusBoxMargin}px`;
focusBox.style.top = `${bbox.y - adjustment.y + top - this.focusBoxMargin}px`;
focusBox.style.width = `${width + (2 * this.focusBoxMargin)}px`;
focusBox.style.height = `${height + (2 * this.focusBoxMargin)}px`;
focusBox.setAttribute("aria-label", `${text}, ${hidden ? "not selected" : "selected"}, ${index + 1} of ${this.hitBoxes.length}`);
};
hideFocusBox();
focusBox.addEventListener("focus", moveFocusBox);
focusBox.addEventListener("blur", hideFocusBox);
focusBox.addEventListener("keydown", keyboardNavigation);
focusBox.addEventListener("click", activateFocusBox);
return focusBox;
};
}
const updateForLegends = (chart, manager) => {
const { legend } = chart;
if (!legend?.legendItems) {
return manager.suppressFocusBox();
}
manager.hitBoxes = legend?.legendItems?.map(({ text, hidden }, index) => {
return {
// @ts-ignore
...(legend.legendHitBoxes?.[index] ?? {}),
text,
hidden
};
}) ?? [];
};
const initialize = (chart, margin) => {
const manager = new ChartLegendManager(chart, margin);
chartStates.set(chart, manager);
return manager;
};
const plugin = {
id: "a11y_legend",
afterInit: (chart, args, options) => {
const manager = initialize(chart, options.margin);
updateForLegends(chart, manager);
},
beforeDraw: (chart, args, options) => {
let manager = chartStates.get(chart);
if (manager === undefined) {
manager = initialize(chart, options.margin);
}
if (!chart.options.plugins?.legend?.display) {
return manager.suppressFocusBox();
}
manager.reviveFocusBox();
manager.focusBoxMargin = options.margin;
updateForLegends(chart, manager);
},
afterDestroy(chart) {
chartStates.delete(chart);
},
defaults: {
margin: 4
}
};
export { plugin as default };