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