Merge branch 'MDL-59674-master-fix' of https://github.com/lameze/moodle
[moodle.git] / lib / amd / src / modal.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  * Contain the logic for modals.
18  *
19  * @module     core/modal
20  * @class      modal
21  * @package    core
22  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
25 define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
26         'core/custom_interaction_events', 'core/modal_backdrop', 'core/event', 'core/modal_events'],
27      function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents) {
29     var SELECTORS = {
30         CONTAINER: '[data-region="modal-container"]',
31         MODAL: '[data-region="modal"]',
32         HEADER: '[data-region="header"]',
33         TITLE: '[data-region="title"]',
34         BODY: '[data-region="body"]',
35         FOOTER: '[data-region="footer"]',
36         HIDE: '[data-action="hide"]',
37         DIALOG: '[role=dialog]',
38         MENU_BAR: '[role=menubar]',
39         HAS_Z_INDEX: '.moodle-has-zindex',
40         CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
41     };
43     var TEMPLATES = {
44         LOADING: 'core/loading',
45         BACKDROP: 'core/modal_backdrop',
46     };
48     /**
49      * Module singleton for the backdrop to be reused by all Modal instances.
50      */
51     var backdropPromise;
53     /**
54      * Constructor for the Modal.
55      *
56      * @param {object} root The root jQuery element for the modal
57      */
58     var Modal = function(root) {
59         this.root = $(root);
60         this.modal = this.root.find(SELECTORS.MODAL);
61         this.header = this.modal.find(SELECTORS.HEADER);
62         this.title = this.header.find(SELECTORS.TITLE);
63         this.body = this.modal.find(SELECTORS.BODY);
64         this.footer = this.modal.find(SELECTORS.FOOTER);
65         this.hiddenSiblings = [];
66         this.isAttached = false;
67         this.bodyJS = null;
68         this.footerJS = null;
70         if (!this.root.is(SELECTORS.CONTAINER)) {
71             Notification.exception({message: 'Element is not a modal container'});
72         }
74         if (!this.modal.length) {
75             Notification.exception({message: 'Container does not contain a modal'});
76         }
78         if (!this.header.length) {
79             Notification.exception({message: 'Modal is missing a header region'});
80         }
82         if (!this.title.length) {
83             Notification.exception({message: 'Modal header is missing a title region'});
84         }
86         if (!this.body.length) {
87             Notification.exception({message: 'Modal is missing a body region'});
88         }
90         if (!this.footer.length) {
91             Notification.exception({message: 'Modal is missing a footer region'});
92         }
94         this.registerEventListeners();
95     };
97     /**
98      * Add the modal to the page, if it hasn't already been added. This includes running any
99      * javascript that has been cached until now.
100      *
101      * @method attachToDOM
102      */
103     Modal.prototype.attachToDOM = function() {
104         if (this.isAttached) {
105             return;
106         }
108         $('body').append(this.root);
110         // If we'd cached any JS then we can run it how that the modal is
111         // attached to the DOM.
112         if (this.bodyJS) {
113             Templates.runTemplateJS(this.bodyJS);
114             this.bodyJS = null;
115         }
117         if (this.footerJS) {
118             Templates.runTemplateJS(this.footerJS);
119             this.footerJS = null;
120         }
122         this.isAttached = true;
123     };
125     /**
126      * Count the number of other visible modals (not including this one).
127      *
128      * @method countOtherVisibleModals
129      * @return {int}
130      */
131     Modal.prototype.countOtherVisibleModals = function() {
132         var count = 0;
133         $('body').find(SELECTORS.CONTAINER).each(function(index, element) {
134             element = $(element);
136             // If we haven't found ourself and the element is visible.
137             if (!this.root.is(element) && element.hasClass('show')) {
138                 count++;
139             }
140         }.bind(this));
142         return count;
143     };
145     /**
146      * Get the modal backdrop.
147      *
148      * @method getBackdrop
149      * @return {object} jQuery promise
150      */
151     Modal.prototype.getBackdrop = function() {
152         if (!backdropPromise) {
153             backdropPromise = Templates.render(TEMPLATES.BACKDROP, {})
154                 .then(function(html) {
155                     var element = $(html);
157                     return new ModalBackdrop(element);
158                 })
159                 .fail(Notification.exception);
160         }
162         return backdropPromise;
163     };
165     /**
166      * Get the root element of this modal.
167      *
168      * @method getRoot
169      * @return {object} jQuery object
170      */
171     Modal.prototype.getRoot = function() {
172         return this.root;
173     };
175     /**
176      * Get the modal element of this modal.
177      *
178      * @method getModal
179      * @return {object} jQuery object
180      */
181     Modal.prototype.getModal = function() {
182         return this.modal;
183     };
185     /**
186      * Get the modal title element.
187      *
188      * @method getTitle
189      * @return {object} jQuery object
190      */
191     Modal.prototype.getTitle = function() {
192         return this.title;
193     };
195     /**
196      * Get the modal body element.
197      *
198      * @method getBody
199      * @return {object} jQuery object
200      */
201     Modal.prototype.getBody = function() {
202         return this.body;
203     };
205     /**
206      * Get the modal footer element.
207      *
208      * @method getFooter
209      * @return {object} jQuery object
210      */
211     Modal.prototype.getFooter = function() {
212         return this.footer;
213     };
215     /**
216      * Set the modal title element.
217      *
218      * This method is overloaded to take either a string value for the title or a jQuery promise that is resolved with
219      * HTML most commonly from a Str.get_string call.
220      *
221      * @method setTitle
222      * @param {(string|object)} value The title string or jQuery promise which resolves to the title.
223      */
224     Modal.prototype.setTitle = function(value) {
225         var title = this.getTitle();
227         this.asyncSet(value, title.html.bind(title));
228     };
230     /**
231      * Set the modal body element.
232      *
233      * This method is overloaded to take either a string value for the body or a jQuery promise that is resolved with
234      * HTML and Javascript most commonly from a Templates.render call.
235      *
236      * @method setBody
237      * @param {(string|object)} value The body string or jQuery promise which resolves to the body.
238      */
239     Modal.prototype.setBody = function(value) {
240         var body = this.getBody();
242         if (typeof value === 'string') {
243             // Just set the value if it's a string.
244             body.html(value);
245             Event.notifyFilterContentUpdated(body);
246             this.getRoot().trigger(ModalEvents.bodyRendered, this);
247         } else {
248             // Otherwise we assume it's a promise to be resolved with
249             // html and javascript.
250             Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
251                 body.html(html);
253                 value.done(function(html, js) {
254                     body.html(html);
256                     if (js) {
257                         if (this.isAttached) {
258                             // If we're in the DOM then run the JS immediately.
259                             Templates.runTemplateJS(js);
260                         } else {
261                             // Otherwise cache it to be run when we're attached.
262                             this.bodyJS = js;
263                         }
264                     }
265                     Event.notifyFilterContentUpdated(body);
266                     this.getRoot().trigger(ModalEvents.bodyRendered, this);
267                 }.bind(this));
268             }.bind(this));
269         }
270     };
272     /**
273      * Set the modal footer element.
274      *
275      * This method is overloaded to take either a string
276      * value for the body or a jQuery promise that is resolved with HTML and Javascript
277      * most commonly from a Templates.render call.
278      *
279      * @method setFooter
280      * @param {(string|object)} value The footer string or jQuery promise
281      */
282     Modal.prototype.setFooter = function(value) {
283         var footer = this.getFooter();
285         if (typeof value === 'string') {
286             // Just set the value if it's a string.
287             footer.html(value);
288         } else {
289             // Otherwise we assume it's a promise to be resolved with
290             // html and javascript.
291             Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
292                 footer.html(html);
294                 value.done(function(html, js) {
295                     footer.html(html);
297                     if (js) {
298                         if (this.isAttached) {
299                             // If we're in the DOM then run the JS immediately.
300                             Templates.runTemplateJS(js);
301                         } else {
302                             // Otherwise cache it to be run when we're attached.
303                             this.footerJS = js;
304                         }
305                     }
306                 }.bind(this));
307             }.bind(this));
308         }
309     };
311     /**
312      * Mark the modal as a large modal.
313      *
314      * @method setLarge
315      */
316     Modal.prototype.setLarge = function() {
317         if (this.isLarge()) {
318             return;
319         }
321         this.getModal().addClass('modal-lg');
322     };
324     /**
325      * Check if the modal is a large modal.
326      *
327      * @method isLarge
328      * @return {bool}
329      */
330     Modal.prototype.isLarge = function() {
331         return this.getModal().hasClass('modal-lg');
332     };
334     /**
335      * Mark the modal as a small modal.
336      *
337      * @method setSmall
338      */
339     Modal.prototype.setSmall = function() {
340         if (this.isSmall()) {
341             return;
342         }
344         this.getModal().removeClass('modal-lg');
345     };
347     /**
348      * Check if the modal is a small modal.
349      *
350      * @method isSmall
351      * @return {bool}
352      */
353     Modal.prototype.isSmall = function() {
354         return !this.getModal().hasClass('modal-lg');
355     };
357     /**
358      * Determine the highest z-index value currently on the page.
359      *
360      * @method calculateZIndex
361      * @return {int}
362      */
363     Modal.prototype.calculateZIndex = function() {
364         var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX);
365         var zIndex = parseInt(this.root.css('z-index'));
367         items.each(function(index, item) {
368             item = $(item);
369             // Note that webkit browsers won't return the z-index value from the CSS stylesheet
370             // if the element doesn't have a position specified. Instead it'll return "auto".
371             var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
373             if (itemZIndex > zIndex) {
374                 zIndex = itemZIndex;
375             }
376         });
378         return zIndex;
379     };
381     /**
382      * Check if this modal is visible.
383      *
384      * @method isVisible
385      * @return {bool}
386      */
387     Modal.prototype.isVisible = function() {
388         return this.root.hasClass('show');
389     };
391     /**
392      * Check if this modal has focus.
393      *
394      * @method hasFocus
395      * @return {bool}
396      */
397     Modal.prototype.hasFocus = function() {
398         var target = $(document.activeElement);
399         return this.root.is(target) || this.root.has(target).length;
400     };
402     /**
403      * Check if this modal has CSS transitions applied.
404      *
405      * @method hasTransitions
406      * @return {bool}
407      */
408     Modal.prototype.hasTransitions = function() {
409         return this.getRoot().hasClass('fade');
410     };
412     /**
413      * Display this modal. The modal will be attached to the DOM if it hasn't
414      * already been.
415      *
416      * @method show
417      */
418     Modal.prototype.show = function() {
419         if (this.isVisible()) {
420             return;
421         }
423         if (!this.isAttached) {
424             this.attachToDOM();
425         }
427         this.getBackdrop().done(function(backdrop) {
428             var currentIndex = this.calculateZIndex();
429             var newIndex = currentIndex + 2;
430             var newBackdropIndex = newIndex - 1;
431             this.root.css('z-index', newIndex);
432             backdrop.setZIndex(newBackdropIndex);
433             backdrop.show();
435             this.root.removeClass('hide').addClass('show');
436             this.accessibilityShow();
437             this.getTitle().focus();
438             $('body').addClass('modal-open');
439             this.root.trigger(ModalEvents.shown, this);
440         }.bind(this));
441     };
443     /**
444      * Hide this modal.
445      *
446      * @method hide
447      */
448     Modal.prototype.hide = function() {
449         if (!this.isVisible()) {
450             return;
451         }
453         this.getBackdrop().done(function(backdrop) {
454             if (!this.countOtherVisibleModals()) {
455                 // Hide the backdrop if we're the last open modal.
456                 backdrop.hide();
457                 $('body').removeClass('modal-open');
458             }
460             var currentIndex = parseInt(this.root.css('z-index'));
461             this.root.css('z-index', '');
462             backdrop.setZIndex(currentIndex - 3);
464             this.accessibilityHide();
466             if (this.hasTransitions()) {
467                 // Wait for CSS transitions to complete before hiding the element.
468                 this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
469                     this.getRoot().removeClass('show').addClass('hide');
470                 }.bind(this));
471             } else {
472                 this.getRoot().removeClass('show').addClass('hide');
473             }
475             this.root.trigger(ModalEvents.hidden, this);
476         }.bind(this));
477     };
479     /**
480      * Remove this modal from the DOM.
481      *
482      * @method destroy
483      */
484     Modal.prototype.destroy = function() {
485         this.root.remove();
486         this.root.trigger(ModalEvents.destroyed, this);
487     };
489     /**
490      * Sets the appropriate aria attributes on this dialogue and the other
491      * elements in the DOM to ensure that screen readers are able to navigate
492      * the dialogue popup correctly.
493      *
494      * @method accessibilityShow
495      */
496     Modal.prototype.accessibilityShow = function() {
497         // We need to get a list containing each sibling element and the shallowest
498         // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
499         // the fact that this dialogue is always appended to the document body therefore
500         // it's siblings are the shallowest non-ancestral nodes. If that changes then
501         // this code should also be updated.
502         $('body').children().each(function(index, child) {
503             // Skip the current modal.
504             if (!this.root.is(child)) {
505                 child = $(child);
506                 var hidden = child.attr('aria-hidden');
507                 // If they are already hidden we can ignore them.
508                 if (hidden !== 'true') {
509                     // Save their current state.
510                     child.data('previous-aria-hidden', hidden);
511                     this.hiddenSiblings.push(child);
513                     // Hide this node from screen readers.
514                     child.attr('aria-hidden', 'true');
515                 }
516             }
517         }.bind(this));
519         // Make us visible to screen readers.
520         this.root.attr('aria-hidden', 'false');
521     };
523     /**
524      * Restores the aria visibility on the DOM elements changed when displaying
525      * the dialogue popup and makes the dialogue aria hidden to allow screen
526      * readers to navigate the main page correctly when the dialogue is closed.
527      *
528      * @method accessibilityHide
529      */
530     Modal.prototype.accessibilityHide = function() {
531         this.root.attr('aria-hidden', 'true');
533         // Restore the sibling nodes back to their original values.
534         $.each(this.hiddenSiblings, function(index, sibling) {
535             sibling = $(sibling);
536             var previousValue = sibling.data('previous-aria-hidden');
537             // If the element didn't previously have an aria-hidden attribute
538             // then we can just remove the one we set.
539             if (typeof previousValue == 'undefined') {
540                 sibling.removeAttr('aria-hidden');
541             } else {
542                 // Otherwise set it back to the old value (which will be false).
543                 sibling.attr('aria-hidden', previousValue);
544             }
545         });
547         // Clear the cache. No longer need to store these.
548         this.hiddenSiblings = [];
549     };
551     /**
552      * Handle the tab event to lock focus within this modal.
553      *
554      * @method handleTabLock
555      * @param {event} e The tab key jQuery event
556      */
557     Modal.prototype.handleTabLock = function(e) {
558         if (!this.hasFocus()) {
559             return;
560         }
562         var target = $(document.activeElement);
563         var focusableElements = this.modal.find(SELECTORS.CAN_RECEIVE_FOCUS);
564         var firstFocusable = focusableElements.first();
565         var lastFocusable = focusableElements.last();
567         if (target.is(firstFocusable) && e.shiftKey) {
568             lastFocusable.focus();
569             e.preventDefault();
570         } else if (target.is(lastFocusable) && !e.shiftKey) {
571             firstFocusable.focus();
572             e.preventDefault();
573         }
574     };
576     /**
577      * Set up all of the event handling for the modal.
578      *
579      * @method registerEventListeners
580      */
581     Modal.prototype.registerEventListeners = function() {
582         this.getRoot().on('keydown', function(e) {
583             if (!this.isVisible()) {
584                 return;
585             }
587             if (e.keyCode == KeyCodes.tab) {
588                 this.handleTabLock(e);
589             } else if (e.keyCode == KeyCodes.escape) {
590                 this.hide();
591             }
592         }.bind(this));
594         CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
595         this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) {
596             this.hide();
597             data.originalEvent.preventDefault();
598         }.bind(this));
599     };
601     /**
602      * Set or resolve and set the value using the function.
603      *
604      * @method asyncSet
605      * @param {(string|object)} value The string or jQuery promise.
606      * @param {function} setFunction The setter
607      * @return {Promise}
608      */
609     Modal.prototype.asyncSet = function(value, setFunction) {
610         var p = value;
611         if (typeof value === 'string') {
612             p = $.Deferred();
613             p.resolve(value);
614         }
616         p.then(function(content) {
617             setFunction(content);
619             return;
620         });
622         return p;
623     };
625     return Modal;
626 });