on-demand release 3.4dev+
[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]',
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 };
42
43 var TEMPLATES = {
44 LOADING: 'core/loading',
45 BACKDROP: 'core/modal_backdrop',
46 };
47
48 /**
49 * Module singleton for the backdrop to be reused by all Modal instances.
50 */
51 var backdropPromise;
52
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;
69
70 if (!this.root.is(SELECTORS.CONTAINER)) {
71 Notification.exception({message: 'Element is not a modal container'});
72 }
73
74 if (!this.modal.length) {
75 Notification.exception({message: 'Container does not contain a modal'});
76 }
77
78 if (!this.header.length) {
79 Notification.exception({message: 'Modal is missing a header region'});
80 }
81
82 if (!this.title.length) {
83 Notification.exception({message: 'Modal header is missing a title region'});
84 }
85
86 if (!this.body.length) {
87 Notification.exception({message: 'Modal is missing a body region'});
88 }
89
90 if (!this.footer.length) {
91 Notification.exception({message: 'Modal is missing a footer region'});
92 }
93
94 this.registerEventListeners();
95 };
96
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 }
107
108 $('body').append(this.root);
109
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 }
116
117 if (this.footerJS) {
118 Templates.runTemplateJS(this.footerJS);
119 this.footerJS = null;
120 }
121
122 this.isAttached = true;
123 };
124
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);
135
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));
141
142 return count;
143 };
144
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);
156
157 return new ModalBackdrop(element);
158 })
159 .fail(Notification.exception);
160 }
161
162 return backdropPromise;
163 };
164
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 };
174
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 };
184
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 };
194
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 };
204
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 };
214
215 /**
216 * Set the modal title element.
217 *
e2b50304
AN
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 *
2bcef559 221 * @method setTitle
e2b50304 222 * @param {(string|object)} value The title string or jQuery promise which resolves to the title.
2bcef559
RW
223 */
224 Modal.prototype.setTitle = function(value) {
225 var title = this.getTitle();
e2b50304
AN
226
227 this.asyncSet(value, title.html.bind(title));
2bcef559
RW
228 };
229
230 /**
231 * Set the modal body element.
232 *
e2b50304
AN
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.
2bcef559
RW
235 *
236 * @method setBody
e2b50304 237 * @param {(string|object)} value The body string or jQuery promise which resolves to the body.
2bcef559
RW
238 */
239 Modal.prototype.setBody = function(value) {
240 var body = this.getBody();
241
242 if (typeof value === 'string') {
243 // Just set the value if it's a string.
244 body.html(value);
f02e119a 245 Event.notifyFilterContentUpdated(body);
97c4a29d 246 this.getRoot().trigger(ModalEvents.bodyRendered, this);
2bcef559
RW
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);
252
253 value.done(function(html, js) {
254 body.html(html);
255
10ea8270
RW
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 }
2bcef559 264 }
f02e119a 265 Event.notifyFilterContentUpdated(body);
97c4a29d 266 this.getRoot().trigger(ModalEvents.bodyRendered, this);
2bcef559
RW
267 }.bind(this));
268 }.bind(this));
269 }
270 };
271
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();
284
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);
293
294 value.done(function(html, js) {
295 footer.html(html);
296
10ea8270
RW
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 }
2bcef559
RW
305 }
306 }.bind(this));
307 }.bind(this));
308 }
309 };
310
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 }
320
4defa05f 321 this.getModal().addClass('modal-lg');
2bcef559
RW
322 };
323
324 /**
325 * Check if the modal is a large modal.
326 *
327 * @method isLarge
328 * @return {bool}
329 */
330 Modal.prototype.isLarge = function() {
4defa05f 331 return this.getModal().hasClass('modal-lg');
2bcef559
RW
332 };
333
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 }
343
4defa05f 344 this.getModal().removeClass('modal-lg');
2bcef559
RW
345 };
346
347 /**
348 * Check if the modal is a small modal.
349 *
350 * @method isSmall
351 * @return {bool}
352 */
353 Modal.prototype.isSmall = function() {
4defa05f 354 return !this.getModal().hasClass('modal-lg');
2bcef559
RW
355 };
356
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'));
366
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;
372
373 if (itemZIndex > zIndex) {
374 zIndex = itemZIndex;
375 }
376 });
377
378 return zIndex;
379 };
380
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 };
390
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 };
401
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 };
411
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 }
422
423 if (!this.isAttached) {
424 this.attachToDOM();
425 }
426
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();
434
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 };
442
443 /**
444 * Hide this modal.
445 *
446 * @method hide
447 */
448 Modal.prototype.hide = function() {
449 if (!this.isVisible()) {
450 return;
451 }
452
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 }
459
460 var currentIndex = parseInt(this.root.css('z-index'));
461 this.root.css('z-index', '');
462 backdrop.setZIndex(currentIndex - 3);
463
464 this.accessibilityHide();
465
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 }
474
475 this.root.trigger(ModalEvents.hidden, this);
476 }.bind(this));
477 };
478
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 };
488
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);
512
513 // Hide this node from screen readers.
514 child.attr('aria-hidden', 'true');
515 }
516 }
517 }.bind(this));
518
519 // Make us visible to screen readers.
520 this.root.attr('aria-hidden', 'false');
521 };
522
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');
532
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 });
546
547 // Clear the cache. No longer need to store these.
548 this.hiddenSiblings = [];
549 };
550
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 }
561
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();
566
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 };
575
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 }
586
587 if (e.keyCode == KeyCodes.tab) {
588 this.handleTabLock(e);
589 } else if (e.keyCode == KeyCodes.escape) {
590 this.hide();
591 }
592 }.bind(this));
593
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 };
600
e2b50304
AN
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;
e5bdf51c 611 if (typeof value !== 'object' || !value.hasOwnProperty('then')) {
e2b50304
AN
612 p = $.Deferred();
613 p.resolve(value);
614 }
615
616 p.then(function(content) {
617 setFunction(content);
618
619 return;
620 });
621
622 return p;
623 };
624
2bcef559
RW
625 return Modal;
626});