/** * @license Highcharts JS v5.0.13 (2017-07-27) * Accessibility module * * (c) 2010-2017 Highsoft AS * Author: Oystein Moseng * * License: www.highcharts.com/license */ 'use strict'; (function(factory) { if (typeof module === 'object' && module.exports) { module.exports = factory; } else { factory(Highcharts); } }(function(Highcharts) { (function(H) { /** * Accessibility module * * (c) 2010-2017 Highsoft AS * Author: Oystein Moseng * * License: www.highcharts.com/license */ var win = H.win, doc = win.document, each = H.each, erase = H.erase, addEvent = H.addEvent, removeEvent = H.removeEvent, fireEvent = H.fireEvent, dateFormat = H.dateFormat, merge = H.merge, // Human readable description of series and each point in singular and plural typeToSeriesMap = { 'default': ['series', 'data point', 'data points'], 'line': ['line', 'data point', 'data points'], 'spline': ['line', 'data point', 'data points'], 'area': ['line', 'data point', 'data points'], 'areaspline': ['line', 'data point', 'data points'], 'pie': ['pie', 'slice', 'slices'], 'column': ['column series', 'column', 'columns'], 'bar': ['bar series', 'bar', 'bars'], 'scatter': ['scatter series', 'data point', 'data points'], 'boxplot': ['boxplot series', 'box', 'boxes'], 'arearange': ['arearange series', 'data point', 'data points'], 'areasplinerange': ['areasplinerange series', 'data point', 'data points'], 'bubble': ['bubble series', 'bubble', 'bubbles'], 'columnrange': ['columnrange series', 'column', 'columns'], 'errorbar': ['errorbar series', 'errorbar', 'errorbars'], 'funnel': ['funnel', 'data point', 'data points'], 'pyramid': ['pyramid', 'data point', 'data points'], 'waterfall': ['waterfall series', 'column', 'columns'], 'map': ['map', 'area', 'areas'], 'mapline': ['line', 'data point', 'data points'], 'mappoint': ['point series', 'data point', 'data points'], 'mapbubble': ['bubble series', 'bubble', 'bubbles'] }, // Descriptions for exotic chart types typeDescriptionMap = { boxplot: ' Box plot charts are typically used to display groups of statistical data. ' + 'Each data point in the chart can have up to 5 values: minimum, lower quartile, median, upper quartile and maximum. ', arearange: ' Arearange charts are line charts displaying a range between a lower and higher value for each point. ', areasplinerange: ' These charts are line charts displaying a range between a lower and higher value for each point. ', bubble: ' Bubble charts are scatter charts where each data point also has a size value. ', columnrange: ' Columnrange charts are column charts displaying a range between a lower and higher value for each point. ', errorbar: ' Errorbar series are used to display the variability of the data. ', funnel: ' Funnel charts are used to display reduction of data in stages. ', pyramid: ' Pyramid charts consist of a single pyramid with item heights corresponding to each point value. ', waterfall: ' A waterfall chart is a column chart where each column contributes towards a total end value. ' }; // If a point has one of the special keys defined, we expose all keys to the // screen reader. H.Series.prototype.commonKeys = ['name', 'id', 'category', 'x', 'value', 'y']; H.Series.prototype.specialKeys = [ 'z', 'open', 'high', 'q3', 'median', 'q1', 'low', 'close' ]; // A pie is always simple. Don't quote me on that. if (H.seriesTypes.pie) { H.seriesTypes.pie.prototype.specialKeys = []; } /** * Accessibility options * @type {Object} * @optionparent */ H.setOptions({ /** * Options for configuring accessibility for the chart. Requires the * [accessibility module](//code.highcharts.com/modules/accessibility. * js) to be loaded. For a description of the module and information * on its features, see [Highcharts Accessibility](http://www.highcharts. * com/docs/chart-concepts/accessibility). * * @since 5.0.0 * @product highcharts highstock highmaps */ accessibility: { /** * Enable accessibility features for the chart. * * @type {Boolean} * @default true * @since 5.0.0 * @product highcharts highstock highmaps */ enabled: true, /** * When a series contains more points than this, we no longer expose * information about individual points to screen readers. * * Set to `false` to disable. * * @type {Number|Boolean} * @default 30 * @since 5.0.0 * @product highcharts highstock highmaps */ pointDescriptionThreshold: 30, // set to false to disable /** * Options for keyboard navigation. * * @type {Object} * @since 5.0.0 * @product highcharts highstock highmaps */ keyboardNavigation: { /** * Enable keyboard navigation for the chart. * * @type {Boolean} * @default true * @since 5.0.0 */ enabled: true, /** * Enable tab navigation for points. Without this, only arrow keys * can be used to navigate between points. * * @type {Boolean} * @default {all} true * @since next */ tabThroughPoints: true /** * Skip null points when navigating through points with the * keyboard. * * @type {Boolean} * @default false * @since 5.0.0 * @apioption accessibility.keyboardNavigation.skipNullPoints */ } /** * Whether or not to add series descriptions to charts with a single * series. * * @type {Boolean} * @default false * @since 5.0.0 * @product highcharts highstock highmaps * @apioption accessibility.describeSingleSeries */ /** * Function to run upon clicking the "View as Data Table" link in the * screen reader region. * * By default Highcharts will insert and set focus to a data table * representation of the chart. * * @type {Function} * @since 5.0.0 * @apioption accessibility.onTableAnchorClick */ /** * Date format to use for points on datetime axes when describing them * to screen reader users. * * Defaults to the same format as in tooltip. * * For an overview of the replacement codes, see [dateFormat]( * #Highcharts.dateFormat). * * @type {String} * @see [pointDateFormatter](#accessibility.pointDateFormatter) * @since 5.0.0 * @apioption accessibility.pointDateFormat */ /** * Formatter function to determine the date/time format used with points * on datetime axes when describing them to screen reader users. Receives * one argument, `point`, referring to the point to describe. Should * return a date format string compatible with [dateFormat](#Highcharts. * dateFormat). * * @type {Function} * @see [pointDateFormat](#accessibility.pointDateFormat) * @since 5.0.0 * @apioption accessibility.pointDateFormatter */ /** * Formatter function to use instead of the default for point descriptions. * Receives one argument, `point`, referring to the point to describe. * Should return a String with the description of the point for a screen * reader user. * * @type {Function} * @see [point.description](#series.data.description) * @since 5.0.0 * @apioption accessibility.pointDescriptionFormatter */ /** * A formatter function to create the HTML contents of the hidden screen * reader information region. Receives one argument, `chart`, referring * to the chart object. Should return a String with the HTML content * of the region. * * The link to view the chart as a data table will be added automatically * after the custom HTML content. * * @type {Function} * @default undefined * @since 5.0.0 * @apioption accessibility.screenReaderSectionFormatter */ /** * Formatter function to use instead of the default for series descriptions. * Receives one argument, `series`, referring to the series to describe. * Should return a String with the description of the series for a * screen reader user. * * @type {Function} * @see [series.description](#plotOptions.series.description) * @since 5.0.0 * @apioption accessibility.seriesDescriptionFormatter */ } }); /** * A text description of the chart. * * If the Accessibility module is loaded, this is included by default * as a long description of the chart and its contents in the hidden * screen reader information region. * * @type {String} * @see [typeDescription](#chart.typeDescription) * @default undefined * @since 5.0.0 * @apioption chart.description */ /** * A text description of the chart type. * * If the Accessibility module is loaded, this will be included in the * description of the chart in the screen reader information region. * * * Highcharts will by default attempt to guess the chart type, but for * more complex charts it is recommended to specify this property for * clarity. * * @type {String} * @default undefined * @since 5.0.0 * @apioption chart.typeDescription */ /** * HTML encode some characters vulnerable for XSS. * @param {string} html The input string * @return {string} The excaped string */ function htmlencode(html) { return html .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/'); } // Utility function. Reverses child nodes of a DOM element function reverseChildNodes(node) { var i = node.childNodes.length; while (i--) { node.appendChild(node.childNodes[i]); } } // Utility function to attempt to fake a click event on an element function fakeClickEvent(element) { var fakeEvent; if (element && element.onclick && doc.createEvent) { fakeEvent = doc.createEvent('Events'); fakeEvent.initEvent('click', true, false); element.onclick(fakeEvent); } } // Whenever drawing series, put info on DOM elements H.wrap(H.Series.prototype, 'render', function(proceed) { proceed.apply(this, Array.prototype.slice.call(arguments, 1)); if (this.chart.options.accessibility.enabled) { this.setA11yDescription(); } }); // Put accessible info on series and points of a series H.Series.prototype.setA11yDescription = function() { var a11yOptions = this.chart.options.accessibility, firstPointEl = this.points && this.points.length && this.points[0].graphic && this.points[0].graphic.element, seriesEl = firstPointEl && firstPointEl.parentNode || this.graph && this.graph.element || this.group && this.group.element; // Could be tracker series depending on series type if (seriesEl) { // For some series types the order of elements do not match the order of points in series // In that case we have to reverse them in order for AT to read them out in an understandable order if (seriesEl.lastChild === firstPointEl) { reverseChildNodes(seriesEl); } // Make individual point elements accessible if possible. Note: If markers are disabled there might not be any elements there to make accessible. if (this.points && (this.points.length < a11yOptions.pointDescriptionThreshold || a11yOptions.pointDescriptionThreshold === false)) { each(this.points, function(point) { if (point.graphic) { point.graphic.element.setAttribute('role', 'img'); point.graphic.element.setAttribute('tabindex', '-1'); point.graphic.element.setAttribute('aria-label', point.series.options.pointDescriptionFormatter && point.series.options.pointDescriptionFormatter(point) || a11yOptions.pointDescriptionFormatter && a11yOptions.pointDescriptionFormatter(point) || point.buildPointInfoString()); } }); } // Make series element accessible if (this.chart.series.length > 1 || a11yOptions.describeSingleSeries) { seriesEl.setAttribute( 'role', this.options.exposeElementToA11y ? 'img' : 'region' ); seriesEl.setAttribute('tabindex', '-1'); seriesEl.setAttribute('aria-label', a11yOptions.seriesDescriptionFormatter && a11yOptions.seriesDescriptionFormatter(this) || this.buildSeriesInfoString()); } } }; // Return string with information about series H.Series.prototype.buildSeriesInfoString = function() { var typeInfo = typeToSeriesMap[this.type] || typeToSeriesMap['default'], // eslint-disable-line dot-notation description = this.description || this.options.description; return (this.name ? this.name + ', ' : '') + (this.chart.types.length === 1 ? typeInfo[0] : 'series') + ' ' + (this.index + 1) + ' of ' + (this.chart.series.length) + (this.chart.types.length === 1 ? ' with ' : '. ' + typeInfo[0] + ' with ') + (this.points.length + ' ' + (this.points.length === 1 ? typeInfo[1] : typeInfo[2])) + (description ? '. ' + description : '') + (this.chart.yAxis.length > 1 && this.yAxis ? '. Y axis, ' + this.yAxis.getDescription() : '') + (this.chart.xAxis.length > 1 && this.xAxis ? '. X axis, ' + this.xAxis.getDescription() : ''); }; // Return string with information about point H.Point.prototype.buildPointInfoString = function() { var point = this, series = point.series, a11yOptions = series.chart.options.accessibility, infoString = '', dateTimePoint = series.xAxis && series.xAxis.isDatetimeAxis, timeDesc = dateTimePoint && dateFormat(a11yOptions.pointDateFormatter && a11yOptions.pointDateFormatter(point) || a11yOptions.pointDateFormat || H.Tooltip.prototype.getXDateFormat(point, series.chart.options.tooltip, series.xAxis), point.x), hasSpecialKey = H.find(series.specialKeys, function(key) { return point[key] !== undefined; }); // If the point has one of the less common properties defined, display all that are defined if (hasSpecialKey) { if (dateTimePoint) { infoString = timeDesc; } each(series.commonKeys.concat(series.specialKeys), function(key) { if (point[key] !== undefined && !(dateTimePoint && key === 'x')) { infoString += (infoString ? '. ' : '') + key + ', ' + point[key]; } }); } else { // Pick and choose properties for a succint label infoString = (this.name || timeDesc || this.category || this.id || 'x, ' + this.x) + ', ' + (this.value !== undefined ? this.value : this.y); } return (this.index + 1) + '. ' + infoString + '.' + (this.description ? ' ' + this.description : ''); }; // Get descriptive label for axis H.Axis.prototype.getDescription = function() { return this.userOptions && this.userOptions.description || this.axisTitle && this.axisTitle.textStr || this.options.id || this.categories && 'categories' || 'values'; }; // Pan along axis in a direction (1 or -1), optionally with a defined granularity (number of steps it takes to walk across current view) H.Axis.prototype.panStep = function(direction, granularity) { var gran = granularity || 3, extremes = this.getExtremes(), step = (extremes.max - extremes.min) / gran * direction, newMax = extremes.max + step, newMin = extremes.min + step, size = newMax - newMin; if (direction < 0 && newMin < extremes.dataMin) { newMin = extremes.dataMin; newMax = newMin + size; } else if (direction > 0 && newMax > extremes.dataMax) { newMax = extremes.dataMax; newMin = newMax - size; } this.setExtremes(newMin, newMax); }; // Whenever adding or removing series, keep track of types present in chart H.wrap(H.Series.prototype, 'init', function(proceed) { proceed.apply(this, Array.prototype.slice.call(arguments, 1)); var chart = this.chart; if (chart.options.accessibility.enabled) { chart.types = chart.types || []; // Add type to list if does not exist if (chart.types.indexOf(this.type) < 0) { chart.types.push(this.type); } addEvent(this, 'remove', function() { var removedSeries = this, hasType = false; // Check if any of the other series have the same type as this one. Otherwise remove it from the list. each(chart.series, function(s) { if (s !== removedSeries && chart.types.indexOf(removedSeries.type) < 0) { hasType = true; } }); if (!hasType) { erase(chart.types, removedSeries.type); } }); } }); // Return simplified description of chart type. Some types will not be familiar to most screen reader users, but we try. H.Chart.prototype.getTypeDescription = function() { var firstType = this.types && this.types[0], mapTitle = this.series[0] && this.series[0].mapTitle; if (!firstType) { return 'Empty chart.'; } else if (firstType === 'map') { return mapTitle ? 'Map of ' + mapTitle : 'Map of unspecified region.'; } else if (this.types.length > 1) { return 'Combination chart.'; } else if (['spline', 'area', 'areaspline'].indexOf(firstType) > -1) { return 'Line chart.'; } return firstType + ' chart.' + (typeDescriptionMap[firstType] || ''); }; // Return object with text description of each of the chart's axes H.Chart.prototype.getAxesDescription = function() { var numXAxes = this.xAxis.length, numYAxes = this.yAxis.length, desc = {}, i; if (numXAxes) { desc.xAxis = 'The chart has ' + numXAxes + (numXAxes > 1 ? ' X axes' : ' X axis') + ' displaying '; if (numXAxes < 2) { desc.xAxis += this.xAxis[0].getDescription() + '.'; } else { for (i = 0; i < numXAxes - 1; ++i) { desc.xAxis += (i ? ', ' : '') + this.xAxis[i].getDescription(); } desc.xAxis += ' and ' + this.xAxis[i].getDescription() + '.'; } } if (numYAxes) { desc.yAxis = 'The chart has ' + numYAxes + (numYAxes > 1 ? ' Y axes' : ' Y axis') + ' displaying '; if (numYAxes < 2) { desc.yAxis += this.yAxis[0].getDescription() + '.'; } else { for (i = 0; i < numYAxes - 1; ++i) { desc.yAxis += (i ? ', ' : '') + this.yAxis[i].getDescription(); } desc.yAxis += ' and ' + this.yAxis[i].getDescription() + '.'; } } return desc; }; // Set a11y attribs on exporting menu H.Chart.prototype.addAccessibleContextMenuAttribs = function() { var exportList = this.exportDivElements; if (exportList) { // Set tabindex on the menu items to allow focusing by script // Set role to give screen readers a chance to pick up the contents each(exportList, function(item) { if (item.tagName === 'DIV' && !(item.children && item.children.length)) { item.setAttribute('role', 'menuitem'); item.setAttribute('tabindex', -1); } }); // Set accessibility properties on parent div exportList[0].parentNode.setAttribute('role', 'menu'); exportList[0].parentNode.setAttribute('aria-label', 'Chart export'); } }; // Highlight a point (show tooltip and display hover state). Returns the highlighted point. H.Point.prototype.highlight = function() { var chart = this.series.chart; if (this.graphic && this.graphic.element.focus) { this.graphic.element.focus(); } if (!this.isNull) { this.onMouseOver(); // Show the hover marker // Show the tooltip if (chart.tooltip) { chart.tooltip.refresh(chart.tooltip.shared ? [this] : this); } } else { if (chart.tooltip) { chart.tooltip.hide(0); } // Don't call blur on the element, as it messes up the chart div's focus } chart.highlightedPoint = this; return this; }; // Function to highlight next/previous point in chart // Returns highlighted point on success, false on failure (no adjacent point to // highlight in chosen direction) H.Chart.prototype.highlightAdjacentPoint = function(next) { var chart = this, series = chart.series, curPoint = chart.highlightedPoint, curPointIndex = curPoint && curPoint.index || 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], newSeries, newPoint, isSkipPoint = function(point) { return point.isNull && chart.options.accessibility.keyboardNavigation.skipNullPoints || point.series.options.skipKeyboardNavigation || !point.series.visible; }, // Handle connecting ends - where the points array has an extra last // point that is a reference to the first one. We skip this. forwardSkipAmount = curPoint && curPoint.series.connectEnds && curPointIndex > curPoints.length - 3 ? 2 : 1; // 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. // Find index of current point in series.points array. Necessary for // dataGrouping (and maybe zoom?) if (curPoints[curPointIndex] !== curPoint) { for (var i = 0; i < curPoints.length; ++i) { if (curPoints[i] === curPoint) { curPointIndex = i; break; } } } // Grab next/prev point & series newSeries = series[curPoint.series.index + (next ? 1 : -1)]; newPoint = curPoints[curPointIndex + (next ? forwardSkipAmount : -1)] || // Done with this series, try next one newSeries && newSeries.points[next ? 0 : newSeries.points.length - ( newSeries.connectEnds ? 2 : 1 )]; // If there is no adjacent point, we return false if (newPoint === undefined) { return false; } } // Recursively skip null points or points in series that should be skipped if (isSkipPoint(newPoint)) { chart.highlightedPoint = newPoint; return chart.highlightAdjacentPoint(next); } // There is an adjacent point, highlight it return newPoint.highlight(); }; // Show the export menu and focus the first item (if exists) H.Chart.prototype.showExportMenu = function() { if (this.exportSVGElements && this.exportSVGElements[0]) { this.exportSVGElements[0].element.onclick(); this.highlightExportItem(0); } }; // Highlight export menu item by index H.Chart.prototype.highlightExportItem = function(ix) { var listItem = this.exportDivElements && this.exportDivElements[ix], curHighlighted = this.exportDivElements && this.exportDivElements[this.highlightedExportItem]; if (listItem && listItem.tagName === 'DIV' && !(listItem.children && listItem.children.length)) { if (listItem.focus) { listItem.focus(); } if (curHighlighted && curHighlighted.onmouseout) { curHighlighted.onmouseout(); } if (listItem.onmouseover) { listItem.onmouseover(); } this.highlightedExportItem = ix; return true; } }; // Highlight range selector button by index H.Chart.prototype.highlightRangeSelectorButton = function(ix) { var buttons = this.rangeSelector.buttons; // Deselect old if (buttons[this.highlightedRangeSelectorItemIx]) { buttons[this.highlightedRangeSelectorItemIx].setState(this.oldRangeSelectorItemState || 0); } // Select new this.highlightedRangeSelectorItemIx = ix; if (buttons[ix]) { if (buttons[ix].element.focus) { buttons[ix].element.focus(); } this.oldRangeSelectorItemState = buttons[ix].state; buttons[ix].setState(2); return true; } return false; }; // Highlight legend item by index H.Chart.prototype.highlightLegendItem = function(ix) { var items = this.legend.allItems; if (items[this.highlightedLegendItemIx]) { fireEvent(items[this.highlightedLegendItemIx].legendGroup.element, 'mouseout'); } this.highlightedLegendItemIx = ix; if (items[ix]) { if (items[ix].legendGroup.element.focus) { items[ix].legendGroup.element.focus(); } fireEvent(items[ix].legendGroup.element, 'mouseover'); return true; } return false; }; // Hide export menu H.Chart.prototype.hideExportMenu = function() { var exportList = this.exportDivElements; if (exportList) { each(exportList, function(el) { fireEvent(el, 'mouseleave'); }); if (exportList[this.highlightedExportItem] && exportList[this.highlightedExportItem].onmouseout) { exportList[this.highlightedExportItem].onmouseout(); } this.highlightedExportItem = 0; this.renderTo.focus(); } }; // Add keyboard navigation handling to chart H.Chart.prototype.addKeyboardNavEvents = function() { var chart = this; // Abstraction layer for keyboard navigation. Keep a map of keyCodes to handler functions, and a next/prev move handler for tab order. // The module's keyCode handlers determine when to move to another module. // Validate holds a function to determine if there are prerequisites for this module to run that are not met. // Init holds a function to run once before any keyCodes are interpreted. // Terminate holds a function to run once before moving to next/prev module. // transformTabs determines whether to transform tabs to left/right events or not. Defaults to true. function KeyboardNavigationModule(options) { this.id = options.id; this.keyCodeMap = options.keyCodeMap; this.move = options.move; this.validate = options.validate; this.init = options.init; this.terminate = options.terminate; this.transformTabs = options.transformTabs !== false; } KeyboardNavigationModule.prototype = { // Find handler function(s) for key code in the keyCodeMap and run it. run: function(e) { var navModule = this, keyCode = e.which || e.keyCode, handled = false; keyCode = this.transformTabs && keyCode === 9 ? (e.shiftKey ? 37 : 39) : keyCode; // Transform tabs each(this.keyCodeMap, function(codeSet) { if (codeSet[0].indexOf(keyCode) > -1) { handled = codeSet[1].call(navModule, keyCode, e) === false ? false : true; // If explicitly returning false, we haven't handled it } }); return handled; } }; // Maintain abstraction between KeyboardNavigationModule and Highcharts // The chart object keeps track of a list of KeyboardNavigationModules that we move through function navModuleFactory(id, keyMap, options) { return new KeyboardNavigationModule(merge({ keyCodeMap: keyMap, // Move to next/prev valid module, or undefined if none, and init it. // Returns true on success and false if there is no valid module to move to. move: function(direction) { if (this.terminate) { this.terminate(direction); } chart.keyboardNavigationModuleIndex += direction; var newModule = chart.keyboardNavigationModules[chart.keyboardNavigationModuleIndex]; if (newModule) { if (newModule.validate && !newModule.validate()) { return this.move(direction); // Invalid module } if (newModule.init) { newModule.init(direction); // Valid module, init it return true; } } // No module chart.keyboardNavigationModuleIndex = 0; // Reset counter chart.slipNextTab = true; // Allow next tab to slip, as we will have focus on chart now return false; } }, { id: id }, options)); } // Route keydown events function keydownHandler(ev) { var e = ev || win.event, keyCode = e.which || e.keyCode, curNavModule = chart.keyboardNavigationModules[chart.keyboardNavigationModuleIndex]; // Handle tabbing if (keyCode === 9) { // If we reached end of chart, we need to let this tab slip through to allow users to tab further if (chart.slipNextTab) { chart.slipNextTab = false; return; } } // If key was not tab, don't slip the next tab chart.slipNextTab = false; // If there is a navigation module for the current index, run it. Otherwise, we are outside of the chart in some direction. if (curNavModule) { if (curNavModule.run(e)) { e.preventDefault(); // If successfully handled, stop the event here. } } } // List of the different keyboard handling modes we use depending on where we are in the chart. // Each mode has a set of handling functions mapped to key codes. // Each mode determines when to move to the next/prev mode. chart.keyboardNavigationModules = [ // Points navModuleFactory('points', [ // Left/Right [ [37, 39], function(keyCode) { if (!chart.highlightAdjacentPoint(keyCode === 39)) { // Try to highlight adjacent point return this.move(keyCode === 39 ? 1 : -1); // Failed. Move to next/prev module } } ], // Up/Down [ [38, 40], function(keyCode) { var newSeries; if (chart.highlightedPoint) { newSeries = chart.series[chart.highlightedPoint.series.index + (keyCode === 38 ? -1 : 1)]; // Find prev/next series if (newSeries && newSeries.points[0]) { // If series exists and has data, go for it newSeries.points[0].highlight(); } else { return this.move(keyCode === 40 ? 1 : -1); // Otherwise, attempt to move to next/prev module } } } ], // Enter/Spacebar [ [13, 32], function() { if (chart.highlightedPoint) { chart.highlightedPoint.firePointEvent('click'); } } ] ], { // Always start highlighting from scratch when entering this module init: function() { delete chart.highlightedPoint; }, // If leaving points, don't show tooltip anymore terminate: function() { if (chart.tooltip) { chart.tooltip.hide(0); } delete chart.highlightedPoint; }, transformTabs: chart.options.accessibility.keyboardNavigation.tabThroughPoints }), // Exporting navModuleFactory('exporting', [ // Left/Up [ [37, 38], function() { var i = chart.highlightedExportItem || 0, reachedEnd = true; // Try to highlight prev item in list. Highlighting e.g. separators will fail. while (i--) { if (chart.highlightExportItem(i)) { reachedEnd = false; break; } } if (reachedEnd) { chart.hideExportMenu(); return this.move(-1); } } ], // Right/Down [ [39, 40], function() { var highlightedExportItem = chart.highlightedExportItem || 0, reachedEnd = true; // Try to highlight next item in list. Highlighting e.g. separators will fail. for (var i = highlightedExportItem + 1; i < chart.exportDivElements.length; ++i) { if (chart.highlightExportItem(i)) { reachedEnd = false; break; } } if (reachedEnd) { chart.hideExportMenu(); return this.move(1); // Next module } } ], // Enter/Spacebar [ [13, 32], function() { fakeClickEvent(chart.exportDivElements[chart.highlightedExportItem]); } ] ], { // Only run exporting navigation if exporting support exists and is enabled on chart validate: function() { return chart.exportChart && !(chart.options.exporting && chart.options.exporting.enabled === false); }, // Show export menu init: function(direction) { chart.highlightedPoint = null; chart.showExportMenu(); // If coming back to export menu from other module, try to highlight last item in menu if (direction < 0 && chart.exportDivElements) { for (var i = chart.exportDivElements.length; i > -1; --i) { if (chart.highlightExportItem(i)) { break; } } } } }), // Map zoom navModuleFactory('mapZoom', [ // Up/down/left/right [ [38, 40, 37, 39], function(keyCode) { chart[keyCode === 38 || keyCode === 40 ? 'yAxis' : 'xAxis'][0].panStep(keyCode < 39 ? -1 : 1); } ], // Tabs [ [9], function(keyCode, e) { var button; chart.mapNavButtons[chart.focusedMapNavButtonIx].setState(0); // Deselect old if (e.shiftKey && !chart.focusedMapNavButtonIx || !e.shiftKey && chart.focusedMapNavButtonIx) { // trying to go somewhere we can't? chart.mapZoom(); // Reset zoom return this.move(e.shiftKey ? -1 : 1); // Nowhere to go, go to prev/next module } chart.focusedMapNavButtonIx += e.shiftKey ? -1 : 1; button = chart.mapNavButtons[chart.focusedMapNavButtonIx]; if (button.element.focus) { button.element.focus(); } button.setState(2); } ], // Enter/Spacebar [ [13, 32], function() { fakeClickEvent(chart.mapNavButtons[chart.focusedMapNavButtonIx].element); } ] ], { // Only run this module if we have map zoom on the chart validate: function() { return chart.mapZoom && chart.mapNavButtons && chart.mapNavButtons.length === 2; }, // Handle tabs separately transformTabs: false, // Make zoom buttons do their magic init: function(direction) { var zoomIn = chart.mapNavButtons[0], zoomOut = chart.mapNavButtons[1], initialButton = direction > 0 ? zoomIn : zoomOut; each(chart.mapNavButtons, function(button, i) { button.element.setAttribute('tabindex', -1); button.element.setAttribute('role', 'button'); button.element.setAttribute('aria-label', 'Zoom ' + (i ? 'out' : '') + 'chart'); }); if (initialButton.element.focus) { initialButton.element.focus(); } initialButton.setState(2); chart.focusedMapNavButtonIx = direction > 0 ? 0 : 1; } }), // Highstock range selector (minus input boxes) navModuleFactory('rangeSelector', [ // Left/Right/Up/Down [ [37, 39, 38, 40], function(keyCode) { var direction = (keyCode === 37 || keyCode === 38) ? -1 : 1; // Try to highlight next/prev button if (!chart.highlightRangeSelectorButton(chart.highlightedRangeSelectorItemIx + direction)) { return this.move(direction); } } ], // Enter/Spacebar [ [13, 32], function() { if (chart.oldRangeSelectorItemState !== 3) { // Don't allow click if button used to be disabled fakeClickEvent(chart.rangeSelector.buttons[chart.highlightedRangeSelectorItemIx].element); } } ] ], { // Only run this module if we have range selector validate: function() { return chart.rangeSelector && chart.rangeSelector.buttons && chart.rangeSelector.buttons.length; }, // Make elements focusable and accessible init: function(direction) { each(chart.rangeSelector.buttons, function(button) { button.element.setAttribute('tabindex', '-1'); button.element.setAttribute('role', 'button'); button.element.setAttribute('aria-label', 'Select range ' + (button.text && button.text.textStr)); }); // Focus first/last button chart.highlightRangeSelectorButton(direction > 0 ? 0 : chart.rangeSelector.buttons.length - 1); } }), // Highstock range selector, input boxes navModuleFactory('rangeSelectorInput', [ // Tab/Up/Down [ [9, 38, 40], function(keyCode, e) { var direction = (keyCode === 9 && e.shiftKey || keyCode === 38) ? -1 : 1, newIx = chart.highlightedInputRangeIx = chart.highlightedInputRangeIx + direction; // Try to highlight next/prev item in list. if (newIx > 1 || newIx < 0) { // Out of range return this.move(direction); } chart.rangeSelector[newIx ? 'maxInput' : 'minInput'].focus(); // Input boxes are HTML, and should have focus support in all browsers } ] ], { // Only run if we have range selector with input boxes validate: function() { var inputVisible = chart.rangeSelector && chart.rangeSelector.inputGroup && chart.rangeSelector.inputGroup.element.getAttribute('visibility') !== 'hidden'; return inputVisible && chart.options.rangeSelector.inputEnabled !== false && chart.rangeSelector.minInput && chart.rangeSelector.maxInput; }, // Handle tabs different from left/right (because we don't want to catch left/right in a text area) transformTabs: false, // Highlight first/last input box init: function(direction) { chart.highlightedInputRangeIx = direction > 0 ? 0 : 1; chart.rangeSelector[chart.highlightedInputRangeIx ? 'maxInput' : 'minInput'].focus(); } }), // Legend navigation navModuleFactory('legend', [ // Left/Right/Up/Down [ [37, 39, 38, 40], function(keyCode) { var direction = (keyCode === 37 || keyCode === 38) ? -1 : 1; // Try to highlight next/prev legend item if (!chart.highlightLegendItem(chart.highlightedLegendItemIx + direction)) { return this.move(direction); } } ], // Enter/Spacebar [ [13, 32], function() { fakeClickEvent(chart.legend.allItems[chart.highlightedLegendItemIx].legendItem.element.parentNode); } ] ], { // Only run this module if we have at least one legend - wait for it - item. // Don't run if the legend is populated by a colorAxis. // Don't run if legend navigation is disabled. validate: function() { return chart.legend && chart.legend.allItems && !(chart.colorAxis && chart.colorAxis.length) && (chart.options.legend && chart.options.legend.keyboardNavigation && chart.options.legend.keyboardNavigation.enabled) !== false; }, // Make elements focusable and accessible init: function(direction) { each(chart.legend.allItems, function(item) { item.legendGroup.element.setAttribute('tabindex', '-1'); item.legendGroup.element.setAttribute('role', 'button'); item.legendGroup.element.setAttribute('aria-label', 'Toggle visibility of series ' + item.name); }); // Focus first/last item chart.highlightLegendItem(direction > 0 ? 0 : chart.legend.allItems.length - 1); } }) ]; // Init nav module index. We start at the first module, and as the user navigates through the chart the index will increase to use different handler modules. chart.keyboardNavigationModuleIndex = 0; // Make chart reachable by tab if ( chart.container.hasAttribute && !chart.container.hasAttribute('tabIndex') ) { chart.container.setAttribute('tabindex', '0'); } // Handle keyboard events addEvent(chart.renderTo, 'keydown', keydownHandler); addEvent(chart, 'destroy', function() { removeEvent(chart.renderTo, 'keydown', keydownHandler); }); }; // Add screen reader region to chart. // tableId is the HTML id of the table to focus when clicking the table anchor in the screen reader region. H.Chart.prototype.addScreenReaderRegion = function(id, tableId) { var chart = this, series = chart.series, options = chart.options, a11yOptions = options.accessibility, hiddenSection = chart.screenReaderRegion = doc.createElement('div'), tableShortcut = doc.createElement('h4'), tableShortcutAnchor = doc.createElement('a'), chartHeading = doc.createElement('h4'), hiddenStyle = { // CSS style to hide element from visual users while still exposing it to screen readers position: 'absolute', left: '-9999px', top: 'auto', width: '1px', height: '1px', overflow: 'hidden' }, chartTypes = chart.types || [], // Build axis info - but not for pies and maps. Consider not adding for certain other types as well (funnel, pyramid?) axesDesc = (chartTypes.length === 1 && chartTypes[0] === 'pie' || chartTypes[0] === 'map') && {} || chart.getAxesDescription(), chartTypeInfo = series[0] && typeToSeriesMap[series[0].type] || typeToSeriesMap['default']; // eslint-disable-line dot-notation hiddenSection.setAttribute('id', id); hiddenSection.setAttribute('role', 'region'); hiddenSection.setAttribute('aria-label', 'Chart screen reader information.'); hiddenSection.innerHTML = a11yOptions.screenReaderSectionFormatter && a11yOptions.screenReaderSectionFormatter(chart) || '
Use regions/landmarks to skip ahead to chart' + (series.length > 1 ? ' and navigate between data series' : '') + '.

' + (options.title.text ? htmlencode(options.title.text) : 'Chart') + (options.subtitle && options.subtitle.text ? '. ' + htmlencode(options.subtitle.text) : '') + '

Long description.

' + (options.chart.description || 'No description available.') + '

Structure.

Chart type: ' + (options.chart.typeDescription || chart.getTypeDescription()) + '
' + (series.length === 1 ? '
' + chartTypeInfo[0] + ' with ' + series[0].points.length + ' ' + (series[0].points.length === 1 ? chartTypeInfo[1] : chartTypeInfo[2]) + '.
' : '') + (axesDesc.xAxis ? ('
' + axesDesc.xAxis + '
') : '') + (axesDesc.yAxis ? ('
' + axesDesc.yAxis + '
') : ''); // Add shortcut to data table if export-data is loaded if (chart.getCSV) { tableShortcutAnchor.innerHTML = 'View as data table.'; tableShortcutAnchor.href = '#' + tableId; tableShortcutAnchor.setAttribute('tabindex', '-1'); // Make this unreachable by user tabbing tableShortcutAnchor.onclick = a11yOptions.onTableAnchorClick || function() { chart.viewData(); doc.getElementById(tableId).focus(); }; tableShortcut.appendChild(tableShortcutAnchor); hiddenSection.appendChild(tableShortcut); } // Note: JAWS seems to refuse to read aria-label on the container, so add an // h4 element as title for the chart. chartHeading.innerHTML = 'Chart graphic.'; chart.renderTo.insertBefore(chartHeading, chart.renderTo.firstChild); chart.renderTo.insertBefore(hiddenSection, chart.renderTo.firstChild); // Hide the section and the chart heading merge(true, chartHeading.style, hiddenStyle); merge(true, hiddenSection.style, hiddenStyle); }; // Make chart container accessible, and wrap table functionality H.Chart.prototype.callbacks.push(function(chart) { var options = chart.options, a11yOptions = options.accessibility; if (!a11yOptions.enabled) { return; } var titleElement = doc.createElementNS('http://www.w3.org/2000/svg', 'title'), exportGroupElement = doc.createElementNS('http://www.w3.org/2000/svg', 'g'), descElement = chart.container.getElementsByTagName('desc')[0], textElements = chart.container.getElementsByTagName('text'), titleId = 'highcharts-title-' + chart.index, tableId = 'highcharts-data-table-' + chart.index, hiddenSectionId = 'highcharts-information-region-' + chart.index, chartTitle = options.title.text || 'Chart', oldColumnHeaderFormatter = options.exporting && options.exporting.csv && options.exporting.csv.columnHeaderFormatter, topLevelColumns = []; // Add SVG title/desc tags titleElement.textContent = htmlencode(chartTitle); titleElement.id = titleId; descElement.parentNode.insertBefore(titleElement, descElement); chart.renderTo.setAttribute('role', 'region'); //chart.container.setAttribute('aria-details', hiddenSectionId); // JAWS currently doesn't handle this too well chart.renderTo.setAttribute('aria-label', 'Interactive chart. ' + chartTitle + '. Use up and down arrows to navigate with most screen readers.'); // Set screen reader properties on export menu if (chart.exportSVGElements && chart.exportSVGElements[0] && chart.exportSVGElements[0].element) { var oldExportCallback = chart.exportSVGElements[0].element.onclick, parent = chart.exportSVGElements[0].element.parentNode; chart.exportSVGElements[0].element.onclick = function() { oldExportCallback.apply(this, Array.prototype.slice.call(arguments)); chart.addAccessibleContextMenuAttribs(); chart.highlightExportItem(0); }; chart.exportSVGElements[0].element.setAttribute('role', 'button'); chart.exportSVGElements[0].element.setAttribute('aria-label', 'View export menu'); exportGroupElement.appendChild(chart.exportSVGElements[0].element); exportGroupElement.setAttribute('role', 'region'); exportGroupElement.setAttribute('aria-label', 'Chart export menu'); parent.appendChild(exportGroupElement); } // Set screen reader properties on input boxes for range selector. We need to do this regardless of whether or not these are visible, as they are // by default part of the page's tabindex unless we set them to -1. if (chart.rangeSelector) { each(['minInput', 'maxInput'], function(key, i) { if (chart.rangeSelector[key]) { chart.rangeSelector[key].setAttribute('tabindex', '-1'); chart.rangeSelector[key].setAttribute('role', 'textbox'); chart.rangeSelector[key].setAttribute('aria-label', 'Select ' + (i ? 'end' : 'start') + ' date.'); } }); } // Hide text elements from screen readers each(textElements, function(el) { el.setAttribute('aria-hidden', 'true'); }); // Add top-secret screen reader region chart.addScreenReaderRegion(hiddenSectionId, tableId); // Enable keyboard navigation if (a11yOptions.keyboardNavigation.enabled) { chart.addKeyboardNavEvents(); } /* Wrap table functionality from export-data */ // Keep track of columns merge(true, options.exporting, { csv: { columnHeaderFormatter: function(item, key, keyLength) { if (!item) { return 'Category'; } if (item instanceof H.Axis) { return (item.options.title && item.options.title.text) || (item.isDatetimeAxis ? 'DateTime' : 'Category'); } var prevCol = topLevelColumns[topLevelColumns.length - 1]; if (keyLength > 1) { // We need multiple levels of column headers // Populate a list of column headers to add in addition to the ones added by export-data if ((prevCol && prevCol.text) !== item.name) { topLevelColumns.push({ text: item.name, span: keyLength }); } } if (oldColumnHeaderFormatter) { return oldColumnHeaderFormatter.call(this, item, key, keyLength); } return keyLength > 1 ? key : item.name; } } }); // Add ID and title/caption to table HTML H.wrap(chart, 'getTable', function(proceed) { return proceed.apply(this, Array.prototype.slice.call(arguments, 1)) .replace('', '
'); }); // Add accessibility attributes and top level columns H.wrap(chart, 'viewData', function(proceed) { if (!this.dataTableDiv) { proceed.apply(this, Array.prototype.slice.call(arguments, 1)); var table = doc.getElementById(tableId), head = table.getElementsByTagName('thead')[0], body = table.getElementsByTagName('tbody')[0], firstRow = head.firstChild.children, columnHeaderRow = '', cell, newCell; // Make table focusable by script table.setAttribute('tabindex', '-1'); // Create row headers each(body.children, function(el) { cell = el.firstChild; newCell = doc.createElement('th'); newCell.setAttribute('scope', 'row'); newCell.innerHTML = cell.innerHTML; cell.parentNode.replaceChild(newCell, cell); }); // Set scope for column headers each(firstRow, function(el) { if (el.tagName === 'TH') { el.setAttribute('scope', 'col'); } }); // Add top level columns if (topLevelColumns.length) { each(topLevelColumns, function(col) { columnHeaderRow += ''; }); head.insertAdjacentHTML('afterbegin', columnHeaderRow); } } }); }); }(Highcharts)); }));
' + chartTitle + '
' + col.text + '