/* * * * (c) 2009-2019 Øystein Moseng * * Accessibility module for Highcharts * * License: www.highcharts.com/license * * */ 'use strict'; import H from '../../parts/Globals.js'; import KeyboardNavigationHandler from './KeyboardNavigationHandler.js'; import AccessibilityComponent from './AccessibilityComponent.js'; import KeyboardNavigation from './KeyboardNavigation.js'; import LegendComponent from './components/LegendComponent.js'; import MenuComponent from './components/MenuComponent.js'; import SeriesComponent from './components/SeriesComponent.js'; import ZoomComponent from './components/ZoomComponent.js'; import RangeSelectorComponent from './components/RangeSelectorComponent.js'; import InfoRegionComponent from './components/InfoRegionComponent.js'; import ContainerComponent from './components/ContainerComponent.js'; import defaultOptions from './options.js'; import '../../modules/accessibility/a11y-i18n.js'; var addEvent = H.addEvent, doc = H.win.document, pick = H.pick, merge = H.merge, extend = H.extend, error = H.error; // Add default options merge(true, H.defaultOptions, defaultOptions); // Expose classes on Highcharts namespace H.KeyboardNavigationHandler = KeyboardNavigationHandler; H.AccessibilityComponent = AccessibilityComponent; /* * Add focus border functionality to SVGElements. Draws a new rect on top of * element around its bounding box. This is used by multiple components. */ H.extend(H.SVGElement.prototype, { /** * @private * @function Highcharts.SVGElement#addFocusBorder * * @param {number} margin * * @param {Higcharts.CSSObject} style */ addFocusBorder: function (margin, style) { // Allow updating by just adding new border if (this.focusBorder) { this.removeFocusBorder(); } // Add the border rect var bb = this.getBBox(), pad = pick(margin, 3); bb.x += this.translateX ? this.translateX : 0; bb.y += this.translateY ? this.translateY : 0; this.focusBorder = this.renderer.rect( bb.x - pad, bb.y - pad, bb.width + 2 * pad, bb.height + 2 * pad, style && style.borderRadius ) .addClass('highcharts-focus-border') .attr({ zIndex: 99 }) .add(this.parentGroup); if (!this.renderer.styledMode) { this.focusBorder.attr({ stroke: style && style.stroke, 'stroke-width': style && style.strokeWidth }); } }, /** * @private * @function Highcharts.SVGElement#removeFocusBorder */ removeFocusBorder: function () { if (this.focusBorder) { this.focusBorder.destroy(); delete this.focusBorder; } } }); /** * Set chart's focus to an SVGElement. Calls focus() on it, and draws the focus * border. This is used by multiple components. * * @private * @function Highcharts.Chart#setFocusToElement * * @param {Highcharts.SVGElement} svgElement * Element to draw the border around. * * @param {SVGDOMElement|HTMLDOMElement} [focusElement] * If supplied, it draws the border around svgElement and sets the focus * to focusElement. */ H.Chart.prototype.setFocusToElement = function (svgElement, focusElement) { var focusBorderOptions = this.options.accessibility .keyboardNavigation.focusBorder, browserFocusElement = focusElement || svgElement.element; // Set browser focus if possible if ( browserFocusElement && browserFocusElement.focus ) { // If there is no focusin-listener, add one to work around Edge issue // where Narrator is not reading out points despite calling focus(). if (!( browserFocusElement.hcEvents && browserFocusElement.hcEvents.focusin )) { addEvent(browserFocusElement, 'focusin', function () {}); } browserFocusElement.focus(); // Hide default focus ring if (focusBorderOptions.hideBrowserFocusOutline) { browserFocusElement.style.outline = 'none'; } } if (focusBorderOptions.enabled) { // Remove old focus border if (this.focusElement) { this.focusElement.removeFocusBorder(); } // Draw focus border (since some browsers don't do it automatically) svgElement.addFocusBorder(focusBorderOptions.margin, { stroke: focusBorderOptions.style.color, strokeWidth: focusBorderOptions.style.lineWidth, borderRadius: focusBorderOptions.style.borderRadius }); this.focusElement = svgElement; } }; /** * Get descriptive label for axis. This is used by multiple components. * * @private * @function Highcharts.Axis#getDescription * * @return {string} */ H.Axis.prototype.getDescription = function () { return ( this.userOptions && this.userOptions.accessibility && this.userOptions.accessibility.description || this.axisTitle && this.axisTitle.textStr || this.options.id || this.categories && 'categories' || this.isDatetimeAxis && 'Time' || 'values' ); }; /** * The Accessibility class * * @private * @requires module:modules/accessibility * * @class * @name Highcharts.Accessibility * * @param {Highcharts.Chart} chart * Chart object */ function Accessibility(chart) { this.init(chart); } Accessibility.prototype = { /** * Initialize the accessibility class * @private * @param {Highcharts.Chart} chart * Chart object */ init: function (chart) { var a11yOptions = chart.options.accessibility; this.chart = chart; // Abort on old browsers if (!doc.addEventListener || !chart.renderer.isSVG) { chart.renderTo.setAttribute('aria-hidden', true); return; } // Copy over any deprecated options that are used. We could do this on // every update, but it is probably not needed. this.copyDeprecatedOptions(); // Add the components var components = this.components = { container: new ContainerComponent(chart), infoRegion: new InfoRegionComponent(chart), legend: new LegendComponent(chart), chartMenu: new MenuComponent(chart), rangeSelector: new RangeSelectorComponent(chart), series: new SeriesComponent(chart), zoom: new ZoomComponent(chart) }; if (a11yOptions.customComponents) { extend(this.components, a11yOptions.customComponents); } this.keyboardNavigation = new KeyboardNavigation(chart, components); this.update(); }, /** * Update all components. */ update: function () { var components = this.components, a11yOptions = this.chart.options.accessibility; // Update the chart type list as this is used by multiple modules this.chart.types = this.getChartTypes(); // Update markup Object.keys(components).forEach(function (componentName) { components[componentName].onChartUpdate(); }); // Update keyboard navigation this.keyboardNavigation.update( a11yOptions.keyboardNavigation.order ); }, /** * Destroy all elements. */ destroy: function () { var chart = this.chart || {}; // Destroy components var components = this.components; Object.keys(components).forEach(function (componentName) { components[componentName].destroy(); }); // Kill keyboard nav this.keyboardNavigation.destroy(); // Hide container from screen readers if it exists if (chart.renderTo) { chart.renderTo.setAttribute('aria-hidden', true); } // Remove focus border if it exists if (chart.focusElement) { chart.focusElement.removeFocusBorder(); } }, /** * Return a list of the types of series we have in the chart. * @private */ getChartTypes: function () { var types = {}; this.chart.series.forEach(function (series) { types[series.type] = 1; }); return Object.keys(types); }, /** * Copy options that are deprecated over to new options. Logs warnings to * console for deprecated options used. The following options are * deprecated: * * chart.description -> accessibility.description * chart.typeDescription -> accessibility.typeDescription * series.description -> series.accessibility.description * series.exposeElementToA11y -> series.accessibility.exposeAsGroupOnly * series.pointDescriptionFormatter -> * series.accessibility.pointDescriptionFormatter * series.skipKeyboardNavigation -> * series.accessibility.keyboardNavigation.enabled * point.description -> point.accessibility.description * axis.description -> axis.accessibility.description * * @private */ copyDeprecatedOptions: function () { var chart = this.chart, // Warn user that a deprecated option was used warn = function (oldOption, newOption) { error( 'Highcharts: Deprecated option ' + oldOption + ' used. Use ' + newOption + ' instead.', false, chart ); }, // Set a new option on a root prop, where the option is defined as // an array of suboptions. traverseSetOption = function (val, optionAsArray, root) { var opt = root, prop, i = 0; for (;i < optionAsArray.length - 1; ++i) { prop = optionAsArray[i]; opt = opt[prop] = pick(opt[prop], {}); } opt[optionAsArray[optionAsArray.length - 1]] = val; }, // Map of deprecated series options. New options are defined as // arrays of paths under series.options. oldToNewSeriesOptions = { description: ['accessibility', 'description'], exposeElementToA11y: ['accessibility', 'exposeAsGroupOnly'], pointDescriptionFormatter: [ 'accessibility', 'pointDescriptionFormatter' ], skipKeyboardNavigation: [ 'accessibility', 'keyboardNavigation', 'enabled' ] }; // Deal with chart wide options (description, typeDescription) var chartOptions = chart.options.chart || {}, a11yOptions = chart.options.accessibility || {}; ['description', 'typeDescription'].forEach(function (prop) { if (chartOptions[prop]) { a11yOptions[prop] = chartOptions[prop]; warn('chart.' + prop, 'accessibility.' + prop); } }); // Deal with axis description chart.axes.forEach(function (axis) { var opts = axis.options; if (opts && opts.description) { opts.accessibility = opts.accessibility || {}; opts.accessibility.description = opts.description; warn('axis.description', 'axis.accessibility.description'); } }); // Loop through all series and handle options if (!chart.series) { return; } chart.series.forEach(function (series) { // Handle series wide options Object.keys(oldToNewSeriesOptions).forEach(function (oldOption) { var optionVal = series.options[oldOption]; if (optionVal !== undefined) { // Set the new option traverseSetOption( // Note that skipKeyboardNavigation has inverted option // value, since we set enabled rather than disabled oldOption === 'skipKeyboardNavigation' ? !optionVal : optionVal, oldToNewSeriesOptions[oldOption], series.options ); warn( 'series.' + oldOption, 'series.' + oldToNewSeriesOptions[oldOption].join('.') ); } }); // Loop through the points and handle point.description if (series.points) { series.points.forEach(function (point) { if (point.options && point.options.description) { point.options.accessibility = point.options.accessibility || {}; point.options.accessibility.description = point.options.description; warn('point.description', 'point.accessibility.description'); } }); } }); } }; // Handle updates to the module and send render updates to components addEvent(H.Chart, 'render', function (e) { var a11y = this.accessibility; // Update/destroy if (this.a11yDirty && this.renderTo) { delete this.a11yDirty; var accessibilityOptions = this.options.accessibility; if (accessibilityOptions && accessibilityOptions.enabled) { if (a11y) { a11y.update(); } else { this.accessibility = a11y = new Accessibility(this); } } else if (a11y) { // Destroy if after update we have a11y and it is disabled if (a11y.destroy) { a11y.destroy(); } delete this.accessibility; } else { // Just hide container this.renderTo.setAttribute('aria-hidden', true); } } // Update markup regardless if (a11y) { Object.keys(a11y.components).forEach(function (componentName) { a11y.components[componentName].onChartRender(e); }); } }); // Update with chart/series/point updates addEvent(H.Chart, 'update', function (e) { // Merge new options var newOptions = e.options.accessibility; if (newOptions) { // Handle custom component updating specifically if (newOptions.customComponents) { this.options.accessibility.customComponents = newOptions.customComponents; delete newOptions.customComponents; } merge(true, this.options.accessibility, newOptions); // Recreate from scratch if (this.accessibility && this.accessibility.destroy) { this.accessibility.destroy(); delete this.accessibility; } } // Mark dirty for update this.a11yDirty = true; }); // Mark dirty for update addEvent(H.Point, 'update', function () { if (this.series.chart.accessibility) { this.series.chart.a11yDirty = true; } }); ['addSeries', 'init'].forEach(function (event) { addEvent(H.Chart, event, function () { this.a11yDirty = true; }); }); ['update', 'updatedData', 'remove'].forEach(function (event) { addEvent(H.Series, event, function () { if (this.chart.accessibility) { this.chart.a11yDirty = true; } }); }); // Direct updates (events happen after render) [ 'afterDrilldown', 'drillupall' ].forEach(function (event) { addEvent(H.Chart, event, function () { if (this.accessibility) { this.accessibility.update(); } }); }); // Destroy with chart addEvent(H.Chart, 'destroy', function () { if (this.accessibility) { this.accessibility.destroy(); } });