highcharts
Version:
JavaScript charting framework
722 lines (721 loc) • 26.3 kB
JavaScript
/* *
*
* (c) 2009-2025 Øystein Moseng
*
* Handle keyboard navigation for series.
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import Point from '../../../Core/Series/Point.js';
import Series from '../../../Core/Series/Series.js';
import SeriesRegistry from '../../../Core/Series/SeriesRegistry.js';
const { seriesTypes } = SeriesRegistry;
import H from '../../../Core/Globals.js';
const { doc } = H;
import U from '../../../Core/Utilities.js';
const { defined, fireEvent } = U;
import KeyboardNavigationHandler from '../../KeyboardNavigationHandler.js';
import EventProvider from '../../Utils/EventProvider.js';
import ChartUtilities from '../../Utils/ChartUtilities.js';
const { getPointFromXY, getSeriesFromName, scrollAxisToPoint } = ChartUtilities;
/* *
*
* Functions
*
* */
/* eslint-disable valid-jsdoc */
/**
* Get the index of a point in a series. This is needed when using e.g. data
* grouping.
*
* @private
* @function getPointIndex
* @param {Highcharts.AccessibilityPoint} point
* The point to find index of.
* @return {number|undefined}
* The index in the series.points array of the point.
*/
function getPointIndex(point) {
const index = point.index, points = point.series.points;
let i = points.length;
if (points[index] !== point) {
while (i--) {
if (points[i] === point) {
return i;
}
}
}
else {
return index;
}
}
/**
* Determine if series navigation should be skipped
* @private
*/
function isSkipSeries(series) {
const a11yOptions = series.chart.options.accessibility, seriesNavOptions = a11yOptions.keyboardNavigation.seriesNavigation, seriesA11yOptions = series.options.accessibility || {}, seriesKbdNavOptions = seriesA11yOptions.keyboardNavigation;
return seriesKbdNavOptions && seriesKbdNavOptions.enabled === false ||
seriesA11yOptions.enabled === false ||
series.options.enableMouseTracking === false || // #8440
!series.visible ||
// Skip all points in a series where pointNavigationEnabledThreshold is
// reached
(seriesNavOptions.pointNavigationEnabledThreshold &&
+seriesNavOptions.pointNavigationEnabledThreshold <=
series.points.length);
}
/**
* Determine if navigation for a point should be skipped
* @private
*/
function isSkipPoint(point) {
const series = point.series, nullInteraction = series.options.nullInteraction, pointOptions = point.options, pointA11yOptions = pointOptions.accessibility, a11yOptions = series.chart.options.accessibility, pointA11yDisabled = pointA11yOptions?.enabled === false;
return a11yOptions
.keyboardNavigation
.seriesNavigation
.skipNullPoints ?? (!(!point.isNull || nullInteraction) &&
point.visible === false ||
point.isInside === false ||
pointA11yDisabled ||
isSkipSeries(series));
}
/**
* Get the first point that is not a skip point in this series.
* @private
*/
function getFirstValidPointInSeries(series) {
const points = series.points || [], len = points.length;
for (let i = 0; i < len; ++i) {
if (!isSkipPoint(points[i])) {
return points[i];
}
}
return null;
}
/**
* Get the first point that is not a skip point in this chart.
* @private
*/
function getFirstValidPointInChart(chart) {
const series = chart.series || [], len = series.length;
for (let i = 0; i < len; ++i) {
if (!isSkipSeries(series[i])) {
const point = getFirstValidPointInSeries(series[i]);
if (point) {
return point;
}
}
}
return null;
}
/**
* @private
*/
function highlightLastValidPointInChart(chart) {
const numSeries = chart.series.length;
let i = numSeries, res = false;
while (i--) {
chart.highlightedPoint = chart.series[i].points[chart.series[i].points.length - 1];
// Highlight first valid point in the series will also
// look backwards. It always starts from currently
// highlighted point.
res = chart.series[i].highlightNextValidPoint();
if (res) {
break;
}
}
return res;
}
/**
* After drilling down/up, we need to set focus to the first point for
* screen readers and keyboard nav.
* @private
*/
function updateChartFocusAfterDrilling(chart) {
const point = getFirstValidPointInChart(chart);
if (point) {
point.highlight(false); // Do not visually highlight
}
}
/**
* Highlight the first point in chart that is not a skip point
* @private
*/
function highlightFirstValidPointInChart(chart) {
delete chart.highlightedPoint;
const point = getFirstValidPointInChart(chart);
return point ? point.highlight() : false;
}
/* *
*
* Class
*
* */
/**
* @private
* @class
* @name Highcharts.SeriesKeyboardNavigation
*/
class SeriesKeyboardNavigation {
/* *
*
* Constructor
*
* */
constructor(chart, keyCodes) {
this.keyCodes = keyCodes;
this.chart = chart;
}
/* *
*
* Functions
*
* */
/* eslint-disable valid-jsdoc */
/**
* Init the keyboard navigation
*/
init() {
const keyboardNavigation = this, chart = this.chart, e = this.eventProvider = new EventProvider();
e.addEvent(Series, 'destroy', function () {
return keyboardNavigation.onSeriesDestroy(this);
});
e.addEvent(chart, 'afterApplyDrilldown', function () {
updateChartFocusAfterDrilling(this);
});
e.addEvent(chart, 'drilldown', function (e) {
const point = e.point, series = point.series;
keyboardNavigation.lastDrilledDownPoint = {
x: point.x,
y: point.y,
seriesName: series ? series.name : ''
};
});
e.addEvent(chart, 'drillupall', function () {
setTimeout(function () {
keyboardNavigation.onDrillupAll();
}, 10);
});
// Heatmaps et al. alter z-index in setState, causing elements
// to lose focus
e.addEvent(Point, 'afterSetState', function () {
const point = this;
const pointEl = point.graphic && point.graphic.element;
const focusedElement = doc.activeElement;
// VO brings focus with it to container, causing series nav to run.
// If then navigating with virtual cursor, it is possible to leave
// keyboard nav module state on the data points and still activate
// proxy buttons.
const focusedElClassName = (focusedElement && focusedElement.getAttribute('class'));
const isProxyFocused = focusedElClassName &&
focusedElClassName.indexOf('highcharts-a11y-proxy-element') > -1;
if (chart.highlightedPoint === point &&
focusedElement !== pointEl &&
!isProxyFocused &&
pointEl &&
pointEl.focus) {
pointEl.focus();
}
});
}
/**
* After drillup we want to find the point that was drilled down to and
* highlight it.
* @private
*/
onDrillupAll() {
const last = this.lastDrilledDownPoint, chart = this.chart, series = last && getSeriesFromName(chart, last.seriesName);
let point;
if (last && series && defined(last.x) && defined(last.y)) {
point = getPointFromXY(series, last.x, last.y);
}
point = point || getFirstValidPointInChart(chart);
// Container focus can be lost on drillup due to deleted elements.
if (chart.container) {
chart.container.focus();
}
if (point && point.highlight) {
point.highlight(false); // Do not visually highlight
}
}
/**
* @private
*/
getKeyboardNavigationHandler() {
const keyboardNavigation = this, keys = this.keyCodes, chart = this.chart, inverted = chart.inverted;
return new KeyboardNavigationHandler(chart, {
keyCodeMap: [
[
inverted ? [keys.up, keys.down] : [keys.left, keys.right],
function (keyCode) {
return keyboardNavigation.onKbdSideways(this, keyCode);
}
],
[
inverted ? [keys.left, keys.right] : [keys.up, keys.down],
function (keyCode) {
return keyboardNavigation.onKbdVertical(this, keyCode);
}
],
[
[keys.enter, keys.space],
function (keyCode, event) {
const point = chart.highlightedPoint;
if (point) {
const { plotLeft, plotTop } = this.chart, { plotX = 0, plotY = 0 } = point;
event = {
...event,
chartX: plotLeft + plotX,
chartY: plotTop + plotY,
point: point,
target: point.graphic?.element || event.target
};
fireEvent(point.series, 'click', event);
point.firePointEvent('click', event);
}
return this.response.success;
}
],
[
[keys.home],
function () {
highlightFirstValidPointInChart(chart);
return this.response.success;
}
],
[
[keys.end],
function () {
highlightLastValidPointInChart(chart);
return this.response.success;
}
],
[
[keys.pageDown, keys.pageUp],
function (keyCode) {
chart.highlightAdjacentSeries(keyCode === keys.pageDown);
return this.response.success;
}
]
],
init: function () {
return keyboardNavigation.onHandlerInit(this);
},
validate: function () {
return !!getFirstValidPointInChart(chart);
},
terminate: function () {
return keyboardNavigation.onHandlerTerminate();
}
});
}
/**
* @private
* @param {Highcharts.KeyboardNavigationHandler} handler
* @param {number} keyCode
* @return {number}
* response
*/
onKbdSideways(handler, keyCode) {
const keys = this.keyCodes, isNext = keyCode === keys.right || keyCode === keys.down;
return this.attemptHighlightAdjacentPoint(handler, isNext);
}
/**
* When keyboard navigation inits.
* @private
* @param {Highcharts.KeyboardNavigationHandler} handler The handler object
* @return {number}
* response
*/
onHandlerInit(handler) {
const chart = this.chart, kbdNavOptions = chart.options.accessibility.keyboardNavigation;
if (kbdNavOptions.seriesNavigation.rememberPointFocus &&
chart.highlightedPoint) {
chart.highlightedPoint.highlight();
}
else {
highlightFirstValidPointInChart(chart);
}
return handler.response.success;
}
/**
* @private
* @param {Highcharts.KeyboardNavigationHandler} handler
* @param {number} keyCode
* @return {number}
* response
*/
onKbdVertical(handler, keyCode) {
const chart = this.chart, keys = this.keyCodes, isNext = keyCode === keys.down || keyCode === keys.right, navOptions = chart.options.accessibility.keyboardNavigation
.seriesNavigation;
// Handle serialized mode, act like left/right
if (navOptions.mode && navOptions.mode === 'serialize') {
return this.attemptHighlightAdjacentPoint(handler, isNext);
}
// Normal mode, move between series
const highlightMethod = (chart.highlightedPoint &&
chart.highlightedPoint.series.keyboardMoveVertical) ?
'highlightAdjacentPointVertical' :
'highlightAdjacentSeries';
chart[highlightMethod](isNext);
return handler.response.success;
}
/**
* @private
*/
onHandlerTerminate() {
const chart = this.chart, kbdNavOptions = chart.options.accessibility.keyboardNavigation;
if (chart.tooltip) {
chart.tooltip.hide(0);
}
const hoverSeries = (chart.highlightedPoint && chart.highlightedPoint.series);
if (hoverSeries && hoverSeries.onMouseOut) {
hoverSeries.onMouseOut();
}
if (chart.highlightedPoint && chart.highlightedPoint.onMouseOut) {
chart.highlightedPoint.onMouseOut();
}
if (!kbdNavOptions.seriesNavigation.rememberPointFocus) {
delete chart.highlightedPoint;
}
}
/**
* Function that attempts to highlight next/prev point. Handles wrap around.
* @private
*/
attemptHighlightAdjacentPoint(handler, directionIsNext) {
const chart = this.chart, wrapAround = chart.options.accessibility.keyboardNavigation
.wrapAround, highlightSuccessful = chart.highlightAdjacentPoint(directionIsNext);
if (!highlightSuccessful) {
if (wrapAround && (directionIsNext ?
highlightFirstValidPointInChart(chart) :
highlightLastValidPointInChart(chart))) {
return handler.response.success;
}
return handler.response[directionIsNext ? 'next' : 'prev'];
}
return handler.response.success;
}
/**
* @private
*/
onSeriesDestroy(series) {
const chart = this.chart, currentHighlightedPointDestroyed = chart.highlightedPoint &&
chart.highlightedPoint.series === series;
if (currentHighlightedPointDestroyed) {
delete chart.highlightedPoint;
if (chart.focusElement) {
chart.focusElement.removeFocusBorder();
}
}
}
/**
* @private
*/
destroy() {
this.eventProvider.removeAddedEvents();
}
}
/* *
*
* Class Namespace
*
* */
(function (SeriesKeyboardNavigation) {
/* *
*
* Declarations
*
* */
/* *
*
* Functions
*
* */
/**
* Function to highlight next/previous point in chart.
*
* @private
* @function Highcharts.Chart#highlightAdjacentPoint
*
* @param {boolean} next
* Flag for the direction.
*
* @return {Highcharts.Point|boolean}
* Returns highlighted point on success, false on failure (no adjacent point
* to highlight in chosen direction).
*/
function chartHighlightAdjacentPoint(next) {
const chart = this, series = chart.series, curPoint = chart.highlightedPoint, curPointIndex = curPoint && getPointIndex(curPoint) || 0, curPoints = curPoint && curPoint.series.points || [], lastSeries = chart.series && chart.series[chart.series.length - 1], lastPoint = lastSeries &&
lastSeries.points &&
lastSeries.points[lastSeries.points.length - 1];
let newSeries, newPoint;
// If no points, return false
if (!series[0] || !series[0].points) {
return false;
}
if (!curPoint) {
// No point is highlighted yet. Try first/last point depending on
// move direction
newPoint = next ? series[0].points[0] : lastPoint;
}
else {
// We have a highlighted point. Grab next/prev point & series.
newSeries = series[curPoint.series.index + (next ? 1 : -1)];
newPoint = curPoints[curPointIndex + (next ? 1 : -1)];
if (!newPoint && newSeries) {
// Done with this series, try next one
newPoint = newSeries.points[next ? 0 : newSeries.points.length - 1];
}
// If there is no adjacent point, we return false
if (!newPoint) {
return false;
}
}
// Recursively skip points
if (isSkipPoint(newPoint)) {
// If we skip this whole series, move to the end of the series
// before we recurse, just to optimize
newSeries = newPoint.series;
if (isSkipSeries(newSeries)) {
chart.highlightedPoint = next ?
newSeries.points[newSeries.points.length - 1] :
newSeries.points[0];
}
else {
// Otherwise, just move one point
chart.highlightedPoint = newPoint;
}
// Retry
return chart.highlightAdjacentPoint(next);
}
// There is an adjacent point, highlight it
return newPoint.highlight();
}
/**
* Highlight the closest point vertically.
* @private
*/
function chartHighlightAdjacentPointVertical(down) {
const curPoint = this.highlightedPoint;
let minDistance = Infinity, bestPoint;
if (!defined(curPoint.plotX) || !defined(curPoint.plotY)) {
return false;
}
this.series.forEach((series) => {
if (isSkipSeries(series)) {
return;
}
series.points.forEach((point) => {
if (!defined(point.plotY) || !defined(point.plotX) ||
point === curPoint) {
return;
}
let yDistance = point.plotY - curPoint.plotY;
const width = Math.abs(point.plotX - curPoint.plotX), distance = Math.abs(yDistance) * Math.abs(yDistance) +
width * width * 4; // Weigh horizontal distance highly
// Reverse distance number if axis is reversed
if (series.yAxis && series.yAxis.reversed) {
yDistance *= -1;
}
if (yDistance <= 0 && down || yDistance >= 0 && !down ||
distance < 5 || // Points in same spot => infinite loop
isSkipPoint(point)) {
return;
}
if (distance < minDistance) {
minDistance = distance;
bestPoint = point;
}
});
});
return bestPoint ? bestPoint.highlight() : false;
}
/**
* Highlight next/previous series in chart. Returns false if no adjacent
* series in the direction, otherwise returns new highlighted point.
* @private
*/
function chartHighlightAdjacentSeries(down) {
const chart = this, curPoint = chart.highlightedPoint, lastSeries = chart.series && chart.series[chart.series.length - 1], lastPoint = lastSeries && lastSeries.points &&
lastSeries.points[lastSeries.points.length - 1];
let newSeries, newPoint, adjacentNewPoint;
// If no point is highlighted, highlight the first/last point
if (!chart.highlightedPoint) {
newSeries = down ? (chart.series && chart.series[0]) : lastSeries;
newPoint = down ?
(newSeries && newSeries.points && newSeries.points[0]) :
lastPoint;
return newPoint ? newPoint.highlight() : false;
}
newSeries = (chart.series[curPoint.series.index + (down ? -1 : 1)]);
if (!newSeries) {
return false;
}
// We have a new series in this direction, find the right point
// Weigh xDistance as counting much higher than Y distance
newPoint = getClosestPoint(curPoint, newSeries, 4);
if (!newPoint) {
return false;
}
// New series and point exists, but we might want to skip it
if (isSkipSeries(newSeries)) {
// Skip the series
newPoint.highlight();
// Try recurse
adjacentNewPoint = chart.highlightAdjacentSeries(down);
if (!adjacentNewPoint) {
// Recurse failed
curPoint.highlight();
return false;
}
// Recurse succeeded
return adjacentNewPoint;
}
// Highlight the new point or any first valid point back or forwards
// from it
newPoint.highlight();
return newPoint.series.highlightNextValidPoint();
}
/**
* @private
*/
function compose(ChartClass, PointClass, SeriesClass) {
const chartProto = ChartClass.prototype, pointProto = PointClass.prototype, seriesProto = SeriesClass.prototype;
if (!chartProto.highlightAdjacentPoint) {
chartProto.highlightAdjacentPoint = chartHighlightAdjacentPoint;
chartProto.highlightAdjacentPointVertical = (chartHighlightAdjacentPointVertical);
chartProto.highlightAdjacentSeries = chartHighlightAdjacentSeries;
pointProto.highlight = pointHighlight;
/**
* Set for which series types it makes sense to move to the closest
* point with up/down arrows, and which series types should just
* move to next series.
* @private
*/
seriesProto.keyboardMoveVertical = true;
[
'column',
'gantt',
'pie'
].forEach((type) => {
if (seriesTypes[type]) {
seriesTypes[type].prototype.keyboardMoveVertical = false;
}
});
seriesProto.highlightNextValidPoint = (seriesHighlightNextValidPoint);
}
}
SeriesKeyboardNavigation.compose = compose;
/**
* Get the point in a series that is closest (in pixel distance) to a
* reference point. Optionally supply weight factors for x and y directions.
* @private
*/
function getClosestPoint(point, series, xWeight, yWeight) {
let minDistance = Infinity, dPoint, minIx, distance, i = series.points.length;
const hasUndefinedPosition = (point) => (!(defined(point.plotX) && defined(point.plotY)));
if (hasUndefinedPosition(point)) {
return;
}
while (i--) {
dPoint = series.points[i];
if (hasUndefinedPosition(dPoint)) {
continue;
}
distance = (point.plotX - dPoint.plotX) *
(point.plotX - dPoint.plotX) *
(xWeight || 1) +
(point.plotY - dPoint.plotY) *
(point.plotY - dPoint.plotY) *
(yWeight || 1);
if (distance < minDistance) {
minDistance = distance;
minIx = i;
}
}
return defined(minIx) ? series.points[minIx] : void 0;
}
/**
* Highlights a point (show tooltip, display hover state, focus element).
*
* @private
* @function Highcharts.Point#highlight
*
* @return {Highcharts.Point}
* This highlighted point.
*/
function pointHighlight(highlightVisually = true) {
const chart = this.series.chart, tooltipElement = chart.tooltip?.label?.element;
if ((!this.isNull ||
this.series.options?.nullInteraction) && highlightVisually) {
this.onMouseOver(); // Show the hover marker and tooltip
}
else {
if (chart.tooltip) {
chart.tooltip.hide(0);
}
// Do not call blur on the element, as it messes up the focus of the
// div element of the chart
}
scrollAxisToPoint(this);
// We focus only after calling onMouseOver because the state change can
// change z-index and mess up the element.
if (this.graphic) {
chart.setFocusToElement(this.graphic);
if (!highlightVisually && chart.focusElement) {
chart.focusElement.removeFocusBorder();
}
}
chart.highlightedPoint = this;
// Get position of the tooltip.
const tooltipTop = tooltipElement?.getBoundingClientRect().top;
if (tooltipElement && tooltipTop && tooltipTop < 0) {
// Calculate scroll position.
const scrollTop = window.scrollY, newScrollTop = scrollTop + tooltipTop;
// Scroll window to new position.
window.scrollTo({
behavior: 'smooth',
top: newScrollTop
});
}
return this;
}
/**
* Highlight first valid point in a series. Returns the point if
* successfully highlighted, otherwise false. If there is a highlighted
* point in the series, use that as starting point.
*
* @private
* @function Highcharts.Series#highlightNextValidPoint
*/
function seriesHighlightNextValidPoint() {
const curPoint = this.chart.highlightedPoint, start = (curPoint && curPoint.series) === this ?
getPointIndex(curPoint) :
0, points = this.points, len = points.length;
if (points && len) {
for (let i = start; i < len; ++i) {
if (!isSkipPoint(points[i])) {
return points[i].highlight();
}
}
for (let j = start; j >= 0; --j) {
if (!isSkipPoint(points[j])) {
return points[j].highlight();
}
}
}
return false;
}
})(SeriesKeyboardNavigation || (SeriesKeyboardNavigation = {}));
/* *
*
* Default Export
*
* */
export default SeriesKeyboardNavigation;