MDL-67264 core_course: Activity chooser behat
[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',
35], function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents, FocusLock) {
2bcef559
RW
36
37 var SELECTORS = {
38 CONTAINER: '[data-region="modal-container"]',
39 MODAL: '[data-region="modal"]',
40 HEADER: '[data-region="header"]',
41 TITLE: '[data-region="title"]',
42 BODY: '[data-region="body"]',
43 FOOTER: '[data-region="footer"]',
44 HIDE: '[data-action="hide"]',
45 DIALOG: '[role=dialog]',
cdc73904 46 FORM: 'form',
2bcef559
RW
47 MENU_BAR: '[role=menubar]',
48 HAS_Z_INDEX: '.moodle-has-zindex',
49 CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
50 };
51
52 var TEMPLATES = {
53 LOADING: 'core/loading',
54 BACKDROP: 'core/modal_backdrop',
55 };
56
57 /**
58 * Module singleton for the backdrop to be reused by all Modal instances.
59 */
60 var backdropPromise;
61
946f9d0a
RW
62 /**
63 * A counter that gets incremented for each modal created. This can be
64 * used to generate unique values for the modals.
65 */
66 var modalCounter = 0;
67
2bcef559
RW
68 /**
69 * Constructor for the Modal.
70 *
71 * @param {object} root The root jQuery element for the modal
72 */
73 var Modal = function(root) {
74 this.root = $(root);
75 this.modal = this.root.find(SELECTORS.MODAL);
76 this.header = this.modal.find(SELECTORS.HEADER);
77 this.title = this.header.find(SELECTORS.TITLE);
78 this.body = this.modal.find(SELECTORS.BODY);
79 this.footer = this.modal.find(SELECTORS.FOOTER);
80 this.hiddenSiblings = [];
81 this.isAttached = false;
82 this.bodyJS = null;
83 this.footerJS = null;
946f9d0a 84 this.modalCount = modalCounter++;
2bcef559
RW
85
86 if (!this.root.is(SELECTORS.CONTAINER)) {
87 Notification.exception({message: 'Element is not a modal container'});
88 }
89
90 if (!this.modal.length) {
91 Notification.exception({message: 'Container does not contain a modal'});
92 }
93
94 if (!this.header.length) {
95 Notification.exception({message: 'Modal is missing a header region'});
96 }
97
98 if (!this.title.length) {
99 Notification.exception({message: 'Modal header is missing a title region'});
100 }
101
102 if (!this.body.length) {
103 Notification.exception({message: 'Modal is missing a body region'});
104 }
105
106 if (!this.footer.length) {
107 Notification.exception({message: 'Modal is missing a footer region'});
108 }
109
110 this.registerEventListeners();
111 };
112
113 /**
114 * Add the modal to the page, if it hasn't already been added. This includes running any
115 * javascript that has been cached until now.
116 *
117 * @method attachToDOM
118 */
119 Modal.prototype.attachToDOM = function() {
120 if (this.isAttached) {
121 return;
122 }
123
124 $('body').append(this.root);
c50bc1bf 125 FocusLock.trapFocus(this.root[0]);
2bcef559
RW
126
127 // If we'd cached any JS then we can run it how that the modal is
128 // attached to the DOM.
129 if (this.bodyJS) {
130 Templates.runTemplateJS(this.bodyJS);
131 this.bodyJS = null;
132 }
133
134 if (this.footerJS) {
135 Templates.runTemplateJS(this.footerJS);
136 this.footerJS = null;
137 }
138
139 this.isAttached = true;
140 };
141
142 /**
143 * Count the number of other visible modals (not including this one).
144 *
145 * @method countOtherVisibleModals
146 * @return {int}
147 */
148 Modal.prototype.countOtherVisibleModals = function() {
149 var count = 0;
150 $('body').find(SELECTORS.CONTAINER).each(function(index, element) {
151 element = $(element);
152
153 // If we haven't found ourself and the element is visible.
154 if (!this.root.is(element) && element.hasClass('show')) {
155 count++;
156 }
157 }.bind(this));
158
159 return count;
160 };
161
162 /**
163 * Get the modal backdrop.
164 *
165 * @method getBackdrop
166 * @return {object} jQuery promise
167 */
168 Modal.prototype.getBackdrop = function() {
169 if (!backdropPromise) {
170 backdropPromise = Templates.render(TEMPLATES.BACKDROP, {})
171 .then(function(html) {
172 var element = $(html);
173
174 return new ModalBackdrop(element);
175 })
176 .fail(Notification.exception);
177 }
178
179 return backdropPromise;
180 };
181
182 /**
183 * Get the root element of this modal.
184 *
185 * @method getRoot
186 * @return {object} jQuery object
187 */
188 Modal.prototype.getRoot = function() {
189 return this.root;
190 };
191
192 /**
193 * Get the modal element of this modal.
194 *
195 * @method getModal
196 * @return {object} jQuery object
197 */
198 Modal.prototype.getModal = function() {
199 return this.modal;
200 };
201
202 /**
203 * Get the modal title element.
204 *
205 * @method getTitle
206 * @return {object} jQuery object
207 */
208 Modal.prototype.getTitle = function() {
209 return this.title;
210 };
211
212 /**
213 * Get the modal body element.
214 *
215 * @method getBody
216 * @return {object} jQuery object
217 */
218 Modal.prototype.getBody = function() {
219 return this.body;
220 };
221
222 /**
223 * Get the modal footer element.
224 *
225 * @method getFooter
226 * @return {object} jQuery object
227 */
228 Modal.prototype.getFooter = function() {
229 return this.footer;
230 };
231
946f9d0a
RW
232 /**
233 * Get the unique modal count.
234 *
235 * @method getModalCount
236 * @return {int}
237 */
238 Modal.prototype.getModalCount = function() {
239 return this.modalCount;
240 };
241
2bcef559
RW
242 /**
243 * Set the modal title element.
244 *
e2b50304
AN
245 * This method is overloaded to take either a string value for the title or a jQuery promise that is resolved with
246 * HTML most commonly from a Str.get_string call.
247 *
2bcef559 248 * @method setTitle
e2b50304 249 * @param {(string|object)} value The title string or jQuery promise which resolves to the title.
2bcef559
RW
250 */
251 Modal.prototype.setTitle = function(value) {
252 var title = this.getTitle();
e2b50304
AN
253
254 this.asyncSet(value, title.html.bind(title));
2bcef559
RW
255 };
256
257 /**
258 * Set the modal body element.
259 *
e2b50304
AN
260 * This method is overloaded to take either a string value for the body or a jQuery promise that is resolved with
261 * HTML and Javascript most commonly from a Templates.render call.
2bcef559
RW
262 *
263 * @method setBody
e2b50304 264 * @param {(string|object)} value The body string or jQuery promise which resolves to the body.
2bcef559
RW
265 */
266 Modal.prototype.setBody = function(value) {
267 var body = this.getBody();
268
269 if (typeof value === 'string') {
270 // Just set the value if it's a string.
271 body.html(value);
f02e119a 272 Event.notifyFilterContentUpdated(body);
97c4a29d 273 this.getRoot().trigger(ModalEvents.bodyRendered, this);
2bcef559 274 } else {
946f9d0a
RW
275 var jsPendingId = 'amd-modal-js-pending-id-' + this.getModalCount();
276 M.util.js_pending(jsPendingId);
2bcef559
RW
277 // Otherwise we assume it's a promise to be resolved with
278 // html and javascript.
946f9d0a
RW
279 var contentPromise = null;
280 body.css('overflow', 'hidden');
281
282 if (value.state() == 'pending') {
283 // We're still waiting for the body promise to resolve so
284 // let's show a loading icon.
2328bccc
RW
285 var height = body.innerHeight();
286 if (height < 100) {
287 height = 100;
288 }
289
290 body.animate({height: height + 'px'}, 150);
946f9d0a
RW
291
292 body.html('');
293 contentPromise = Templates.render(TEMPLATES.LOADING, {})
294 .then(function(html) {
295 var loadingIcon = $(html).hide();
296 body.html(loadingIcon);
297 loadingIcon.fadeIn(150);
298
299 // We only want the loading icon to fade out
300 // when the content for the body has finished
301 // loading.
302 return $.when(loadingIcon.promise(), value);
303 })
304 .then(function(loadingIcon) {
305 // Once the content has finished loading and
306 // the loading icon has been shown then we can
307 // fade the icon away to reveal the content.
308 return loadingIcon.fadeOut(100).promise();
309 })
310 .then(function() {
311 return value;
312 });
313 } else {
314 // The content is already loaded so let's just display
315 // it to the user. No need for a loading icon.
316 contentPromise = value;
317 }
2bcef559 318
946f9d0a
RW
319 // Now we can actually display the content.
320 contentPromise.then(function(html, js) {
321 var result = null;
2bcef559 322
946f9d0a
RW
323 if (this.isVisible()) {
324 // If the modal is visible then we should display
325 // the content gracefully for the user.
326 body.css('opacity', 0);
327 var currentHeight = body.innerHeight();
328 body.html(html);
329 // We need to clear any height values we've set here
330 // in order to measure the height of the content being
331 // added. This then allows us to animate the height
332 // transition.
333 body.css('height', '');
334 var newHeight = body.innerHeight();
335 body.css('height', currentHeight + 'px');
336 result = body.animate(
337 {height: newHeight + 'px', opacity: 1},
338 {duration: 150, queue: false}
339 ).promise();
340 } else {
341 // Since the modal isn't visible we can just immediately
342 // set the content. No need to animate it.
343 body.html(html);
344 }
345
346 if (js) {
347 if (this.isAttached) {
348 // If we're in the DOM then run the JS immediately.
349 Templates.runTemplateJS(js);
350 } else {
351 // Otherwise cache it to be run when we're attached.
352 this.bodyJS = js;
2bcef559 353 }
946f9d0a 354 }
035bd996
RW
355
356 return result;
357 }.bind(this))
358 .then(function(result) {
946f9d0a
RW
359 Event.notifyFilterContentUpdated(body);
360 this.getRoot().trigger(ModalEvents.bodyRendered, this);
946f9d0a
RW
361 return result;
362 }.bind(this))
363 .fail(Notification.exception)
364 .always(function() {
365 // When we're done displaying all of the content we need
366 // to clear the custom values we've set here.
367 body.css('height', '');
368 body.css('overflow', '');
369 body.css('opacity', '');
370 M.util.js_complete(jsPendingId);
371
372 return;
4c250a5b
AN
373 })
374 .fail(Notification.exception);
2bcef559
RW
375 }
376 };
377
378 /**
368832d5
RW
379 * Set the modal footer element. The footer element is made visible, if it
380 * isn't already.
2bcef559
RW
381 *
382 * This method is overloaded to take either a string
383 * value for the body or a jQuery promise that is resolved with HTML and Javascript
384 * most commonly from a Templates.render call.
385 *
386 * @method setFooter
387 * @param {(string|object)} value The footer string or jQuery promise
388 */
389 Modal.prototype.setFooter = function(value) {
368832d5
RW
390 // Make sure the footer is visible.
391 this.showFooter();
392
2bcef559
RW
393 var footer = this.getFooter();
394
395 if (typeof value === 'string') {
396 // Just set the value if it's a string.
397 footer.html(value);
398 } else {
399 // Otherwise we assume it's a promise to be resolved with
400 // html and javascript.
401 Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
402 footer.html(html);
403
404 value.done(function(html, js) {
405 footer.html(html);
406
10ea8270
RW
407 if (js) {
408 if (this.isAttached) {
409 // If we're in the DOM then run the JS immediately.
410 Templates.runTemplateJS(js);
411 } else {
412 // Otherwise cache it to be run when we're attached.
413 this.footerJS = js;
414 }
2bcef559
RW
415 }
416 }.bind(this));
417 }.bind(this));
418 }
419 };
420
368832d5
RW
421 /**
422 * Check if the footer has any content in it.
423 *
424 * @method hasFooterContent
425 * @return {bool}
426 */
427 Modal.prototype.hasFooterContent = function() {
428 return this.getFooter().children().length ? true : false;
429 };
430
431 /**
432 * Hide the footer element.
433 *
434 * @method hideFooter
435 */
436 Modal.prototype.hideFooter = function() {
437 this.getFooter().addClass('hidden');
438 };
439
440 /**
441 * Show the footer element.
442 *
443 * @method showFooter
444 */
445 Modal.prototype.showFooter = function() {
446 this.getFooter().removeClass('hidden');
447 };
448
2bcef559
RW
449 /**
450 * Mark the modal as a large modal.
451 *
452 * @method setLarge
453 */
454 Modal.prototype.setLarge = function() {
455 if (this.isLarge()) {
456 return;
457 }
458
4defa05f 459 this.getModal().addClass('modal-lg');
2bcef559
RW
460 };
461
462 /**
463 * Check if the modal is a large modal.
464 *
465 * @method isLarge
466 * @return {bool}
467 */
468 Modal.prototype.isLarge = function() {
4defa05f 469 return this.getModal().hasClass('modal-lg');
2bcef559
RW
470 };
471
472 /**
473 * Mark the modal as a small modal.
474 *
475 * @method setSmall
476 */
477 Modal.prototype.setSmall = function() {
478 if (this.isSmall()) {
479 return;
480 }
481
4defa05f 482 this.getModal().removeClass('modal-lg');
2bcef559
RW
483 };
484
485 /**
486 * Check if the modal is a small modal.
487 *
488 * @method isSmall
489 * @return {bool}
490 */
491 Modal.prototype.isSmall = function() {
4defa05f 492 return !this.getModal().hasClass('modal-lg');
2bcef559
RW
493 };
494
495 /**
496 * Determine the highest z-index value currently on the page.
497 *
498 * @method calculateZIndex
499 * @return {int}
500 */
501 Modal.prototype.calculateZIndex = function() {
502 var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX);
503 var zIndex = parseInt(this.root.css('z-index'));
504
505 items.each(function(index, item) {
506 item = $(item);
507 // Note that webkit browsers won't return the z-index value from the CSS stylesheet
508 // if the element doesn't have a position specified. Instead it'll return "auto".
509 var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
510
511 if (itemZIndex > zIndex) {
512 zIndex = itemZIndex;
513 }
514 });
515
516 return zIndex;
517 };
518
519 /**
520 * Check if this modal is visible.
521 *
522 * @method isVisible
523 * @return {bool}
524 */
525 Modal.prototype.isVisible = function() {
526 return this.root.hasClass('show');
527 };
528
529 /**
530 * Check if this modal has focus.
531 *
532 * @method hasFocus
533 * @return {bool}
534 */
535 Modal.prototype.hasFocus = function() {
536 var target = $(document.activeElement);
537 return this.root.is(target) || this.root.has(target).length;
538 };
539
540 /**
541 * Check if this modal has CSS transitions applied.
542 *
543 * @method hasTransitions
544 * @return {bool}
545 */
546 Modal.prototype.hasTransitions = function() {
547 return this.getRoot().hasClass('fade');
548 };
549
550 /**
551 * Display this modal. The modal will be attached to the DOM if it hasn't
552 * already been.
553 *
554 * @method show
555 */
556 Modal.prototype.show = function() {
557 if (this.isVisible()) {
558 return;
559 }
560
368832d5
RW
561 if (this.hasFooterContent()) {
562 this.showFooter();
563 } else {
564 this.hideFooter();
565 }
566
2bcef559
RW
567 if (!this.isAttached) {
568 this.attachToDOM();
569 }
570
571 this.getBackdrop().done(function(backdrop) {
572 var currentIndex = this.calculateZIndex();
573 var newIndex = currentIndex + 2;
574 var newBackdropIndex = newIndex - 1;
575 this.root.css('z-index', newIndex);
576 backdrop.setZIndex(newBackdropIndex);
577 backdrop.show();
578
579 this.root.removeClass('hide').addClass('show');
580 this.accessibilityShow();
ae0629d2 581 this.getModal().focus();
2bcef559
RW
582 $('body').addClass('modal-open');
583 this.root.trigger(ModalEvents.shown, this);
584 }.bind(this));
585 };
586
cdc73904
DW
587 /**
588 * Hide this modal if it does not contain a form.
589 *
590 * @method hideIfNotForm
591 */
592 Modal.prototype.hideIfNotForm = function() {
593 var formElement = this.modal.find(SELECTORS.FORM);
594 if (formElement.length == 0) {
595 this.hide();
596 }
597 };
598
2bcef559
RW
599 /**
600 * Hide this modal.
601 *
602 * @method hide
603 */
604 Modal.prototype.hide = function() {
2bcef559 605 this.getBackdrop().done(function(backdrop) {
c50bc1bf 606 FocusLock.untrapFocus();
2bcef559
RW
607 if (!this.countOtherVisibleModals()) {
608 // Hide the backdrop if we're the last open modal.
609 backdrop.hide();
610 $('body').removeClass('modal-open');
611 }
612
613 var currentIndex = parseInt(this.root.css('z-index'));
614 this.root.css('z-index', '');
615 backdrop.setZIndex(currentIndex - 3);
616
617 this.accessibilityHide();
618
619 if (this.hasTransitions()) {
620 // Wait for CSS transitions to complete before hiding the element.
621 this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
622 this.getRoot().removeClass('show').addClass('hide');
623 }.bind(this));
624 } else {
625 this.getRoot().removeClass('show').addClass('hide');
626 }
627
628 this.root.trigger(ModalEvents.hidden, this);
629 }.bind(this));
630 };
631
632 /**
633 * Remove this modal from the DOM.
634 *
635 * @method destroy
636 */
637 Modal.prototype.destroy = function() {
638 this.root.remove();
639 this.root.trigger(ModalEvents.destroyed, this);
640 };
641
642 /**
643 * Sets the appropriate aria attributes on this dialogue and the other
644 * elements in the DOM to ensure that screen readers are able to navigate
645 * the dialogue popup correctly.
646 *
647 * @method accessibilityShow
648 */
649 Modal.prototype.accessibilityShow = function() {
650 // We need to get a list containing each sibling element and the shallowest
651 // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
652 // the fact that this dialogue is always appended to the document body therefore
653 // it's siblings are the shallowest non-ancestral nodes. If that changes then
654 // this code should also be updated.
655 $('body').children().each(function(index, child) {
656 // Skip the current modal.
657 if (!this.root.is(child)) {
658 child = $(child);
659 var hidden = child.attr('aria-hidden');
660 // If they are already hidden we can ignore them.
661 if (hidden !== 'true') {
662 // Save their current state.
663 child.data('previous-aria-hidden', hidden);
664 this.hiddenSiblings.push(child);
665
666 // Hide this node from screen readers.
667 child.attr('aria-hidden', 'true');
668 }
669 }
670 }.bind(this));
671
672 // Make us visible to screen readers.
673 this.root.attr('aria-hidden', 'false');
674 };
675
676 /**
677 * Restores the aria visibility on the DOM elements changed when displaying
678 * the dialogue popup and makes the dialogue aria hidden to allow screen
679 * readers to navigate the main page correctly when the dialogue is closed.
680 *
681 * @method accessibilityHide
682 */
683 Modal.prototype.accessibilityHide = function() {
684 this.root.attr('aria-hidden', 'true');
685
686 // Restore the sibling nodes back to their original values.
687 $.each(this.hiddenSiblings, function(index, sibling) {
688 sibling = $(sibling);
689 var previousValue = sibling.data('previous-aria-hidden');
690 // If the element didn't previously have an aria-hidden attribute
691 // then we can just remove the one we set.
692 if (typeof previousValue == 'undefined') {
693 sibling.removeAttr('aria-hidden');
694 } else {
695 // Otherwise set it back to the old value (which will be false).
696 sibling.attr('aria-hidden', previousValue);
697 }
698 });
699
700 // Clear the cache. No longer need to store these.
701 this.hiddenSiblings = [];
702 };
703
2bcef559
RW
704 /**
705 * Set up all of the event handling for the modal.
706 *
707 * @method registerEventListeners
708 */
709 Modal.prototype.registerEventListeners = function() {
710 this.getRoot().on('keydown', function(e) {
711 if (!this.isVisible()) {
712 return;
713 }
714
c50bc1bf 715 if (e.keyCode == KeyCodes.escape) {
2bcef559
RW
716 this.hide();
717 }
718 }.bind(this));
719
30e1f5a0
TQ
720 // Listen for clicks on the modal container.
721 this.getRoot().click(function(e) {
722 // If the click wasn't inside the modal element then we should
723 // hide the modal.
724 if (!$(e.target).closest(SELECTORS.MODAL).length) {
b6ece79d
SR
725 // The check above fails to detect the click was inside the modal when the DOM tree is already changed.
726 // So, we check if we can still find the container element or not. If not, then the DOM tree is changed.
727 // It's best not to hide the modal in that case.
728 if ($(e.target).closest(SELECTORS.CONTAINER).length) {
cdc73904 729 this.hideIfNotForm();
b6ece79d 730 }
30e1f5a0
TQ
731 }
732 }.bind(this));
733
2bcef559
RW
734 CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
735 this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) {
736 this.hide();
737 data.originalEvent.preventDefault();
738 }.bind(this));
739 };
740
e2b50304
AN
741 /**
742 * Set or resolve and set the value using the function.
743 *
744 * @method asyncSet
745 * @param {(string|object)} value The string or jQuery promise.
746 * @param {function} setFunction The setter
747 * @return {Promise}
748 */
749 Modal.prototype.asyncSet = function(value, setFunction) {
750 var p = value;
e5bdf51c 751 if (typeof value !== 'object' || !value.hasOwnProperty('then')) {
e2b50304
AN
752 p = $.Deferred();
753 p.resolve(value);
754 }
755
756 p.then(function(content) {
757 setFunction(content);
758
759 return;
4c250a5b
AN
760 })
761 .fail(Notification.exception);
e2b50304
AN
762
763 return p;
764 };
765
2bcef559
RW
766 return Modal;
767});