KeyboardNavigation.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. /* *
  2. *
  3. * (c) 2009-2021 Øystein Moseng
  4. *
  5. * Main keyboard navigation handling.
  6. *
  7. * License: www.highcharts.com/license
  8. *
  9. * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
  10. *
  11. * */
  12. 'use strict';
  13. import Chart from '../Core/Chart/Chart.js';
  14. import H from '../Core/Globals.js';
  15. var doc = H.doc, win = H.win;
  16. import U from '../Core/Utilities.js';
  17. var addEvent = U.addEvent, fireEvent = U.fireEvent;
  18. import HTMLUtilities from './Utils/HTMLUtilities.js';
  19. var getElement = HTMLUtilities.getElement;
  20. import EventProvider from './Utils/EventProvider.js';
  21. /* eslint-disable valid-jsdoc */
  22. // Add event listener to document to detect ESC key press and dismiss
  23. // hover/popup content.
  24. addEvent(doc, 'keydown', function (e) {
  25. var keycode = e.which || e.keyCode;
  26. var esc = 27;
  27. if (keycode === esc && H.charts) {
  28. H.charts.forEach(function (chart) {
  29. if (chart && chart.dismissPopupContent) {
  30. chart.dismissPopupContent();
  31. }
  32. });
  33. }
  34. });
  35. /**
  36. * Dismiss popup content in chart, including export menu and tooltip.
  37. */
  38. Chart.prototype.dismissPopupContent = function () {
  39. var chart = this;
  40. fireEvent(this, 'dismissPopupContent', {}, function () {
  41. if (chart.tooltip) {
  42. chart.tooltip.hide(0);
  43. }
  44. chart.hideExportMenu();
  45. });
  46. };
  47. /**
  48. * The KeyboardNavigation class, containing the overall keyboard navigation
  49. * logic for the chart.
  50. *
  51. * @requires module:modules/accessibility
  52. *
  53. * @private
  54. * @class
  55. * @param {Highcharts.Chart} chart
  56. * Chart object
  57. * @param {object} components
  58. * Map of component names to AccessibilityComponent objects.
  59. * @name Highcharts.KeyboardNavigation
  60. */
  61. function KeyboardNavigation(chart, components) {
  62. this.init(chart, components);
  63. }
  64. KeyboardNavigation.prototype = {
  65. /**
  66. * Initialize the class
  67. * @private
  68. * @param {Highcharts.Chart} chart
  69. * Chart object
  70. * @param {object} components
  71. * Map of component names to AccessibilityComponent objects.
  72. */
  73. init: function (chart, components) {
  74. var _this = this;
  75. var ep = this.eventProvider = new EventProvider();
  76. this.chart = chart;
  77. this.components = components;
  78. this.modules = [];
  79. this.currentModuleIx = 0;
  80. // Run an update to get all modules
  81. this.update();
  82. ep.addEvent(this.tabindexContainer, 'keydown', function (e) { return _this.onKeydown(e); });
  83. ep.addEvent(this.tabindexContainer, 'focus', function (e) { return _this.onFocus(e); });
  84. ['mouseup', 'touchend'].forEach(function (eventName) {
  85. return ep.addEvent(doc, eventName, function () { return _this.onMouseUp(); });
  86. });
  87. ['mousedown', 'touchstart'].forEach(function (eventName) {
  88. return ep.addEvent(chart.renderTo, eventName, function () {
  89. _this.isClickingChart = true;
  90. });
  91. });
  92. ep.addEvent(chart.renderTo, 'mouseover', function () {
  93. _this.pointerIsOverChart = true;
  94. });
  95. ep.addEvent(chart.renderTo, 'mouseout', function () {
  96. _this.pointerIsOverChart = false;
  97. });
  98. // Init first module
  99. if (this.modules.length) {
  100. this.modules[0].init(1);
  101. }
  102. },
  103. /**
  104. * Update the modules for the keyboard navigation.
  105. * @param {Array<string>} [order]
  106. * Array specifying the tab order of the components.
  107. */
  108. update: function (order) {
  109. var a11yOptions = this.chart.options.accessibility, keyboardOptions = a11yOptions && a11yOptions.keyboardNavigation, components = this.components;
  110. this.updateContainerTabindex();
  111. if (keyboardOptions &&
  112. keyboardOptions.enabled &&
  113. order &&
  114. order.length) {
  115. // We (still) have keyboard navigation. Update module list
  116. this.modules = order.reduce(function (modules, componentName) {
  117. var navModules = components[componentName].getKeyboardNavigation();
  118. return modules.concat(navModules);
  119. }, []);
  120. this.updateExitAnchor();
  121. }
  122. else {
  123. this.modules = [];
  124. this.currentModuleIx = 0;
  125. this.removeExitAnchor();
  126. }
  127. },
  128. /**
  129. * Function to run on container focus
  130. * @private
  131. * @param {global.FocusEvent} e Browser focus event.
  132. */
  133. onFocus: function (e) {
  134. var chart = this.chart;
  135. var focusComesFromChart = (e.relatedTarget &&
  136. chart.container.contains(e.relatedTarget));
  137. // Init keyboard nav if tabbing into chart
  138. if (!this.exiting &&
  139. !this.tabbingInBackwards &&
  140. !this.isClickingChart &&
  141. !focusComesFromChart &&
  142. this.modules[0]) {
  143. this.modules[0].init(1);
  144. }
  145. this.exiting = false;
  146. },
  147. /**
  148. * Reset chart navigation state if we click outside the chart and it's
  149. * not already reset.
  150. * @private
  151. */
  152. onMouseUp: function () {
  153. delete this.isClickingChart;
  154. if (!this.keyboardReset && !this.pointerIsOverChart) {
  155. var chart = this.chart, curMod = this.modules &&
  156. this.modules[this.currentModuleIx || 0];
  157. if (curMod && curMod.terminate) {
  158. curMod.terminate();
  159. }
  160. if (chart.focusElement) {
  161. chart.focusElement.removeFocusBorder();
  162. }
  163. this.currentModuleIx = 0;
  164. this.keyboardReset = true;
  165. }
  166. },
  167. /**
  168. * Function to run on keydown
  169. * @private
  170. * @param {global.KeyboardEvent} ev Browser keydown event.
  171. */
  172. onKeydown: function (ev) {
  173. var e = ev || win.event, preventDefault, curNavModule = this.modules && this.modules.length &&
  174. this.modules[this.currentModuleIx];
  175. // Used for resetting nav state when clicking outside chart
  176. this.keyboardReset = false;
  177. // Used for sending focus out of the chart by the modules.
  178. this.exiting = false;
  179. // If there is a nav module for the current index, run it.
  180. // Otherwise, we are outside of the chart in some direction.
  181. if (curNavModule) {
  182. var response = curNavModule.run(e);
  183. if (response === curNavModule.response.success) {
  184. preventDefault = true;
  185. }
  186. else if (response === curNavModule.response.prev) {
  187. preventDefault = this.prev();
  188. }
  189. else if (response === curNavModule.response.next) {
  190. preventDefault = this.next();
  191. }
  192. if (preventDefault) {
  193. e.preventDefault();
  194. e.stopPropagation();
  195. }
  196. }
  197. },
  198. /**
  199. * Go to previous module.
  200. * @private
  201. */
  202. prev: function () {
  203. return this.move(-1);
  204. },
  205. /**
  206. * Go to next module.
  207. * @private
  208. */
  209. next: function () {
  210. return this.move(1);
  211. },
  212. /**
  213. * Move to prev/next module.
  214. * @private
  215. * @param {number} direction
  216. * Direction to move. +1 for next, -1 for prev.
  217. * @return {boolean}
  218. * True if there was a valid module in direction.
  219. */
  220. move: function (direction) {
  221. var curModule = this.modules && this.modules[this.currentModuleIx];
  222. if (curModule && curModule.terminate) {
  223. curModule.terminate(direction);
  224. }
  225. // Remove existing focus border if any
  226. if (this.chart.focusElement) {
  227. this.chart.focusElement.removeFocusBorder();
  228. }
  229. this.currentModuleIx += direction;
  230. var newModule = this.modules && this.modules[this.currentModuleIx];
  231. if (newModule) {
  232. if (newModule.validate && !newModule.validate()) {
  233. return this.move(direction); // Invalid module, recurse
  234. }
  235. if (newModule.init) {
  236. newModule.init(direction); // Valid module, init it
  237. return true;
  238. }
  239. }
  240. // No module
  241. this.currentModuleIx = 0; // Reset counter
  242. // Set focus to chart or exit anchor depending on direction
  243. this.exiting = true;
  244. if (direction > 0) {
  245. this.exitAnchor.focus();
  246. }
  247. else {
  248. this.tabindexContainer.focus();
  249. }
  250. return false;
  251. },
  252. /**
  253. * We use an exit anchor to move focus out of chart whenever we want, by
  254. * setting focus to this div and not preventing the default tab action. We
  255. * also use this when users come back into the chart by tabbing back, in
  256. * order to navigate from the end of the chart.
  257. * @private
  258. */
  259. updateExitAnchor: function () {
  260. var endMarkerId = 'highcharts-end-of-chart-marker-' + this.chart.index, endMarker = getElement(endMarkerId);
  261. this.removeExitAnchor();
  262. if (endMarker) {
  263. this.makeElementAnExitAnchor(endMarker);
  264. this.exitAnchor = endMarker;
  265. }
  266. else {
  267. this.createExitAnchor();
  268. }
  269. },
  270. /**
  271. * Chart container should have tabindex if navigation is enabled.
  272. * @private
  273. */
  274. updateContainerTabindex: function () {
  275. var a11yOptions = this.chart.options.accessibility, keyboardOptions = a11yOptions && a11yOptions.keyboardNavigation, shouldHaveTabindex = !(keyboardOptions && keyboardOptions.enabled === false), chart = this.chart, container = chart.container;
  276. var tabindexContainer;
  277. if (chart.renderTo.hasAttribute('tabindex')) {
  278. container.removeAttribute('tabindex');
  279. tabindexContainer = chart.renderTo;
  280. }
  281. else {
  282. tabindexContainer = container;
  283. }
  284. this.tabindexContainer = tabindexContainer;
  285. var curTabindex = tabindexContainer.getAttribute('tabindex');
  286. if (shouldHaveTabindex && !curTabindex) {
  287. tabindexContainer.setAttribute('tabindex', '0');
  288. }
  289. else if (!shouldHaveTabindex) {
  290. chart.container.removeAttribute('tabindex');
  291. }
  292. },
  293. /**
  294. * @private
  295. */
  296. makeElementAnExitAnchor: function (el) {
  297. var chartTabindex = this.tabindexContainer.getAttribute('tabindex') || 0;
  298. el.setAttribute('class', 'highcharts-exit-anchor');
  299. el.setAttribute('tabindex', chartTabindex);
  300. el.setAttribute('aria-hidden', false);
  301. // Handle focus
  302. this.addExitAnchorEventsToEl(el);
  303. },
  304. /**
  305. * Add new exit anchor to the chart.
  306. *
  307. * @private
  308. */
  309. createExitAnchor: function () {
  310. var chart = this.chart, exitAnchor = this.exitAnchor = doc.createElement('div');
  311. chart.renderTo.appendChild(exitAnchor);
  312. this.makeElementAnExitAnchor(exitAnchor);
  313. },
  314. /**
  315. * @private
  316. */
  317. removeExitAnchor: function () {
  318. if (this.exitAnchor && this.exitAnchor.parentNode) {
  319. this.exitAnchor.parentNode
  320. .removeChild(this.exitAnchor);
  321. delete this.exitAnchor;
  322. }
  323. },
  324. /**
  325. * @private
  326. */
  327. addExitAnchorEventsToEl: function (element) {
  328. var chart = this.chart, keyboardNavigation = this;
  329. this.eventProvider.addEvent(element, 'focus', function (ev) {
  330. var e = ev || win.event, curModule, focusComesFromChart = (e.relatedTarget &&
  331. chart.container.contains(e.relatedTarget)), comingInBackwards = !(focusComesFromChart || keyboardNavigation.exiting);
  332. if (comingInBackwards) {
  333. // Focus the container instead
  334. keyboardNavigation.tabbingInBackwards = true;
  335. keyboardNavigation.tabindexContainer.focus();
  336. delete keyboardNavigation.tabbingInBackwards;
  337. e.preventDefault();
  338. // Move to last valid keyboard nav module
  339. // Note the we don't run it, just set the index
  340. if (keyboardNavigation.modules &&
  341. keyboardNavigation.modules.length) {
  342. keyboardNavigation.currentModuleIx =
  343. keyboardNavigation.modules.length - 1;
  344. curModule = keyboardNavigation.modules[keyboardNavigation.currentModuleIx];
  345. // Validate the module
  346. if (curModule &&
  347. curModule.validate && !curModule.validate()) {
  348. // Invalid. Try moving backwards to find next valid.
  349. keyboardNavigation.prev();
  350. }
  351. else if (curModule) {
  352. // We have a valid module, init it
  353. curModule.init(-1);
  354. }
  355. }
  356. }
  357. else {
  358. // Don't skip the next focus, we only skip once.
  359. keyboardNavigation.exiting = false;
  360. }
  361. });
  362. },
  363. /**
  364. * Remove all traces of keyboard navigation.
  365. * @private
  366. */
  367. destroy: function () {
  368. this.removeExitAnchor();
  369. this.eventProvider.removeAddedEvents();
  370. this.chart.container.removeAttribute('tabindex');
  371. }
  372. };
  373. export default KeyboardNavigation;