c429e9263416b27bb3baddbf4ea607b41de63599
[moodle.git] / 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/>.
16 /**
17  * This module provides a wrapper to encapsulate a lot of the common combinations of
18  * user interaction we use in Moodle.
19  *
20  * @module     core/custom_interaction_events
21  * @class      custom_interaction_events
22  * @package    core
23  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  * @since      3.2
26  */
27 define(['jquery', 'core/key_codes'], function($, keyCodes) {
28     // The list of events provided by this module. Namespaced to avoid clashes.
29     var events = {
30         activate: 'cie:activate',
31         keyboardActivate: 'cie:keyboardactivate',
32         escape: 'cie:escape',
33         down: 'cie:down',
34         up: 'cie:up',
35         home: 'cie:home',
36         end: 'cie:end',
37         next: 'cie:next',
38         previous: 'cie:previous',
39         asterix: 'cie:asterix',
40         scrollLock: 'cie:scrollLock',
41         scrollTop: 'cie:scrollTop',
42         scrollBottom: 'cie:scrollBottom',
43         ctrlPageUp: 'cie:ctrlPageUp',
44         ctrlPageDown: 'cie:ctrlPageDown',
45         enter: 'cie:enter',
46         accessibleChange: 'cie:accessibleChange',
47     };
48     // Static cache of jQuery events that have been handled. This should
49     // only be populated by JavaScript generated events (which will keep it
50     // fairly small).
51     var triggeredEvents = {};
53     /**
54      * Check if the caller has asked for the given event type to be
55      * registered.
56      *
57      * @method shouldAddEvent
58      * @private
59      * @param {string} eventType name of the event (see events above)
60      * @param {array} include the list of events to be added
61      * @return {bool} true if the event should be added, false otherwise.
62      */
63     var shouldAddEvent = function(eventType, include) {
64         include = include || [];
66         if (include.length && include.indexOf(eventType) !== -1) {
67             return true;
68         }
70         return false;
71     };
73     /**
74      * Check if any of the modifier keys have been pressed on the event.
75      *
76      * @method isModifierPressed
77      * @private
78      * @param {event} e jQuery event
79      * @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed
80      */
81     var isModifierPressed = function(e) {
82         return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
83     };
85     /**
86      * Trigger the custom event for the given jQuery event.
87      *
88      * This function will only fire the custom event if one hasn't already been
89      * fired for the jQuery event.
90      *
91      * This is to prevent multiple custom event handlers triggering multiple
92      * custom events for a single jQuery event as it bubbles up the stack.
93      *
94      * @param  {string} eventName The name of the custom event
95      * @param  {event} e          The jQuery event
96      * @return {void}
97      */
98     var triggerEvent = function(eventName, e) {
99         var eventTypeKey = "";
101         if (!e.hasOwnProperty('originalEvent')) {
102             // This is a jQuery event generated from JavaScript not a browser event so
103             // we need to build the cache key for the event.
104             eventTypeKey = "" + eventName + e.type + e.timeStamp;
106             if (!triggeredEvents.hasOwnProperty(eventTypeKey)) {
107                 // If we haven't seen this jQuery event before then fire a custom
108                 // event for it and remember the event for later.
109                 triggeredEvents[eventTypeKey] = true;
110                 $(e.target).trigger(eventName, [{originalEvent: e}]);
111             }
112             return;
113         }
115         eventTypeKey = "triggeredCustom_" + eventName;
116         if (!e.originalEvent.hasOwnProperty(eventTypeKey)) {
117             // If this is a jQuery event generated by the browser then set a
118             // property on the original event to track that we've seen it before.
119             // The property is set on the original event because it's the only part
120             // of the jQuery event that is maintained through multiple event handlers.
121             e.originalEvent[eventTypeKey] = true;
122             $(e.target).trigger(eventName, [{originalEvent: e}]);
123             return;
124         }
125     };
127     /**
128      * Register a keyboard event that ignores modifier keys.
129      *
130      * @method addKeyboardEvent
131      * @private
132      * @param {object} element A jQuery object of the element to bind events to
133      * @param {string} event The custom interaction event name
134      * @param {int} keyCode The key code.
135      */
136     var addKeyboardEvent = function(element, event, keyCode) {
137         element.off('keydown.' + event).on('keydown.' + event, function(e) {
138             if (!isModifierPressed(e)) {
139                 if (e.keyCode == keyCode) {
140                     triggerEvent(event, e);
141                 }
142             }
143         });
144     };
146     /**
147      * Trigger the activate event on the given element if it is clicked or the enter
148      * or space key are pressed without a modifier key.
149      *
150      * @method addActivateListener
151      * @private
152      * @param {object} element jQuery object to add event listeners to
153      */
154     var addActivateListener = function(element) {
155         element.off('click.cie.activate').on('click.cie.activate', function(e) {
156             triggerEvent(events.activate, e);
157         });
158         element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) {
159             if (!isModifierPressed(e)) {
160                 if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
161                     triggerEvent(events.activate, e);
162                 }
163             }
164         });
165     };
167     /**
168      * Trigger the keyboard activate event on the given element if the enter
169      * or space key are pressed without a modifier key.
170      *
171      * @method addKeyboardActivateListener
172      * @private
173      * @param {object} element jQuery object to add event listeners to
174      */
175     var addKeyboardActivateListener = function(element) {
176         element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) {
177             if (!isModifierPressed(e)) {
178                 if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
179                     triggerEvent(events.keyboardActivate, e);
180                 }
181             }
182         });
183     };
185     /**
186      * Trigger the escape event on the given element if the escape key is pressed
187      * without a modifier key.
188      *
189      * @method addEscapeListener
190      * @private
191      * @param {object} element jQuery object to add event listeners to
192      */
193     var addEscapeListener = function(element) {
194         addKeyboardEvent(element, events.escape, keyCodes.escape);
195     };
197     /**
198      * Trigger the down event on the given element if the down arrow key is pressed
199      * without a modifier key.
200      *
201      * @method addDownListener
202      * @private
203      * @param {object} element jQuery object to add event listeners to
204      */
205     var addDownListener = function(element) {
206         addKeyboardEvent(element, events.down, keyCodes.arrowDown);
207     };
209     /**
210      * Trigger the up event on the given element if the up arrow key is pressed
211      * without a modifier key.
212      *
213      * @method addUpListener
214      * @private
215      * @param {object} element jQuery object to add event listeners to
216      */
217     var addUpListener = function(element) {
218         addKeyboardEvent(element, events.up, keyCodes.arrowUp);
219     };
221     /**
222      * Trigger the home event on the given element if the home key is pressed
223      * without a modifier key.
224      *
225      * @method addHomeListener
226      * @private
227      * @param {object} element jQuery object to add event listeners to
228      */
229     var addHomeListener = function(element) {
230         addKeyboardEvent(element, events.home, keyCodes.home);
231     };
233     /**
234      * Trigger the end event on the given element if the end key is pressed
235      * without a modifier key.
236      *
237      * @method addEndListener
238      * @private
239      * @param {object} element jQuery object to add event listeners to
240      */
241     var addEndListener = function(element) {
242         addKeyboardEvent(element, events.end, keyCodes.end);
243     };
245     /**
246      * Trigger the next event on the given element if the right arrow key is pressed
247      * without a modifier key in LTR mode or left arrow key in RTL mode.
248      *
249      * @method addNextListener
250      * @private
251      * @param {object} element jQuery object to add event listeners to
252      */
253     var addNextListener = function(element) {
254         // Left and right are flipped in RTL mode.
255         var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight;
257         addKeyboardEvent(element, events.next, keyCode);
258     };
260     /**
261      * Trigger the previous event on the given element if the left arrow key is pressed
262      * without a modifier key in LTR mode or right arrow key in RTL mode.
263      *
264      * @method addPreviousListener
265      * @private
266      * @param {object} element jQuery object to add event listeners to
267      */
268     var addPreviousListener = function(element) {
269         // Left and right are flipped in RTL mode.
270         var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft;
272         addKeyboardEvent(element, events.previous, keyCode);
273     };
275     /**
276      * Trigger the asterix event on the given element if the asterix key is pressed
277      * without a modifier key.
278      *
279      * @method addAsterixListener
280      * @private
281      * @param {object} element jQuery object to add event listeners to
282      */
283     var addAsterixListener = function(element) {
284         addKeyboardEvent(element, events.asterix, keyCodes.asterix);
285     };
288     /**
289      * Trigger the scrollTop event on the given element if the user scrolls to
290      * the top of the given element.
291      *
292      * @method addScrollTopListener
293      * @private
294      * @param {object} element jQuery object to add event listeners to
295      */
296     var addScrollTopListener = function(element) {
297         element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function(e) {
298             var scrollTop = element.scrollTop();
299             if (scrollTop === 0) {
300                 triggerEvent(events.scrollTop, e);
301             }
302         });
303     };
305     /**
306      * Trigger the scrollBottom event on the given element if the user scrolls to
307      * the bottom of the given element.
308      *
309      * @method addScrollBottomListener
310      * @private
311      * @param {object} element jQuery object to add event listeners to
312      */
313     var addScrollBottomListener = function(element) {
314         element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function(e) {
315             var scrollTop = element.scrollTop();
316             var innerHeight = element.innerHeight();
317             var scrollHeight = element[0].scrollHeight;
319             if (scrollTop + innerHeight >= scrollHeight) {
320                 triggerEvent(events.scrollBottom, e);
321             }
322         });
323     };
325     /**
326      * Trigger the scrollLock event on the given element if the user scrolls to
327      * the bottom or top of the given element.
328      *
329      * @method addScrollLockListener
330      * @private
331      * @param {jQuery} element jQuery object to add event listeners to
332      */
333     var addScrollLockListener = function(element) {
334         // Lock mousewheel scrolling within the element to stop the annoying window scroll.
335         element.off('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock')
336             .on('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock', function(e) {
337                 var scrollTop = element.scrollTop();
338                 var scrollHeight = element[0].scrollHeight;
339                 var height = element.height();
340                 var delta = (e.type == 'DOMMouseScroll' ?
341                     e.originalEvent.detail * -40 :
342                     e.originalEvent.wheelDelta);
343                 var up = delta > 0;
345                 if (!up && -delta > scrollHeight - height - scrollTop) {
346                     // Scrolling down past the bottom.
347                     element.scrollTop(scrollHeight);
348                     e.stopPropagation();
349                     e.preventDefault();
350                     e.returnValue = false;
351                     // Fire the scroll lock event.
352                     triggerEvent(events.scrollLock, e);
354                     return false;
355                 } else if (up && delta > scrollTop) {
356                     // Scrolling up past the top.
357                     element.scrollTop(0);
358                     e.stopPropagation();
359                     e.preventDefault();
360                     e.returnValue = false;
361                     // Fire the scroll lock event.
362                     triggerEvent(events.scrollLock, e);
364                     return false;
365                 }
367                 return true;
368             });
369     };
371     /**
372      * Trigger the ctrlPageUp event on the given element if the user presses the
373      * control and page up key.
374      *
375      * @method addCtrlPageUpListener
376      * @private
377      * @param {object} element jQuery object to add event listeners to
378      */
379     var addCtrlPageUpListener = function(element) {
380         element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) {
381             if (e.ctrlKey) {
382                 if (e.keyCode == keyCodes.pageUp) {
383                     triggerEvent(events.ctrlPageUp, e);
384                 }
385             }
386         });
387     };
389     /**
390      * Trigger the ctrlPageDown event on the given element if the user presses the
391      * control and page down key.
392      *
393      * @method addCtrlPageDownListener
394      * @private
395      * @param {object} element jQuery object to add event listeners to
396      */
397     var addCtrlPageDownListener = function(element) {
398         element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) {
399             if (e.ctrlKey) {
400                 if (e.keyCode == keyCodes.pageDown) {
401                     triggerEvent(events.ctrlPageDown, e);
402                 }
403             }
404         });
405     };
407     /**
408      * Trigger the enter event on the given element if the enter key is pressed
409      * without a modifier key.
410      *
411      * @method addEnterListener
412      * @private
413      * @param {object} element jQuery object to add event listeners to
414      */
415     var addEnterListener = function(element) {
416         addKeyboardEvent(element, events.enter, keyCodes.enter);
417     };
419     /**
420      * Trigger the AccessibleChange event on the given element if the value of the element is changed.
421      *
422      * @method addAccessibleChangeListener
423      * @private
424      * @param {object} element jQuery object to add event listeners to
425      */
426     var addAccessibleChangeListener = function(element) {
427         var onMac = navigator.userAgent.indexOf('Macintosh') !== -1;
428         var touchEnabled = ('ontouchstart' in window) || (('msMaxTouchPoints' in navigator) && (navigator.msMaxTouchPoints > 0));
429         if (onMac || touchEnabled) {
430             element.on('change', function(e) {
431                 triggerEvent(events.accessibleChange, e);
432             });
433         } else {
434             var nativeElement = element.get()[0];
435             // The `focus` and `blur` events do not support bubbling. Use Event Capture instead.
436             nativeElement.addEventListener('focus', function(e) {
437                 $(e.target).data('initValue', e.target.value);
438             }, true);
439             nativeElement.addEventListener('blur', function(e) {
440                 var initValue = $(e.target).data('initValue');
441                 $(e.target).removeData('initValue');
442                 if (e.target.value !== initValue) {
443                     triggerEvent(events.accessibleChange, e);
444                 }
445             }, true);
446             element.on('keydown', function(e) {
447                 if ((e.which === keyCodes.enter) && e.target.value !== $(e.target).data('initValue')) {
448                     triggerEvent(events.accessibleChange, e);
449                 } else if (e.which === keyCodes.escape) {
450                     e.target.value = $(e.target).data('initValue');
451                 }
452             });
453             element.on('click', function(e) {
454                 var initValue = $(e.target).data('initValue');
455                 // Some browsers trigger onclick before onblur, therefore it is possible that initValue is undefined.
456                 if (typeof initValue !== 'undefined' && initValue != e.target.value) {
457                     triggerEvent(events.accessibleChange, e);
458                 }
459             });
460         }
461     };
463     /**
464      * Get the list of events and their handlers.
465      *
466      * @method getHandlers
467      * @private
468      * @return {object} object key of event names and value of handler functions
469      */
470     var getHandlers = function() {
471         var handlers = {};
473         handlers[events.activate] = addActivateListener;
474         handlers[events.keyboardActivate] = addKeyboardActivateListener;
475         handlers[events.escape] = addEscapeListener;
476         handlers[events.down] = addDownListener;
477         handlers[events.up] = addUpListener;
478         handlers[events.home] = addHomeListener;
479         handlers[events.end] = addEndListener;
480         handlers[events.next] = addNextListener;
481         handlers[events.previous] = addPreviousListener;
482         handlers[events.asterix] = addAsterixListener;
483         handlers[events.scrollLock] = addScrollLockListener;
484         handlers[events.scrollTop] = addScrollTopListener;
485         handlers[events.scrollBottom] = addScrollBottomListener;
486         handlers[events.ctrlPageUp] = addCtrlPageUpListener;
487         handlers[events.ctrlPageDown] = addCtrlPageDownListener;
488         handlers[events.enter] = addEnterListener;
489         handlers[events.accessibleChange] = addAccessibleChangeListener;
491         return handlers;
492     };
494     /**
495      * Add all of the listeners on the given element for the requested events.
496      *
497      * @method define
498      * @public
499      * @param {object} element the DOM element to register event listeners on
500      * @param {array} include the array of events to be triggered
501      */
502     var define = function(element, include) {
503         element = $(element);
504         include = include || [];
506         if (!element.length || !include.length) {
507             return;
508         }
510         $.each(getHandlers(), function(eventType, handler) {
511             if (shouldAddEvent(eventType, include)) {
512                 handler(element);
513             }
514         });
515     };
517     return /** @module core/custom_interaction_events */ {
518         define: define,
519         events: events,
520     };
521 });