accessibility.src.js 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073
  1. /**
  2. * @license Highcharts JS v5.0.6 (2016-12-07)
  3. * Accessibility module
  4. *
  5. * (c) 2010-2016 Highsoft AS
  6. * Author: Oystein Moseng
  7. *
  8. * License: www.highcharts.com/license
  9. */
  10. (function(factory) {
  11. if (typeof module === 'object' && module.exports) {
  12. module.exports = factory;
  13. } else {
  14. factory(Highcharts);
  15. }
  16. }(function(Highcharts) {
  17. (function(H) {
  18. /**
  19. * Accessibility module
  20. *
  21. * (c) 2010-2016 Highsoft AS
  22. * Author: Oystein Moseng
  23. *
  24. * License: www.highcharts.com/license
  25. */
  26. 'use strict';
  27. var win = H.win,
  28. doc = win.document,
  29. each = H.each,
  30. erase = H.erase,
  31. addEvent = H.addEvent,
  32. removeEvent = H.removeEvent,
  33. fireEvent = H.fireEvent,
  34. dateFormat = H.dateFormat,
  35. merge = H.merge,
  36. // Human readable description of series and each point in singular and plural
  37. typeToSeriesMap = {
  38. 'default': ['series', 'data point', 'data points'],
  39. 'line': ['line', 'data point', 'data points'],
  40. 'spline': ['line', 'data point', 'data points'],
  41. 'area': ['line', 'data point', 'data points'],
  42. 'areaspline': ['line', 'data point', 'data points'],
  43. 'pie': ['pie', 'slice', 'slices'],
  44. 'column': ['column series', 'column', 'columns'],
  45. 'bar': ['bar series', 'bar', 'bars'],
  46. 'scatter': ['scatter series', 'data point', 'data points'],
  47. 'boxplot': ['boxplot series', 'box', 'boxes'],
  48. 'arearange': ['arearange series', 'data point', 'data points'],
  49. 'areasplinerange': ['areasplinerange series', 'data point', 'data points'],
  50. 'bubble': ['bubble series', 'bubble', 'bubbles'],
  51. 'columnrange': ['columnrange series', 'column', 'columns'],
  52. 'errorbar': ['errorbar series', 'errorbar', 'errorbars'],
  53. 'funnel': ['funnel', 'data point', 'data points'],
  54. 'pyramid': ['pyramid', 'data point', 'data points'],
  55. 'waterfall': ['waterfall series', 'column', 'columns'],
  56. 'map': ['map', 'area', 'areas'],
  57. 'mapline': ['line', 'data point', 'data points'],
  58. 'mappoint': ['point series', 'data point', 'data points'],
  59. 'mapbubble': ['bubble series', 'bubble', 'bubbles']
  60. },
  61. // Descriptions for exotic chart types
  62. typeDescriptionMap = {
  63. boxplot: ' Box plot charts are typically used to display groups of statistical data. ' +
  64. 'Each data point in the chart can have up to 5 values: minimum, lower quartile, median, upper quartile and maximum. ',
  65. arearange: ' Arearange charts are line charts displaying a range between a lower and higher value for each point. ',
  66. areasplinerange: ' These charts are line charts displaying a range between a lower and higher value for each point. ',
  67. bubble: ' Bubble charts are scatter charts where each data point also has a size value. ',
  68. columnrange: ' Columnrange charts are column charts displaying a range between a lower and higher value for each point. ',
  69. errorbar: ' Errorbar series are used to display the variability of the data. ',
  70. funnel: ' Funnel charts are used to display reduction of data in stages. ',
  71. pyramid: ' Pyramid charts consist of a single pyramid with item heights corresponding to each point value. ',
  72. waterfall: ' A waterfall chart is a column chart where each column contributes towards a total end value. '
  73. },
  74. commonKeys = ['name', 'id', 'category', 'x', 'value', 'y'],
  75. specialKeys = ['z', 'open', 'high', 'q3', 'median', 'q1', 'low', 'close']; // Tell user about all properties if points have one of these defined
  76. // Default a11y options
  77. H.setOptions({
  78. accessibility: {
  79. enabled: true,
  80. pointDescriptionThreshold: 30, // set to false to disable
  81. keyboardNavigation: {
  82. enabled: true
  83. // skipNullPoints: false
  84. }
  85. // describeSingleSeries: false
  86. }
  87. });
  88. // Utility function. Reverses child nodes of a DOM element
  89. function reverseChildNodes(node) {
  90. var i = node.childNodes.length;
  91. while (i--) {
  92. node.appendChild(node.childNodes[i]);
  93. }
  94. }
  95. // Utility function to attempt to fake a click event on an element
  96. function fakeClickEvent(element) {
  97. var fakeEvent;
  98. if (element && element.onclick) {
  99. fakeEvent = doc.createEvent('Events');
  100. fakeEvent.initEvent('click', true, false);
  101. element.onclick(fakeEvent);
  102. }
  103. }
  104. // Whenever drawing series, put info on DOM elements
  105. H.wrap(H.Series.prototype, 'render', function(proceed) {
  106. proceed.apply(this, Array.prototype.slice.call(arguments, 1));
  107. if (this.chart.options.accessibility.enabled) {
  108. this.setA11yDescription();
  109. }
  110. });
  111. // Put accessible info on series and points of a series
  112. H.Series.prototype.setA11yDescription = function() {
  113. var a11yOptions = this.chart.options.accessibility,
  114. firstPointEl = this.points && this.points.length && this.points[0].graphic && this.points[0].graphic.element,
  115. seriesEl = firstPointEl && firstPointEl.parentNode || this.graph && this.graph.element || this.group && this.group.element; // Could be tracker series depending on series type
  116. if (seriesEl) {
  117. // For some series types the order of elements do not match the order of points in series
  118. // In that case we have to reverse them in order for AT to read them out in an understandable order
  119. if (seriesEl.lastChild === firstPointEl) {
  120. reverseChildNodes(seriesEl);
  121. }
  122. // Make individual point elements accessible if possible. Note: If markers are disabled there might not be any elements there to make accessible.
  123. if (this.points && (this.points.length < a11yOptions.pointDescriptionThreshold || a11yOptions.pointDescriptionThreshold === false)) {
  124. each(this.points, function(point) {
  125. if (point.graphic) {
  126. point.graphic.element.setAttribute('role', 'img');
  127. point.graphic.element.setAttribute('tabindex', '-1');
  128. point.graphic.element.setAttribute('aria-label', a11yOptions.pointDescriptionFormatter && a11yOptions.pointDescriptionFormatter(point) ||
  129. point.buildPointInfoString());
  130. }
  131. });
  132. }
  133. // Make series element accessible
  134. if (this.chart.series.length > 1 || a11yOptions.describeSingleSeries) {
  135. seriesEl.setAttribute('role', 'region');
  136. seriesEl.setAttribute('tabindex', '-1');
  137. seriesEl.setAttribute('aria-label', a11yOptions.seriesDescriptionFormatter && a11yOptions.seriesDescriptionFormatter(this) ||
  138. this.buildSeriesInfoString());
  139. }
  140. }
  141. };
  142. // Return string with information about series
  143. H.Series.prototype.buildSeriesInfoString = function() {
  144. var typeInfo = typeToSeriesMap[this.type] || typeToSeriesMap.default,
  145. description = this.description || this.options.description;
  146. return (this.name ? this.name + ', ' : '') +
  147. (this.chart.types.length === 1 ? typeInfo[0] : 'series') + ' ' + (this.index + 1) + ' of ' + (this.chart.series.length) +
  148. (this.chart.types.length === 1 ? ' with ' : '. ' + typeInfo[0] + ' with ') +
  149. (this.points.length + ' ' + (this.points.length === 1 ? typeInfo[1] : typeInfo[2])) +
  150. (description ? '. ' + description : '') +
  151. (this.chart.yAxis.length > 1 && this.yAxis ? '. Y axis, ' + this.yAxis.getDescription() : '') +
  152. (this.chart.xAxis.length > 1 && this.xAxis ? '. X axis, ' + this.xAxis.getDescription() : '');
  153. };
  154. // Return string with information about point
  155. H.Point.prototype.buildPointInfoString = function() {
  156. var point = this,
  157. series = point.series,
  158. a11yOptions = series.chart.options.accessibility,
  159. infoString = '',
  160. hasSpecialKey = false,
  161. dateTimePoint = series.xAxis && series.xAxis.isDatetimeAxis,
  162. timeDesc = dateTimePoint && dateFormat(a11yOptions.pointDateFormatter && a11yOptions.pointDateFormatter(point) || a11yOptions.pointDateFormat ||
  163. H.Tooltip.prototype.getXDateFormat(point, series.chart.options.tooltip, series.xAxis), point.x);
  164. each(specialKeys, function(key) {
  165. if (point[key] !== undefined) {
  166. hasSpecialKey = true;
  167. }
  168. });
  169. // If the point has one of the less common properties defined, display all that are defined
  170. if (hasSpecialKey) {
  171. if (dateTimePoint) {
  172. infoString = timeDesc;
  173. }
  174. each(commonKeys.concat(specialKeys), function(key) {
  175. if (point[key] !== undefined && !(dateTimePoint && key === 'x')) {
  176. infoString += (infoString ? '. ' : '') + key + ', ' + this[key];
  177. }
  178. });
  179. } else {
  180. // Pick and choose properties for a succint label
  181. infoString = (this.name || timeDesc || this.category || this.id || 'x, ' + this.x) + ', ' +
  182. (this.value !== undefined ? this.value : this.y);
  183. }
  184. return (this.index + 1) + '. ' + infoString + '.' + (this.description ? ' ' + this.description : '');
  185. };
  186. // Get descriptive label for axis
  187. H.Axis.prototype.getDescription = function() {
  188. return this.userOptions && this.userOptions.description || this.axisTitle && this.axisTitle.textStr ||
  189. this.options.id || this.categories && 'categories' || 'values';
  190. };
  191. // Pan along axis in a direction (1 or -1), optionally with a defined granularity (number of steps it takes to walk across current view)
  192. H.Axis.prototype.panStep = function(direction, granularity) {
  193. var gran = granularity || 3,
  194. extremes = this.getExtremes(),
  195. step = (extremes.max - extremes.min) / gran * direction,
  196. newMax = extremes.max + step,
  197. newMin = extremes.min + step,
  198. size = newMax - newMin;
  199. if (direction < 0 && newMin < extremes.dataMin) {
  200. newMin = extremes.dataMin;
  201. newMax = newMin + size;
  202. } else if (direction > 0 && newMax > extremes.dataMax) {
  203. newMax = extremes.dataMax;
  204. newMin = newMax - size;
  205. }
  206. this.setExtremes(newMin, newMax);
  207. };
  208. // Whenever adding or removing series, keep track of types present in chart
  209. H.wrap(H.Series.prototype, 'init', function(proceed) {
  210. proceed.apply(this, Array.prototype.slice.call(arguments, 1));
  211. var chart = this.chart;
  212. if (chart.options.accessibility.enabled) {
  213. chart.types = chart.types || [];
  214. // Add type to list if does not exist
  215. if (chart.types.indexOf(this.type) < 0) {
  216. chart.types.push(this.type);
  217. }
  218. addEvent(this, 'remove', function() {
  219. var removedSeries = this,
  220. hasType = false;
  221. // Check if any of the other series have the same type as this one. Otherwise remove it from the list.
  222. each(chart.series, function(s) {
  223. if (s !== removedSeries && chart.types.indexOf(removedSeries.type) < 0) {
  224. hasType = true;
  225. }
  226. });
  227. if (!hasType) {
  228. erase(chart.types, removedSeries.type);
  229. }
  230. });
  231. }
  232. });
  233. // Return simplified description of chart type. Some types will not be familiar to most screen reader users, but we try.
  234. H.Chart.prototype.getTypeDescription = function() {
  235. var firstType = this.types && this.types[0],
  236. mapTitle = this.series[0] && this.series[0].mapTitle;
  237. if (!firstType) {
  238. return 'Empty chart.';
  239. } else if (firstType === 'map') {
  240. return mapTitle ? 'Map of ' + mapTitle : 'Map of unspecified region.';
  241. } else if (this.types.length > 1) {
  242. return 'Combination chart.';
  243. } else if (['spline', 'area', 'areaspline'].indexOf(firstType) > -1) {
  244. return 'Line chart.';
  245. }
  246. return firstType + ' chart.' + (typeDescriptionMap[firstType] || '');
  247. };
  248. // Return object with text description of each of the chart's axes
  249. H.Chart.prototype.getAxesDescription = function() {
  250. var numXAxes = this.xAxis.length,
  251. numYAxes = this.yAxis.length,
  252. desc = {},
  253. i;
  254. if (numXAxes) {
  255. desc.xAxis = 'The chart has ' + numXAxes + (numXAxes > 1 ? ' X axes' : ' X axis') + ' displaying ';
  256. if (numXAxes < 2) {
  257. desc.xAxis += this.xAxis[0].getDescription() + '.';
  258. } else {
  259. for (i = 0; i < numXAxes - 1; ++i) {
  260. desc.xAxis += (i ? ', ' : '') + this.xAxis[i].getDescription();
  261. }
  262. desc.xAxis += ' and ' + this.xAxis[i].getDescription() + '.';
  263. }
  264. }
  265. if (numYAxes) {
  266. desc.yAxis = 'The chart has ' + numYAxes + (numYAxes > 1 ? ' Y axes' : ' Y axis') + ' displaying ';
  267. if (numYAxes < 2) {
  268. desc.yAxis += this.yAxis[0].getDescription() + '.';
  269. } else {
  270. for (i = 0; i < numYAxes - 1; ++i) {
  271. desc.yAxis += (i ? ', ' : '') + this.yAxis[i].getDescription();
  272. }
  273. desc.yAxis += ' and ' + this.yAxis[i].getDescription() + '.';
  274. }
  275. }
  276. return desc;
  277. };
  278. // Set a11y attribs on exporting menu
  279. H.Chart.prototype.addAccessibleContextMenuAttribs = function() {
  280. var exportList = this.exportDivElements;
  281. if (exportList) {
  282. // Set tabindex on the menu items to allow focusing by script
  283. // Set role to give screen readers a chance to pick up the contents
  284. each(exportList, function(item) {
  285. if (item.tagName === 'DIV' &&
  286. !(item.children && item.children.length)) {
  287. item.setAttribute('role', 'menuitem');
  288. item.setAttribute('tabindex', -1);
  289. }
  290. });
  291. // Set accessibility properties on parent div
  292. exportList[0].parentNode.setAttribute('role', 'menu');
  293. exportList[0].parentNode.setAttribute('aria-label', 'Chart export');
  294. }
  295. };
  296. // Highlight a point (show tooltip and display hover state). Returns the highlighted point.
  297. H.Point.prototype.highlight = function() {
  298. var chart = this.series.chart;
  299. if (this.graphic && this.graphic.element.focus) {
  300. this.graphic.element.focus();
  301. }
  302. if (!this.isNull) {
  303. this.onMouseOver(); // Show the hover marker
  304. chart.tooltip.refresh(chart.tooltip.shared ? [this] : this); // Show the tooltip
  305. } else {
  306. chart.tooltip.hide(0);
  307. // Don't call blur on the element, as it messes up the chart div's focus
  308. }
  309. chart.highlightedPoint = this;
  310. return this;
  311. };
  312. // Function to highlight next/previous point in chart
  313. // Returns highlighted point on success, false on failure (no adjacent point to highlight in chosen direction)
  314. H.Chart.prototype.highlightAdjacentPoint = function(next) {
  315. var series = this.series,
  316. curPoint = this.highlightedPoint,
  317. curPointIndex = curPoint && curPoint.index || 0,
  318. newSeries,
  319. newPoint;
  320. // If no points, return false
  321. if (!series[0] || !series[0].points) {
  322. return false;
  323. }
  324. // Use first point if none already highlighted
  325. if (!curPoint) {
  326. return series[0].points[0].highlight();
  327. }
  328. // Find index of current point in series.points array. Necessary for dataGrouping (and maybe zoom?)
  329. if (curPoint.series.points[curPointIndex] !== curPoint) {
  330. for (var i = 0; i < curPoint.series.points.length; ++i) {
  331. if (curPoint.series.points[i] === curPoint) {
  332. curPointIndex = i;
  333. break;
  334. }
  335. }
  336. }
  337. // Try to grab next/prev point
  338. newSeries = series[curPoint.series.index + (next ? 1 : -1)];
  339. newPoint = curPoint.series.points[curPointIndex + (next ? 1 : -1)] || newSeries && newSeries.points[next ? 0 : newSeries.points.length - 1];
  340. // If there is no adjacent point, we return false
  341. if (newPoint === undefined) {
  342. return false;
  343. }
  344. // Recursively skip null points
  345. if (newPoint.isNull && this.options.accessibility.keyboardNavigation &&
  346. this.options.accessibility.keyboardNavigation.skipNullPoints) {
  347. this.highlightedPoint = newPoint;
  348. return this.highlightAdjacentPoint(next);
  349. }
  350. // There is an adjacent point, highlight it
  351. return newPoint.highlight();
  352. };
  353. // Show the export menu and focus the first item (if exists)
  354. H.Chart.prototype.showExportMenu = function() {
  355. if (this.exportSVGElements && this.exportSVGElements[0]) {
  356. this.exportSVGElements[0].element.onclick();
  357. this.highlightExportItem(0);
  358. }
  359. };
  360. // Highlight export menu item by index
  361. H.Chart.prototype.highlightExportItem = function(ix) {
  362. var listItem = this.exportDivElements && this.exportDivElements[ix],
  363. curHighlighted = this.exportDivElements && this.exportDivElements[this.highlightedExportItem];
  364. if (listItem && listItem.tagName === 'DIV' && !(listItem.children && listItem.children.length)) {
  365. if (listItem.focus) {
  366. listItem.focus();
  367. }
  368. if (curHighlighted && curHighlighted.onmouseout) {
  369. curHighlighted.onmouseout();
  370. }
  371. if (listItem.onmouseover) {
  372. listItem.onmouseover();
  373. }
  374. this.highlightedExportItem = ix;
  375. return true;
  376. }
  377. };
  378. // Highlight range selector button by index
  379. H.Chart.prototype.highlightRangeSelectorButton = function(ix) {
  380. var buttons = this.rangeSelector.buttons;
  381. // Deselect old
  382. if (buttons[this.highlightedRangeSelectorItemIx]) {
  383. buttons[this.highlightedRangeSelectorItemIx].setState(this.oldRangeSelectorItemState || 0);
  384. }
  385. // Select new
  386. this.highlightedRangeSelectorItemIx = ix;
  387. if (buttons[ix]) {
  388. if (buttons[ix].element.focus) {
  389. buttons[ix].element.focus();
  390. }
  391. this.oldRangeSelectorItemState = buttons[ix].state;
  392. buttons[ix].setState(2);
  393. return true;
  394. }
  395. return false;
  396. };
  397. // Highlight legend item by index
  398. H.Chart.prototype.highlightLegendItem = function(ix) {
  399. var items = this.legend.allItems;
  400. if (items[this.highlightedLegendItemIx]) {
  401. fireEvent(items[this.highlightedLegendItemIx].legendGroup.element, 'mouseout');
  402. }
  403. this.highlightedLegendItemIx = ix;
  404. if (items[ix]) {
  405. if (items[ix].legendGroup.element.focus) {
  406. items[ix].legendGroup.element.focus();
  407. }
  408. fireEvent(items[ix].legendGroup.element, 'mouseover');
  409. return true;
  410. }
  411. return false;
  412. };
  413. // Hide export menu
  414. H.Chart.prototype.hideExportMenu = function() {
  415. var exportList = this.exportDivElements;
  416. if (exportList) {
  417. each(exportList, function(el) {
  418. fireEvent(el, 'mouseleave');
  419. });
  420. if (exportList[this.highlightedExportItem] && exportList[this.highlightedExportItem].onmouseout) {
  421. exportList[this.highlightedExportItem].onmouseout();
  422. }
  423. this.highlightedExportItem = 0;
  424. this.renderTo.focus();
  425. }
  426. };
  427. // Add keyboard navigation handling to chart
  428. H.Chart.prototype.addKeyboardNavEvents = function() {
  429. var chart = this;
  430. // Abstraction layer for keyboard navigation. Keep a map of keyCodes to handler functions, and a next/prev move handler for tab order.
  431. // The module's keyCode handlers determine when to move to another module.
  432. // Validate holds a function to determine if there are prerequisites for this module to run that are not met.
  433. // Init holds a function to run once before any keyCodes are interpreted.
  434. // transformTabs determines whether to transform tabs to left/right events or not. Defaults to true.
  435. function KeyboardNavigationModule(options) {
  436. this.keyCodeMap = options.keyCodeMap;
  437. this.move = options.move;
  438. this.validate = options.validate;
  439. this.init = options.init;
  440. this.transformTabs = options.transformTabs !== false;
  441. }
  442. KeyboardNavigationModule.prototype = {
  443. // Find handler function(s) for key code in the keyCodeMap and run it.
  444. run: function(e) {
  445. var navModule = this,
  446. keyCode = e.which || e.keyCode,
  447. handled = false;
  448. keyCode = this.transformTabs && keyCode === 9 ? (e.shiftKey ? 37 : 39) : keyCode; // Transform tabs
  449. each(this.keyCodeMap, function(codeSet) {
  450. if (codeSet[0].indexOf(keyCode) > -1) {
  451. handled = codeSet[1].call(navModule, keyCode, e) === false ? false : true; // If explicitly returning false, we haven't handled it
  452. }
  453. });
  454. return handled;
  455. }
  456. };
  457. // Maintain abstraction between KeyboardNavigationModule and Highcharts
  458. // The chart object keeps track of a list of KeyboardNavigationModules that we move through
  459. function navModuleFactory(keyMap, options) {
  460. return new KeyboardNavigationModule(merge({
  461. keyCodeMap: keyMap,
  462. // Move to next/prev valid module, or undefined if none, and init it.
  463. // Returns true on success and false if there is no valid module to move to.
  464. move: function(direction) {
  465. chart.keyboardNavigationModuleIndex += direction;
  466. var newModule = chart.keyboardNavigationModules[chart.keyboardNavigationModuleIndex];
  467. if (newModule) {
  468. if (newModule.validate && !newModule.validate()) {
  469. return this.move(direction); // Invalid module
  470. }
  471. if (newModule.init) {
  472. newModule.init(direction); // Valid module, init it
  473. return true;
  474. }
  475. }
  476. // No module
  477. chart.keyboardNavigationModuleIndex = 0; // Reset counter
  478. chart.slipNextTab = true; // Allow next tab to slip, as we will have focus on chart now
  479. return false;
  480. }
  481. }, options));
  482. }
  483. // Route keydown events
  484. function keydownHandler(ev) {
  485. var e = ev || win.event,
  486. keyCode = e.which || e.keyCode,
  487. curNavModule = chart.keyboardNavigationModules[chart.keyboardNavigationModuleIndex];
  488. // Handle tabbing
  489. if (keyCode === 9) {
  490. // If we reached end of chart, we need to let this tab slip through to allow users to tab further
  491. if (chart.slipNextTab) {
  492. chart.slipNextTab = false;
  493. return;
  494. }
  495. }
  496. // If key was not tab, don't slip the next tab
  497. chart.slipNextTab = false;
  498. // If there is a navigation module for the current index, run it. Otherwise, we are outside of the chart in some direction.
  499. if (curNavModule) {
  500. if (curNavModule.run(e)) {
  501. e.preventDefault(); // If successfully handled, stop the event here.
  502. }
  503. }
  504. }
  505. // List of the different keyboard handling modes we use depending on where we are in the chart.
  506. // Each mode has a set of handling functions mapped to key codes.
  507. // Each mode determines when to move to the next/prev mode.
  508. chart.keyboardNavigationModules = [
  509. // Points
  510. navModuleFactory([
  511. // Left/Right
  512. [
  513. [37, 39],
  514. function(keyCode) {
  515. if (!chart.highlightAdjacentPoint(keyCode === 39)) { // Try to highlight adjacent point
  516. return this.move(keyCode === 39 ? 1 : -1); // Failed. Move to next/prev module
  517. }
  518. }
  519. ],
  520. // Up/Down
  521. [
  522. [38, 40],
  523. function(keyCode) {
  524. var newSeries;
  525. if (chart.highlightedPoint) {
  526. newSeries = chart.series[chart.highlightedPoint.series.index + (keyCode === 38 ? -1 : 1)]; // Find prev/next series
  527. if (newSeries && newSeries.points[0]) { // If series exists and has data, go for it
  528. newSeries.points[0].highlight();
  529. } else {
  530. return this.move(keyCode === 40 ? 1 : -1); // Otherwise, attempt to move to next/prev module
  531. }
  532. }
  533. }
  534. ],
  535. // Enter/Spacebar
  536. [
  537. [13, 32],
  538. function() {
  539. if (chart.highlightedPoint) {
  540. chart.highlightedPoint.firePointEvent('click');
  541. }
  542. }
  543. ]
  544. ], {
  545. // If coming back to points from other module, highlight last point
  546. init: function(direction) {
  547. var lastSeries = chart.series && chart.series[chart.series.length - 1],
  548. lastPoint = lastSeries && lastSeries.points && lastSeries.points[lastSeries.points.length - 1];
  549. if (direction < 0 && lastPoint) {
  550. lastPoint.highlight();
  551. }
  552. }
  553. }),
  554. // Exporting
  555. navModuleFactory([
  556. // Left/Up
  557. [
  558. [37, 38],
  559. function() {
  560. var i = chart.highlightedExportItem || 0,
  561. reachedEnd = true,
  562. series = chart.series,
  563. newSeries;
  564. // Try to highlight prev item in list. Highlighting e.g. separators will fail.
  565. while (i--) {
  566. if (chart.highlightExportItem(i)) {
  567. reachedEnd = false;
  568. break;
  569. }
  570. }
  571. if (reachedEnd) {
  572. chart.hideExportMenu();
  573. // Wrap to last point
  574. if (series && series.length) {
  575. newSeries = series[series.length - 1];
  576. if (newSeries.points.length) {
  577. newSeries.points[newSeries.points.length - 1].highlight();
  578. }
  579. }
  580. // Try to move to prev module (should be points, since we wrapped to last point)
  581. return this.move(-1);
  582. }
  583. }
  584. ],
  585. // Right/Down
  586. [
  587. [39, 40],
  588. function() {
  589. var highlightedExportItem = chart.highlightedExportItem || 0,
  590. reachedEnd = true;
  591. // Try to highlight next item in list. Highlighting e.g. separators will fail.
  592. for (var i = highlightedExportItem + 1; i < chart.exportDivElements.length; ++i) {
  593. if (chart.highlightExportItem(i)) {
  594. reachedEnd = false;
  595. break;
  596. }
  597. }
  598. if (reachedEnd) {
  599. chart.hideExportMenu();
  600. return this.move(1); // Next module
  601. }
  602. }
  603. ],
  604. // Enter/Spacebar
  605. [
  606. [13, 32],
  607. function() {
  608. fakeClickEvent(chart.exportDivElements[chart.highlightedExportItem]);
  609. }
  610. ]
  611. ], {
  612. // Only run exporting navigation if exporting support exists and is enabled on chart
  613. validate: function() {
  614. return chart.exportChart && !(chart.options.exporting && chart.options.exporting.enabled === false);
  615. },
  616. // Show export menu
  617. init: function(direction) {
  618. chart.highlightedPoint = null;
  619. chart.showExportMenu();
  620. // If coming back to export menu from other module, try to highlight last item in menu
  621. if (direction < 0 && chart.exportDivElements) {
  622. for (var i = chart.exportDivElements.length; i > -1; --i) {
  623. if (chart.highlightExportItem(i)) {
  624. break;
  625. }
  626. }
  627. }
  628. }
  629. }),
  630. // Map zoom
  631. navModuleFactory([
  632. // Up/down/left/right
  633. [
  634. [38, 40, 37, 39],
  635. function(keyCode) {
  636. chart[keyCode === 38 || keyCode === 40 ? 'yAxis' : 'xAxis'][0].panStep(keyCode < 39 ? -1 : 1);
  637. }
  638. ],
  639. // Tabs
  640. [
  641. [9],
  642. function(keyCode, e) {
  643. var button;
  644. chart.mapNavButtons[chart.focusedMapNavButtonIx].setState(0); // Deselect old
  645. if (e.shiftKey && !chart.focusedMapNavButtonIx || !e.shiftKey && chart.focusedMapNavButtonIx) { // trying to go somewhere we can't?
  646. chart.mapZoom(); // Reset zoom
  647. return this.move(e.shiftKey ? -1 : 1); // Nowhere to go, go to prev/next module
  648. }
  649. chart.focusedMapNavButtonIx += e.shiftKey ? -1 : 1;
  650. button = chart.mapNavButtons[chart.focusedMapNavButtonIx];
  651. if (button.element.focus) {
  652. button.element.focus();
  653. }
  654. button.setState(2);
  655. }
  656. ],
  657. // Enter/Spacebar
  658. [
  659. [13, 32],
  660. function() {
  661. fakeClickEvent(chart.mapNavButtons[chart.focusedMapNavButtonIx].element);
  662. }
  663. ]
  664. ], {
  665. // Only run this module if we have map zoom on the chart
  666. validate: function() {
  667. return chart.mapZoom && chart.mapNavButtons && chart.mapNavButtons.length === 2;
  668. },
  669. // Handle tabs separately
  670. transformTabs: false,
  671. // Make zoom buttons do their magic
  672. init: function(direction) {
  673. var zoomIn = chart.mapNavButtons[0],
  674. zoomOut = chart.mapNavButtons[1],
  675. initialButton = direction > 0 ? zoomIn : zoomOut;
  676. each(chart.mapNavButtons, function(button, i) {
  677. button.element.setAttribute('tabindex', -1);
  678. button.element.setAttribute('role', 'button');
  679. button.element.setAttribute('aria-label', 'Zoom ' + (i ? 'out' : '') + 'chart');
  680. });
  681. if (initialButton.element.focus) {
  682. initialButton.element.focus();
  683. }
  684. initialButton.setState(2);
  685. chart.focusedMapNavButtonIx = direction > 0 ? 0 : 1;
  686. }
  687. }),
  688. // Highstock range selector (minus input boxes)
  689. navModuleFactory([
  690. // Left/Right/Up/Down
  691. [
  692. [37, 39, 38, 40],
  693. function(keyCode) {
  694. var direction = (keyCode === 37 || keyCode === 38) ? -1 : 1;
  695. // Try to highlight next/prev button
  696. if (!chart.highlightRangeSelectorButton(chart.highlightedRangeSelectorItemIx + direction)) {
  697. return this.move(direction);
  698. }
  699. }
  700. ],
  701. // Enter/Spacebar
  702. [
  703. [13, 32],
  704. function() {
  705. if (chart.oldRangeSelectorItemState !== 3) { // Don't allow click if button used to be disabled
  706. fakeClickEvent(chart.rangeSelector.buttons[chart.highlightedRangeSelectorItemIx].element);
  707. }
  708. }
  709. ]
  710. ], {
  711. // Only run this module if we have range selector
  712. validate: function() {
  713. return chart.rangeSelector && chart.rangeSelector.buttons && chart.rangeSelector.buttons.length;
  714. },
  715. // Make elements focusable and accessible
  716. init: function(direction) {
  717. each(chart.rangeSelector.buttons, function(button) {
  718. button.element.setAttribute('tabindex', '-1');
  719. button.element.setAttribute('role', 'button');
  720. button.element.setAttribute('aria-label', 'Select range ' + (button.text && button.text.textStr));
  721. });
  722. // Focus first/last button
  723. chart.highlightRangeSelectorButton(direction > 0 ? 0 : chart.rangeSelector.buttons.length - 1);
  724. }
  725. }),
  726. // Highstock range selector, input boxes
  727. navModuleFactory([
  728. // Tab/Up/Down
  729. [
  730. [9, 38, 40],
  731. function(keyCode, e) {
  732. var direction = (keyCode === 9 && e.shiftKey || keyCode === 38) ? -1 : 1,
  733. newIx = chart.highlightedInputRangeIx = chart.highlightedInputRangeIx + direction;
  734. // Try to highlight next/prev item in list.
  735. if (newIx > 1 || newIx < 0) { // Out of range
  736. return this.move(direction);
  737. }
  738. chart.rangeSelector[newIx ? 'maxInput' : 'minInput'].focus(); // Input boxes are HTML, and should have focus support in all browsers
  739. }
  740. ]
  741. ], {
  742. // Only run if we have range selector with input boxes
  743. validate: function() {
  744. var inputVisible = chart.rangeSelector && chart.rangeSelector.inputGroup && chart.rangeSelector.inputGroup.element.getAttribute('visibility') !== 'hidden';
  745. return inputVisible && chart.options.rangeSelector.inputEnabled !== false && chart.rangeSelector.minInput && chart.rangeSelector.maxInput;
  746. },
  747. // Handle tabs different from left/right (because we don't want to catch left/right in a text area)
  748. transformTabs: false,
  749. // Highlight first/last input box
  750. init: function(direction) {
  751. chart.highlightedInputRangeIx = direction > 0 ? 0 : 1;
  752. chart.rangeSelector[chart.highlightedInputRangeIx ? 'maxInput' : 'minInput'].focus();
  753. }
  754. }),
  755. // Legend navigation
  756. navModuleFactory([
  757. // Left/Right/Up/Down
  758. [
  759. [37, 39, 38, 40],
  760. function(keyCode) {
  761. var direction = (keyCode === 37 || keyCode === 38) ? -1 : 1;
  762. // Try to highlight next/prev legend item
  763. if (!chart.highlightLegendItem(chart.highlightedLegendItemIx + direction)) {
  764. return this.move(direction);
  765. }
  766. }
  767. ],
  768. // Enter/Spacebar
  769. [
  770. [13, 32],
  771. function() {
  772. fakeClickEvent(chart.legend.allItems[chart.highlightedLegendItemIx].legendItem.element.parentNode);
  773. }
  774. ]
  775. ], {
  776. // Only run this module if we have at least one legend - wait for it - item.
  777. validate: function() {
  778. return chart.legend && chart.legend.allItems && !chart.colorAxis;
  779. },
  780. // Make elements focusable and accessible
  781. init: function(direction) {
  782. each(chart.legend.allItems, function(item) {
  783. item.legendGroup.element.setAttribute('tabindex', '-1');
  784. item.legendGroup.element.setAttribute('role', 'button');
  785. item.legendGroup.element.setAttribute('aria-label', 'Toggle visibility of series ' + item.name);
  786. });
  787. // Focus first/last item
  788. chart.highlightLegendItem(direction > 0 ? 0 : chart.legend.allItems.length - 1);
  789. }
  790. })
  791. ];
  792. // 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.
  793. chart.keyboardNavigationModuleIndex = 0;
  794. // Make chart reachable by tab
  795. if (!chart.renderTo.tabIndex) {
  796. chart.renderTo.setAttribute('tabindex', '0');
  797. }
  798. // Handle keyboard events
  799. addEvent(chart.renderTo, 'keydown', keydownHandler);
  800. addEvent(chart, 'destroy', function() {
  801. removeEvent(chart.renderTo, 'keydown', keydownHandler);
  802. });
  803. };
  804. // Add screen reader region to chart.
  805. // tableId is the HTML id of the table to focus when clicking the table anchor in the screen reader region.
  806. H.Chart.prototype.addScreenReaderRegion = function(tableId) {
  807. var chart = this,
  808. series = chart.series,
  809. options = chart.options,
  810. a11yOptions = options.accessibility,
  811. hiddenSection = chart.screenReaderRegion = doc.createElement('div'),
  812. tableShortcut = doc.createElement('h3'),
  813. tableShortcutAnchor = doc.createElement('a'),
  814. chartHeading = doc.createElement('h3'),
  815. hiddenStyle = { // CSS style to hide element from visual users while still exposing it to screen readers
  816. position: 'absolute',
  817. left: '-9999px',
  818. top: 'auto',
  819. width: '1px',
  820. height: '1px',
  821. overflow: 'hidden'
  822. },
  823. chartTypes = chart.types || [],
  824. // Build axis info - but not for pies and maps. Consider not adding for certain other types as well (funnel, pyramid?)
  825. axesDesc = (chartTypes.length === 1 && chartTypes[0] === 'pie' || chartTypes[0] === 'map') && {} || chart.getAxesDescription(),
  826. chartTypeInfo = series[0] && typeToSeriesMap[series[0].type] || typeToSeriesMap.default;
  827. hiddenSection.setAttribute('role', 'region');
  828. hiddenSection.setAttribute('aria-label', 'Chart screen reader information.');
  829. hiddenSection.innerHTML = a11yOptions.screenReaderSectionFormatter && a11yOptions.screenReaderSectionFormatter(chart) ||
  830. '<div tabindex="0">Use regions/landmarks to skip ahead to chart' +
  831. (series.length > 1 ? ' and navigate between data series' : '') + '.</div><h3>Summary.</h3><div>' + (options.title.text || 'Chart') +
  832. (options.subtitle && options.subtitle.text ? '. ' + options.subtitle.text : '') +
  833. '</div><h3>Long description.</h3><div>' + (options.chart.description || 'No description available.') +
  834. '</div><h3>Structure.</h3><div>Chart type: ' + (options.chart.typeDescription || chart.getTypeDescription()) + '</div>' +
  835. (series.length === 1 ? '<div>' + chartTypeInfo[0] + ' with ' + series[0].points.length + ' ' +
  836. (series[0].points.length === 1 ? chartTypeInfo[1] : chartTypeInfo[2]) + '.</div>' : '') +
  837. (axesDesc.xAxis ? ('<div>' + axesDesc.xAxis + '</div>') : '') +
  838. (axesDesc.yAxis ? ('<div>' + axesDesc.yAxis + '</div>') : '');
  839. // Add shortcut to data table if export-csv is loaded
  840. if (chart.getCSV) {
  841. tableShortcutAnchor.innerHTML = 'View as data table.';
  842. tableShortcutAnchor.href = '#' + tableId;
  843. tableShortcutAnchor.setAttribute('tabindex', '-1'); // Make this unreachable by user tabbing
  844. tableShortcutAnchor.onclick = a11yOptions.onTableAnchorClick || function() {
  845. chart.viewData();
  846. doc.getElementById(tableId).focus();
  847. };
  848. tableShortcut.appendChild(tableShortcutAnchor);
  849. hiddenSection.appendChild(tableShortcut);
  850. }
  851. chartHeading.innerHTML = 'Chart graphic.';
  852. chart.renderTo.insertBefore(chartHeading, chart.renderTo.firstChild);
  853. chart.renderTo.insertBefore(hiddenSection, chart.renderTo.firstChild);
  854. // Hide the section and the chart heading
  855. merge(true, chartHeading.style, hiddenStyle);
  856. merge(true, hiddenSection.style, hiddenStyle);
  857. };
  858. // Make chart container accessible, and wrap table functionality
  859. H.Chart.prototype.callbacks.push(function(chart) {
  860. var options = chart.options,
  861. a11yOptions = options.accessibility;
  862. if (!a11yOptions.enabled) {
  863. return;
  864. }
  865. var titleElement = doc.createElementNS('http://www.w3.org/2000/svg', 'title'),
  866. exportGroupElement = doc.createElementNS('http://www.w3.org/2000/svg', 'g'),
  867. descElement = chart.container.getElementsByTagName('desc')[0],
  868. textElements = chart.container.getElementsByTagName('text'),
  869. titleId = 'highcharts-title-' + chart.index,
  870. tableId = 'highcharts-data-table-' + chart.index,
  871. chartTitle = options.title.text || 'Chart',
  872. oldColumnHeaderFormatter = options.exporting && options.exporting.csv && options.exporting.csv.columnHeaderFormatter,
  873. topLevelColumns = [];
  874. // Add SVG title/desc tags
  875. titleElement.textContent = chartTitle;
  876. titleElement.id = titleId;
  877. descElement.parentNode.insertBefore(titleElement, descElement);
  878. chart.renderTo.setAttribute('role', 'region');
  879. chart.renderTo.setAttribute('aria-label', chartTitle + '. Use up and down arrows to navigate.');
  880. // Set screen reader properties on export menu
  881. if (chart.exportSVGElements && chart.exportSVGElements[0] && chart.exportSVGElements[0].element) {
  882. var oldExportCallback = chart.exportSVGElements[0].element.onclick,
  883. parent = chart.exportSVGElements[0].element.parentNode;
  884. chart.exportSVGElements[0].element.onclick = function() {
  885. oldExportCallback.apply(this, Array.prototype.slice.call(arguments));
  886. chart.addAccessibleContextMenuAttribs();
  887. chart.highlightExportItem(0);
  888. };
  889. chart.exportSVGElements[0].element.setAttribute('role', 'button');
  890. chart.exportSVGElements[0].element.setAttribute('aria-label', 'View export menu');
  891. exportGroupElement.appendChild(chart.exportSVGElements[0].element);
  892. exportGroupElement.setAttribute('role', 'region');
  893. exportGroupElement.setAttribute('aria-label', 'Chart export menu');
  894. parent.appendChild(exportGroupElement);
  895. }
  896. // 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
  897. // by default part of the page's tabindex unless we set them to -1.
  898. if (chart.rangeSelector) {
  899. each(['minInput', 'maxInput'], function(key, i) {
  900. if (chart.rangeSelector[key]) {
  901. chart.rangeSelector[key].setAttribute('tabindex', '-1');
  902. chart.rangeSelector[key].setAttribute('role', 'textbox');
  903. chart.rangeSelector[key].setAttribute('aria-label', 'Select ' + (i ? 'end' : 'start') + ' date.');
  904. }
  905. });
  906. }
  907. // Hide text elements from screen readers
  908. each(textElements, function(el) {
  909. el.setAttribute('aria-hidden', 'true');
  910. });
  911. // Add top-secret screen reader region
  912. chart.addScreenReaderRegion(tableId);
  913. // Enable keyboard navigation
  914. if (a11yOptions.keyboardNavigation) {
  915. chart.addKeyboardNavEvents();
  916. }
  917. /* Wrap table functionality from export-csv */
  918. // Keep track of columns
  919. merge(true, options.exporting, {
  920. csv: {
  921. columnHeaderFormatter: function(series, key, keyLength) {
  922. var prevCol = topLevelColumns[topLevelColumns.length - 1];
  923. if (keyLength > 1) {
  924. // We need multiple levels of column headers
  925. // Populate a list of column headers to add in addition to the ones added by export-csv
  926. if ((prevCol && prevCol.text) !== series.name) {
  927. topLevelColumns.push({
  928. text: series.name,
  929. span: keyLength
  930. });
  931. }
  932. }
  933. if (oldColumnHeaderFormatter) {
  934. return oldColumnHeaderFormatter.call(this, series, key, keyLength);
  935. }
  936. return keyLength > 1 ? key : series.name;
  937. }
  938. }
  939. });
  940. // Add ID and title/caption to table HTML
  941. H.wrap(chart, 'getTable', function(proceed) {
  942. return proceed.apply(this, Array.prototype.slice.call(arguments, 1))
  943. .replace('<table>', '<table id="' + tableId + '" summary="Table representation of chart"><caption>' + chartTitle + '</caption>');
  944. });
  945. // Add accessibility attributes and top level columns
  946. H.wrap(chart, 'viewData', function(proceed) {
  947. if (!this.insertedTable) {
  948. proceed.apply(this, Array.prototype.slice.call(arguments, 1));
  949. var table = doc.getElementById(tableId),
  950. body = table.getElementsByTagName('tbody')[0],
  951. firstRow = body.firstChild.children,
  952. columnHeaderRow = '<tr><td></td>',
  953. cell,
  954. newCell;
  955. // Make table focusable by script
  956. table.setAttribute('tabindex', '-1');
  957. // Create row headers
  958. each(body.children, function(el) {
  959. cell = el.firstChild;
  960. newCell = doc.createElement('th');
  961. newCell.setAttribute('scope', 'row');
  962. newCell.innerHTML = cell.innerHTML;
  963. cell.parentNode.replaceChild(newCell, cell);
  964. });
  965. // Set scope for column headers
  966. each(firstRow, function(el) {
  967. if (el.tagName === 'TH') {
  968. el.setAttribute('scope', 'col');
  969. }
  970. });
  971. // Add top level columns
  972. if (topLevelColumns.length) {
  973. each(topLevelColumns, function(col) {
  974. columnHeaderRow += '<th scope="col" colspan="' + col.span + '">' + col.text + '</th>';
  975. });
  976. body.insertAdjacentHTML('afterbegin', columnHeaderRow);
  977. }
  978. }
  979. });
  980. });
  981. }(Highcharts));
  982. }));