lib/amd/src/modal.js

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