Commit | Line | Data |
---|---|---|
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 | */ | |
25 | define(['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 | * | |
218 | * @method setTitle | |
219 | * @param {string} value The title string | |
220 | */ | |
221 | Modal.prototype.setTitle = function(value) { | |
222 | var title = this.getTitle(); | |
223 | title.html(value); | |
224 | }; | |
225 | ||
226 | /** | |
227 | * Set the modal body element. | |
228 | * | |
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. | |
232 | * | |
233 | * @method setBody | |
234 | * @param {(string|object)} value The body string or jQuery promise | |
235 | */ | |
236 | Modal.prototype.setBody = function(value) { | |
237 | var body = this.getBody(); | |
238 | ||
239 | if (typeof value === 'string') { | |
240 | // Just set the value if it's a string. | |
241 | body.html(value); | |
f02e119a | 242 | Event.notifyFilterContentUpdated(body); |
97c4a29d | 243 | this.getRoot().trigger(ModalEvents.bodyRendered, this); |
2bcef559 RW |
244 | } else { |
245 | // Otherwise we assume it's a promise to be resolved with | |
246 | // html and javascript. | |
247 | Templates.render(TEMPLATES.LOADING, {}).done(function(html) { | |
248 | body.html(html); | |
249 | ||
250 | value.done(function(html, js) { | |
251 | body.html(html); | |
252 | ||
10ea8270 RW |
253 | if (js) { |
254 | if (this.isAttached) { | |
255 | // If we're in the DOM then run the JS immediately. | |
256 | Templates.runTemplateJS(js); | |
257 | } else { | |
258 | // Otherwise cache it to be run when we're attached. | |
259 | this.bodyJS = js; | |
260 | } | |
2bcef559 | 261 | } |
f02e119a | 262 | Event.notifyFilterContentUpdated(body); |
97c4a29d | 263 | this.getRoot().trigger(ModalEvents.bodyRendered, this); |
2bcef559 RW |
264 | }.bind(this)); |
265 | }.bind(this)); | |
266 | } | |
267 | }; | |
268 | ||
269 | /** | |
270 | * Set the modal footer element. | |
271 | * | |
272 | * This method is overloaded to take either a string | |
273 | * value for the body or a jQuery promise that is resolved with HTML and Javascript | |
274 | * most commonly from a Templates.render call. | |
275 | * | |
276 | * @method setFooter | |
277 | * @param {(string|object)} value The footer string or jQuery promise | |
278 | */ | |
279 | Modal.prototype.setFooter = function(value) { | |
280 | var footer = this.getFooter(); | |
281 | ||
282 | if (typeof value === 'string') { | |
283 | // Just set the value if it's a string. | |
284 | footer.html(value); | |
285 | } else { | |
286 | // Otherwise we assume it's a promise to be resolved with | |
287 | // html and javascript. | |
288 | Templates.render(TEMPLATES.LOADING, {}).done(function(html) { | |
289 | footer.html(html); | |
290 | ||
291 | value.done(function(html, js) { | |
292 | footer.html(html); | |
293 | ||
10ea8270 RW |
294 | if (js) { |
295 | if (this.isAttached) { | |
296 | // If we're in the DOM then run the JS immediately. | |
297 | Templates.runTemplateJS(js); | |
298 | } else { | |
299 | // Otherwise cache it to be run when we're attached. | |
300 | this.footerJS = js; | |
301 | } | |
2bcef559 RW |
302 | } |
303 | }.bind(this)); | |
304 | }.bind(this)); | |
305 | } | |
306 | }; | |
307 | ||
308 | /** | |
309 | * Mark the modal as a large modal. | |
310 | * | |
311 | * @method setLarge | |
312 | */ | |
313 | Modal.prototype.setLarge = function() { | |
314 | if (this.isLarge()) { | |
315 | return; | |
316 | } | |
317 | ||
318 | this.getRoot().addClass('large'); | |
319 | }; | |
320 | ||
321 | /** | |
322 | * Check if the modal is a large modal. | |
323 | * | |
324 | * @method isLarge | |
325 | * @return {bool} | |
326 | */ | |
327 | Modal.prototype.isLarge = function() { | |
328 | return this.getRoot().hasClass('large'); | |
329 | }; | |
330 | ||
331 | /** | |
332 | * Mark the modal as a small modal. | |
333 | * | |
334 | * @method setSmall | |
335 | */ | |
336 | Modal.prototype.setSmall = function() { | |
337 | if (this.isSmall()) { | |
338 | return; | |
339 | } | |
340 | ||
341 | this.getRoot().removeClass('large'); | |
342 | }; | |
343 | ||
344 | /** | |
345 | * Check if the modal is a small modal. | |
346 | * | |
347 | * @method isSmall | |
348 | * @return {bool} | |
349 | */ | |
350 | Modal.prototype.isSmall = function() { | |
351 | return !this.getRoot().hasClass('large'); | |
352 | }; | |
353 | ||
354 | /** | |
355 | * Determine the highest z-index value currently on the page. | |
356 | * | |
357 | * @method calculateZIndex | |
358 | * @return {int} | |
359 | */ | |
360 | Modal.prototype.calculateZIndex = function() { | |
361 | var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX); | |
362 | var zIndex = parseInt(this.root.css('z-index')); | |
363 | ||
364 | items.each(function(index, item) { | |
365 | item = $(item); | |
366 | // Note that webkit browsers won't return the z-index value from the CSS stylesheet | |
367 | // if the element doesn't have a position specified. Instead it'll return "auto". | |
368 | var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0; | |
369 | ||
370 | if (itemZIndex > zIndex) { | |
371 | zIndex = itemZIndex; | |
372 | } | |
373 | }); | |
374 | ||
375 | return zIndex; | |
376 | }; | |
377 | ||
378 | /** | |
379 | * Check if this modal is visible. | |
380 | * | |
381 | * @method isVisible | |
382 | * @return {bool} | |
383 | */ | |
384 | Modal.prototype.isVisible = function() { | |
385 | return this.root.hasClass('show'); | |
386 | }; | |
387 | ||
388 | /** | |
389 | * Check if this modal has focus. | |
390 | * | |
391 | * @method hasFocus | |
392 | * @return {bool} | |
393 | */ | |
394 | Modal.prototype.hasFocus = function() { | |
395 | var target = $(document.activeElement); | |
396 | return this.root.is(target) || this.root.has(target).length; | |
397 | }; | |
398 | ||
399 | /** | |
400 | * Check if this modal has CSS transitions applied. | |
401 | * | |
402 | * @method hasTransitions | |
403 | * @return {bool} | |
404 | */ | |
405 | Modal.prototype.hasTransitions = function() { | |
406 | return this.getRoot().hasClass('fade'); | |
407 | }; | |
408 | ||
409 | /** | |
410 | * Display this modal. The modal will be attached to the DOM if it hasn't | |
411 | * already been. | |
412 | * | |
413 | * @method show | |
414 | */ | |
415 | Modal.prototype.show = function() { | |
416 | if (this.isVisible()) { | |
417 | return; | |
418 | } | |
419 | ||
420 | if (!this.isAttached) { | |
421 | this.attachToDOM(); | |
422 | } | |
423 | ||
424 | this.getBackdrop().done(function(backdrop) { | |
425 | var currentIndex = this.calculateZIndex(); | |
426 | var newIndex = currentIndex + 2; | |
427 | var newBackdropIndex = newIndex - 1; | |
428 | this.root.css('z-index', newIndex); | |
429 | backdrop.setZIndex(newBackdropIndex); | |
430 | backdrop.show(); | |
431 | ||
432 | this.root.removeClass('hide').addClass('show'); | |
433 | this.accessibilityShow(); | |
434 | this.getTitle().focus(); | |
435 | $('body').addClass('modal-open'); | |
436 | this.root.trigger(ModalEvents.shown, this); | |
437 | }.bind(this)); | |
438 | }; | |
439 | ||
440 | /** | |
441 | * Hide this modal. | |
442 | * | |
443 | * @method hide | |
444 | */ | |
445 | Modal.prototype.hide = function() { | |
446 | if (!this.isVisible()) { | |
447 | return; | |
448 | } | |
449 | ||
450 | this.getBackdrop().done(function(backdrop) { | |
451 | if (!this.countOtherVisibleModals()) { | |
452 | // Hide the backdrop if we're the last open modal. | |
453 | backdrop.hide(); | |
454 | $('body').removeClass('modal-open'); | |
455 | } | |
456 | ||
457 | var currentIndex = parseInt(this.root.css('z-index')); | |
458 | this.root.css('z-index', ''); | |
459 | backdrop.setZIndex(currentIndex - 3); | |
460 | ||
461 | this.accessibilityHide(); | |
462 | ||
463 | if (this.hasTransitions()) { | |
464 | // Wait for CSS transitions to complete before hiding the element. | |
465 | this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() { | |
466 | this.getRoot().removeClass('show').addClass('hide'); | |
467 | }.bind(this)); | |
468 | } else { | |
469 | this.getRoot().removeClass('show').addClass('hide'); | |
470 | } | |
471 | ||
472 | this.root.trigger(ModalEvents.hidden, this); | |
473 | }.bind(this)); | |
474 | }; | |
475 | ||
476 | /** | |
477 | * Remove this modal from the DOM. | |
478 | * | |
479 | * @method destroy | |
480 | */ | |
481 | Modal.prototype.destroy = function() { | |
482 | this.root.remove(); | |
483 | this.root.trigger(ModalEvents.destroyed, this); | |
484 | }; | |
485 | ||
486 | /** | |
487 | * Sets the appropriate aria attributes on this dialogue and the other | |
488 | * elements in the DOM to ensure that screen readers are able to navigate | |
489 | * the dialogue popup correctly. | |
490 | * | |
491 | * @method accessibilityShow | |
492 | */ | |
493 | Modal.prototype.accessibilityShow = function() { | |
494 | // We need to get a list containing each sibling element and the shallowest | |
495 | // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging | |
496 | // the fact that this dialogue is always appended to the document body therefore | |
497 | // it's siblings are the shallowest non-ancestral nodes. If that changes then | |
498 | // this code should also be updated. | |
499 | $('body').children().each(function(index, child) { | |
500 | // Skip the current modal. | |
501 | if (!this.root.is(child)) { | |
502 | child = $(child); | |
503 | var hidden = child.attr('aria-hidden'); | |
504 | // If they are already hidden we can ignore them. | |
505 | if (hidden !== 'true') { | |
506 | // Save their current state. | |
507 | child.data('previous-aria-hidden', hidden); | |
508 | this.hiddenSiblings.push(child); | |
509 | ||
510 | // Hide this node from screen readers. | |
511 | child.attr('aria-hidden', 'true'); | |
512 | } | |
513 | } | |
514 | }.bind(this)); | |
515 | ||
516 | // Make us visible to screen readers. | |
517 | this.root.attr('aria-hidden', 'false'); | |
518 | }; | |
519 | ||
520 | /** | |
521 | * Restores the aria visibility on the DOM elements changed when displaying | |
522 | * the dialogue popup and makes the dialogue aria hidden to allow screen | |
523 | * readers to navigate the main page correctly when the dialogue is closed. | |
524 | * | |
525 | * @method accessibilityHide | |
526 | */ | |
527 | Modal.prototype.accessibilityHide = function() { | |
528 | this.root.attr('aria-hidden', 'true'); | |
529 | ||
530 | // Restore the sibling nodes back to their original values. | |
531 | $.each(this.hiddenSiblings, function(index, sibling) { | |
532 | sibling = $(sibling); | |
533 | var previousValue = sibling.data('previous-aria-hidden'); | |
534 | // If the element didn't previously have an aria-hidden attribute | |
535 | // then we can just remove the one we set. | |
536 | if (typeof previousValue == 'undefined') { | |
537 | sibling.removeAttr('aria-hidden'); | |
538 | } else { | |
539 | // Otherwise set it back to the old value (which will be false). | |
540 | sibling.attr('aria-hidden', previousValue); | |
541 | } | |
542 | }); | |
543 | ||
544 | // Clear the cache. No longer need to store these. | |
545 | this.hiddenSiblings = []; | |
546 | }; | |
547 | ||
548 | /** | |
549 | * Handle the tab event to lock focus within this modal. | |
550 | * | |
551 | * @method handleTabLock | |
552 | * @param {event} e The tab key jQuery event | |
553 | */ | |
554 | Modal.prototype.handleTabLock = function(e) { | |
555 | if (!this.hasFocus()) { | |
556 | return; | |
557 | } | |
558 | ||
559 | var target = $(document.activeElement); | |
560 | var focusableElements = this.modal.find(SELECTORS.CAN_RECEIVE_FOCUS); | |
561 | var firstFocusable = focusableElements.first(); | |
562 | var lastFocusable = focusableElements.last(); | |
563 | ||
564 | if (target.is(firstFocusable) && e.shiftKey) { | |
565 | lastFocusable.focus(); | |
566 | e.preventDefault(); | |
567 | } else if (target.is(lastFocusable) && !e.shiftKey) { | |
568 | firstFocusable.focus(); | |
569 | e.preventDefault(); | |
570 | } | |
571 | }; | |
572 | ||
573 | /** | |
574 | * Set up all of the event handling for the modal. | |
575 | * | |
576 | * @method registerEventListeners | |
577 | */ | |
578 | Modal.prototype.registerEventListeners = function() { | |
579 | this.getRoot().on('keydown', function(e) { | |
580 | if (!this.isVisible()) { | |
581 | return; | |
582 | } | |
583 | ||
584 | if (e.keyCode == KeyCodes.tab) { | |
585 | this.handleTabLock(e); | |
586 | } else if (e.keyCode == KeyCodes.escape) { | |
587 | this.hide(); | |
588 | } | |
589 | }.bind(this)); | |
590 | ||
591 | CustomEvents.define(this.getModal(), [CustomEvents.events.activate]); | |
592 | this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) { | |
593 | this.hide(); | |
594 | data.originalEvent.preventDefault(); | |
595 | }.bind(this)); | |
596 | }; | |
597 | ||
598 | return Modal; | |
599 | }); |