MDL-67513 core: Only move modal to body if currently attached
[moodle.git] / lib / amd / src / modal.js
CommitLineData
2bcef559
RW
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/**
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 */
c50bc1bf
AN
25define([
26 'jquery',
27 'core/templates',
28 'core/notification',
29 'core/key_codes',
30 'core/custom_interaction_events',
31 'core/modal_backdrop',
32 'core/event',
33 'core/modal_events',
34 'core/local/aria/focuslock',
604887ce 35 'core/pending',
4f1c8ce7 36 'core/aria',
a2dcfd5b
NM
37 'core/fullscreen'
38], function(
39 $,
40 Templates,
41 Notification,
42 KeyCodes,
43 CustomEvents,
44 ModalBackdrop,
45 Event,
46 ModalEvents,
47 FocusLock,
48 Pending,
49 Aria,
50 Fullscreen
51) {
2bcef559
RW
52
53 var SELECTORS = {
54 CONTAINER: '[data-region="modal-container"]',
55 MODAL: '[data-region="modal"]',
56 HEADER: '[data-region="header"]',
57 TITLE: '[data-region="title"]',
58 BODY: '[data-region="body"]',
59 FOOTER: '[data-region="footer"]',
60 HIDE: '[data-action="hide"]',
61 DIALOG: '[role=dialog]',
cdc73904 62 FORM: 'form',
2bcef559
RW
63 MENU_BAR: '[role=menubar]',
64 HAS_Z_INDEX: '.moodle-has-zindex',
65 CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
66 };
67
68 var TEMPLATES = {
69 LOADING: 'core/loading',
70 BACKDROP: 'core/modal_backdrop',
71 };
72
73 /**
74 * Module singleton for the backdrop to be reused by all Modal instances.
75 */
76 var backdropPromise;
77
946f9d0a
RW
78 /**
79 * A counter that gets incremented for each modal created. This can be
80 * used to generate unique values for the modals.
81 */
82 var modalCounter = 0;
83
2bcef559
RW
84 /**
85 * Constructor for the Modal.
86 *
87 * @param {object} root The root jQuery element for the modal
88 */
89 var Modal = function(root) {
90 this.root = $(root);
91 this.modal = this.root.find(SELECTORS.MODAL);
92 this.header = this.modal.find(SELECTORS.HEADER);
e6a52983 93 this.headerPromise = $.Deferred();
2bcef559 94 this.title = this.header.find(SELECTORS.TITLE);
e6a52983 95 this.titlePromise = $.Deferred();
2bcef559 96 this.body = this.modal.find(SELECTORS.BODY);
e6a52983 97 this.bodyPromise = $.Deferred();
2bcef559 98 this.footer = this.modal.find(SELECTORS.FOOTER);
e6a52983 99 this.footerPromise = $.Deferred();
2bcef559
RW
100 this.hiddenSiblings = [];
101 this.isAttached = false;
102 this.bodyJS = null;
103 this.footerJS = null;
946f9d0a 104 this.modalCount = modalCounter++;
2bcef559
RW
105
106 if (!this.root.is(SELECTORS.CONTAINER)) {
107 Notification.exception({message: 'Element is not a modal container'});
108 }
109
110 if (!this.modal.length) {
111 Notification.exception({message: 'Container does not contain a modal'});
112 }
113
114 if (!this.header.length) {
115 Notification.exception({message: 'Modal is missing a header region'});
116 }
117
118 if (!this.title.length) {
119 Notification.exception({message: 'Modal header is missing a title region'});
120 }
121
122 if (!this.body.length) {
123 Notification.exception({message: 'Modal is missing a body region'});
124 }
125
126 if (!this.footer.length) {
127 Notification.exception({message: 'Modal is missing a footer region'});
128 }
129
130 this.registerEventListeners();
131 };
132
133 /**
a2dcfd5b
NM
134 * Attach the modal to the correct part of the page.
135 *
136 * If it hasn't already been added it runs any
2bcef559
RW
137 * javascript that has been cached until now.
138 *
139 * @method attachToDOM
140 */
141 Modal.prototype.attachToDOM = function() {
a2dcfd5b
NM
142 this.getAttachmentPoint().append(this.root);
143
2bcef559
RW
144 if (this.isAttached) {
145 return;
146 }
147
c50bc1bf 148 FocusLock.trapFocus(this.root[0]);
2bcef559
RW
149
150 // If we'd cached any JS then we can run it how that the modal is
151 // attached to the DOM.
152 if (this.bodyJS) {
153 Templates.runTemplateJS(this.bodyJS);
154 this.bodyJS = null;
155 }
156
157 if (this.footerJS) {
158 Templates.runTemplateJS(this.footerJS);
159 this.footerJS = null;
160 }
161
162 this.isAttached = true;
163 };
164
165 /**
166 * Count the number of other visible modals (not including this one).
167 *
168 * @method countOtherVisibleModals
169 * @return {int}
170 */
171 Modal.prototype.countOtherVisibleModals = function() {
172 var count = 0;
173 $('body').find(SELECTORS.CONTAINER).each(function(index, element) {
174 element = $(element);
175
176 // If we haven't found ourself and the element is visible.
177 if (!this.root.is(element) && element.hasClass('show')) {
178 count++;
179 }
180 }.bind(this));
181
182 return count;
183 };
184
185 /**
186 * Get the modal backdrop.
187 *
188 * @method getBackdrop
189 * @return {object} jQuery promise
190 */
191 Modal.prototype.getBackdrop = function() {
192 if (!backdropPromise) {
193 backdropPromise = Templates.render(TEMPLATES.BACKDROP, {})
194 .then(function(html) {
195 var element = $(html);
196
197 return new ModalBackdrop(element);
198 })
199 .fail(Notification.exception);
200 }
201
202 return backdropPromise;
203 };
204
205 /**
206 * Get the root element of this modal.
207 *
208 * @method getRoot
209 * @return {object} jQuery object
210 */
211 Modal.prototype.getRoot = function() {
212 return this.root;
213 };
214
215 /**
216 * Get the modal element of this modal.
217 *
218 * @method getModal
219 * @return {object} jQuery object
220 */
221 Modal.prototype.getModal = function() {
222 return this.modal;
223 };
224
225 /**
226 * Get the modal title element.
227 *
228 * @method getTitle
229 * @return {object} jQuery object
230 */
231 Modal.prototype.getTitle = function() {
232 return this.title;
233 };
234
235 /**
236 * Get the modal body element.
237 *
238 * @method getBody
239 * @return {object} jQuery object
240 */
241 Modal.prototype.getBody = function() {
242 return this.body;
243 };
244
245 /**
246 * Get the modal footer element.
247 *
248 * @method getFooter
249 * @return {object} jQuery object
250 */
251 Modal.prototype.getFooter = function() {
252 return this.footer;
253 };
254
e6a52983
MM
255 /**
256 * Get a promise resolving to the title region.
257 *
258 * @method getTitlePromise
259 * @return {Promise}
260 */
261 Modal.prototype.getTitlePromise = function() {
262 return this.titlePromise;
263 };
264
265 /**
266 * Get a promise resolving to the body region.
267 *
268 * @method getBodyPromise
269 * @return {object} jQuery object
270 */
271 Modal.prototype.getBodyPromise = function() {
272 return this.bodyPromise;
273 };
274
275 /**
276 * Get a promise resolving to the footer region.
277 *
278 * @method getFooterPromise
279 * @return {object} jQuery object
280 */
281 Modal.prototype.getFooterPromise = function() {
282 return this.footerPromise;
283 };
284
946f9d0a
RW
285 /**
286 * Get the unique modal count.
287 *
288 * @method getModalCount
289 * @return {int}
290 */
291 Modal.prototype.getModalCount = function() {
292 return this.modalCount;
293 };
294
2bcef559
RW
295 /**
296 * Set the modal title element.
297 *
e2b50304
AN
298 * This method is overloaded to take either a string value for the title or a jQuery promise that is resolved with
299 * HTML most commonly from a Str.get_string call.
300 *
2bcef559 301 * @method setTitle
e2b50304 302 * @param {(string|object)} value The title string or jQuery promise which resolves to the title.
2bcef559
RW
303 */
304 Modal.prototype.setTitle = function(value) {
305 var title = this.getTitle();
e6a52983 306 this.titlePromise = $.Deferred();
e2b50304 307
e6a52983
MM
308 this.asyncSet(value, title.html.bind(title))
309 .then(function() {
310 this.titlePromise.resolve(title);
311 }.bind(this))
312 .catch(Notification.exception);
2bcef559
RW
313 };
314
315 /**
316 * Set the modal body element.
317 *
e2b50304
AN
318 * This method is overloaded to take either a string value for the body or a jQuery promise that is resolved with
319 * HTML and Javascript most commonly from a Templates.render call.
2bcef559
RW
320 *
321 * @method setBody
e2b50304 322 * @param {(string|object)} value The body string or jQuery promise which resolves to the body.
2bcef559
RW
323 */
324 Modal.prototype.setBody = function(value) {
e6a52983
MM
325 this.bodyPromise = $.Deferred();
326
2bcef559
RW
327 var body = this.getBody();
328
329 if (typeof value === 'string') {
330 // Just set the value if it's a string.
331 body.html(value);
f02e119a 332 Event.notifyFilterContentUpdated(body);
97c4a29d 333 this.getRoot().trigger(ModalEvents.bodyRendered, this);
e6a52983 334 this.bodyPromise.resolve(body);
2bcef559 335 } else {
946f9d0a
RW
336 var jsPendingId = 'amd-modal-js-pending-id-' + this.getModalCount();
337 M.util.js_pending(jsPendingId);
2bcef559
RW
338 // Otherwise we assume it's a promise to be resolved with
339 // html and javascript.
946f9d0a
RW
340 var contentPromise = null;
341 body.css('overflow', 'hidden');
342
f2d033a2
MM
343 // Ensure that the `value` is a jQuery Promise.
344 value = $.when(value);
345
946f9d0a
RW
346 if (value.state() == 'pending') {
347 // We're still waiting for the body promise to resolve so
348 // let's show a loading icon.
2328bccc
RW
349 var height = body.innerHeight();
350 if (height < 100) {
351 height = 100;
352 }
353
354 body.animate({height: height + 'px'}, 150);
946f9d0a
RW
355
356 body.html('');
357 contentPromise = Templates.render(TEMPLATES.LOADING, {})
358 .then(function(html) {
359 var loadingIcon = $(html).hide();
360 body.html(loadingIcon);
361 loadingIcon.fadeIn(150);
362
363 // We only want the loading icon to fade out
364 // when the content for the body has finished
365 // loading.
366 return $.when(loadingIcon.promise(), value);
367 })
368 .then(function(loadingIcon) {
369 // Once the content has finished loading and
370 // the loading icon has been shown then we can
371 // fade the icon away to reveal the content.
372 return loadingIcon.fadeOut(100).promise();
373 })
374 .then(function() {
375 return value;
376 });
377 } else {
378 // The content is already loaded so let's just display
379 // it to the user. No need for a loading icon.
380 contentPromise = value;
381 }
2bcef559 382
946f9d0a
RW
383 // Now we can actually display the content.
384 contentPromise.then(function(html, js) {
385 var result = null;
2bcef559 386
946f9d0a
RW
387 if (this.isVisible()) {
388 // If the modal is visible then we should display
389 // the content gracefully for the user.
390 body.css('opacity', 0);
391 var currentHeight = body.innerHeight();
392 body.html(html);
393 // We need to clear any height values we've set here
394 // in order to measure the height of the content being
395 // added. This then allows us to animate the height
396 // transition.
397 body.css('height', '');
398 var newHeight = body.innerHeight();
399 body.css('height', currentHeight + 'px');
400 result = body.animate(
401 {height: newHeight + 'px', opacity: 1},
402 {duration: 150, queue: false}
403 ).promise();
404 } else {
405 // Since the modal isn't visible we can just immediately
406 // set the content. No need to animate it.
407 body.html(html);
408 }
409
410 if (js) {
411 if (this.isAttached) {
412 // If we're in the DOM then run the JS immediately.
413 Templates.runTemplateJS(js);
414 } else {
415 // Otherwise cache it to be run when we're attached.
416 this.bodyJS = js;
2bcef559 417 }
946f9d0a 418 }
035bd996
RW
419
420 return result;
421 }.bind(this))
422 .then(function(result) {
946f9d0a
RW
423 Event.notifyFilterContentUpdated(body);
424 this.getRoot().trigger(ModalEvents.bodyRendered, this);
946f9d0a
RW
425 return result;
426 }.bind(this))
e6a52983
MM
427 .then(function() {
428 this.bodyPromise.resolve(body);
429 return;
430 }.bind(this))
946f9d0a
RW
431 .fail(Notification.exception)
432 .always(function() {
433 // When we're done displaying all of the content we need
434 // to clear the custom values we've set here.
435 body.css('height', '');
436 body.css('overflow', '');
437 body.css('opacity', '');
438 M.util.js_complete(jsPendingId);
439
440 return;
4c250a5b
AN
441 })
442 .fail(Notification.exception);
2bcef559
RW
443 }
444 };
445
446 /**
368832d5
RW
447 * Set the modal footer element. The footer element is made visible, if it
448 * isn't already.
2bcef559
RW
449 *
450 * This method is overloaded to take either a string
451 * value for the body or a jQuery promise that is resolved with HTML and Javascript
452 * most commonly from a Templates.render call.
453 *
454 * @method setFooter
455 * @param {(string|object)} value The footer string or jQuery promise
456 */
457 Modal.prototype.setFooter = function(value) {
368832d5
RW
458 // Make sure the footer is visible.
459 this.showFooter();
e6a52983 460 this.footerPromise = $.Deferred();
368832d5 461
2bcef559
RW
462 var footer = this.getFooter();
463
464 if (typeof value === 'string') {
465 // Just set the value if it's a string.
466 footer.html(value);
e6a52983 467 this.footerPromise.resolve(footer);
2bcef559
RW
468 } else {
469 // Otherwise we assume it's a promise to be resolved with
470 // html and javascript.
e6a52983
MM
471 Templates.render(TEMPLATES.LOADING, {})
472 .then(function(html) {
2bcef559
RW
473 footer.html(html);
474
e6a52983
MM
475 return value;
476 })
477 .then(function(html, js) {
478 footer.html(html);
479
480 if (js) {
481 if (this.isAttached) {
482 // If we're in the DOM then run the JS immediately.
483 Templates.runTemplateJS(js);
484 } else {
485 // Otherwise cache it to be run when we're attached.
486 this.footerJS = js;
2bcef559 487 }
e6a52983
MM
488 }
489
490 return footer;
491 }.bind(this))
492 .then(function(footer) {
493 this.footerPromise.resolve(footer);
494 return;
495 }.bind(this))
496 .catch(Notification.exception);
2bcef559
RW
497 }
498 };
499
368832d5
RW
500 /**
501 * Check if the footer has any content in it.
502 *
503 * @method hasFooterContent
504 * @return {bool}
505 */
506 Modal.prototype.hasFooterContent = function() {
507 return this.getFooter().children().length ? true : false;
508 };
509
510 /**
511 * Hide the footer element.
512 *
513 * @method hideFooter
514 */
515 Modal.prototype.hideFooter = function() {
516 this.getFooter().addClass('hidden');
517 };
518
519 /**
520 * Show the footer element.
521 *
522 * @method showFooter
523 */
524 Modal.prototype.showFooter = function() {
525 this.getFooter().removeClass('hidden');
526 };
527
2bcef559
RW
528 /**
529 * Mark the modal as a large modal.
530 *
531 * @method setLarge
532 */
533 Modal.prototype.setLarge = function() {
534 if (this.isLarge()) {
535 return;
536 }
537
4defa05f 538 this.getModal().addClass('modal-lg');
2bcef559
RW
539 };
540
541 /**
542 * Check if the modal is a large modal.
543 *
544 * @method isLarge
545 * @return {bool}
546 */
547 Modal.prototype.isLarge = function() {
4defa05f 548 return this.getModal().hasClass('modal-lg');
2bcef559
RW
549 };
550
551 /**
552 * Mark the modal as a small modal.
553 *
554 * @method setSmall
555 */
556 Modal.prototype.setSmall = function() {
557 if (this.isSmall()) {
558 return;
559 }
560
4defa05f 561 this.getModal().removeClass('modal-lg');
2bcef559
RW
562 };
563
564 /**
565 * Check if the modal is a small modal.
566 *
567 * @method isSmall
568 * @return {bool}
569 */
570 Modal.prototype.isSmall = function() {
4defa05f 571 return !this.getModal().hasClass('modal-lg');
2bcef559
RW
572 };
573
e13cbf9e
MG
574 /**
575 * Set this modal to be scrollable or not.
576 *
577 * @method setScrollable
578 * @param {bool} value Whether the modal is scrollable or not
579 */
580 Modal.prototype.setScrollable = function(value) {
581 if (!value) {
582 this.getModal()[0].classList.remove('modal-dialog-scrollable');
583 return;
584 }
585
586 this.getModal()[0].classList.add('modal-dialog-scrollable');
587 };
588
589
2bcef559
RW
590 /**
591 * Determine the highest z-index value currently on the page.
592 *
593 * @method calculateZIndex
594 * @return {int}
595 */
596 Modal.prototype.calculateZIndex = function() {
597 var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX);
598 var zIndex = parseInt(this.root.css('z-index'));
599
600 items.each(function(index, item) {
601 item = $(item);
602 // Note that webkit browsers won't return the z-index value from the CSS stylesheet
603 // if the element doesn't have a position specified. Instead it'll return "auto".
604 var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
605
606 if (itemZIndex > zIndex) {
607 zIndex = itemZIndex;
608 }
609 });
610
611 return zIndex;
612 };
613
614 /**
615 * Check if this modal is visible.
616 *
617 * @method isVisible
618 * @return {bool}
619 */
620 Modal.prototype.isVisible = function() {
621 return this.root.hasClass('show');
622 };
623
624 /**
625 * Check if this modal has focus.
626 *
627 * @method hasFocus
628 * @return {bool}
629 */
630 Modal.prototype.hasFocus = function() {
631 var target = $(document.activeElement);
632 return this.root.is(target) || this.root.has(target).length;
633 };
634
635 /**
636 * Check if this modal has CSS transitions applied.
637 *
638 * @method hasTransitions
639 * @return {bool}
640 */
641 Modal.prototype.hasTransitions = function() {
642 return this.getRoot().hasClass('fade');
643 };
644
a2dcfd5b
NM
645 /**
646 * Gets the jQuery wrapped node that the Modal should be attached to.
647 *
648 * @returns {jQuery}
649 */
650 Modal.prototype.getAttachmentPoint = function() {
651 return $(Fullscreen.getElement() || document.body);
652 };
653
2bcef559
RW
654 /**
655 * Display this modal. The modal will be attached to the DOM if it hasn't
656 * already been.
657 *
658 * @method show
fc3d7d69 659 * @returns {Promise}
2bcef559
RW
660 */
661 Modal.prototype.show = function() {
662 if (this.isVisible()) {
fc3d7d69 663 return $.Deferred().resolve();
2bcef559
RW
664 }
665
604887ce
AN
666 var pendingPromise = new Pending('core/modal:show');
667
368832d5
RW
668 if (this.hasFooterContent()) {
669 this.showFooter();
670 } else {
671 this.hideFooter();
672 }
673
a2dcfd5b 674 this.attachToDOM();
2bcef559 675
fc3d7d69 676 return this.getBackdrop()
604887ce 677 .then(function(backdrop) {
2bcef559
RW
678 var currentIndex = this.calculateZIndex();
679 var newIndex = currentIndex + 2;
680 var newBackdropIndex = newIndex - 1;
681 this.root.css('z-index', newIndex);
682 backdrop.setZIndex(newBackdropIndex);
683 backdrop.show();
684
685 this.root.removeClass('hide').addClass('show');
686 this.accessibilityShow();
ae0629d2 687 this.getModal().focus();
2bcef559
RW
688 $('body').addClass('modal-open');
689 this.root.trigger(ModalEvents.shown, this);
604887ce
AN
690
691 return;
692 }.bind(this))
693 .then(pendingPromise.resolve);
2bcef559
RW
694 };
695
cdc73904
DW
696 /**
697 * Hide this modal if it does not contain a form.
698 *
699 * @method hideIfNotForm
700 */
701 Modal.prototype.hideIfNotForm = function() {
702 var formElement = this.modal.find(SELECTORS.FORM);
703 if (formElement.length == 0) {
704 this.hide();
705 }
706 };
707
2bcef559
RW
708 /**
709 * Hide this modal.
710 *
711 * @method hide
712 */
713 Modal.prototype.hide = function() {
2bcef559 714 this.getBackdrop().done(function(backdrop) {
c50bc1bf 715 FocusLock.untrapFocus();
a2dcfd5b 716
2bcef559
RW
717 if (!this.countOtherVisibleModals()) {
718 // Hide the backdrop if we're the last open modal.
719 backdrop.hide();
720 $('body').removeClass('modal-open');
721 }
722
723 var currentIndex = parseInt(this.root.css('z-index'));
724 this.root.css('z-index', '');
725 backdrop.setZIndex(currentIndex - 3);
726
727 this.accessibilityHide();
728
729 if (this.hasTransitions()) {
730 // Wait for CSS transitions to complete before hiding the element.
731 this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
732 this.getRoot().removeClass('show').addClass('hide');
733 }.bind(this));
734 } else {
735 this.getRoot().removeClass('show').addClass('hide');
736 }
737
be2afb52
AN
738 // Ensure the modal is moved onto the body node if it is still attached to the DOM.
739 if ($(document.body).find(this.getRoot()).length) {
740 $(document.body).append(this.getRoot());
741 }
a2dcfd5b 742
2bcef559
RW
743 this.root.trigger(ModalEvents.hidden, this);
744 }.bind(this));
745 };
746
747 /**
748 * Remove this modal from the DOM.
749 *
750 * @method destroy
751 */
752 Modal.prototype.destroy = function() {
fa6101ba 753 this.hide();
2bcef559
RW
754 this.root.remove();
755 this.root.trigger(ModalEvents.destroyed, this);
756 };
757
758 /**
759 * Sets the appropriate aria attributes on this dialogue and the other
760 * elements in the DOM to ensure that screen readers are able to navigate
761 * the dialogue popup correctly.
762 *
763 * @method accessibilityShow
764 */
765 Modal.prototype.accessibilityShow = function() {
2bcef559 766 // Make us visible to screen readers.
7cc18dc2
AN
767 Aria.unhide(this.root.get());
768
769 // Hide siblings.
770 Aria.hideSiblings(this.root.get()[0]);
2bcef559
RW
771 };
772
773 /**
774 * Restores the aria visibility on the DOM elements changed when displaying
775 * the dialogue popup and makes the dialogue aria hidden to allow screen
776 * readers to navigate the main page correctly when the dialogue is closed.
777 *
778 * @method accessibilityHide
779 */
780 Modal.prototype.accessibilityHide = function() {
7cc18dc2 781 // Unhide siblings.
4f1c8ce7 782 Aria.unhideSiblings(this.root.get()[0]);
7cc18dc2
AN
783
784 // Hide this modal.
785 Aria.hide(this.root.get());
2bcef559
RW
786 };
787
2bcef559
RW
788 /**
789 * Set up all of the event handling for the modal.
790 *
791 * @method registerEventListeners
792 */
793 Modal.prototype.registerEventListeners = function() {
794 this.getRoot().on('keydown', function(e) {
795 if (!this.isVisible()) {
796 return;
797 }
798
c50bc1bf 799 if (e.keyCode == KeyCodes.escape) {
2bcef559
RW
800 this.hide();
801 }
802 }.bind(this));
803
30e1f5a0
TQ
804 // Listen for clicks on the modal container.
805 this.getRoot().click(function(e) {
806 // If the click wasn't inside the modal element then we should
807 // hide the modal.
808 if (!$(e.target).closest(SELECTORS.MODAL).length) {
b6ece79d
SR
809 // The check above fails to detect the click was inside the modal when the DOM tree is already changed.
810 // So, we check if we can still find the container element or not. If not, then the DOM tree is changed.
811 // It's best not to hide the modal in that case.
812 if ($(e.target).closest(SELECTORS.CONTAINER).length) {
761898c8
MG
813 var outsideClickEvent = $.Event(ModalEvents.outsideClick);
814 this.getRoot().trigger(outsideClickEvent, this);
815
816 if (!outsideClickEvent.isDefaultPrevented()) {
817 this.hideIfNotForm();
818 }
b6ece79d 819 }
30e1f5a0
TQ
820 }
821 }.bind(this));
822
2bcef559
RW
823 CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
824 this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) {
825 this.hide();
826 data.originalEvent.preventDefault();
827 }.bind(this));
828 };
829
fa6101ba
AN
830 /**
831 * Register a listener to close the dialogue when the cancel button is pressed.
832 *
833 * @method registerCloseOnCancel
834 */
835 Modal.prototype.registerCloseOnCancel = function() {
836 // Handle the clicking of the Cancel button.
837 this.getModal().on(CustomEvents.events.activate, this.getActionSelector('cancel'), function(e, data) {
838 var cancelEvent = $.Event(ModalEvents.cancel);
839 this.getRoot().trigger(cancelEvent, this);
840
841 if (!cancelEvent.isDefaultPrevented()) {
842 data.originalEvent.preventDefault();
843
844 if (this.removeOnClose) {
845 this.destroy();
846 } else {
847 this.hide();
848 }
849 }
850 }.bind(this));
851 };
852
853 /**
854 * Register a listener to close the dialogue when the save button is pressed.
855 *
856 * @method registerCloseOnSave
857 */
858 Modal.prototype.registerCloseOnSave = function() {
859 // Handle the clicking of the Cancel button.
860 this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), function(e, data) {
861 var saveEvent = $.Event(ModalEvents.save);
862 this.getRoot().trigger(saveEvent, this);
863
864 if (!saveEvent.isDefaultPrevented()) {
865 data.originalEvent.preventDefault();
866
867 if (this.removeOnClose) {
868 this.destroy();
869 } else {
870 this.hide();
871 }
872 }
873 }.bind(this));
874 };
875
e2b50304
AN
876 /**
877 * Set or resolve and set the value using the function.
878 *
879 * @method asyncSet
880 * @param {(string|object)} value The string or jQuery promise.
881 * @param {function} setFunction The setter
882 * @return {Promise}
883 */
884 Modal.prototype.asyncSet = function(value, setFunction) {
885 var p = value;
e5bdf51c 886 if (typeof value !== 'object' || !value.hasOwnProperty('then')) {
e2b50304
AN
887 p = $.Deferred();
888 p.resolve(value);
889 }
890
891 p.then(function(content) {
892 setFunction(content);
893
894 return;
4c250a5b
AN
895 })
896 .fail(Notification.exception);
e2b50304
AN
897
898 return p;
899 };
900
92810f7a
AN
901 /**
902 * Set the title text of a button.
903 *
904 * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
905 * text most commonly from a Str.get_string call.
906 *
907 * @param {DOMString} action The action of the button
908 * @param {(String|object)} value The button text, or a promise which will resolve to it
909 * @returns {Promise}
910 */
911 Modal.prototype.setButtonText = function(action, value) {
912 const button = this.getFooter().find(this.getActionSelector(action));
913
914 if (!button) {
915 throw new Error("Unable to find the '" + action + "' button");
916 }
917
918 return this.asyncSet(value, button.text.bind(button));
919 };
fa6101ba
AN
920
921 /**
922 * Get the Selector for an action.
923 *
924 * @param {String} action
925 * @returns {DOMString}
926 */
927 Modal.prototype.getActionSelector = function(action) {
928 return "[data-action='" + action + "']";
929 };
930
931 /**
932 * Set the flag to remove the modal from the DOM on close.
933 *
934 * @param {Boolean} remove
935 */
936 Modal.prototype.setRemoveOnClose = function(remove) {
937 this.removeOnClose = remove;
938 };
939
2bcef559
RW
940 return Modal;
941});