KeyboardNavigation.js 13 KB

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