Proyectos de Subversion Moodle

Rev

Ir a la última revisión | | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
import $ from 'jquery';
25
import * as Templates from 'core/templates';
26
import * as Notification from 'core/notification';
27
import * as KeyCodes from 'core/key_codes';
28
import ModalBackdrop from 'core/modal_backdrop';
29
import ModalEvents from 'core/modal_events';
30
import * as ModalRegistry from 'core/modal_registry';
31
import Pending from 'core/pending';
32
import * as CustomEvents from 'core/custom_interaction_events';
33
import * as FilterEvents from 'core_filters/events';
34
import * as FocusLock from 'core/local/aria/focuslock';
35
import * as Aria from 'core/aria';
36
import * as Fullscreen from 'core/fullscreen';
37
import {removeToastRegion} from './toast';
38
 
39
/**
40
 * A configuration to provide to the modal.
41
 *
42
 * @typedef {Object} ModalConfig
43
 *
44
 * @property {string} [type] The type of modal to create.
45
 * @property {string|Promise<string>} [title] The title of the modal.
46
 * @property {string|Promise<string>} [body] The body of the modal.
47
 * @property {string|Promise<string>} [footer] The footer of the modal.
48
 * @property {boolean} [show=false] Whether to show the modal immediately.
49
 * @property {boolean} [scrollable=true] Whether the modal should be scrollable.
50
 * @property {boolean} [removeOnClose=true] Whether the modal should be removed from the DOM when it is closed.
51
 * @property {Element|jQuery} [returnElement] The element to focus when closing the modal.
52
 * @property {boolean} [large=false] Whether the modal should be a large modal.
53
 * @property {boolean} [isVerticallyCentered=false] Whether the modal should be vertically centered.
54
 * @property {object} [buttons={}] The buttons to display in the footer as a key => title pair.
55
 */
56
 
57
const SELECTORS = {
58
    CONTAINER: '[data-region="modal-container"]',
59
    MODAL: '[data-region="modal"]',
60
    HEADER: '[data-region="header"]',
61
    TITLE: '[data-region="title"]',
62
    BODY: '[data-region="body"]',
63
    FOOTER: '[data-region="footer"]',
64
    HIDE: '[data-action="hide"]',
65
    DIALOG: '[role=dialog]',
66
    FORM: 'form',
67
    MENU_BAR: '[role=menubar]',
68
    HAS_Z_INDEX: '.moodle-has-zindex',
69
    CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
70
};
71
 
72
const TEMPLATES = {
73
    LOADING: 'core/loading',
74
    BACKDROP: 'core/modal_backdrop',
75
};
76
 
77
export default class Modal {
78
    /** @var {string} The type of modal */
79
    static TYPE = 'default';
80
 
81
    /** @var {string} The template to use for this modal */
82
    static TEMPLATE = 'core/modal';
83
 
84
    /** @var {Promise} Module singleton for the backdrop to be reused by all Modal instances */
85
    static backdropPromise = null;
86
 
87
    /**
88
     * @var {Number} A counter that gets incremented for each modal created.
89
     * This can be used to generate unique values for the modals.
90
     */
91
    static modalCounter = 0;
92
 
93
    /**
94
     * Constructor for the Modal.
95
     *
96
     * @param {HTMLElement} root The HTMLElement at the root of the Modal content
97
     */
98
    constructor(root) {
99
        this.root = $(root);
100
 
101
        this.modal = this.root.find(SELECTORS.MODAL);
102
        this.header = this.modal.find(SELECTORS.HEADER);
103
        this.headerPromise = $.Deferred();
104
        this.title = this.header.find(SELECTORS.TITLE);
105
        this.titlePromise = $.Deferred();
106
        this.body = this.modal.find(SELECTORS.BODY);
107
        this.bodyPromise = $.Deferred();
108
        this.footer = this.modal.find(SELECTORS.FOOTER);
109
        this.footerPromise = $.Deferred();
110
        this.hiddenSiblings = [];
111
        this.isAttached = false;
112
        this.bodyJS = null;
113
        this.footerJS = null;
114
        this.modalCount = Modal.modalCounter++;
115
        this.attachmentPoint = document.createElement('div');
116
        document.body.append(this.attachmentPoint);
117
        this.focusOnClose = null;
118
 
119
        if (!this.root.is(SELECTORS.CONTAINER)) {
120
            Notification.exception({message: 'Element is not a modal container'});
121
        }
122
 
123
        if (!this.modal.length) {
124
            Notification.exception({message: 'Container does not contain a modal'});
125
        }
126
 
127
        if (!this.header.length) {
128
            Notification.exception({message: 'Modal is missing a header region'});
129
        }
130
 
131
        if (!this.title.length) {
132
            Notification.exception({message: 'Modal header is missing a title region'});
133
        }
134
 
135
        if (!this.body.length) {
136
            Notification.exception({message: 'Modal is missing a body region'});
137
        }
138
 
139
        if (!this.footer.length) {
140
            Notification.exception({message: 'Modal is missing a footer region'});
141
        }
142
 
143
        this.registerEventListeners();
144
    }
145
 
146
    /**
147
     * Register a modal with the legacy modal registry.
148
     *
149
     * This is provided to allow backwards-compatibility with existing code that uses the legacy modal registry.
150
     * It is not necessary to register modals for code only present in Moodle 4.3 and later.
151
     */
152
    static registerModalType() {
153
        if (!this.TYPE) {
154
            throw new Error(`Unknown modal type`, this);
155
        }
156
 
157
        if (!this.TEMPLATE) {
158
            throw new Error(`Unknown modal template`, this);
159
        }
160
        ModalRegistry.register(
161
            this.TYPE,
162
            this,
163
            this.TEMPLATE,
164
        );
165
    }
166
 
167
    /**
168
     * Create a new modal using the ModalFactory.
169
     * This is a shortcut to creating the modal.
170
     * Create a new modal using the supplied configuration.
171
     *
172
     * @param {ModalConfig} modalConfig
173
     * @returns {Promise<Modal>}
174
     */
175
    static async create(modalConfig = {}) {
176
        const pendingModalPromise = new Pending('core/modal_factory:create');
177
        modalConfig.type = this.TYPE;
178
 
179
        const templateName = this._getTemplateName(modalConfig);
180
        const templateContext = modalConfig.templateContext || {};
181
        const {html} = await Templates.renderForPromise(templateName, templateContext);
182
 
183
        const modal = new this(html);
184
        modal.configure(modalConfig);
185
 
186
        pendingModalPromise.resolve();
187
 
188
        return modal;
189
    }
190
 
191
    /**
192
     * A helper to get the template name for this modal.
193
     *
194
     * @param {ModalConfig} modalConfig
195
     * @returns {string}
196
     * @protected
197
     */
198
    static _getTemplateName(modalConfig) {
199
        if (modalConfig.template) {
200
            return modalConfig.template;
201
        }
202
 
203
        if (this.TEMPLATE) {
204
            return this.TEMPLATE;
205
        }
206
 
207
        if (ModalRegistry.has(this.TYPE)) {
208
            // Note: This is provided as an interim backwards-compatability layer and will be removed four releases after 4.3.
209
            window.console.warning(
210
                'Use of core/modal_registry is deprecated. ' +
211
                'Please define your modal template in a new static TEMPLATE property on your modal class.',
212
            );
213
            const config = ModalRegistry.get(this.TYPE);
214
            return config.template;
215
        }
216
 
217
        throw new Error(`Unable to determine template name for modal ${this.TYPE}`);
218
    }
219
 
220
    /**
221
     * Configure the modal.
222
     *
223
     * @param {ModalConfig} param0 The configuration options
224
     */
225
    configure({
226
        show = false,
227
        large = false,
228
        isVerticallyCentered = false,
229
        removeOnClose = false,
230
        scrollable = true,
231
        returnElement,
232
        title,
233
        body,
234
        footer,
235
        buttons = {},
236
    } = {}) {
237
        if (large) {
238
            this.setLarge();
239
        }
240
 
241
        if (isVerticallyCentered) {
242
            this.setVerticallyCentered();
243
        }
244
 
245
        // If configured remove the modal when hiding it.
246
        // Ideally this should be true, but we need to identify places that this breaks first.
247
        this.setRemoveOnClose(removeOnClose);
248
        this.setReturnElement(returnElement);
249
        this.setScrollable(scrollable);
250
 
251
        if (title !== undefined) {
252
            this.setTitle(title);
253
        }
254
 
255
        if (body !== undefined) {
256
            this.setBody(body);
257
        }
258
 
259
        if (footer !== undefined) {
260
            this.setFooter(footer);
261
        }
262
 
263
        Object.entries(buttons).forEach(([key, value]) => this.setButtonText(key, value));
264
 
265
        // If configured show the modal.
266
        if (show) {
267
            this.show();
268
        }
269
    }
270
 
271
    /**
272
     * Attach the modal to the correct part of the page.
273
     *
274
     * If it hasn't already been added it runs any
275
     * javascript that has been cached until now.
276
     *
277
     * @method attachToDOM
278
     */
279
    attachToDOM() {
280
        this.getAttachmentPoint().append(this.root);
281
 
282
        if (this.isAttached) {
283
            return;
284
        }
285
 
286
        FocusLock.trapFocus(this.root[0]);
287
 
288
        // If we'd cached any JS then we can run it how that the modal is
289
        // attached to the DOM.
290
        if (this.bodyJS) {
291
            Templates.runTemplateJS(this.bodyJS);
292
            this.bodyJS = null;
293
        }
294
 
295
        if (this.footerJS) {
296
            Templates.runTemplateJS(this.footerJS);
297
            this.footerJS = null;
298
        }
299
 
300
        this.isAttached = true;
301
    }
302
 
303
    /**
304
     * Count the number of other visible modals (not including this one).
305
     *
306
     * @method countOtherVisibleModals
307
     * @return {int}
308
     */
309
    countOtherVisibleModals() {
310
        let count = 0;
311
        $('body').find(SELECTORS.CONTAINER).each((index, element) => {
312
            element = $(element);
313
 
314
            // If we haven't found ourself and the element is visible.
315
            if (!this.root.is(element) && element.hasClass('show')) {
316
                count++;
317
            }
318
        });
319
 
320
        return count;
321
    }
322
 
323
    /**
324
     * Get the modal backdrop.
325
     *
326
     * @method getBackdrop
327
     * @return {object} jQuery promise
328
     */
329
    getBackdrop() {
330
        if (!Modal.backdropPromise) {
331
            Modal.backdropPromise = Templates.render(TEMPLATES.BACKDROP, {})
332
                .then((html) => new ModalBackdrop($(html)))
333
                .catch(Notification.exception);
334
        }
335
 
336
        return Modal.backdropPromise;
337
    }
338
 
339
    /**
340
     * Get the root element of this modal.
341
     *
342
     * @method getRoot
343
     * @return {object} jQuery object
344
     */
345
    getRoot() {
346
        return this.root;
347
    }
348
 
349
    /**
350
     * Get the modal element of this modal.
351
     *
352
     * @method getModal
353
     * @return {object} jQuery object
354
     */
355
    getModal() {
356
        return this.modal;
357
    }
358
 
359
    /**
360
     * Get the modal title element.
361
     *
362
     * @method getTitle
363
     * @return {object} jQuery object
364
     */
365
    getTitle() {
366
        return this.title;
367
    }
368
 
369
    /**
370
     * Get the modal body element.
371
     *
372
     * @method getBody
373
     * @return {object} jQuery object
374
     */
375
    getBody() {
376
        return this.body;
377
    }
378
 
379
    /**
380
     * Get the modal footer element.
381
     *
382
     * @method getFooter
383
     * @return {object} jQuery object
384
     */
385
    getFooter() {
386
        return this.footer;
387
    }
388
 
389
    /**
390
     * Get a promise resolving to the title region.
391
     *
392
     * @method getTitlePromise
393
     * @return {Promise}
394
     */
395
    getTitlePromise() {
396
        return this.titlePromise;
397
    }
398
 
399
    /**
400
     * Get a promise resolving to the body region.
401
     *
402
     * @method getBodyPromise
403
     * @return {object} jQuery object
404
     */
405
    getBodyPromise() {
406
        return this.bodyPromise;
407
    }
408
 
409
    /**
410
     * Get a promise resolving to the footer region.
411
     *
412
     * @method getFooterPromise
413
     * @return {object} jQuery object
414
     */
415
    getFooterPromise() {
416
        return this.footerPromise;
417
    }
418
 
419
    /**
420
     * Get the unique modal count.
421
     *
422
     * @method getModalCount
423
     * @return {int}
424
     */
425
    getModalCount() {
426
        return this.modalCount;
427
    }
428
 
429
    /**
430
     * Set the modal title element.
431
     *
432
     * This method is overloaded to take either a string value for the title or a jQuery promise that is resolved with
433
     * HTML most commonly from a Str.get_string call.
434
     *
435
     * @method setTitle
436
     * @param {(string|object)} value The title string or jQuery promise which resolves to the title.
437
     */
438
    setTitle(value) {
439
        const title = this.getTitle();
440
        this.titlePromise = $.Deferred();
441
 
442
        this.asyncSet(value, title.html.bind(title))
443
        .then(() => {
444
            this.titlePromise.resolve(title);
445
            return;
446
        })
447
        .catch(Notification.exception);
448
    }
449
 
450
    /**
451
     * Set the modal body element.
452
     *
453
     * This method is overloaded to take either a string value for the body or a jQuery promise that is resolved with
454
     * HTML and Javascript most commonly from a Templates.render call.
455
     *
456
     * @method setBody
457
     * @param {(string|object)} value The body string or jQuery promise which resolves to the body.
458
     * @fires event:filterContentUpdated
459
     */
460
    setBody(value) {
461
        this.bodyPromise = $.Deferred();
462
 
463
        const body = this.getBody();
464
 
465
        if (typeof value === 'string') {
466
            // Just set the value if it's a string.
467
            body.html(value);
468
            FilterEvents.notifyFilterContentUpdated(body);
469
            this.getRoot().trigger(ModalEvents.bodyRendered, this);
470
            this.bodyPromise.resolve(body);
471
        } else {
472
            const modalPromise = new Pending(`amd-modal-js-pending-id-${this.getModalCount()}`);
473
            // Otherwise we assume it's a promise to be resolved with
474
            // html and javascript.
475
            let contentPromise = null;
476
            body.css('overflow', 'hidden');
477
 
478
            // Ensure that the `value` is a jQuery Promise.
479
            value = $.when(value);
480
 
481
            if (value.state() == 'pending') {
482
                // We're still waiting for the body promise to resolve so
483
                // let's show a loading icon.
484
                let height = body.innerHeight();
485
                if (height < 100) {
486
                    height = 100;
487
                }
488
 
489
                body.animate({height: `${height}px`}, 150);
490
 
491
                body.html('');
492
                contentPromise = Templates.render(TEMPLATES.LOADING, {})
493
                    .then((html) => {
494
                        const loadingIcon = $(html).hide();
495
                        body.html(loadingIcon);
496
                        loadingIcon.fadeIn(150);
497
 
498
                        // We only want the loading icon to fade out
499
                        // when the content for the body has finished
500
                        // loading.
501
                        return $.when(loadingIcon.promise(), value);
502
                    })
503
                    .then((loadingIcon) => {
504
                        // Once the content has finished loading and
505
                        // the loading icon has been shown then we can
506
                        // fade the icon away to reveal the content.
507
                        return loadingIcon.fadeOut(100).promise();
508
                    })
509
                    .then(() => {
510
                        return value;
511
                    });
512
            } else {
513
                // The content is already loaded so let's just display
514
                // it to the user. No need for a loading icon.
515
                contentPromise = value;
516
            }
517
 
518
            // Now we can actually display the content.
519
            contentPromise.then((html, js) => {
520
                let result = null;
521
 
522
                if (this.isVisible()) {
523
                    // If the modal is visible then we should display
524
                    // the content gracefully for the user.
525
                    body.css('opacity', 0);
526
                    const currentHeight = body.innerHeight();
527
                    body.html(html);
528
                    // We need to clear any height values we've set here
529
                    // in order to measure the height of the content being
530
                    // added. This then allows us to animate the height
531
                    // transition.
532
                    body.css('height', '');
533
                    const newHeight = body.innerHeight();
534
                    body.css('height', `${currentHeight}px`);
535
                    result = body.animate(
536
                        {height: `${newHeight}px`, opacity: 1},
537
                        {duration: 150, queue: false}
538
                    ).promise();
539
                } else {
540
                    // Since the modal isn't visible we can just immediately
541
                    // set the content. No need to animate it.
542
                    body.html(html);
543
                }
544
 
545
                if (js) {
546
                    if (this.isAttached) {
547
                        // If we're in the DOM then run the JS immediately.
548
                        Templates.runTemplateJS(js);
549
                    } else {
550
                        // Otherwise cache it to be run when we're attached.
551
                        this.bodyJS = js;
552
                    }
553
                }
554
 
555
                return result;
556
            })
557
            .then((result) => {
558
                FilterEvents.notifyFilterContentUpdated(body);
559
                this.getRoot().trigger(ModalEvents.bodyRendered, this);
560
                return result;
561
            })
562
            .then(() => {
563
                this.bodyPromise.resolve(body);
564
                return;
565
            })
566
            .catch(Notification.exception)
567
            .always(() => {
568
                // When we're done displaying all of the content we need
569
                // to clear the custom values we've set here.
570
                body.css('height', '');
571
                body.css('overflow', '');
572
                body.css('opacity', '');
573
                modalPromise.resolve();
574
 
575
                return;
576
            });
577
        }
578
    }
579
 
580
    /**
581
     * Alternative to setBody() that can be used from non-Jquery modules
582
     *
583
     * @param {Promise} promise promise that returns {html, js} object
584
     * @return {Promise}
585
     */
586
    setBodyContent(promise) {
587
        // Call the leegacy API for now and pass it a jQuery Promise.
588
        // This is a non-spec feature of jQuery and cannot be produced with spec promises.
589
        // We can encourage people to migrate to this approach, and in future we can swap
590
        // it so that setBody() calls setBodyPromise().
591
        return promise.then(({html, js}) => this.setBody($.when(html, js)))
592
            .catch(exception => {
593
                this.hide();
594
                throw exception;
595
            });
596
    }
597
 
598
    /**
599
     * Set the modal footer element. The footer element is made visible, if it
600
     * isn't already.
601
     *
602
     * This method is overloaded to take either a string
603
     * value for the body or a jQuery promise that is resolved with HTML and Javascript
604
     * most commonly from a Templates.render call.
605
     *
606
     * @method setFooter
607
     * @param {(string|object)} value The footer string or jQuery promise
608
     */
609
    setFooter(value) {
610
        // Make sure the footer is visible.
611
        this.showFooter();
612
        this.footerPromise = $.Deferred();
613
 
614
        const footer = this.getFooter();
615
 
616
        if (typeof value === 'string') {
617
            // Just set the value if it's a string.
618
            footer.html(value);
619
            this.footerPromise.resolve(footer);
620
        } else {
621
            // Otherwise we assume it's a promise to be resolved with
622
            // html and javascript.
623
            Templates.render(TEMPLATES.LOADING, {})
624
            .then((html) => {
625
                footer.html(html);
626
 
627
                return value;
628
            })
629
            .then((html, js) => {
630
                footer.html(html);
631
 
632
                if (js) {
633
                    if (this.isAttached) {
634
                        // If we're in the DOM then run the JS immediately.
635
                        Templates.runTemplateJS(js);
636
                    } else {
637
                        // Otherwise cache it to be run when we're attached.
638
                        this.footerJS = js;
639
                    }
640
                }
641
 
642
                return footer;
643
            })
644
            .then((footer) => {
645
                this.footerPromise.resolve(footer);
646
                this.showFooter();
647
                return;
648
            })
649
            .catch(Notification.exception);
650
        }
651
    }
652
 
653
    /**
654
     * Check if the footer has any content in it.
655
     *
656
     * @method hasFooterContent
657
     * @return {bool}
658
     */
659
    hasFooterContent() {
660
        return this.getFooter().children().length ? true : false;
661
    }
662
 
663
    /**
664
     * Hide the footer element.
665
     *
666
     * @method hideFooter
667
     */
668
    hideFooter() {
669
        this.getFooter().addClass('hidden');
670
    }
671
 
672
    /**
673
     * Show the footer element.
674
     *
675
     * @method showFooter
676
     */
677
    showFooter() {
678
        this.getFooter().removeClass('hidden');
679
    }
680
 
681
    /**
682
     * Mark the modal as a large modal.
683
     *
684
     * @method setLarge
685
     */
686
    setLarge() {
687
        if (this.isLarge()) {
688
            return;
689
        }
690
 
691
        this.getModal().addClass('modal-lg');
692
    }
693
 
694
    /**
695
     * Mark the modal as a centered modal.
696
     *
697
     * @method setVerticallyCentered
698
     */
699
    setVerticallyCentered() {
700
        if (this.isVerticallyCentered()) {
701
            return;
702
        }
703
        this.getModal().addClass('modal-dialog-centered');
704
    }
705
 
706
    /**
707
     * Check if the modal is a large modal.
708
     *
709
     * @method isLarge
710
     * @return {bool}
711
     */
712
    isLarge() {
713
        return this.getModal().hasClass('modal-lg');
714
    }
715
 
716
    /**
717
     * Check if the modal is vertically centered.
718
     *
719
     * @method isVerticallyCentered
720
     * @return {bool}
721
     */
722
    isVerticallyCentered() {
723
        return this.getModal().hasClass('modal-dialog-centered');
724
    }
725
 
726
    /**
727
     * Mark the modal as a small modal.
728
     *
729
     * @method setSmall
730
     */
731
    setSmall() {
732
        if (this.isSmall()) {
733
            return;
734
        }
735
 
736
        this.getModal().removeClass('modal-lg');
737
    }
738
 
739
    /**
740
     * Check if the modal is a small modal.
741
     *
742
     * @method isSmall
743
     * @return {bool}
744
     */
745
    isSmall() {
746
        return !this.getModal().hasClass('modal-lg');
747
    }
748
 
749
    /**
750
     * Set this modal to be scrollable or not.
751
     *
752
     * @method setScrollable
753
     * @param {bool} value Whether the modal is scrollable or not
754
     */
755
    setScrollable(value) {
756
        if (!value) {
757
            this.getModal()[0].classList.remove('modal-dialog-scrollable');
758
            return;
759
        }
760
 
761
        this.getModal()[0].classList.add('modal-dialog-scrollable');
762
    }
763
 
764
 
765
    /**
766
     * Determine the highest z-index value currently on the page.
767
     *
768
     * @method calculateZIndex
769
     * @return {int}
770
     */
771
    calculateZIndex() {
772
        const items = $(`${SELECTORS.DIALOG}, ${SELECTORS.MENU_BAR}, ${SELECTORS.HAS_Z_INDEX}`);
773
        let zIndex = parseInt(this.root.css('z-index'));
774
 
775
        items.each((index, item) => {
776
            item = $(item);
777
            if (!item.is(':visible')) {
778
                // Do not include items which are not visible in the z-index calculation.
779
                // This is important because some dialogues are not removed from the DOM.
780
                return;
781
            }
782
            // Note that webkit browsers won't return the z-index value from the CSS stylesheet
783
            // if the element doesn't have a position specified. Instead it'll return "auto".
784
            const itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
785
 
786
            if (itemZIndex > zIndex) {
787
                zIndex = itemZIndex;
788
            }
789
        });
790
 
791
        return zIndex;
792
    }
793
 
794
    /**
795
     * Check if this modal is visible.
796
     *
797
     * @method isVisible
798
     * @return {bool}
799
     */
800
    isVisible() {
801
        return this.root.hasClass('show');
802
    }
803
 
804
    /**
805
     * Check if this modal has focus.
806
     *
807
     * @method hasFocus
808
     * @return {bool}
809
     */
810
    hasFocus() {
811
        const target = $(document.activeElement);
812
        return this.root.is(target) || this.root.has(target).length;
813
    }
814
 
815
    /**
816
     * Check if this modal has CSS transitions applied.
817
     *
818
     * @method hasTransitions
819
     * @return {bool}
820
     */
821
    hasTransitions() {
822
        return this.getRoot().hasClass('fade');
823
    }
824
 
825
    /**
826
     * Gets the jQuery wrapped node that the Modal should be attached to.
827
     *
828
     * @returns {jQuery}
829
     */
830
    getAttachmentPoint() {
831
        return $(Fullscreen.getElement() || this.attachmentPoint);
832
    }
833
 
834
    /**
835
     * Display this modal. The modal will be attached to the DOM if it hasn't
836
     * already been.
837
     *
838
     * @method show
839
     * @returns {Promise}
840
     */
841
    show() {
842
        if (this.isVisible()) {
843
            return $.Deferred().resolve();
844
        }
845
 
846
        const pendingPromise = new Pending('core/modal:show');
847
 
848
        if (this.hasFooterContent()) {
849
            this.showFooter();
850
        } else {
851
            this.hideFooter();
852
        }
853
 
854
        this.attachToDOM();
855
 
856
        // If the focusOnClose was not set. Set the focus back to triggered element.
857
        if (!this.focusOnClose && document.activeElement) {
858
            this.focusOnClose = document.activeElement;
859
        }
860
 
861
        return this.getBackdrop()
862
        .then((backdrop) => {
863
            const currentIndex = this.calculateZIndex();
864
            const newIndex = currentIndex + 2;
865
            const newBackdropIndex = newIndex - 1;
866
            this.root.css('z-index', newIndex);
867
            backdrop.setZIndex(newBackdropIndex);
868
            backdrop.show();
869
 
870
            this.root.removeClass('hide').addClass('show');
871
            this.accessibilityShow();
872
            this.getModal().focus();
873
            $('body').addClass('modal-open');
874
            this.root.trigger(ModalEvents.shown, this);
875
 
876
            return;
877
        })
878
        .then(pendingPromise.resolve);
879
    }
880
 
881
    /**
882
     * Hide this modal if it does not contain a form.
883
     *
884
     * @method hideIfNotForm
885
     */
886
    hideIfNotForm() {
887
        const formElement = this.modal.find(SELECTORS.FORM);
888
        if (formElement.length == 0) {
889
            this.hide();
890
        }
891
    }
892
 
893
    /**
894
     * Hide this modal.
895
     *
896
     * @method hide
897
     */
898
    hide() {
899
        this.getBackdrop().done((backdrop) => {
900
            FocusLock.untrapFocus();
901
 
902
            if (!this.countOtherVisibleModals()) {
903
                // Hide the backdrop if we're the last open modal.
904
                backdrop.hide();
905
                $('body').removeClass('modal-open');
906
            }
907
 
908
            const currentIndex = parseInt(this.root.css('z-index'));
909
            this.root.css('z-index', '');
910
            backdrop.setZIndex(currentIndex - 3);
911
 
912
            this.accessibilityHide();
913
 
914
            if (this.hasTransitions()) {
915
                // Wait for CSS transitions to complete before hiding the element.
916
                this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', () => {
917
                    this.getRoot().removeClass('show').addClass('hide');
918
                });
919
            } else {
920
                this.getRoot().removeClass('show').addClass('hide');
921
            }
922
 
923
            // Ensure the modal is moved onto the body node if it is still attached to the DOM.
924
            if ($(document.body).find(this.getRoot()).length) {
925
                $(document.body).append(this.getRoot());
926
            }
927
 
928
            this.root.trigger(ModalEvents.hidden, this);
929
        });
930
    }
931
 
932
    /**
933
     * Remove this modal from the DOM.
934
     *
935
     * @method destroy
936
     */
937
    destroy() {
938
        this.hide();
939
        removeToastRegion(this.getBody().get(0));
940
        this.root.remove();
941
        this.root.trigger(ModalEvents.destroyed, this);
942
        this.attachmentPoint.remove();
943
    }
944
 
945
    /**
946
     * Sets the appropriate aria attributes on this dialogue and the other
947
     * elements in the DOM to ensure that screen readers are able to navigate
948
     * the dialogue popup correctly.
949
     *
950
     * @method accessibilityShow
951
     */
952
    accessibilityShow() {
953
        // Make us visible to screen readers.
954
        Aria.unhide(this.root.get());
955
 
956
        // Hide siblings.
957
        Aria.hideSiblings(this.root.get()[0]);
958
    }
959
 
960
    /**
961
     * Restores the aria visibility on the DOM elements changed when displaying
962
     * the dialogue popup and makes the dialogue aria hidden to allow screen
963
     * readers to navigate the main page correctly when the dialogue is closed.
964
     *
965
     * @method accessibilityHide
966
     */
967
    accessibilityHide() {
968
        // Unhide siblings.
969
        Aria.unhideSiblings(this.root.get()[0]);
970
 
971
        // Hide this modal.
972
        Aria.hide(this.root.get());
973
    }
974
 
975
    /**
976
     * Set up all of the event handling for the modal.
977
     *
978
     * @method registerEventListeners
979
     */
980
    registerEventListeners() {
981
        this.getRoot().on('keydown', (e) => {
982
            if (!this.isVisible()) {
983
                return;
984
            }
985
 
986
            if (e.keyCode == KeyCodes.escape) {
987
                if (this.removeOnClose) {
988
                    this.destroy();
989
                } else {
990
                    this.hide();
991
                }
992
            }
993
        });
994
 
995
        // Listen for clicks on the modal container.
996
        this.getRoot().click((e) => {
997
            // If the click wasn't inside the modal element then we should
998
            // hide the modal.
999
            if (!$(e.target).closest(SELECTORS.MODAL).length) {
1000
                // The check above fails to detect the click was inside the modal when the DOM tree is already changed.
1001
                // So, we check if we can still find the container element or not. If not, then the DOM tree is changed.
1002
                // It's best not to hide the modal in that case.
1003
                if ($(e.target).closest(SELECTORS.CONTAINER).length) {
1004
                    const outsideClickEvent = $.Event(ModalEvents.outsideClick);
1005
                    this.getRoot().trigger(outsideClickEvent, this);
1006
 
1007
                    if (!outsideClickEvent.isDefaultPrevented()) {
1008
                        this.hideIfNotForm();
1009
                    }
1010
                }
1011
            }
1012
        });
1013
 
1014
        CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
1015
        this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, (e, data) => {
1016
            if (this.removeOnClose) {
1017
                this.destroy();
1018
            } else {
1019
                this.hide();
1020
            }
1021
            data.originalEvent.preventDefault();
1022
        });
1023
 
1024
        this.getRoot().on(ModalEvents.hidden, () => {
1025
            if (this.focusOnClose) {
1026
                // Focus on the element that actually triggers the modal.
1027
                this.focusOnClose.focus();
1028
            }
1029
        });
1030
    }
1031
 
1032
    /**
1033
     * Register a listener to close the dialogue when the cancel button is pressed.
1034
     *
1035
     * @method registerCloseOnCancel
1036
     */
1037
    registerCloseOnCancel() {
1038
        // Handle the clicking of the Cancel button.
1039
        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('cancel'), (e, data) => {
1040
            const cancelEvent = $.Event(ModalEvents.cancel);
1041
            this.getRoot().trigger(cancelEvent, this);
1042
 
1043
            if (!cancelEvent.isDefaultPrevented()) {
1044
                data.originalEvent.preventDefault();
1045
 
1046
                if (this.removeOnClose) {
1047
                    this.destroy();
1048
                } else {
1049
                    this.hide();
1050
                }
1051
            }
1052
        });
1053
    }
1054
 
1055
    /**
1056
     * Register a listener to close the dialogue when the save button is pressed.
1057
     *
1058
     * @method registerCloseOnSave
1059
     */
1060
    registerCloseOnSave() {
1061
        // Handle the clicking of the Cancel button.
1062
        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), (e, data) => {
1063
            const saveEvent = $.Event(ModalEvents.save);
1064
            this.getRoot().trigger(saveEvent, this);
1065
 
1066
            if (!saveEvent.isDefaultPrevented()) {
1067
                data.originalEvent.preventDefault();
1068
 
1069
                if (this.removeOnClose) {
1070
                    this.destroy();
1071
                } else {
1072
                    this.hide();
1073
                }
1074
            }
1075
        });
1076
    }
1077
 
1078
 
1079
    /**
1080
     * Register a listener to close the dialogue when the delete button is pressed.
1081
     *
1082
     * @method registerCloseOnDelete
1083
     */
1084
    registerCloseOnDelete() {
1085
        // Handle the clicking of the Cancel button.
1086
        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('delete'), (e, data) => {
1087
            const deleteEvent = $.Event(ModalEvents.delete);
1088
            this.getRoot().trigger(deleteEvent, this);
1089
 
1090
            if (!deleteEvent.isDefaultPrevented()) {
1091
                data.originalEvent.preventDefault();
1092
 
1093
                if (this.removeOnClose) {
1094
                    this.destroy();
1095
                } else {
1096
                    this.hide();
1097
                }
1098
            }
1099
        });
1100
    }
1101
 
1102
    /**
1103
     * Set or resolve and set the value using the function.
1104
     *
1105
     * @method asyncSet
1106
     * @param {(string|object)} value The string or jQuery promise.
1107
     * @param {function} setFunction The setter
1108
     * @return {Promise}
1109
     */
1110
    asyncSet(value, setFunction) {
1111
        const getWrappedValue = (value) => {
1112
            if (value instanceof Promise) {
1113
                return $.when(value);
1114
            }
1115
 
1116
            if (typeof value !== 'object' || !value.hasOwnProperty('then')) {
1117
                return $.Deferred().resolve(value);
1118
            }
1119
 
1120
            return value;
1121
        };
1122
 
1123
        return getWrappedValue(value)
1124
            .then((content) => setFunction(content))
1125
            .catch(Notification.exception);
1126
    }
1127
 
1128
    /**
1129
     * Set the title text of a button.
1130
     *
1131
     * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
1132
     * text most commonly from a Str.get_string call.
1133
     *
1134
     * @param {DOMString} action The action of the button
1135
     * @param {(String|object)} value The button text, or a promise which will resolve to it
1136
     * @returns {Promise}
1137
     */
1138
    setButtonText(action, value) {
1139
        const button = this.getFooter().find(this.getActionSelector(action));
1140
 
1141
        if (!button) {
1142
            throw new Error("Unable to find the '" + action + "' button");
1143
        }
1144
 
1145
        return this.asyncSet(value, button.text.bind(button));
1146
    }
1147
 
1148
    /**
1149
     * Get the Selector for an action.
1150
     *
1151
     * @param {String} action
1152
     * @returns {DOMString}
1153
     */
1154
    getActionSelector(action) {
1155
        return "[data-action='" + action + "']";
1156
    }
1157
 
1158
    /**
1159
     * Set the flag to remove the modal from the DOM on close.
1160
     *
1161
     * @param {Boolean} remove
1162
     */
1163
    setRemoveOnClose(remove) {
1164
        this.removeOnClose = remove;
1165
    }
1166
 
1167
    /**
1168
     * Set the return element for the modal.
1169
     *
1170
     * @param {Element|jQuery} element Element to focus when the modal is closed
1171
     */
1172
    setReturnElement(element) {
1173
        this.focusOnClose = element;
1174
    }
1175
 
1176
    /**
1177
     * Set the a button enabled or disabled.
1178
     *
1179
     * @param {DOMString} action The action of the button
1180
     * @param {Boolean} disabled the new disabled value
1181
     */
1182
    setButtonDisabled(action, disabled) {
1183
        const button = this.getFooter().find(this.getActionSelector(action));
1184
 
1185
        if (!button) {
1186
            throw new Error("Unable to find the '" + action + "' button");
1187
        }
1188
        if (disabled) {
1189
            button.attr('disabled', '');
1190
        } else {
1191
            button.removeAttr('disabled');
1192
        }
1193
    }
1194
}