dialogs/dialogs.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/* eslint-env browser */

define("dialogs", [
  "jquery",
  "Promise",
  "underscore",
  'nmodule/js/rc/asyncUtils/asyncUtils',
  "lex!js",
  "hbs!nmodule/js/rc/dialogs/loader",
  "css!nmodule/js/rc/dialogs/dialogs" ], function (
  $,
  Promise,
  _,
  asyncUtils,
  lexicons,
  loaderTemplate) {

  "use strict";

  const [ jsLex ] = lexicons;
  const { $deferred } = asyncUtils;
  const { escape, findWhere, noop, pluck, throttle } = _;

  const notHtmlEscaped = (content) => content;
  const tplLoadingTitle = (title) => `<div class='js-dialog-loading'>${ escape(title) }</div>`;
  const tplLoadingContent = (content) => {
    let loaderTxt;
    let loaderImg = content;
    if (content && typeof content !== "string") {
      loaderImg = content.loaderImg;
      loaderTxt = content.loaderTxt;
    }

    return `
      ${ loaderTxt ? tplLoadingMsg(loaderTxt) : '' }
      <img class='js-dialog-loading-img' src='${ escape(loaderImg) }'/>
      `;
  };

  const tplLoadingMsg = (loaderTxt) => `
  <div class='js-dialog-loading-txt'>${ escape(loaderTxt) }</div>
  `;

  const tplHeader = ({ title, loading }) => `
<div class='js-dialog-header ux-bg'>${ loading ? tplLoadingTitle(title) : escape(title) }</div>
`;

  const tplContent = ({ content, isTextContent, loading }) => {
    let classList = `js-dialog-content-wrapper js-dialog-content ux-fullscreen-support`;
    if (isTextContent) {
      classList += ' js-dialog-wrapTextContent';
    }
    return `
<div class="${ classList }">${
      loading ? tplLoadingContent(content) : notHtmlEscaped(content)
    }</div>
`;
  };

  const tplButton = ({ displayName, name }) => `
<li>
  <button type='button' class='ux-btn js-dialog-button js-dialog-button-${ escape(name) }'>${ escape(displayName || name) }</button>
</li>
`;
  const tplButtons = (buttons) => `
<ul class='js-dialog-button-content ux-fg'>${ buttons.map(tplButton).join('') }</ul>
`;

  const tplDialog = ({ title, loading, content, isTextContent, buttons }) => `
<div class='js-dialog-container bajaux-widget-container' style="display:none;">
  <div class='js-dialog ux-root ux-fg ux-shadow ux-border'>
    ${ title ? tplHeader({ title, loading }) : '' }
    ${ content ? tplContent({ content, isTextContent, loading }) : '' }
    ${ buttons && buttons.length ? tplButtons(buttons) : '' }
  </div>
</div>

  `;

  const defaultLoadImg = loaderTemplate();
  const unknownErr = 'Unknown error';

  let openDialogs = [];
  let resizeHandler;

  /**
   * This is a convenience method used for working with functions that take
   * an Object Literal as an argument.
   *
   * This method always ensures an Object is returned so its properties can be
   * further validated.
   *
   * If the obj param is an object literal, this will return a copy of it to ensure the original
   * object is not mutated causing unintended issues from dialogs.
   *
   * @inner
   */
  function objectify(obj, propName) {
    if (!(obj === undefined || obj === null)) {
      if (obj.constructor === Object) {
        return Object.assign({}, obj);
      } else if (typeof propName === "string") {
        if (typeof obj === 'function') {
          propName = "content";
        }
        var o = {};
        o[propName] = obj;
        return o;
      }
    }
    return {};
  }

  /**
   * A default button handler that returns a resolved promise.
   *
   * @inner
   *
   * @returns {Promise}
   */
  function defaultButtonHandler() {
    return Promise.resolve();
  }

  /**
   * Define a default value for possibly undefined variables.
   *
   * @inner
   *
   * @param val The value to be tested.
   * @param defVal The default value to be returned if the value is undefined.
   * @returns The default value if the value is undefined.
   */
  function def(val, defVal) {
    return val === undefined ? defVal : val;
  }

  ////////////////////////////////////////////////////////////////
  // Button DOM
  ////////////////////////////////////////////////////////////////

  /**
   * Return true if the Button's DOM Element is in a state
   * where it can be clicked (i.e. it's not hidden or disabled).
   *
   * @inner
   *
   * @param  {JQuery} buttonJq
   * @return {Boolean} true if the button can be clicked.
   */
  function canClick(buttonJq) {
    return buttonJq &&
      !$("button", buttonJq).attr("disabled") &&
      buttonJq.css("display") !== "none";
  }

  /**
   * Set the button to be enabled/disabled.
   *
   * @inner
   *
   * @param {JQuery} buttonJq The DOM for the button.
   * @param {Boolean} enable
   */
  function setButtonEnable(buttonJq, enable) {
    var b = $("button", buttonJq);
    if (enable) {
      b.removeAttr("disabled");
    } else {
      b.attr("disabled", "disabled");
    }
  }

  /**
   * Show or hide a button.
   *
   * @inner
   *
   * @param {JQuery} buttonJq The button's DOM.
   * @param {boolean} show
   */
  function setButtonShow(buttonJq, show) {
    if (show) {
      buttonJq.show();
    } else {
      buttonJq.hide();
    }
  }

  /**
   * Set the data on the button's DOM.
   *
   * @inner
   *
   * @param {JQuery} buttonJq The button's DOM.
   * @param {Object} data
   */
  function setButtonData(buttonJq, data) {
    $("button", buttonJq).data("js-dialog-button", data);
  }

  /**
   * Return the button object for the button name if it exists.
   *
   * @param  {module:dialogs~Dialog} dlg The Dialog instance.
   * @param  {String} name The name of the button.
   * @return {module:dialogs~Button|undefined} the button data.
   */
  function findButton(dlg, name) {
    return findWhere(dlg.$params.buttons, { name });
  }

  /**
   * Return the button object for the button name.
   *
   * @param  {module:dialogs~Dialog} dlg The Dialog instance.
   * @param  {String} name The name of the button.
   * @return {module:dialogs~Button} the button data.
   * @throws {Error} if button was not found.
   */
  function requireButton(dlg, name) {
    const button = findButton(dlg, name);
    if (!button) {
      throw new Error("Unable to find dialog button: " + name);
    }
    return button;
  }

  /**
   * Find the button DOM under the dialog's DOM via its name.
   *
   * @param  {JQuery} dialogJq The Dialog's DOM object.
   * @param  {String} name      The name of the button to look up.
   * @return {JQuery}           The button's DOM object.
   */
  function findButtonDom(dialogJq, name) {
    return $(".js-dialog-button-" + name, dialogJq).parent();
  }

  ////////////////////////////////////////////////////////////////
  // DOM Creation
  ////////////////////////////////////////////////////////////////


  /**
   * Initialize the DOM for the button.
   *
   * @param {module:dialogs~Dialog} dlg
   * @param {JQuery} buttonJq
   * @param {Object} buttonData
   */
  function initButtonDom(dlg, buttonJq, buttonData) {
    buttonData.handlerArray = [ buttonData.handler || defaultButtonHandler ];

    var name = buttonData.name,
      displayName = buttonData.displayName || name,
      handlerArray = buttonData.handlerArray,
      hide = buttonData.hide,
      disable = buttonData.disable;

    buttonData.jq = buttonJq;

    // Ensure the button name is always defined.
    if (typeof name !== "string") {
      throw new Error("Dialog button has no name!");
    }

    buttonJq.on('click', function () {
      var args = [ dlg ].concat(Array.prototype.slice.call(arguments)),
        i,
        promises = [],
        retVal;

      if (!canClick(buttonJq)) {
        return;
      }

      try {
        for (i = 0; i < handlerArray.length; ++i) {
          try {
            retVal = handlerArray[i].apply(this, args);
          } catch (e) {
            return dlg.close(name, /*fail*/ e || new Error());
          }

          // If a handler returns false then this means we need to use a
          // rejected promise.
          if (retVal === false) {
            retVal = Promise.reject(new Error());
          }

          // When the button is clicked invoke the handler and hold any promises returned
          promises.push(retVal);
        }

        // Close the dialog once all the promises have been resolved
        Promise.all(promises)
          .then(function (results) {
            let result;
            results.forEach((r) => { if (r !== undefined) { result = r; } });
            dlg.$close({ name, result });
          })
          .catch(noop);
      } finally {
        $(window).trigger("jsdialog:buttonpressed",
          [ dlg, name, displayName, handlerArray ]);
      }
    });

    if (disable) {
      setButtonEnable(buttonJq, /*enable*/false);
    }

    if (hide) {
      setButtonShow(buttonJq, /*show*/false);
    }

    setButtonData(buttonJq, buttonData);
  }

  /**
   * Initialize the DOM for the Dialog box.
   *
   * @inner
   *
   * @param  {module:dialogs~Dialog} dlg
   */
  function initDom(dlg) {
    const { buttons, title, content, loading, fade, cancel, no, yes, ok, text } = dlg.$params;
    const templateData = {
      title,
      content: typeof content === "function" ? "<span></span>" : content,
      isTextContent: text && text.length,
      loading,
      buttons,
      fade
    };

    if (cancel) {
      buttons.push({
        name: "cancel",
        handler: cancel,
        esc: true
      });
    }

    if (no) {
      buttons.unshift({
        name: "no",
        handler: no
      });
    }

    if (yes) {
      buttons.unshift({
        name: "yes",
        handler: yes
      });
    }

    if (ok) {
      buttons.unshift({
        name: "ok",
        handler: ok
      });
    }

    // Initialize each button displayName
    buttons.forEach((bt) => {
      bt.displayName = getButtonDisplayName(bt.name, bt.displayName);
    });

    const dialogJq = $(tplDialog(templateData));
    findMainDialogElement(dialogJq).data("js-dialog", dlg);

    // Initialize the buttons
    buttons.forEach((bt) => {
      initButtonDom(dlg, findButtonDom(dialogJq, bt.name), bt);
    });

    dlg.$dialogJq = dialogJq;
  }

  /**
   * Get the displayName for a given button.
   * @param {String} name
   * @param {String} [displayName]
   * @returns {String}
   */
  function getButtonDisplayName(name, displayName) {
    if (displayName) {
      return displayName;
    }
    switch (name) {
      case 'ok':
      case 'cancel':
      case 'yes':
      case 'no':
        return jsLex.get("dialogs." + name) || name;
    }

    return name;
  }

  function findMainDialogElement(dialogJq) {
    return dialogJq.children('.js-dialog');
  }

  function findContentElement(dialogJq) {
    return findMainDialogElement(dialogJq).children('.js-dialog-content');
  }

  function findButtonsElement(dialogJq) {
    return findMainDialogElement(dialogJq).children('.js-dialog-button-content');
  }

  function findHeaderElement(dialogJq) {
    return findMainDialogElement(dialogJq).children('.js-dialog-header');
  }

  ////////////////////////////////////////////////////////////////
  // Dialog
  ////////////////////////////////////////////////////////////////

  /**
   * A class for a Dialog box.
   *
   * An instance of a Dialog can be accessed indirectly by use of
   * one of the showXxx methods.
   *
   * @class
   * @inner
   * @public
   * @memberOf module:dialogs
   *
   * @example
   *   <caption>Show a basic simple OK Dialog box</caption>
   *   dialogs.showOk("Here's a nice OK dialog box!");
   */
  var Dialog = function Dialog(params) {
    var that = this;
    params = objectify(params, "text");

    //do not allow both content and text parameters
    if (params.content && params.text) {
      throw new Error('The "text" and "content" parameters are not allowed at the same time');
    }

    // Set up default dialog parameters
    params.title = def(params.title, "");
    params.buttonNames = params.buttonNames || {};
    params.buttons = params.buttons || [];
    params.parent = params.parent || $("body");
    params.private = params.private || false;

    // if text is supplied then set the content to that instead of the content parameter
    if (params.text) {
      params.content = escape(def(params.text, ""));
    } else {
      params.content = def(params.content, "");
    }

    that.$params = params;
    that.$closed = false;
    that.$hidden = false;
    that.$dialogJq = null;

    // Inner deferred promise. This promise will be resolved
    // when the dialog is closed.
    that.$df = $deferred();

    // Create the DOM but don't attach it.
    initDom(that);
  };

  function cancelDelayId(dlg) {
    if (dlg.$delayId) {
      clearTimeout(dlg.$delayId);
    }
  }

  /**
   * When a dialog is shown or the window is resized, the content wrapper
   * element needs to have its max-height set to prevent the dialog buttons
   * from being scrolled off the bottom of the page.
   *
   * @param {module:dialogs~Dialog} dlg
   */
  function layout(dlg) {
    if (!resizeHandler) {
      resizeHandler = throttle(function () {
        dialogs.each(function (i, dlg) { layout(dlg); });
      }, 1000 / 60);
      $(window).on('resize', resizeHandler);
    }

    var layoutParam = dlg.$params.layout;
    if (typeof layoutParam === 'function') {
      layoutParam(dlg);
    }
  }

  /**
   * Show the Dialog.
   *
   * @returns {module:dialogs~Dialog}
   *
   * @example
   *   <caption>Create a blank dialog box and then show it.</caption>
   *   dialogs.make("A dialog box with no buttons!")
   *          .show();
   */
  Dialog.prototype.show = function show() {
    var that = this,
      dialogJq = that.$dialogJq;

    cancelDelayId(that);


    function autoFocus() {
      //Only call focus on the first button if the top most dialog is mine and there is a button.
      const topDialogFirstButtonJq = $(".js-dialog-button-content:last button:first");
      const topDialogFirstButtonElem = topDialogFirstButtonJq[0];
      const dialogElem =  dialogJq && dialogJq[0];
      if (topDialogFirstButtonElem && dialogElem && dialogElem.contains(topDialogFirstButtonElem)) {
        topDialogFirstButtonJq.focus();
      }
    }

    //Sets the background opacity to screen the contents when the dialog is showing
    function setPrivate() {
      if (that.$params.private) {
        dialogJq.addClass("js-dialog-container-private");
      }
    }

    if (!that.$closed) {
      // Lazily attach the dialog to the DOM
      if (dialogJq.parent().length === 0) {
        openDialogs.push(that);
        that.$params.parent.append(dialogJq);
        $(window).trigger("jsdialog:created", [ that ]);
      }

      that.$hidden = false;

      if (that.$params.fade) {
        dialogJq.stop(/*clearQueue*/true, /*jumpToEnd*/true);
        dialogJq.fadeIn("fast", autoFocus);
      } else {
        dialogJq.show();
        setPrivate();
        autoFocus();
      }
      layout(that);
    }

    $(window).trigger("jsdialog:show", [ that ]);

    return that;
  };

  /**
   * Hide the Dialog without closing it. The preferred method
   * to use is close.
   *
   * @returns {module:dialogs~Dialog}
   *
   * @example
   * <caption>
   *   Hide a dialog box after 2 seconds
   * </caption>
   * dialogs.showYesNo("Meeting alert! Do you want to be reminded in 10 seconds?")
   *        .yes(function (dialog) {
   *          dialog.hide();
   *            setTimeout(function () {
   *            dialog.show();
   *          }, 10000);
   *          return false;
   *        });
   */
  Dialog.prototype.hide = function hide() {
    var that = this,
      dialogJq = that.$dialogJq;

    cancelDelayId(that);

    if (!that.$closed) {
      that.$hidden = true;

      if (that.$params.fade) {
        dialogJq.stop(/*clearQueue*/true, /*jumpToEnd*/true);
        dialogJq.fadeOut("fast");
      } else {
        dialogJq.hide();
      }


      $(window).trigger("jsdialog:hide", [ that ]);
    }

    return that;
  };

  function removeFromOpenDialogs(dlg) {
    // Remove the dialog from the array
    var i;
    for (i = 0; i < openDialogs.length; ++i) {
      if (openDialogs[i] === dlg) {
        openDialogs.splice(i, 1);
        break;
      }
    }
  }

  /**
   * Close the Dialog. This will remove the Dialog box from the screen.
   *
   * @param {String} [name] The name of the button used to close the dialog box.
   * This parameter is designed to be called from the Dialog JS framework itself.
   * @param {*} [fail] optional failure reason. If truthy, the dialog's promise
   * will be rejected with this failure reason; otherwise, the promise will be
   * resolved.
   * @returns {module:dialogs~Dialog}
   *
   * @example
   * <caption>
   *   Open a Dialog and close it after 2 seconds
   * </caption>
   * var dlg = dialogs.showOk("A notification");
   *
   * setTimeout(function () {
   *   dlg.close();
   * }, 2000);
   */
  Dialog.prototype.close = function close(name, fail) {
    return this.$close({ name, fail });
  };

  /**
   * @private
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.$close = function ({ name, fail, result }) {
    const that = this;
    const dialogJq = that.$dialogJq;
    const df = that.$df;

    name = name || "cancel";

    that.$closed = true;

    cancelDelayId(that);

    if (dialogJq) {
      dialogJq.stop(/*clearQueue*/true, /*jumpToEnd*/true);
      if (that.$params.fade) {
        dialogJq.fadeOut("fast", function () {
          dialogJq.remove();
        });
      } else {
        dialogJq.remove();
      }
      removeFromOpenDialogs(that);
      $(window).trigger("jsdialog:close", [ that ]);
    }

    // Once closed, resolve the promise with the specified name
    if (!fail) {
      df.resolve([ that, name, result ]);
    } else {
      df.reject([ that, name, fail ]);
    }

    return that;
  };

  /**
   * Move the Dialog to the front.
   *
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.toFront = function toFront() {
    var that = this,
      dialogJq = that.$dialogJq;

    if (dialogJq && !that.$closed && openDialogs.length > 1) {
      findMainDialogElement(dialogJq).stop(/*clearQueue*/true, /*jumpToEnd*/true);
      removeFromOpenDialogs(that);
      openDialogs.push(that);
      that.$params.parent.append(dialogJq);
    }

    return that;
  };

  /**
   * Move the Dialog to the back.
   *
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.toBack = function toBack() {
    var that = this,
      dialogJq = that.$dialogJq;
    if (dialogJq &&
      !that.$closed &&
      openDialogs.length > 1 &&
      openDialogs[1].$dialogJq) {
      findMainDialogElement(dialogJq).stop(/*clearQueue*/true, /*jumpToEnd*/true);
      removeFromOpenDialogs(that);
      openDialogs.unshift(that);
      dialogJq.insertBefore(openDialogs[1].$dialogJq);
    }

    return that;
  };

  /**
   * Return true if the Dialog is closed and removed from the DOM.
   *
   * @returns {Boolean} Return true if the Dialog has been closed.
   */
  Dialog.prototype.isClosed = function isClosed() {
    return this.$closed;
  };

  /**
   * Return true if the Dialog is hidden.
   *
   * @returns {Dialog} Return true if the Dialog has been hidden.
   */
  Dialog.prototype.isHidden = function isHidden() {
    return this.$hidden;
  };

  /**
   * Add a callback handler for a button via its name. This callback
   * handler will be invoked when the button is clicked.
   *
   * Any handler function can return a Promise. This
   * can control when and if the Dialog box closes after the handler
   * has been invoked. It should be noted that multiple handlers
   * can be registered on a button.
   *
   * * If the handlers return nothing, the Dialog will be closed after
   * all the Handlers have been invoked.
   * * If one or more handlers return a Promise, the Dialog
   * will only close after all the Promises have been resolved.
   * * If one of the Promises is rejected, the Dialog will not close.
   *
   * @param {String} name The name of the button to register the handler on.
   * @param {function(module:dialogs~Dialog): Promise} handler The handler of the function to be
   * invoked when the button is clicked. When invoked, the first argument of the
   * handler is the Dialog instance.
   * @returns {module:dialogs~Dialog}
   *
   * @see module:dialogs~Dialog#ok
   * @see module:dialogs~Dialog#cancel
   * @see module:dialogs~Dialog#yes
   * @see module:dialogs~Dialog#no
   *
   * @example
   * <caption>
   *   Register a function be to be called when the 'foo' button
   *   is clicked.
   * </caption>
   * dialogs.show({
   *   content: "Show some stuff",
   *   buttons: [
   *     {
   *       name: "foo",
   *       handler: function () {
   *         alert("First annoying alert!");
   *       }
   *     }
   *   ]
   * }).on("foo", function () {
   *   alert("This will also be called when foo button is clicked.");
   * });
   */
  Dialog.prototype.on = function on(name, handler) {
    requireButton(this, name).handlerArray.push(handler);
    return this;
  };

  /**
   * Click one of the Dialog's buttons.
   *
   * @example
   * <caption>
   *   Show a Dialog with an OK button and click it 2 seconds later
   * </caption>
   * var dlg = dialogs.showOk("This is an OK Dialog")
   * setTimeout(function () {
   *   dlg.click("ok")
   * }, 2000);
   *
   * @param  {String} name The name of the Dialog button to click.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.click = function click(name) {
    var bt = requireButton(this, name);
    if (canClick(bt.jq)) {
      bt.jq.trigger('click');
    }
    return this;
  };

  function onButton(dlg, name, handler) {
    if (handler) {
      return dlg.on(name, handler);
    } else {
      return dlg.click(name);
    }
  }

  /**
   * Add a 'ok' handler to the Dialog or if no handler is specified,
   * simulate clicking the Dialog's 'ok' button.
   *
   * @see module:dialogs~Dialog#on
   *
   * @param {Function} [handler] if specified, the handler to be invoked
   * when the Dialog's 'ok' button is clicked. The first argument of the
   * function callback is the Dialog instance.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.ok = function ok(handler) {
    return onButton(this, "ok", handler);
  };

  /**
   * Add a 'cancel' handler to the Dialog or if no handler is specified,
   * simulate clicking the Dialog's 'cancel' button.
   *
   * @see module:dialogs~Dialog#on
   *
   * @param {Function} [handler] if specified, the handler to be invoked
   * when the Dialog's 'cancel' button is clicked. The first argument of the
   * function callback is the Dialog instance.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.cancel = function cancel(handler) {
    return onButton(this, "cancel", handler);
  };

  /**
   * Add a 'yes' handler to the Dialog or if no handler is specified,
   * simulate clicking the Dialog's 'yes' button.
   *
   * @see module:dialogs~Dialog#on
   *
   * @param {Function} [handler] if specified, the handler to be invoked
   * when the Dialog's 'yes' button is clicked. The first argument of the
   * function callback is the Dialog instance.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.yes = function yes(handler) {
    return onButton(this, "yes", handler);
  };

  /**
   * Add a 'no' handler to the Dialog or if no handler is specified,
   * simulate clicking the Dialog's 'no' button. The first argument of the
   * function callback is the Dialog instance.
   *
   * @see module:dialogs~Dialog#on
   *
   * @param {Function} [handler] if specified, the handler to be invoked
   * when the Dialog's 'no' button is clicked.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.no = function no(handler) {
    return onButton(this, "no", handler);
  };

  /**
   * Return the button DOM for the given name.
   *
   * @param {String} name The name of the button.
   * @returns {JQuery|null} the Button's jQuery DOM object or null if nothing found.
   */
  Dialog.prototype.buttonJq = function buttonJq(name) {
    const button = findButton(this, name);
    return button ? button.jq : null;
  };

  /**
   * Disable a button.
   *
   * @param {String} name The name of the button.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.disableButton = function disableButton(name) {
    setButtonEnable(this.buttonJq(name), /*enable*/false);
    return this;
  };

  /**
   * Enable a button.
   *
   * @param {String} name The name of the button.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.enableButton = function enableButton(name) {
    setButtonEnable(this.buttonJq(name), /*enable*/true);
    return this;
  };

  /**
   * Show a button.
   *
   * @param {String} name The name of the button.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.showButton = function showButton(name) {
    setButtonShow(this.buttonJq(name), /*show*/true);
    return this;
  };

  /**
   * Hide a button.
   *
   * @param {String} name The name of the button.
   * @returns {module:dialogs~Dialog}
   */
  Dialog.prototype.hideButton = function hideButton(name) {
    setButtonShow(this.buttonJq(name), /*show*/false);
    return this;
  };

  /**
   * Adds a new button to this dialog. If a button with the same name already exists, no
   * action will be taken.
   *
   * @private
   * @param {module:dialogs~Button} btn
   * @since Niagara 4.12
   */
  Dialog.prototype.$addButton = function (btn) {
    const { name } = btn;

    if (findButton(this, name)) { return; }

    const dialogJq = this.$dialogJq;
    const buttonsElement = findButtonsElement(dialogJq);
    const buttons = this.$params.buttons;

    let btnJq;
    if (buttonsElement.length) {
      btnJq = $(tplButton(btn)).appendTo(buttonsElement);
    } else {
      findMainDialogElement(dialogJq).append(tplButtons([ btn ]));
      btnJq = findButtonDom(dialogJq, name);
    }

    buttons.push(btn);
    initButtonDom(this, btnJq, btn);

    // ensure 'cancel' is always last.
    const buttonNames = pluck(buttons, 'name');
    const cancelIndex = buttonNames.indexOf('cancel');
    if (cancelIndex >= 0) {
      buttonNames.push(buttonNames.splice(cancelIndex, 1)[0]);
      this.$reorderButtons(buttonNames);
    }
    layout(this);
  };

  /**
   * Reorders the buttons in this dialog. This can be done in two ways:
   *
   * - A sort function that receives and compares two instances of {@link module:dialogs~Button}.
   * - An array of strings indicating the desired ordering of button names.
   *
   * @private
   * @param {Function|Array.<string>} sort
   * @since Niagara 4.12
   */
  Dialog.prototype.$reorderButtons = function (sort) {
    if (Array.isArray(sort)) {
      const arr = sort;
      sort = (a, b) => {
        let indexA = arr.indexOf(a.name);
        let indexB = arr.indexOf(b.name);
        if (indexA < 0) { indexA = Number.POSITIVE_INFINITY; }
        if (indexB < 0) { indexB = Number.POSITIVE_INFINITY; }
        return indexA - indexB;
      };
    }

    const buttons = this.$params.buttons;
    const buttonsElement = findButtonsElement(this.$dialogJq);

    buttons.sort(sort);
    buttonsElement[0].replaceChildren(...buttons.map((btn) => btn.jq[0]));
  };


  /**
   * Return the internal jQuery wrapped DOM element for the entire Dialog.
   *
   * @returns {JQuery} the Dialog's jQuery DOM object.
   */
  Dialog.prototype.jq = function jq() {
    return this.$dialogJq;
  };

  /**
   * If this dialog has content, return the jQuery DOM wrapper for it.
   *
   * @returns {JQuery} The DOM wrapper for the content. This wrapper will
   * be empty if the dialog is shown with no content.
   */
  Dialog.prototype.content = function () {
    return findContentElement(this.$dialogJq);
  };

  /**
   * If this dialog has a header, return the jQuery DOM wrapper for it.
   *
   * @returns {JQuery} The DOM wrapper for the header. This wrapper will
   * be empty if the dialog is shown with no header.
   * @since Niagara 4.12
   */
  Dialog.prototype.header = function () {
    return findHeaderElement(this.$dialogJq);
  };

  /**
   * Return a promise for the dialog that will be resolved when the dialog closes.
   * This is useful when wanting to use dialogs in a promise chain when creating a user interface.
   *
   * @returns {Promise} The promise to be resolved.
   */
  Dialog.prototype.promise = function () {
    return this.$df.promise;
  };

  /**
   * An Object that defines a Button.
   *
   * @typedef {Object} module:dialogs~Button
   * @inner
   * @public
   *
   * @see module:dialogs~Dialog#on
   *
   * @property {String} [name] A button's unique name so it can be identified.
   * @property {String} [displayName] The button's display name. This name will used
   * on the button. If not specified, the button's name will be used instead.
   * @property {Function} [handler] A function that will be invoked when the button is
   * clicked. Returning false will keep the dialog from closing after this handler
   * has been invoked.
   * @property {boolean} [disable] if set to true, the button will be disabled until `enableButton`
   * is called.
   * @property {boolean} [hide] if set to true, the button will be hidden until `showButton` is
   * called.
   * @property {Boolean} [esc] If true, this handler will be invoked when the user
   * hits the escape key.
   */

  ////////////////////////////////////////////////////////////////
  // Exports
  ////////////////////////////////////////////////////////////////


  /**
   * Create stunning, modal Dialog boxes in JavaScript.
   *
   * This is a UI library used to create dynamic, modal Dialog boxes in your
   * browser.
   *
   * @example
   * <caption>
   *   A simple modal OK dialog box.
   * </caption>
   * dialogs.showOk("Some Dialog Box Content")
   *        .ok(function () {
   *          console.log("The OK button has been clicked");
   *        });
   *
   * @example
   * <caption>
   *   A Dialog box with a title and some HTML content.
   * </caption>
   * dialogs.showOk({
   *          title: "The Dialog Box's Title!",
   *          content: "&lt;p&gt;Some HTML Content&lt;/p&gt;"
   *        })
   *        .ok(function () {
   *          console.log("The OK button has been clicked");
   *        });
   *
   *  @example
   *  <caption>
   *    A simple Yes, No, Cancel dialog box.
   *  </caption>
   *  dialogs.showYesNoCancel("Would you like some tea with that?")
   *         .yes(function () {
   *           console.log("The user clicked Yes");
   *         })
   *         .no(function() {
   *           console.log("The user clicked No");
   *         })
   *         .cancel(function () {
   *           console.log("The user clicked Cancel");
   *         });
   *
   * @example
   * <caption>
   *   Show a loading dialog box and have it close after the AJAX call has finished.
   * </caption>
   * dialogs.showLoading(0, $.ajax(uri, options));
   *
   * @example
   * <caption>
   *   Use promises to show a loading dialog box and then pop up another dialog.
   * </caption>
   * var dlg = dialogs.showLoading();
   * // After 2 seconds, close the loading box.
   * setTimeout(function () {
   *   dlg.close();
   * }, 2000);
   * dlg.promise().then(([ dlg, buttonClicked ]) => {
   *   // Prints 'ok'
   *   console.log(buttonClicked);
   *      
   *   dialogs.showOk("The foobar has finished loading!");
   * });
   *
   * @example
   * <caption>
   *   Show a dialog. Have the content dynamically created by
   *   passing in a function for the content.
   * </caption>
   * dialogs.show(function(dlg, jq) {
   *   jq.html("&lt;div&gt;I love Niagara 4!&lt;/div&gt;");
   * });
   *
   * @example
   * <caption>
   *   Show a dialog. Have the content dynamically created
   *   by passing in a function for the content. The dialog
   *   will only show when the return promise has been resolved.
   * </caption>
   * dialogs.show(function(dlg, jq) {
   *   return Promise.resolve($.ajax("/myajax")
   *     .then(function (response) {
   *       jq.html("The answer is..." + JSON.parse(response).answer);
   *     });
   * });
   *
   * @example
   * <caption>
   *   A Dialog BOX with background privacy setting.
   * </caption>
   * dialogs.showOk({
   *          title: "The Dialog Box's Title!",
   *          content: "&lt;p&gt;Some HTML Content&lt;/p&gt;",
   *          private: true //ensures background contents are screened when the dialog is showing
   *        })
   *        .ok(function () {
   *          console.log("The OK button has been clicked");
   *        });
   *
   *
   * @module dialogs
   * @requires jquery
   * @requires lex!js
   * @requires css!nmodule/js/rc/dialogs/dialogs
   */
  const dialogs = {

    /**
     * Create a Dialog box and return it.
     *
     * Please note, this will not show the Dialog box but just return
     * an instance of a new one.
     *
     * This method can take either an Object Literal for parameters or a
     * singular String argument for the Dialog's Content.
     *
     * @see module:dialogs~Button
     *
     * @param {Object|String|Function} params parameters for launching a Dialog,
     * or directly, the `text` to be shown.
     * @param {String} [params.text] the Dialog's content as a string of plain
     * text. This string will be escaped to remove any unsafe HTML content before
     * being displayed to the user. If you require HTML content in your dialog,
     * use the "content" parameter instead.
     * @param {String|Function} [params.content] the Dialog's content as HTML.
     * Please note, if what is to be shown is text and not HTML, use of the text parameter SHOULD
     * be used.  If content is used it should be known safe HTML. This can also be a function used
     * to generate the content dynamically. The callback function is passed the
     * Dialog instance and content jQuery element as parameters. The callback
     * can return a promise that when resolved will show the dialog box. By
     * default, if a promise is returned, a loading dialog box will appear.
     * @param {String} [params.title] the Dialog's title. This should not contain HTML as any HTML will be escaped.
     * @param {Boolean} [params.fade] if true, the Dialog box will fade in
     * quickly.
     * @param {Function} [params.layout] an optional callback function to be
     * called when the dialog lays itself out. It will receive the
     * {@link module:dialogs~Dialog dialog} instance as the first parameter.
     * If your content needs to perform some logic to lay itself out when the
     * dialog changes dimensions, use this callback to do so.
     * @param {Function} [params.ok] handler to be invoked when the 'OK' button
     * is clicked. By defining this handler, this will also cause an 'OK' button
     * to be added to the Dialog box.
     * @param {Function} [params.cancel] handler to be invoked when the 'Cancel'
     * button is clicked. By defining this handler, this will also cause a
     * 'Cancel' button to be added to the Dialog box.
     * @param {Function} [params.yes] handler to be invoked when the 'Yes'
     * button is clicked. By defining this handler, this will also cause a 'Yes'
     * button to be added to the Dialog box.
     * @param {Function} [params.no] handler to be invoked when the 'No' button
     * is clicked. By defining this handler, this will also cause a 'No' button
     * to be added to the Dialog box.
     * @param {Array.<module:dialogs~Button>} [params.buttons] an array of additional
     * buttons.
     * @param {JQuery} [params.parent] A parent jQuery wrapped DOM element to
     * attach the dialog to. If not specified, the dialog is attached to the
     * HTML document's body element.
     * @param {Boolean} [params.private] Defaults to false. If true, the background
     * contents are not visible when the dialog is showing.
     * @returns {module:dialogs~Dialog}
     * @throws {Error} the error generated by new Dialog if both a text and content parameter
     * is provided
     *
     * @example
     * <caption>Make a dialog box and show it</caption>
     * dialogs.make("A dialog with no buttons")
     *        .show();
     */
    make: function make(params) {
      return new Dialog(params);
    },

    /**
     * Create and show a Dialog box. Shortcut for `dialogs.make(params).show()`.
     *
     * @see module:dialogs.make
     *
     * @param {Object} params parameters for launching a Dialog. See
     * {@link module:dialogs.make}.
     *
     * @returns {module:dialogs~Dialog}
     *
     * @example
     * <caption>
     *   Show a simple Dialog box.
     * </caption>
     * dialogs.show({
     *   title: "Dialog",
     *   content: "Hey this is a Dialog!",
     *   ok: function () {
     *     console.log("The OK button has been clicked");
     *   }
     * });
     *
     * dialogs.show(function(dlg, jq) {
     *   jq.html("&lt;div&gt;I love Niagara 4!&lt;/div&gt;");
     * });
     *
     * dialogs.show(function(dlg, jq) {
     *   return Promise.resolve($.ajax("/myajax")
     *     .then(function (response) {
     *       jq.html("The answer is..." + JSON.parse(response).answer);
     *     });
     * });
     */
    show: function show(params) {
      params = objectify(params, "text");

      const { content, delay } = params;

      var func,
        loading = false,
        dlg,
        promise;

      if (typeof content === "function") {
        func = content;

        // When a callback is used to generate the content, the loading dialog is on
        // by default if the callback returns a promise.
        loading = params.loading || params.loading === undefined;
        delete params.loading;
      }

      dlg = dialogs.make(params);

      if (func) {
        promise = func(dlg, dlg.content());

        // If the callback function returns a promise then 
        // make sure the dialog is shown when it resolves.
        if (promise && typeof promise.then === "function") {
          promise.then(function () {
            dlg.show();
          })
            .catch(dialogs.showOk);

          // If a promise is returned and we can show the loading dialog
          // then show it.
          if (loading) {
            dialogs.showLoading(delay || 0, promise);
          }
        } else {
          dlg.show();
        }
      } else {
        dlg.show();
      }
      return dlg;
    },

    /**
     * Iterate through each Dialog box.
     *
     * @param {Function} func called for each Dialog box whereby
     * the first argument is index of the Dialog box with the second
     * being the Dialog instance.
     */
    each: function each(func) {
      openDialogs.slice().forEach((dlg, i) => func(i, dlg));
    },

    /**
     * Return the number of Open Dialogs (includes hidden).
     *
     * @return {Number} the number of open Dialogs.
     */
    size: function size() {
      return openDialogs.length;
    },

    /**
     * Creates and shows a Dialog box with an OK button.
     *
     * @param {Object} params parameters for launching a Dialog. See
     * {@link module:dialogs.make}.
     *
     * @see module:dialogs.make
     * @returns {module:dialogs~Dialog}
     */
    showOk: function showOk(params) {
      return showWithButtons(params, 'ok');
    },


    /**
     * Creates and shows a Dialog box with OK and Cancel buttons.
     *
     * @param {Object} params parameters for launching a Dialog. See
     * {@link module:dialogs.make}.
     *
     * @see module:dialogs.make
     * @returns {module:dialogs~Dialog}
     */
    showOkCancel: function showOkCancel(params) {
      return showWithButtons(params, 'ok', 'cancel');
    },

    /**
     * Creates and shows a Dialog box with a Cancel button.
     *
     * @param {Object} params parameters for launching a Dialog. See
     * {@link module:dialogs.make}.
     *
     * @see module:dialogs.make
     * @returns {module:dialogs~Dialog}
     */
    showCancel: function showCancel(params) {
      return showWithButtons(params, 'cancel');
    },

    /**
     * Creates and shows a Dialog box with Yes and No buttons.
     *
     * @param {Object} params parameters for launching a Dialog. See
     * {@link module:dialogs.make}.
     *
     * @see module:dialogs.make
     * @returns {module:dialogs~Dialog}
     */
    showYesNo: function showYesNo(params) {
      return showWithButtons(params, 'yes', 'no');
    },

    /**
     * Creates and shows a Dialog box with Yes, No and Cancel buttons.
     *
     * @param {Object} params parameters for launching a Dialog. See
     * {@link module:dialogs.make}.
     *
     * @see module:dialogs.make
     * @returns {module:dialogs~Dialog}
     */
    showYesNoCancel: function showYesNoCancel(params) {
      return showWithButtons(params, 'yes', 'no', 'cancel');
    },

    /**
     * Creates and shows a Loading Dialog box.
     *
     * @param {Object|Number} [params] Starting in Niagara 4.13 if an object literal is passed, this provides
     * all the parameters for the function. If this is a Number, it's used as the delay.
     * @param {Number} [params.delay] the delay (in milliseconds) before the
     * Dialog box appears. By default, there is no delay in showing the Dialog.
     * @param {Promise} [params.promise] An optional Promise that can be passed
     * into the loading Dialog box. If defined, the loading Dialog box will
     * automatically close after the promise has completed.
     * @param {JQuery} [params.parent] The DOM parent to attach the dialog to. If not specified,
     * the dialog is attached to the HTML page's body.
     * @param {String} [params.loaderImg] an alternate loader image to use that is a URL.
     * @param {String} [params.loaderTxt] an optional message to show while the dialog is loading.
     * @param {String} [params.title] an optional title to show while the dialog is loading.
     * @param {Array.<module:dialogs~Button|string>} [params.buttons] optional buttons to add to the loading dialog.
     * This can be helpful if you want to make a cancelable loading dialog.
     * @param {Promise} [promise] An optional Promise that can be passed
     * into the loading Dialog box. If defined, the loading Dialog box will
     * automatically close after the promise has completed.
     * @param {JQuery} [parent] The DOM parent to attach the dialog to. If not specified,
     * the dialog is attached to the HTML page's body.
     * @param {String} [loaderImg] an alternate loader image to use that is a URL.
     * @returns {module:dialogs~Dialog}
     *
     * @example
     * <caption>
     *   Show a loading Dialog box if an AJAX call takes longer than
     *   half a second to complete.
     * </caption>
     *
     * // Make an AJAX call that may take longer that half a second.
     * var promise = $.ajax({
     *   url: "test.html",
     *   context: document.body
     * }).done(function() {
     *   $(this).addClass("done");
     * });
     *
     * // If the AJAX call takes longer that half a second, display a
     * // Loading Dialog box.
     * dialogs.showLoading(500, promise);
     *
     * <caption>
     *   Show a loading Dialog box with an OK button that can be pressed to let work continue in the background.
     * </caption>
     * dialogs.showLoading({
     *   promise,
     *   loaderTxt: "Running Job now. Click OK to allow this to be completed in the background.",
     *   buttons: [
     *     {
     *       name: "ok",
     *       handler: function (dialog) {
     *         dialog.close();
     *       }
     *     }
     *   ]
     * });
     */
    showLoading: function showLoading(params, promise, parent, loaderImg) {

      let delay, buttons, loaderTxt, title;
      if (typeof params === 'number') {
        delay = params;
      } else if (params) {
        promise = params.promise;
        parent = params.parent;
        loaderImg = params.loaderImg;
        buttons = params.buttons;
        loaderTxt = params.loaderTxt;
        title = params.title;
      }

      loaderImg = loaderImg || defaultLoadImg;

      let content = loaderImg;
      if (loaderTxt) {
        content = { loaderImg, loaderTxt };
      }


      // Ensure the image is loaded before we display the loading dialog
      var img,
        dlg = dialogs.make({
          title: title || jsLex.get("dialogs.loading"),
          content,
          parent: parent,
          loading: true,
          fade: true,
          buttons
        });

      delay = delay || 0;

      img = $(".js-dialog-loading-img", dlg.$dialogJq)[0];

      img.onload = function () {
        if (!dlg.$closed && !dlg.$hidden) {
          // Lazily initialize the content once the image has loaded...
          dlg.$delayId = setTimeout(function () {
            dlg.show();
          }, delay);
        }
      };

      // If a promise is passed in then automatically close the 
      // dialog box when it's resolved or rejected.
      if (promise) {
        promise.then(function () {
          dlg.close();
        }, function (err) {
          dlg.close("cancel", err || unknownErr);
        });
      }

      img.src = loaderImg;
      return dlg;
    }
  };

  function showWithButtons(params, ...buttons) {
    params = objectify(params, "text");
    buttons.forEach((buttonName) => { params[buttonName] = params[buttonName] || defaultButtonHandler; });
    return dialogs.show(params);
  }

  ////////////////////////////////////////////////////////////////
  // Handle key presses
  ////////////////////////////////////////////////////////////////

  // Handle any key presses
  $(function handleKeyPresses() {
    $(document).on("keyup", function (e) {
      var esc = e.keyCode === /*esc key*/27,
        buttons,
        bt,
        i,
        x;

      if (esc && openDialogs.length > 0) {
        for (i = openDialogs.length - 1; i >= 0; --i) {
          // Find the first dialog that's not hidden or closed.
          if (openDialogs[i].$dialogJq &&
            !openDialogs[i].isClosed() &&
            !openDialogs[i].isHidden()) {

            buttons = openDialogs[i].$params.buttons;

            if (esc) {
              // Invoke any buttons that handle an escape press
              for (x = 0; x < buttons.length; ++x) {
                bt = buttons[x];
                if (bt.esc && canClick(bt.jq)) {
                  bt.jq.trigger('click');
                }
              }
            }
            break;
          }
        }
      }
    });
  });

  return dialogs;
});