lib/amd/src/custom_interaction_events.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * This module provides a wrapper to encapsulate a lot of the common combinations of
  17. * user interaction we use in Moodle.
  18. *
  19. * @module core/custom_interaction_events
  20. * @copyright 2016 Ryan Wyllie <ryan@moodle.com>
  21. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  22. * @since 3.2
  23. */
  24. define(['jquery', 'core/key_codes'], function($, keyCodes) {
  25. // The list of events provided by this module. Namespaced to avoid clashes.
  26. var events = {
  27. activate: 'cie:activate',
  28. keyboardActivate: 'cie:keyboardactivate',
  29. escape: 'cie:escape',
  30. down: 'cie:down',
  31. up: 'cie:up',
  32. home: 'cie:home',
  33. end: 'cie:end',
  34. next: 'cie:next',
  35. previous: 'cie:previous',
  36. asterix: 'cie:asterix',
  37. scrollLock: 'cie:scrollLock',
  38. scrollTop: 'cie:scrollTop',
  39. scrollBottom: 'cie:scrollBottom',
  40. ctrlPageUp: 'cie:ctrlPageUp',
  41. ctrlPageDown: 'cie:ctrlPageDown',
  42. enter: 'cie:enter',
  43. accessibleChange: 'cie:accessibleChange',
  44. };
  45. // Static cache of jQuery events that have been handled. This should
  46. // only be populated by JavaScript generated events (which will keep it
  47. // fairly small).
  48. var triggeredEvents = {};
  49. /**
  50. * Check if the caller has asked for the given event type to be
  51. * registered.
  52. *
  53. * @method shouldAddEvent
  54. * @private
  55. * @param {string} eventType name of the event (see events above)
  56. * @param {array} include the list of events to be added
  57. * @return {bool} true if the event should be added, false otherwise.
  58. */
  59. var shouldAddEvent = function(eventType, include) {
  60. include = include || [];
  61. if (include.length && include.indexOf(eventType) !== -1) {
  62. return true;
  63. }
  64. return false;
  65. };
  66. /**
  67. * Check if any of the modifier keys have been pressed on the event.
  68. *
  69. * @method isModifierPressed
  70. * @private
  71. * @param {event} e jQuery event
  72. * @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed
  73. */
  74. var isModifierPressed = function(e) {
  75. return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
  76. };
  77. /**
  78. * Trigger the custom event for the given jQuery event.
  79. *
  80. * This function will only fire the custom event if one hasn't already been
  81. * fired for the jQuery event.
  82. *
  83. * This is to prevent multiple custom event handlers triggering multiple
  84. * custom events for a single jQuery event as it bubbles up the stack.
  85. *
  86. * @param {string} eventName The name of the custom event
  87. * @param {event} e The jQuery event
  88. * @return {void}
  89. */
  90. var triggerEvent = function(eventName, e) {
  91. var eventTypeKey = "";
  92. if (!e.hasOwnProperty('originalEvent')) {
  93. // This is a jQuery event generated from JavaScript not a browser event so
  94. // we need to build the cache key for the event.
  95. eventTypeKey = "" + eventName + e.type + e.timeStamp;
  96. if (!triggeredEvents.hasOwnProperty(eventTypeKey)) {
  97. // If we haven't seen this jQuery event before then fire a custom
  98. // event for it and remember the event for later.
  99. triggeredEvents[eventTypeKey] = true;
  100. $(e.target).trigger(eventName, [{originalEvent: e}]);
  101. }
  102. return;
  103. }
  104. eventTypeKey = "triggeredCustom_" + eventName;
  105. if (!e.originalEvent.hasOwnProperty(eventTypeKey)) {
  106. // If this is a jQuery event generated by the browser then set a
  107. // property on the original event to track that we've seen it before.
  108. // The property is set on the original event because it's the only part
  109. // of the jQuery event that is maintained through multiple event handlers.
  110. e.originalEvent[eventTypeKey] = true;
  111. $(e.target).trigger(eventName, [{originalEvent: e}]);
  112. return;
  113. }
  114. };
  115. /**
  116. * Register a keyboard event that ignores modifier keys.
  117. *
  118. * @method addKeyboardEvent
  119. * @private
  120. * @param {object} element A jQuery object of the element to bind events to
  121. * @param {string} event The custom interaction event name
  122. * @param {int} keyCode The key code.
  123. */
  124. var addKeyboardEvent = function(element, event, keyCode) {
  125. element.off('keydown.' + event).on('keydown.' + event, function(e) {
  126. if (!isModifierPressed(e)) {
  127. if (e.keyCode == keyCode) {
  128. triggerEvent(event, e);
  129. }
  130. }
  131. });
  132. };
  133. /**
  134. * Trigger the activate event on the given element if it is clicked or the enter
  135. * or space key are pressed without a modifier key.
  136. *
  137. * @method addActivateListener
  138. * @private
  139. * @param {object} element jQuery object to add event listeners to
  140. */
  141. var addActivateListener = function(element) {
  142. element.off('click.cie.activate').on('click.cie.activate', function(e) {
  143. triggerEvent(events.activate, e);
  144. });
  145. element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) {
  146. if (!isModifierPressed(e)) {
  147. if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
  148. triggerEvent(events.activate, e);
  149. }
  150. }
  151. });
  152. };
  153. /**
  154. * Trigger the keyboard activate event on the given element if the enter
  155. * or space key are pressed without a modifier key.
  156. *
  157. * @method addKeyboardActivateListener
  158. * @private
  159. * @param {object} element jQuery object to add event listeners to
  160. */
  161. var addKeyboardActivateListener = function(element) {
  162. element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) {
  163. if (!isModifierPressed(e)) {
  164. if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
  165. triggerEvent(events.keyboardActivate, e);
  166. }
  167. }
  168. });
  169. };
  170. /**
  171. * Trigger the escape event on the given element if the escape key is pressed
  172. * without a modifier key.
  173. *
  174. * @method addEscapeListener
  175. * @private
  176. * @param {object} element jQuery object to add event listeners to
  177. */
  178. var addEscapeListener = function(element) {
  179. addKeyboardEvent(element, events.escape, keyCodes.escape);
  180. };
  181. /**
  182. * Trigger the down event on the given element if the down arrow key is pressed
  183. * without a modifier key.
  184. *
  185. * @method addDownListener
  186. * @private
  187. * @param {object} element jQuery object to add event listeners to
  188. */
  189. var addDownListener = function(element) {
  190. addKeyboardEvent(element, events.down, keyCodes.arrowDown);
  191. };
  192. /**
  193. * Trigger the up event on the given element if the up arrow key is pressed
  194. * without a modifier key.
  195. *
  196. * @method addUpListener
  197. * @private
  198. * @param {object} element jQuery object to add event listeners to
  199. */
  200. var addUpListener = function(element) {
  201. addKeyboardEvent(element, events.up, keyCodes.arrowUp);
  202. };
  203. /**
  204. * Trigger the home event on the given element if the home key is pressed
  205. * without a modifier key.
  206. *
  207. * @method addHomeListener
  208. * @private
  209. * @param {object} element jQuery object to add event listeners to
  210. */
  211. var addHomeListener = function(element) {
  212. addKeyboardEvent(element, events.home, keyCodes.home);
  213. };
  214. /**
  215. * Trigger the end event on the given element if the end key is pressed
  216. * without a modifier key.
  217. *
  218. * @method addEndListener
  219. * @private
  220. * @param {object} element jQuery object to add event listeners to
  221. */
  222. var addEndListener = function(element) {
  223. addKeyboardEvent(element, events.end, keyCodes.end);
  224. };
  225. /**
  226. * Trigger the next event on the given element if the right arrow key is pressed
  227. * without a modifier key in LTR mode or left arrow key in RTL mode.
  228. *
  229. * @method addNextListener
  230. * @private
  231. * @param {object} element jQuery object to add event listeners to
  232. */
  233. var addNextListener = function(element) {
  234. // Left and right are flipped in RTL mode.
  235. var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight;
  236. addKeyboardEvent(element, events.next, keyCode);
  237. };
  238. /**
  239. * Trigger the previous event on the given element if the left arrow key is pressed
  240. * without a modifier key in LTR mode or right arrow key in RTL mode.
  241. *
  242. * @method addPreviousListener
  243. * @private
  244. * @param {object} element jQuery object to add event listeners to
  245. */
  246. var addPreviousListener = function(element) {
  247. // Left and right are flipped in RTL mode.
  248. var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft;
  249. addKeyboardEvent(element, events.previous, keyCode);
  250. };
  251. /**
  252. * Trigger the asterix event on the given element if the asterix key is pressed
  253. * without a modifier key.
  254. *
  255. * @method addAsterixListener
  256. * @private
  257. * @param {object} element jQuery object to add event listeners to
  258. */
  259. var addAsterixListener = function(element) {
  260. addKeyboardEvent(element, events.asterix, keyCodes.asterix);
  261. };
  262. /**
  263. * Trigger the scrollTop event on the given element if the user scrolls to
  264. * the top of the given element.
  265. *
  266. * @method addScrollTopListener
  267. * @private
  268. * @param {object} element jQuery object to add event listeners to
  269. */
  270. var addScrollTopListener = function(element) {
  271. element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function(e) {
  272. var scrollTop = element.scrollTop();
  273. if (scrollTop === 0) {
  274. triggerEvent(events.scrollTop, e);
  275. }
  276. });
  277. };
  278. /**
  279. * Trigger the scrollBottom event on the given element if the user scrolls to
  280. * the bottom of the given element.
  281. *
  282. * @method addScrollBottomListener
  283. * @private
  284. * @param {object} element jQuery object to add event listeners to
  285. */
  286. var addScrollBottomListener = function(element) {
  287. element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function(e) {
  288. var scrollTop = element.scrollTop();
  289. var innerHeight = element.innerHeight();
  290. var scrollHeight = element[0].scrollHeight;
  291. if (scrollTop + innerHeight >= scrollHeight) {
  292. triggerEvent(events.scrollBottom, e);
  293. }
  294. });
  295. };
  296. /**
  297. * Trigger the scrollLock event on the given element if the user scrolls to
  298. * the bottom or top of the given element.
  299. *
  300. * @method addScrollLockListener
  301. * @private
  302. * @param {jQuery} element jQuery object to add event listeners to
  303. */
  304. var addScrollLockListener = function(element) {
  305. // Lock mousewheel scrolling within the element to stop the annoying window scroll.
  306. element.off('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock')
  307. .on('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock', function(e) {
  308. var scrollTop = element.scrollTop();
  309. var scrollHeight = element[0].scrollHeight;
  310. var height = element.height();
  311. var delta = (e.type == 'DOMMouseScroll' ?
  312. e.originalEvent.detail * -40 :
  313. e.originalEvent.wheelDelta);
  314. var up = delta > 0;
  315. if (!up && -delta > scrollHeight - height - scrollTop) {
  316. // Scrolling down past the bottom.
  317. element.scrollTop(scrollHeight);
  318. e.stopPropagation();
  319. e.preventDefault();
  320. e.returnValue = false;
  321. // Fire the scroll lock event.
  322. triggerEvent(events.scrollLock, e);
  323. return false;
  324. } else if (up && delta > scrollTop) {
  325. // Scrolling up past the top.
  326. element.scrollTop(0);
  327. e.stopPropagation();
  328. e.preventDefault();
  329. e.returnValue = false;
  330. // Fire the scroll lock event.
  331. triggerEvent(events.scrollLock, e);
  332. return false;
  333. }
  334. return true;
  335. });
  336. };
  337. /**
  338. * Trigger the ctrlPageUp event on the given element if the user presses the
  339. * control and page up key.
  340. *
  341. * @method addCtrlPageUpListener
  342. * @private
  343. * @param {object} element jQuery object to add event listeners to
  344. */
  345. var addCtrlPageUpListener = function(element) {
  346. element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) {
  347. if (e.ctrlKey) {
  348. if (e.keyCode == keyCodes.pageUp) {
  349. triggerEvent(events.ctrlPageUp, e);
  350. }
  351. }
  352. });
  353. };
  354. /**
  355. * Trigger the ctrlPageDown event on the given element if the user presses the
  356. * control and page down key.
  357. *
  358. * @method addCtrlPageDownListener
  359. * @private
  360. * @param {object} element jQuery object to add event listeners to
  361. */
  362. var addCtrlPageDownListener = function(element) {
  363. element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) {
  364. if (e.ctrlKey) {
  365. if (e.keyCode == keyCodes.pageDown) {
  366. triggerEvent(events.ctrlPageDown, e);
  367. }
  368. }
  369. });
  370. };
  371. /**
  372. * Trigger the enter event on the given element if the enter key is pressed
  373. * without a modifier key.
  374. *
  375. * @method addEnterListener
  376. * @private
  377. * @param {object} element jQuery object to add event listeners to
  378. */
  379. var addEnterListener = function(element) {
  380. addKeyboardEvent(element, events.enter, keyCodes.enter);
  381. };
  382. /**
  383. * Trigger the AccessibleChange event on the given element if the value of the element is changed.
  384. *
  385. * @method addAccessibleChangeListener
  386. * @private
  387. * @param {object} element jQuery object to add event listeners to
  388. */
  389. var addAccessibleChangeListener = function(element) {
  390. var onMac = navigator.userAgent.indexOf('Macintosh') !== -1;
  391. var touchEnabled = ('ontouchstart' in window) || (('msMaxTouchPoints' in navigator) && (navigator.msMaxTouchPoints > 0));
  392. if (onMac || touchEnabled) {
  393. // On Mac devices, and touch-enabled devices, the change event seems to be handled correctly and
  394. // consistently at this time.
  395. element.on('change', function(e) {
  396. triggerEvent(events.accessibleChange, e);
  397. });
  398. } else {
  399. // Some browsers have non-normalised behaviour for handling the selection of values in a <select> element.
  400. // When using Chrome on Linux (and possibly others), a 'change' event is fired when pressing the Escape key.
  401. // When using Firefox on Linux (and possibly others), a 'change' event is fired when navigating through the
  402. // list with a keyboard.
  403. //
  404. // To normalise these behaviours:
  405. // - the initial value is stored in a data attribute when focusing the element
  406. // - the current value is checked against the stored initial value when and the accessibleChange event fired when:
  407. // --- blurring the element
  408. // --- the 'Enter' key is pressed
  409. // --- the element is clicked
  410. // --- the 'change' event is fired, except where it is from a keyboard interaction
  411. //
  412. // To facilitate the change event keyboard interaction check, the 'keyDown' handler sets a flag to ignore
  413. // the change event handler which is unset on the 'keyUp' event.
  414. //
  415. // Unfortunately we cannot control this entirely as some browsers (Chrome) trigger a change event when
  416. // pressign the Escape key, and this is considered to be the correct behaviour.
  417. // Chrome https://bugs.chromium.org/p/chromium/issues/detail?id=839717
  418. //
  419. // Our longer-term solution to this should be to switch away from using <select> boxes as a single-select,
  420. // and make use of a dropdown of action links like the Bootstrap Dropdown menu.
  421. var setInitialValue = function(target) {
  422. target.dataset.initValue = target.value;
  423. };
  424. var resetToInitialValue = function(target) {
  425. if ('initValue' in target.dataset) {
  426. target.value = target.dataset.initValue;
  427. }
  428. };
  429. var checkAndTriggerAccessibleChange = function(e) {
  430. if (!('initValue' in e.target.dataset)) {
  431. // Some browsers trigger click before focus, therefore it is possible that initValue is undefined.
  432. // In this case it's likely that it's being focused for the first time and we should therefore not submit.
  433. return;
  434. }
  435. if (e.target.value !== e.target.dataset.initValue) {
  436. // Update the initValue when the event is triggered.
  437. // This means that if the click handler fires before the focus handler on a subsequent interaction
  438. // with the element, the currently dispalyed value will be the best guess current value.
  439. e.target.dataset.initValue = e.target.value;
  440. triggerEvent(events.accessibleChange, e);
  441. }
  442. };
  443. var nativeElement = element.get()[0];
  444. // The `focus` and `blur` events do not support bubbling. Use Event Capture instead.
  445. nativeElement.addEventListener('focus', function(e) {
  446. setInitialValue(e.target);
  447. }, true);
  448. nativeElement.addEventListener('blur', function(e) {
  449. checkAndTriggerAccessibleChange(e);
  450. }, true);
  451. element.on('keydown', function(e) {
  452. if ((e.which === keyCodes.enter)) {
  453. checkAndTriggerAccessibleChange(e);
  454. } else if (e.which === keyCodes.escape) {
  455. resetToInitialValue(e.target);
  456. e.target.dataset.ignoreChange = true;
  457. } else {
  458. // Firefox triggers a change event when using the keyboard to scroll through the selection.
  459. // Set a data- attribute that the change listener can use to ignore the change event where it was
  460. // generated from a keyboard change such as typing to complete a value, or using arrow keys.
  461. e.target.dataset.ignoreChange = true;
  462. }
  463. });
  464. element.on('change', function(e) {
  465. if (e.target.dataset.ignoreChange) {
  466. // This change event was triggered from a keyboard change which is not yet complete.
  467. // Do not trigger the accessibleChange event until the selection is completed using the [return]
  468. // key.
  469. return;
  470. }
  471. checkAndTriggerAccessibleChange(e);
  472. });
  473. element.on('keyup', function(e) {
  474. // The key has been lifted. Stop ignoring the change event.
  475. delete e.target.dataset.ignoreChange;
  476. });
  477. element.on('click', function(e) {
  478. checkAndTriggerAccessibleChange(e);
  479. });
  480. }
  481. };
  482. /**
  483. * Get the list of events and their handlers.
  484. *
  485. * @method getHandlers
  486. * @private
  487. * @return {object} object key of event names and value of handler functions
  488. */
  489. var getHandlers = function() {
  490. var handlers = {};
  491. handlers[events.activate] = addActivateListener;
  492. handlers[events.keyboardActivate] = addKeyboardActivateListener;
  493. handlers[events.escape] = addEscapeListener;
  494. handlers[events.down] = addDownListener;
  495. handlers[events.up] = addUpListener;
  496. handlers[events.home] = addHomeListener;
  497. handlers[events.end] = addEndListener;
  498. handlers[events.next] = addNextListener;
  499. handlers[events.previous] = addPreviousListener;
  500. handlers[events.asterix] = addAsterixListener;
  501. handlers[events.scrollLock] = addScrollLockListener;
  502. handlers[events.scrollTop] = addScrollTopListener;
  503. handlers[events.scrollBottom] = addScrollBottomListener;
  504. handlers[events.ctrlPageUp] = addCtrlPageUpListener;
  505. handlers[events.ctrlPageDown] = addCtrlPageDownListener;
  506. handlers[events.enter] = addEnterListener;
  507. handlers[events.accessibleChange] = addAccessibleChangeListener;
  508. return handlers;
  509. };
  510. /**
  511. * Add all of the listeners on the given element for the requested events.
  512. *
  513. * @method define
  514. * @public
  515. * @param {object} element the DOM element to register event listeners on
  516. * @param {array} include the array of events to be triggered
  517. */
  518. var define = function(element, include) {
  519. element = $(element);
  520. include = include || [];
  521. if (!element.length || !include.length) {
  522. return;
  523. }
  524. $.each(getHandlers(), function(eventType, handler) {
  525. if (shouldAddEvent(eventType, include)) {
  526. handler(element);
  527. }
  528. });
  529. };
  530. return {
  531. define: define,
  532. events: events,
  533. };
  534. });