1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * Contain the logic for modals.
22 * @copyright 2016 Ryan Wyllie <ryan@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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) {
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]',
44 LOADING: 'core/loading',
45 BACKDROP: 'core/modal_backdrop',
49 * Module singleton for the backdrop to be reused by all Modal instances.
54 * Constructor for the Modal.
56 * @param {object} root The root jQuery element for the modal
58 var Modal = function(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;
70 if (!this.root.is(SELECTORS.CONTAINER)) {
71 Notification.exception({message: 'Element is not a modal container'});
74 if (!this.modal.length) {
75 Notification.exception({message: 'Container does not contain a modal'});
78 if (!this.header.length) {
79 Notification.exception({message: 'Modal is missing a header region'});
82 if (!this.title.length) {
83 Notification.exception({message: 'Modal header is missing a title region'});
86 if (!this.body.length) {
87 Notification.exception({message: 'Modal is missing a body region'});
90 if (!this.footer.length) {
91 Notification.exception({message: 'Modal is missing a footer region'});
94 this.registerEventListeners();
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.
101 * @method attachToDOM
103 Modal.prototype.attachToDOM = function() {
104 if (this.isAttached) {
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.
113 Templates.runTemplateJS(this.bodyJS);
118 Templates.runTemplateJS(this.footerJS);
119 this.footerJS = null;
122 this.isAttached = true;
126 * Count the number of other visible modals (not including this one).
128 * @method countOtherVisibleModals
131 Modal.prototype.countOtherVisibleModals = function() {
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')) {
146 * Get the modal backdrop.
148 * @method getBackdrop
149 * @return {object} jQuery promise
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);
159 .fail(Notification.exception);
162 return backdropPromise;
166 * Get the root element of this modal.
169 * @return {object} jQuery object
171 Modal.prototype.getRoot = function() {
176 * Get the modal element of this modal.
179 * @return {object} jQuery object
181 Modal.prototype.getModal = function() {
186 * Get the modal title element.
189 * @return {object} jQuery object
191 Modal.prototype.getTitle = function() {
196 * Get the modal body element.
199 * @return {object} jQuery object
201 Modal.prototype.getBody = function() {
206 * Get the modal footer element.
209 * @return {object} jQuery object
211 Modal.prototype.getFooter = function() {
216 * Set the modal title element.
219 * @param {string} value The title string
221 Modal.prototype.setTitle = function(value) {
222 var title = this.getTitle();
227 * Set the modal body element.
229 * This method is overloaded to take either a string
230 * value for the body or a jQuery promise that is resolved with HTML and Javascript
231 * most commonly from a Templates.render call.
234 * @param {(string|object)} value The body string or jQuery promise
236 Modal.prototype.setBody = function(value) {
237 var body = this.getBody();
239 if (typeof value === 'string') {
240 // Just set the value if it's a string.
242 Event.notifyFilterContentUpdated(body);
244 // Otherwise we assume it's a promise to be resolved with
245 // html and javascript.
246 Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
249 value.done(function(html, js) {
253 if (this.isAttached) {
254 // If we're in the DOM then run the JS immediately.
255 Templates.runTemplateJS(js);
257 // Otherwise cache it to be run when we're attached.
261 Event.notifyFilterContentUpdated(body);
268 * Set the modal footer element.
270 * This method is overloaded to take either a string
271 * value for the body or a jQuery promise that is resolved with HTML and Javascript
272 * most commonly from a Templates.render call.
275 * @param {(string|object)} value The footer string or jQuery promise
277 Modal.prototype.setFooter = function(value) {
278 var footer = this.getFooter();
280 if (typeof value === 'string') {
281 // Just set the value if it's a string.
284 // Otherwise we assume it's a promise to be resolved with
285 // html and javascript.
286 Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
289 value.done(function(html, js) {
293 if (this.isAttached) {
294 // If we're in the DOM then run the JS immediately.
295 Templates.runTemplateJS(js);
297 // Otherwise cache it to be run when we're attached.
307 * Mark the modal as a large modal.
311 Modal.prototype.setLarge = function() {
312 if (this.isLarge()) {
316 this.getRoot().addClass('large');
320 * Check if the modal is a large modal.
325 Modal.prototype.isLarge = function() {
326 return this.getRoot().hasClass('large');
330 * Mark the modal as a small modal.
334 Modal.prototype.setSmall = function() {
335 if (this.isSmall()) {
339 this.getRoot().removeClass('large');
343 * Check if the modal is a small modal.
348 Modal.prototype.isSmall = function() {
349 return !this.getRoot().hasClass('large');
353 * Determine the highest z-index value currently on the page.
355 * @method calculateZIndex
358 Modal.prototype.calculateZIndex = function() {
359 var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX);
360 var zIndex = parseInt(this.root.css('z-index'));
362 items.each(function(index, item) {
364 // Note that webkit browsers won't return the z-index value from the CSS stylesheet
365 // if the element doesn't have a position specified. Instead it'll return "auto".
366 var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
368 if (itemZIndex > zIndex) {
377 * Check if this modal is visible.
382 Modal.prototype.isVisible = function() {
383 return this.root.hasClass('show');
387 * Check if this modal has focus.
392 Modal.prototype.hasFocus = function() {
393 var target = $(document.activeElement);
394 return this.root.is(target) || this.root.has(target).length;
398 * Check if this modal has CSS transitions applied.
400 * @method hasTransitions
403 Modal.prototype.hasTransitions = function() {
404 return this.getRoot().hasClass('fade');
408 * Display this modal. The modal will be attached to the DOM if it hasn't
413 Modal.prototype.show = function() {
414 if (this.isVisible()) {
418 if (!this.isAttached) {
422 this.getBackdrop().done(function(backdrop) {
423 var currentIndex = this.calculateZIndex();
424 var newIndex = currentIndex + 2;
425 var newBackdropIndex = newIndex - 1;
426 this.root.css('z-index', newIndex);
427 backdrop.setZIndex(newBackdropIndex);
430 this.root.removeClass('hide').addClass('show');
431 this.accessibilityShow();
432 this.getTitle().focus();
433 $('body').addClass('modal-open');
434 this.root.trigger(ModalEvents.shown, this);
443 Modal.prototype.hide = function() {
444 if (!this.isVisible()) {
448 this.getBackdrop().done(function(backdrop) {
449 if (!this.countOtherVisibleModals()) {
450 // Hide the backdrop if we're the last open modal.
452 $('body').removeClass('modal-open');
455 var currentIndex = parseInt(this.root.css('z-index'));
456 this.root.css('z-index', '');
457 backdrop.setZIndex(currentIndex - 3);
459 this.accessibilityHide();
461 if (this.hasTransitions()) {
462 // Wait for CSS transitions to complete before hiding the element.
463 this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
464 this.getRoot().removeClass('show').addClass('hide');
467 this.getRoot().removeClass('show').addClass('hide');
470 this.root.trigger(ModalEvents.hidden, this);
475 * Remove this modal from the DOM.
479 Modal.prototype.destroy = function() {
481 this.root.trigger(ModalEvents.destroyed, this);
485 * Sets the appropriate aria attributes on this dialogue and the other
486 * elements in the DOM to ensure that screen readers are able to navigate
487 * the dialogue popup correctly.
489 * @method accessibilityShow
491 Modal.prototype.accessibilityShow = function() {
492 // We need to get a list containing each sibling element and the shallowest
493 // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
494 // the fact that this dialogue is always appended to the document body therefore
495 // it's siblings are the shallowest non-ancestral nodes. If that changes then
496 // this code should also be updated.
497 $('body').children().each(function(index, child) {
498 // Skip the current modal.
499 if (!this.root.is(child)) {
501 var hidden = child.attr('aria-hidden');
502 // If they are already hidden we can ignore them.
503 if (hidden !== 'true') {
504 // Save their current state.
505 child.data('previous-aria-hidden', hidden);
506 this.hiddenSiblings.push(child);
508 // Hide this node from screen readers.
509 child.attr('aria-hidden', 'true');
514 // Make us visible to screen readers.
515 this.root.attr('aria-hidden', 'false');
519 * Restores the aria visibility on the DOM elements changed when displaying
520 * the dialogue popup and makes the dialogue aria hidden to allow screen
521 * readers to navigate the main page correctly when the dialogue is closed.
523 * @method accessibilityHide
525 Modal.prototype.accessibilityHide = function() {
526 this.root.attr('aria-hidden', 'true');
528 // Restore the sibling nodes back to their original values.
529 $.each(this.hiddenSiblings, function(index, sibling) {
530 sibling = $(sibling);
531 var previousValue = sibling.data('previous-aria-hidden');
532 // If the element didn't previously have an aria-hidden attribute
533 // then we can just remove the one we set.
534 if (typeof previousValue == 'undefined') {
535 sibling.removeAttr('aria-hidden');
537 // Otherwise set it back to the old value (which will be false).
538 sibling.attr('aria-hidden', previousValue);
542 // Clear the cache. No longer need to store these.
543 this.hiddenSiblings = [];
547 * Handle the tab event to lock focus within this modal.
549 * @method handleTabLock
550 * @param {event} e The tab key jQuery event
552 Modal.prototype.handleTabLock = function(e) {
553 if (!this.hasFocus()) {
557 var target = $(document.activeElement);
558 var focusableElements = this.modal.find(SELECTORS.CAN_RECEIVE_FOCUS);
559 var firstFocusable = focusableElements.first();
560 var lastFocusable = focusableElements.last();
562 if (target.is(firstFocusable) && e.shiftKey) {
563 lastFocusable.focus();
565 } else if (target.is(lastFocusable) && !e.shiftKey) {
566 firstFocusable.focus();
572 * Set up all of the event handling for the modal.
574 * @method registerEventListeners
576 Modal.prototype.registerEventListeners = function() {
577 this.getRoot().on('keydown', function(e) {
578 if (!this.isVisible()) {
582 if (e.keyCode == KeyCodes.tab) {
583 this.handleTabLock(e);
584 } else if (e.keyCode == KeyCodes.escape) {
589 CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
590 this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) {
592 data.originalEvent.preventDefault();