Proyectos de Subversion Moodle

Rev

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