KeyboardNavigation.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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 _a;
  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.isClickingChart && !focusComesFromChart) {
  139. (_a = this.modules[0]) === null || _a === void 0 ? void 0 : _a.init(1);
  140. }
  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. // If there is a nav module for the current index, run it.
  173. // Otherwise, we are outside of the chart in some direction.
  174. if (curNavModule) {
  175. var response = curNavModule.run(e);
  176. if (response === curNavModule.response.success) {
  177. preventDefault = true;
  178. }
  179. else if (response === curNavModule.response.prev) {
  180. preventDefault = this.prev();
  181. }
  182. else if (response === curNavModule.response.next) {
  183. preventDefault = this.next();
  184. }
  185. if (preventDefault) {
  186. e.preventDefault();
  187. e.stopPropagation();
  188. }
  189. }
  190. },
  191. /**
  192. * Go to previous module.
  193. * @private
  194. */
  195. prev: function () {
  196. return this.move(-1);
  197. },
  198. /**
  199. * Go to next module.
  200. * @private
  201. */
  202. next: function () {
  203. return this.move(1);
  204. },
  205. /**
  206. * Move to prev/next module.
  207. * @private
  208. * @param {number} direction
  209. * Direction to move. +1 for next, -1 for prev.
  210. * @return {boolean}
  211. * True if there was a valid module in direction.
  212. */
  213. move: function (direction) {
  214. var curModule = this.modules && this.modules[this.currentModuleIx];
  215. if (curModule && curModule.terminate) {
  216. curModule.terminate(direction);
  217. }
  218. // Remove existing focus border if any
  219. if (this.chart.focusElement) {
  220. this.chart.focusElement.removeFocusBorder();
  221. }
  222. this.currentModuleIx += direction;
  223. var newModule = this.modules && this.modules[this.currentModuleIx];
  224. if (newModule) {
  225. if (newModule.validate && !newModule.validate()) {
  226. return this.move(direction); // Invalid module, recurse
  227. }
  228. if (newModule.init) {
  229. newModule.init(direction); // Valid module, init it
  230. return true;
  231. }
  232. }
  233. // No module
  234. this.currentModuleIx = 0; // Reset counter
  235. // Set focus to chart or exit anchor depending on direction
  236. if (direction > 0) {
  237. this.exiting = true;
  238. this.exitAnchor.focus();
  239. }
  240. else {
  241. this.tabindexContainer.focus();
  242. }
  243. return false;
  244. },
  245. /**
  246. * We use an exit anchor to move focus out of chart whenever we want, by
  247. * setting focus to this div and not preventing the default tab action. We
  248. * also use this when users come back into the chart by tabbing back, in
  249. * order to navigate from the end of the chart.
  250. * @private
  251. */
  252. updateExitAnchor: function () {
  253. var endMarkerId = 'highcharts-end-of-chart-marker-' + this.chart.index, endMarker = getElement(endMarkerId);
  254. this.removeExitAnchor();
  255. if (endMarker) {
  256. this.makeElementAnExitAnchor(endMarker);
  257. this.exitAnchor = endMarker;
  258. }
  259. else {
  260. this.createExitAnchor();
  261. }
  262. },
  263. /**
  264. * Chart container should have tabindex if navigation is enabled.
  265. * @private
  266. */
  267. updateContainerTabindex: function () {
  268. var a11yOptions = this.chart.options.accessibility, keyboardOptions = a11yOptions && a11yOptions.keyboardNavigation, shouldHaveTabindex = !(keyboardOptions && keyboardOptions.enabled === false), chart = this.chart, container = chart.container;
  269. var tabindexContainer;
  270. if (chart.renderTo.hasAttribute('tabindex')) {
  271. container.removeAttribute('tabindex');
  272. tabindexContainer = chart.renderTo;
  273. }
  274. else {
  275. tabindexContainer = container;
  276. }
  277. this.tabindexContainer = tabindexContainer;
  278. var curTabindex = tabindexContainer.getAttribute('tabindex');
  279. if (shouldHaveTabindex && !curTabindex) {
  280. tabindexContainer.setAttribute('tabindex', '0');
  281. }
  282. else if (!shouldHaveTabindex) {
  283. chart.container.removeAttribute('tabindex');
  284. }
  285. },
  286. /**
  287. * @private
  288. */
  289. makeElementAnExitAnchor: function (el) {
  290. var chartTabindex = this.tabindexContainer.getAttribute('tabindex') || 0;
  291. el.setAttribute('class', 'highcharts-exit-anchor');
  292. el.setAttribute('tabindex', chartTabindex);
  293. el.setAttribute('aria-hidden', false);
  294. // Handle focus
  295. this.addExitAnchorEventsToEl(el);
  296. },
  297. /**
  298. * Add new exit anchor to the chart.
  299. *
  300. * @private
  301. */
  302. createExitAnchor: function () {
  303. var chart = this.chart, exitAnchor = this.exitAnchor = doc.createElement('div');
  304. chart.renderTo.appendChild(exitAnchor);
  305. this.makeElementAnExitAnchor(exitAnchor);
  306. },
  307. /**
  308. * @private
  309. */
  310. removeExitAnchor: function () {
  311. if (this.exitAnchor && this.exitAnchor.parentNode) {
  312. this.exitAnchor.parentNode
  313. .removeChild(this.exitAnchor);
  314. delete this.exitAnchor;
  315. }
  316. },
  317. /**
  318. * @private
  319. */
  320. addExitAnchorEventsToEl: function (element) {
  321. var chart = this.chart, keyboardNavigation = this;
  322. this.eventProvider.addEvent(element, 'focus', function (ev) {
  323. var e = ev || win.event, curModule, focusComesFromChart = (e.relatedTarget &&
  324. chart.container.contains(e.relatedTarget)), comingInBackwards = !(focusComesFromChart || keyboardNavigation.exiting);
  325. if (comingInBackwards) {
  326. keyboardNavigation.tabindexContainer.focus();
  327. e.preventDefault();
  328. // Move to last valid keyboard nav module
  329. // Note the we don't run it, just set the index
  330. if (keyboardNavigation.modules &&
  331. keyboardNavigation.modules.length) {
  332. keyboardNavigation.currentModuleIx =
  333. keyboardNavigation.modules.length - 1;
  334. curModule = keyboardNavigation.modules[keyboardNavigation.currentModuleIx];
  335. // Validate the module
  336. if (curModule &&
  337. curModule.validate && !curModule.validate()) {
  338. // Invalid. Try moving backwards to find next valid.
  339. keyboardNavigation.prev();
  340. }
  341. else if (curModule) {
  342. // We have a valid module, init it
  343. curModule.init(-1);
  344. }
  345. }
  346. }
  347. else {
  348. // Don't skip the next focus, we only skip once.
  349. keyboardNavigation.exiting = false;
  350. }
  351. });
  352. },
  353. /**
  354. * Remove all traces of keyboard navigation.
  355. * @private
  356. */
  357. destroy: function () {
  358. this.removeExitAnchor();
  359. this.eventProvider.removeAddedEvents();
  360. this.chart.container.removeAttribute('tabindex');
  361. }
  362. };
  363. export default KeyboardNavigation;