From 514965c4df179568bef6f1402d3b86b833a58f77 Mon Sep 17 00:00:00 2001
From: Mark Carver <mark.carver@me.com>
Date: Sun, 14 Jan 2018 04:15:31 -0600
Subject: [PATCH] Issue #2831237 by markcarver, fearlsgroove, tedbow: Bootstrap
 modal does not work well with jQuery UI dialog

Signed-off-by: Mark Carver <mark.carver@me.com>
---
 bootstrap.info.yml                                 |   8 -
 bootstrap.libraries.yml                            |  25 +-
 js/attributes.js                                   | 223 +++++++++++---
 js/drupal.bootstrap.js                             | 194 ++++++++----
 js/misc/dialog.ajax.js                             | 263 ----------------
 js/misc/dialog.js                                  | 104 -------
 js/modal.jquery.ui.bridge.js                       | 188 ++++++++++++
 js/modal.js                                        | 330 ++++++++++++++++-----
 js/theme-settings.js                               |   7 +-
 js/theme.js                                        |  16 +-
 src/Plugin/Alter/LibraryInfo.php                   |  12 +-
 .../Setting/JavaScript/Modals/ModalEnabled.php     |   1 -
 .../JavaScript/Modals/ModalJqueryUIBridge.php      |  55 ++++
 13 files changed, 849 insertions(+), 577 deletions(-)
 delete mode 100644 js/misc/dialog.ajax.js
 delete mode 100644 js/misc/dialog.js
 create mode 100644 js/modal.jquery.ui.bridge.js
 create mode 100644 src/Plugin/Setting/JavaScript/Modals/ModalJqueryUIBridge.php

diff --git a/bootstrap.info.yml b/bootstrap.info.yml
index 957052b..28fe84c 100644
--- a/bootstrap.info.yml
+++ b/bootstrap.info.yml
@@ -71,11 +71,3 @@ libraries-override:
     css:
       theme:
         css/node.preview.css: false
-
-# The following are dynamic library overrides based on certain critera,
-# usually a theme setting.
-#
-# @see \Drupal\bootstrap\Plugin\Alter\LibraryInfo::alter()
-#
-#  core/drupal.dialog: bootstrap/drupal.dialog
-#  core/drupal.dialog.ajax: bootstrap/drupal.dialog.ajax
diff --git a/bootstrap.libraries.yml b/bootstrap.libraries.yml
index b98efe0..351ee41 100644
--- a/bootstrap.libraries.yml
+++ b/bootstrap.libraries.yml
@@ -48,6 +48,18 @@ modal:
   dependencies:
     - bootstrap/theme
 
+modal.jquery.ui.bridge:
+  js:
+    js/modal.jquery.ui.bridge.js: {}
+  dependencies:
+    - bootstrap/modal
+    - core/jquery.ui
+    - core/jquery.ui.widget
+    - core/jquery.ui.draggable
+    - core/jquery.ui.mouse
+    - core/jquery.ui.position
+    - core/jquery.ui.resizable
+
 popover:
   js:
     js/popover.js: {}
@@ -105,19 +117,6 @@ drupal.batch:
     - core/drupal.progress
     - core/jquery.once
 
-drupal.dialog:
-  js:
-    js/misc/dialog.js: {}
-  dependencies:
-    - bootstrap/modal
-
-drupal.dialog.ajax:
-  js:
-    js/misc/dialog.ajax.js: {}
-  dependencies:
-    - core/drupal.ajax
-    - bootstrap/drupal.dialog
-
 drupal.filter:
   version: VERSION
   js:
diff --git a/js/attributes.js b/js/attributes.js
index dd71c5d..1dd79c5 100644
--- a/js/attributes.js
+++ b/js/attributes.js
@@ -1,21 +1,24 @@
 (function ($, _) {
 
   /**
-   * Class to help modify attributes.
+   * @class Attributes
    *
-   * @param {object} object
-   *   An object to initialize attributes with.
+   * Modifies attributes.
    *
-   * @constructor
+   * @param {Object|Attributes} attributes
+   *   An object to initialize attributes with.
    */
-  var Attributes = function (object) {
-    this.data = object && _.isObject(object) && _.clone(object) || {};
+  var Attributes = function (attributes) {
+    this.data = {};
+    this.data['class'] = [];
+    this.merge(attributes);
   };
 
   /**
    * Renders the attributes object as a string to inject into an HTML element.
    *
-   * @returns {string}
+   * @return {String}
+   *   A rendered string suitable for inclusion in HTML markup.
    */
   Attributes.prototype.toString = function () {
     var output = '';
@@ -23,9 +26,10 @@
     var checkPlain = function (str) {
       return str && str.toString().replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;') || '';
     };
-    for (name in this.data) {
-      if (!this.data.hasOwnProperty(name)) continue;
-      value = this.data[name];
+    var data = this.getData();
+    for (name in data) {
+      if (!data.hasOwnProperty(name)) continue;
+      value = data[name];
       if (_.isFunction(value)) value = value();
       if (_.isObject(value)) value = _.values(value);
       if (_.isArray(value)) value = value.join(' ');
@@ -34,6 +38,27 @@
     return output;
   };
 
+  /**
+   * Renders the Attributes object as a plain object.
+   *
+   * @return {Object}
+   *   A plain object suitable for inclusion in DOM elements.
+   */
+  Attributes.prototype.toPlainObject = function () {
+    var object = {};
+    var name, value;
+    var data = this.getData();
+    for (name in data) {
+      if (!data.hasOwnProperty(name)) continue;
+      value = data[name];
+      if (_.isFunction(value)) value = value();
+      if (_.isObject(value)) value = _.values(value);
+      if (_.isArray(value)) value = value.join(' ');
+      object[name] = value;
+    }
+    return object;
+  };
+
   /**
    * Add class(es) to the array.
    *
@@ -45,9 +70,8 @@
    * @chainable
    */
   Attributes.prototype.addClass = function (value) {
-    var classes = this.getClasses();
-    value = [].concat(classes, value);
-    this.set('class', _.uniq(value));
+    var args = Array.prototype.slice.call(arguments);
+    this.data['class'] = this.sanitizeClasses(this.data['class'].concat(args));
     return this;
   };
 
@@ -83,10 +107,10 @@
   /**
    * Retrieves a cloned copy of the internal attributes data object.
    *
-   * @returns {Object}
+   * @return {Object}
    */
   Attributes.prototype.getData = function () {
-    return _.clone(this.data);
+    return _.extend({}, this.data);
   };
 
   /**
@@ -96,32 +120,37 @@
    *   The classes array.
    */
   Attributes.prototype.getClasses = function () {
-    var classes = [].concat(this.get('class', []));
-    return _.uniq(classes);
+    return this.get('class', []);
   };
 
   /**
    * Indicates whether a class is present in the array.
    *
-   * @param {string|Array} name
+   * @param {string|Array} className
    *   The class(es) to search for.
    *
    * @return {boolean}
    *   TRUE or FALSE
    */
-  Attributes.prototype.hasClass = function (name) {
-    name = [].concat(name);
+  Attributes.prototype.hasClass = function (className) {
+    className = this.sanitizeClasses(Array.prototype.slice.call(arguments));
     var classes = this.getClasses();
-    var found = false;
-    _.each(name, function (value) { if (_.indexOf(classes, value) !== -1) found = true; });
-    return found;
+    for (var i = 0, l = className.length; i < l; i++) {
+      // If one of the classes fails, immediately return false.
+      if (_.indexOf(classes, className[i]) === -1) {
+        return false;
+      }
+    }
+    return true;
   };
 
   /**
    * Merges multiple values into the array.
    *
-   * @param {object} values
-   *   An associative key/value array.
+   * @param {Attributes|Node|jQuery|Object} object
+   *   An Attributes object with existing data, a Node DOM element, a jQuery
+   *   instance or a plain object where the key is the attribute name and the
+   *   value is the attribute value.
    * @param {boolean} [recursive]
    *   Flag determining whether or not to recursively merge key/value pairs.
    *
@@ -129,14 +158,54 @@
    *
    * @chainable
    */
-  Attributes.prototype.merge = function (values, recursive) {
-    values = values instanceof Attributes ? values.getData() : values;
-    if (recursive === void(0) || recursive) {
-      this.data = $.extend(true, {}, this.data, values);
+  Attributes.prototype.merge = function (object, recursive) {
+    // Immediately return if there is nothing to merge.
+    if (!object) {
+      return this;
+    }
+
+    // Get attributes from a jQuery element.
+    if (object instanceof $) {
+      object = object[0];
+    }
+
+    // Get attributes from a DOM element.
+    if (object instanceof Node) {
+      object = Array.prototype.slice.call(object.attributes).reduce(function (attributes, attribute) {
+        attributes[attribute.name] = attribute.value;
+        return attributes;
+      }, {});
+    }
+    // Get attributes from an Attributes instance.
+    else if (object instanceof Attributes) {
+      object = object.getData();
+    }
+    // Otherwise, clone the object.
+    else {
+      object = _.extend({}, object);
+    }
+
+    // By this point, there should be a valid plain object.
+    if (!$.isPlainObject(object)) {
+      setTimeout(function () {
+        throw new Error('Passed object is not supported: ' + object);
+      });
+      return this;
+    }
+
+    // Handle classes separately.
+    if (object && object['class'] !== void 0) {
+      this.addClass(object['class']);
+      delete object['class'];
+    }
+
+    if (recursive === void 0 || recursive) {
+      this.data = $.extend(true, {}, this.data, object);
     }
     else {
-      $.extend(this.data, values);
+      this.data = $.extend({}, this.data, object);
     }
+
     return this;
   };
 
@@ -158,15 +227,16 @@
   /**
    * Removes a class from the attributes array.
    *
-   * @param {string|Array} value
+   * @param {...string|Array} className
    *   An individual class or an array of classes to remove.
    *
    * @return {Attributes}
    *
    * @chainable
    */
-  Attributes.prototype.removeClass = function (value) {
-    this.set('class', _.without(this.getClasses(), [].concat(value)));
+  Attributes.prototype.removeClass = function (className) {
+    var remove = this.sanitizeClasses(Array.prototype.slice.apply(arguments));
+    this.data['class'] = _.without(this.getClasses(), remove);
     return this;
   };
 
@@ -184,7 +254,7 @@
    */
   Attributes.prototype.replaceClass = function (oldValue, newValue) {
     var classes = this.getClasses();
-    var i = _.indexOf(oldValue, classes);
+    var i = _.indexOf(this.sanitizeClasses(oldValue), classes);
     if (i >= 0) {
       classes[i] = newValue;
       this.set('class', classes);
@@ -192,6 +262,30 @@
     return this;
   };
 
+  /**
+   * Ensures classes are flattened into a single is an array and sanitized.
+   *
+   * @param {...String|Array} classes
+   *   The class or classes to sanitize.
+   *
+   * @return {Array}
+   *   A sanitized array of classes.
+   */
+  Attributes.prototype.sanitizeClasses = function (classes) {
+    return _.chain(Array.prototype.slice.call(arguments))
+      .flatten()
+      // Split classes that may have been added with a space as a separator.
+      .map(function (string) {
+        return string.split(' ');
+      })
+      // Flatten again.
+      .flatten()
+      // Clean the class to ensure it's
+      .map(Attributes.cleanClass)
+      .uniq()
+      .value();
+  };
+
   /**
    * Sets an attribute on the array.
    *
@@ -205,24 +299,69 @@
    * @chainable
    */
   Attributes.prototype.set = function (name, value) {
-    this.data[name] = value;
-    return this;
+    var obj = $.isPlainObject(name) ? name : {};
+    if (typeof name === 'string') {
+      obj[name] = value;
+    }
+    return this.merge(obj);
+  };
+
+  /**
+   * Prepares a string for use as a CSS identifier (element, class, or ID name).
+   *
+   * Note: this is essentially a direct copy from
+   * \Drupal\Component\Utility\Html::cleanCssIdentifier
+   *
+   * @param {string} identifier
+   *   The identifier to clean.
+   * @param {Object} [filter]
+   *   An object of string replacements to use on the identifier.
+   *
+   * @return {string}
+   *   The cleaned identifier.
+   */
+  Attributes.cleanClass = function (identifier, filter) {
+    filter = filter || {
+      ' ': '-',
+      '_': '-',
+      '/': '-',
+      '[': '-',
+      ']': ''
+    };
+
+    identifier = identifier.toLowerCase();
+
+    if (filter['__'] === void 0) {
+      identifier = identifier.replace('__', '#DOUBLE_UNDERSCORE#', identifier);
+    }
+
+    identifier = identifier.replace(Object.keys(filter), Object.values(filter), identifier);
+
+    if (filter['__'] === void 0) {
+      identifier = identifier.replace('#DOUBLE_UNDERSCORE#', '__', identifier);
+    }
+
+    identifier = identifier.replace(/[^\u002D\u0030-\u0039\u0041-\u005A\u005F\u0061-\u007A\u00A1-\uFFFF]/u, '', identifier);
+    identifier = identifier.replace(['/^[0-9]/', '/^(-[0-9])|^(--)/'], ['_', '__'], identifier);
+
+    return identifier;
   };
 
   /**
    * Creates an Attributes instance.
    *
-   * @param {object|Attributes} object
+   * @param {object|Attributes} attributes
    *   An object to initialize attributes with.
    *
-   * @returns {Attributes}
-   *
-   * @global
+   * @return {Attributes}
+   *   An Attributes instance.
    *
    * @constructor
    */
-  window.Attributes = function (object) {
-    return object instanceof Attributes ? object : new Attributes(object);
+  Attributes.create = function (attributes) {
+    return new Attributes(attributes);
   };
 
+  window.Attributes = Attributes;
+
 })(window.jQuery, window._);
diff --git a/js/drupal.bootstrap.js b/js/drupal.bootstrap.js
index 332fcdd..433985d 100644
--- a/js/drupal.bootstrap.js
+++ b/js/drupal.bootstrap.js
@@ -8,13 +8,48 @@
  *
  * @namespace
  */
-(function ($, Drupal, drupalSettings) {
+(function (_, $, Drupal, drupalSettings) {
   'use strict';
 
   Drupal.bootstrap = {
     settings: drupalSettings.bootstrap || {}
   };
 
+  /**
+   * Adds a jQuery plugin.
+   *
+   * @param {string} id
+   *   A plugin identifier located in $.fn.
+   * @param {function} [callback]
+   *   A callback for the jQuery plugin. The callback must return a function
+   *   that is used to construct a jQuery plugin.
+   * @param {Boolean} [noConflict]
+   *   Flag indicating whether or not to create a ".noConflict()" helper method
+   *   for the plugin.
+   */
+  Drupal.bootstrap.addPlugin = function (id, callback, noConflict) {
+    // Immediately return if callback isn't a function.
+    if (!$.isFunction(callback)) return false;
+
+    var constructor = $.fn[id] && $.fn[id].Constructor;
+    var plugin = callback.apply(constructor);
+    if (!$.isFunction(plugin)) return false;
+
+    if (constructor && !plugin.Constructor) {
+      plugin.Constructor = constructor;
+    }
+
+    if (noConflict === void 0 || noConflict) {
+      var old = $.fn[id];
+      plugin.noConflict = function () {
+        $.fn[id] = old;
+        return this;
+      };
+    }
+
+    $.fn[id] = plugin;
+  };
+
   /**
    * Wraps Drupal.checkPlain() to ensure value passed isn't empty.
    *
@@ -32,66 +67,119 @@
     return str && Drupal.checkPlain(str) || '';
   };
 
+  /**
+   * Diff object properties.
+   *
+   * @param {...Object} objects
+   *   Two or more objects. The first object will be used to return properties
+   *   values.
+   *
+   * @return {Object}
+   *   Returns the properties of the first passed object that are not present
+   *   in all other passed objects.
+   */
+  Drupal.bootstrap.diffObjects = function (objects) {
+    var args = Array.prototype.slice.call(arguments);
+    return _.pick(args[0], _.difference.apply(_, _.map(args, function (obj) {
+      return Object.keys(obj);
+    })));
+  };
+
   /**
    * Extends a Bootstrap plugin constructor.
    *
-   * @param {string} id
-   *   A Bootstrap plugin identifier located in $.fn.
-   * @param {function} [callback]
+   * @param {String} id
+   *   A jQuery plugin identifier located in $.fn.
+   * @param {Function} [callback]
    *   A callback to extend the plugin constructor.
    *
-   * @return {function|boolean}
-   *   The Bootstrap plugin or FALSE if the plugin does not exist.
+   * @return {Function|Boolean}
+   *   The jQuery plugin constructor or FALSE if the plugin does not exist.
    */
   Drupal.bootstrap.extendPlugin = function (id, callback) {
-    // Immediately return if the plugin does not exist.
-    if (!$.fn[id] || !$.fn[id].Constructor) return false;
-
-    // Extend the plugin if a callback was provided.
-    if ($.isFunction(callback)) {
-      var ret = callback.apply($.fn[id].Constructor, [this.settings]);
-      if ($.isPlainObject(ret)) {
-        $.extend(true, $.fn[id].Constructor, ret);
+    // Immediately return if plugin does not exist or not a valid callback.
+    if (!$.fn[id] || !$.isFunction(callback)) return false;
+
+    // Determine plugin constructor.
+    var constructor = $.fn[id] && $.fn[id].Constructor || $.fn[id];
+
+    var obj = callback.apply(constructor, [this.settings]);
+    if ($.isPlainObject(obj)) {
+      for (var key in obj) {
+        if (!obj.hasOwnProperty(key)) continue;
+        var value = obj[key];
+        if (constructor.prototype[key] !== void 0 || typeof value === 'function') {
+          constructor.prototype[key] = $.isPlainObject(value) ? $.extend(true, {}, constructor.prototype[key], value) : value;
+        }
+        else {
+          constructor[key] = $.isPlainObject(value) ? $.extend(true, {}, constructor[key], value) : value;
+        }
       }
     }
 
     // Add a jQuery UI like option getter/setter method.
-    if ($.fn[id].Constructor.prototype.option === void(0)) {
-      $.fn[id].Constructor.prototype.option = this.option;
+    var option = this.option;
+    if (constructor.prototype.option === void(0)) {
+      constructor.prototype.option = function () {
+        return option.apply(this, arguments);
+      };
     }
 
-    return $.fn[id].Constructor;
+    return constructor;
+  };
+
+  /**
+   * Intersects object properties.
+   *
+   * @param {...Object} objects
+   *   Two or more objects. The first object will be used to return properties
+   *   values.
+   *
+   * @return {Object}
+   *   Returns the properties of first passed object that intersects with all
+   *   other passed objects.
+   */
+  Drupal.bootstrap.intersectObjects = function (objects) {
+    var args = Array.prototype.slice.call(arguments);
+    return _.pick(args[0], _.intersection.apply(_, _.map(args, function (obj) {
+      return Object.keys(obj);
+    })));
   };
 
   /**
    * Replaces a Bootstrap jQuery plugin definition.
    *
-   * @param {string} id
+   * @param {String} id
    *   A Bootstrap plugin identifier located in $.fn.
-   * @param {function} [callback]
+   * @param {Function} callback
    *   A callback to replace the jQuery plugin definition. The callback must
    *   return a function that is used to construct a jQuery plugin.
-   *
-   * @return {function|boolean}
-   *   The Bootstrap jQuery plugin definition or FALSE if the plugin does not
-   *   exist.
+   * @param {Boolean} [noConflict]
+   *   Flag indicating whether or not to create a ".noConflict()" helper method
+   *   for the plugin.
    */
-  Drupal.bootstrap.replacePlugin = function (id, callback) {
+  Drupal.bootstrap.replacePlugin = function (id, callback, noConflict) {
     // Immediately return if plugin does not exist or not a valid callback.
-    if (!$.fn[id] || !$.fn[id].Constructor || !$.isFunction(callback)) return false;
-    var constructor = $.fn[id].Constructor;
+    if (!$.fn[id] || !$.isFunction(callback)) return false;
 
+    // Save Bootstrap plugin Constructor.
+    var constructor = $.fn[id] && $.fn[id].Constructor;
     var plugin = callback.apply(constructor);
-    if ($.isFunction(plugin)) {
+    if (!$.isFunction(plugin)) return false;
+
+    if (constructor && !plugin.Constructor) {
       plugin.Constructor = constructor;
+    }
 
+    if (noConflict === void 0 || noConflict) {
       var old = $.fn[id];
       plugin.noConflict = function () {
         $.fn[id] = old;
         return this;
       };
-      $.fn[id] = plugin;
     }
+
+    $.fn[id] = plugin;
   };
 
   /**
@@ -187,51 +275,41 @@
    *   value parameter was provided.
    *
    * @see https://github.com/jquery/jquery-ui/blob/master/ui/widget.js
-   *
-   * @todo This isn't fully working since Bootstrap plugins don't allow
-   * methods to return values.
    */
   Drupal.bootstrap.option = function (key, value) {
-    var options = key;
-    var parts, curOption, i;
+    var options = $.isPlainObject(key) ? $.extend({}, key) : {};
 
-    // Don't return a reference to the internal hash.
+    // Get all options (clone so it doesn't reference the internal object).
     if (arguments.length === 0) {
       return $.extend({}, this.options);
     }
 
-    // Handle a specific option.
+    // Get/set single option.
     if (typeof key === "string") {
-      // Handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } }
-      options = {};
-      parts = key.split(".");
+      // Handle nested keys in dot notation.
+      // e.g., "foo.bar" => { foo: { bar: true } }
+      var parts = key.split('.');
       key = parts.shift();
+      var obj = options;
       if (parts.length) {
-        curOption = options[key] = $.extend({}, this.options[key]);
-        for (i = 0; i < parts.length - 1; i++) {
-          curOption[parts[i]] = curOption[parts[i]] || {};
-          curOption = curOption[parts[i]];
+        for (var i = 0; i < parts.length - 1; i++) {
+          obj[parts[i]] = obj[parts[i]] || {};
+          obj = obj[parts[i]];
         }
         key = parts.pop();
-        if (arguments.length === 1) {
-          return curOption[key] === undefined ? null : curOption[key];
-        }
-        curOption[key] = value;
       }
-      else {
-        if (arguments.length === 1) {
-          return this.options[key] === undefined ? null : this.options[key];
-        }
-        options[key] = value;
+
+      // Get.
+      if (arguments.length === 1) {
+        return obj[key] === void 0 ? null : obj[key];
       }
-    }
 
-    // Set the new option(s).
-    for (key in options) {
-      if (!options.hasOwnProperty(key)) continue;
-      this.options[key] = options[key];
+      // Set.
+      obj[key] = value;
     }
-    return this;
+
+    // Set multiple options.
+    $.extend(true, this.options, options);
   };
 
-})(window.jQuery, window.Drupal, window.drupalSettings);
+})(window._, window.jQuery, window.Drupal, window.drupalSettings);
diff --git a/js/misc/dialog.ajax.js b/js/misc/dialog.ajax.js
deleted file mode 100644
index b12c7e9..0000000
--- a/js/misc/dialog.ajax.js
+++ /dev/null
@@ -1,263 +0,0 @@
-/**
- * @file
- * Extends the Drupal AJAX functionality to integrate the dialog API.
- */
-
-(function ($, Drupal) {
-  'use strict';
-
-  /**
-   * Initialize dialogs for Ajax purposes.
-   *
-   * @type {Drupal~behavior}
-   */
-  Drupal.behaviors.dialog = {
-    attach: function (context, settings) {
-      var $context = $(context);
-
-      // Provide a known 'drupal-modal' DOM element for Drupal-based modal
-      // dialogs. Non-modal dialogs are responsible for creating their own
-      // elements, since there can be multiple non-modal dialogs at a time.
-      if (!$('#drupal-modal').length) {
-        $(Drupal.theme.bootstrapModal()).appendTo('body');
-      }
-
-      // Special behaviors specific when attaching content within a dialog.
-      // These behaviors usually fire after a validation error inside a dialog.
-      var $dialog = $context.closest('.modal');
-      if ($dialog.length) {
-        // Remove and replace the dialog buttons with those from the new form.
-        if ($dialog.modal('option', 'drupalAutoButtons')) {
-          // Trigger an event to detect/sync changes to buttons.
-          $dialog.trigger('dialogButtonsChange');
-        }
-
-        // Attempt to force focus on the first visible input (not :input) in
-        // the modal body when the behavior is run.
-        Drupal.behaviors.dialog.focus($dialog);
-      }
-
-      var originalClose = settings.dialog.close;
-      // Overwrite the close method to remove the dialog on closing.
-      settings.dialog.close = function (event) {
-        originalClose.apply(settings.dialog, arguments);
-        $(event.target).remove();
-      };
-    },
-
-    /**
-     * Attempt to focus the first visible input (not :input) in the modal body.
-     *
-     * @param {jQuery} $dialog
-     *   An jQuery object containing the element that is the dialog target.
-     */
-    focus: function ($dialog) {
-      $dialog.find('.modal-body input:visible:first').trigger('focus');
-    },
-
-    /**
-     * Scan a dialog for any primary buttons and move them to the button area.
-     *
-     * @param {jQuery} $dialog
-     *   An jQuery object containing the element that is the dialog target.
-     *
-     * @return {Array}
-     *   An array of buttons that need to be added to the button area.
-     */
-    prepareDialogButtons: function ($dialog) {
-      var buttons = [];
-      var $buttons = $dialog.find('.form-actions :input[type=submit]');
-      $buttons.each(function () {
-        // Hidden form buttons need special attention. For browser consistency,
-        // the button needs to be "visible" in order to have the enter key fire
-        // the form submit event. So instead of a simple "hide" or
-        // "display: none", we set its dimensions to zero.
-        // See http://mattsnider.com/how-forms-submit-when-pressing-enter/
-        var $originalButton = $(this).css({
-          display: 'block',
-          width: 0,
-          height: 0,
-          padding: 0,
-          border: 0
-        });
-        buttons.push({
-          text: $originalButton.html() || $originalButton.attr('value'),
-          class: $originalButton.attr('class'),
-          click: function (e) {
-            $originalButton.trigger('mousedown').trigger('mouseup').trigger('click');
-            e.preventDefault();
-          }
-        });
-      });
-      return buttons;
-    }
-  };
-
-  /**
-   * Command to open a dialog.
-   *
-   * @param {Drupal.Ajax} ajax
-   * @param {object} response
-   * @param {number} [status]
-   *
-   * @return {bool|undefined}
-   */
-  Drupal.AjaxCommands.prototype.openDialog = function (ajax, response, status) {
-    if (!response.selector) {
-      return false;
-    }
-
-    var $dialog = $(response.selector);
-    if (!$dialog.length) {
-      // Create the element if needed.
-      $dialog = $(Drupal.theme.bootstrapModal({ id: response.selector.replace(/^#/, '') })).appendTo('body');
-    }
-    // Set up the wrapper, if there isn't one.
-    var id = $dialog.attr('id');
-    if (response.selector === '#' + id) {
-      response.selector = '#' + id + '--body';
-    }
-    else if (!ajax.wrapper) {
-      ajax.wrapper = id + '--body';
-    }
-
-    // Use the ajax.js insert command to populate the dialog contents.
-    response.command = 'insert';
-    response.method = 'html';
-    ajax.commands.insert(ajax, response, status);
-
-    // Move the buttons to the jQuery UI dialog buttons area.
-    if (!response.dialogOptions.buttons) {
-      response.dialogOptions.drupalAutoButtons = true;
-      response.dialogOptions.buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog);
-    }
-
-    // Bind dialogButtonsChange.
-    $dialog.on('dialogButtonsChange', function () {
-      var buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog);
-      $dialog.modal('option', 'buttons', buttons);
-    });
-
-    // Open the dialog itself.
-    response.dialogOptions = response.dialogOptions || {};
-    var dialog = Drupal.dialog($dialog.get(0), response.dialogOptions);
-    if (response.dialogOptions.modal) {
-      dialog.showModal();
-    }
-    else {
-      dialog.show();
-    }
-
-    // Add the standard Drupal class for buttons for style consistency.
-    $dialog.parent().find('.ui-dialog-buttonset').addClass('form-actions');
-  };
-
-  /**
-   * Command to close a dialog.
-   *
-   * If no selector is given, it defaults to trying to close the modal.
-   *
-   * @param {Drupal.Ajax} [ajax]
-   * @param {object} response
-   * @param {string} response.selector
-   * @param {bool} response.persist
-   * @param {number} [status]
-   */
-  Drupal.AjaxCommands.prototype.closeDialog = function (ajax, response, status) {
-    var $dialog = $(response.selector);
-    if ($dialog.length) {
-      Drupal.dialog($dialog.get(0)).close();
-      if (!response.persist) {
-        // Wrap this in a timer so animations can finish.
-        setTimeout(function() {
-          $dialog.remove();
-        }, 1000);
-      }
-    }
-
-    // Unbind dialogButtonsChange.
-    $dialog.off('dialogButtonsChange');
-  };
-
-  /**
-   * Command to set a dialog property.
-   *
-   * JQuery UI specific way of setting dialog options.
-   *
-   * @param {Drupal.Ajax} [ajax]
-   * @param {object} response
-   * @param {string} response.selector
-   * @param {string} response.optionsName
-   * @param {string} response.optionValue
-   * @param {number} [status]
-   */
-  Drupal.AjaxCommands.prototype.setDialogOption = function (ajax, response, status) {
-    var $dialog = $(response.selector);
-    if ($dialog.length) {
-      $dialog.modal('option', response.optionName, response.optionValue);
-    }
-  };
-
-  /**
-   * Binds a listener on dialog before creation to setup title and buttons.
-   *
-   * @param {jQuery.Event} e
-   * @param {Drupal.dialog} dialog
-   * @param {jQuery} $element
-   * @param {object} settings
-   */
-  $(window).on('dialog:beforecreate', function (e, dialog, $element, settings) {
-    // Replace title.
-    if (settings.title) {
-      var $header = $element.find('.modal-header');
-      if (!$header[0]) {
-        $header = $(Drupal.theme.bootstrapModalHeader()).prependTo($element.find('.modal-content'));
-      }
-      $header.find('.modal-title').text(Drupal.checkPlain(settings.title));
-    }
-
-    // Remove any existing buttons.
-    $element.find('.modal-footer').remove();
-
-    // Add new buttons.
-    if (settings.buttons && settings.buttons.length) {
-      var $footer = $(Drupal.theme.bootstrapModalFooter('', true)).appendTo($element.find('.modal-content'));
-      for (var i in settings.buttons) {
-        if (!settings.buttons.hasOwnProperty(i)) continue;
-        var button = settings.buttons[i];
-        $('<button class="' + button.class + '">' + button.text + '</button>')
-          .appendTo($footer)
-          .on('click', button.click);
-      }
-    }
-  });
-
-  /**
-   * Binds a listener on dialog creation to handle the cancel link.
-   *
-   * @param {jQuery.Event} e
-   * @param {Drupal.dialog} dialog
-   * @param {jQuery} $element
-   * @param {object} settings
-   */
-  $(window).on('dialog:aftercreate', function (e, dialog, $element, settings) {
-    Drupal.behaviors.dialog.focus($element);
-    $element.on('click.dialog', '.dialog-cancel', function (e) {
-      dialog.close('cancel');
-      e.preventDefault();
-      e.stopPropagation();
-    });
-  });
-
-  /**
-   * Removes all 'dialog' listeners.
-   *
-   * @param {jQuery.Event} e
-   * @param {Drupal.dialog} dialog
-   * @param {jQuery} $element
-   */
-  $(window).on('dialog:beforeclose', function (e, dialog, $element) {
-    $element.off('.modal.drupal');
-  });
-
-})(jQuery, Drupal);
diff --git a/js/misc/dialog.js b/js/misc/dialog.js
deleted file mode 100644
index 5a92e82..0000000
--- a/js/misc/dialog.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @file
- * Dialog API inspired by HTML5 dialog element.
- *
- * @see http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#the-dialog-element
- */
-
-(function ($, Drupal, drupalSettings) {
-
-  'use strict';
-
-  /**
-   * Default dialog options.
-   *
-   * @type {object}
-   *
-   * @prop {bool} [autoOpen=true]
-   * @prop {string} [dialogClass='']
-   * @prop {string} [buttonClass='button']
-   * @prop {string} [buttonPrimaryClass='button--primary']
-   * @prop {function} close
-   */
-  drupalSettings.dialog = {
-    autoOpen: true,
-    dialogClass: '',
-    // Drupal-specific extensions: see dialog.jquery-ui.js.
-    buttonClass: 'button',
-    buttonPrimaryClass: 'button--primary',
-    // When using this API directly (when generating dialogs on the client
-    // side), you may want to override this method and do
-    // `jQuery(event.target).remove()` as well, to remove the dialog on
-    // closing.
-    close: function (event) {
-      Drupal.detachBehaviors(event.target, null, 'unload');
-    }
-  };
-
-  /**
-   * @typedef {object} Drupal.dialog~dialogDefinition
-   *
-   * @prop {boolean} open
-   *   Is the dialog open or not.
-   * @prop {*} returnValue
-   *   Return value of the dialog.
-   * @prop {function} show
-   *   Method to display the dialog on the page.
-   * @prop {function} showModal
-   *   Method to display the dialog as a modal on the page.
-   * @prop {function} close
-   *   Method to hide the dialog from the page.
-   */
-
-  /**
-   * Polyfill HTML5 dialog element with jQueryUI.
-   *
-   * @param {HTMLElement} element
-   * @param {object} options
-   *   jQuery UI options to be passed to the dialog.
-   *
-   * @return {Drupal.dialog~dialogDefinition}
-   */
-  Drupal.dialog = function (element, options) {
-    var $element = $(element);
-
-    function openDialog(settings) {
-      settings = $.extend({}, drupalSettings.dialog, options, settings);
-      // Trigger a global event to allow scripts to bind events to the dialog.
-      $(window).trigger('dialog:beforecreate', [dialog, $element, settings]);
-      $element
-        .modal(settings)
-        .on('shown.bs.modal.drupal', function () {
-          dialog.open = true;
-          $(window).trigger('dialog:aftercreate', [dialog, $element, settings]);
-        })
-      ;
-    }
-
-    function closeDialog(value) {
-      $(window).trigger('dialog:beforeclose', [dialog, $element]);
-      $element
-        .on('hidden.bs.modal.drupal', function () {
-          dialog.returnValue = value;
-          dialog.open = false;
-          $(window).trigger('dialog:afterclose', [dialog, $element]);
-        })
-        .modal('hide');
-    }
-
-    var dialog = {
-      open: false,
-      returnValue: void(0),
-      show: function () {
-        openDialog({show: false});
-      },
-      showModal: function () {
-        openDialog({show: true});
-      },
-      close: closeDialog
-    };
-
-    return dialog;
-  };
-
-})(jQuery, Drupal, drupalSettings);
diff --git a/js/modal.jquery.ui.bridge.js b/js/modal.jquery.ui.bridge.js
new file mode 100644
index 0000000..033a6d0
--- /dev/null
+++ b/js/modal.jquery.ui.bridge.js
@@ -0,0 +1,188 @@
+/**
+ * @file
+ * Bootstrap Modals.
+ */
+(function ($, Drupal, Bootstrap, Attributes) {
+  "use strict";
+
+  /**
+   * Proxy $.fn.dialog to $.fn.modal.
+   */
+  Bootstrap.addPlugin('dialog', function () {
+    return function () {
+      // Extract the arguments.
+      var args = Array.prototype.slice.call(arguments);
+      var method = args.shift();
+      if ($.isPlainObject(method)) {
+        method = null;
+      }
+
+      // When no method or arguments are passed, jQuery UI dialog treats this
+      // like a instantiation method. Destroy any existing Bootstrap modal and
+      // recreate it using the contents of the dialog HTML.
+      if (!method && !args.length) {
+        this.each(function () {
+          var $this = $(this);
+          var $modal = $(Drupal.theme.bootstrapModal({
+            attributes: Attributes.create(this).remove('style'),
+            body: $this.html()
+          }));
+          $this
+            .removeData('bs.modal')
+            .attr(Attributes.create($modal).toPlainObject())
+            .html($modal.html());
+        });
+      }
+
+      // Proxy to the Bootstrap Modal plugin.
+      return $.fn.modal.apply(this, arguments);
+    };
+  });
+
+  /**
+   * Extend the Bootstrap Modal plugin constructor class.
+   */
+  Bootstrap.extendPlugin('modal', function () {
+    // Save the original option method.
+    var option = this.prototype.option;
+
+    var unsupportedMethod = function (method) {
+      Drupal.throwError(new Error('Unsupported jQuery UI dialog -> Bootstrap Modal bridge method: ' + method));
+    };
+
+    return {
+      /**
+       * Handler for $.fn.dialog('close').
+       */
+      close: function () {
+        this.hide.apply(this, arguments);
+      },
+
+      /**
+       * Handler for $.fn.dialog('destroy').
+       */
+      destroy: function () {
+        this.hide();
+        this.$element.removeData('bs.modal').remove();
+      },
+
+      /**
+       * Handler for $.fn.dialog('instance').
+       */
+      instance: function () {
+        unsupportedMethod('instance');
+      },
+
+      /**
+       * Handler for $.fn.dialog('isOpen').
+       */
+      isOpen: function () {
+        return !!this.isShown;
+      },
+
+      /**
+       * Maps dialog options to the modal.
+       *
+       * @param {Object} options
+       *   The options to map.
+       */
+      mapDialogOptions: function (options) {
+        var $content = this.$dialog.find('.modal-content');
+        for (var name in options) {
+          if (!options.hasOwnProperty(name) || options[name] === void 0) continue;
+          var value = options[name];
+
+          switch (name) {
+            // Draggable support (must initialize first).
+            case 'draggable':
+              $content
+                .draggable({ handle: '.modal-header' })
+                .draggable(options.draggable ? 'enable' : 'disable');
+              break;
+
+            // Modal support (this is a drupal.dialog option).
+            case 'modal':
+              this.options.backdrop = options.backdrop = !!value;
+              if (!options.modal && !options.position) {
+                options.position = { my: 'center', of: window };
+              }
+              break;
+
+            // Position support.
+            case 'position':
+              // Reset modal styling.
+              this.$element.css({
+                bottom: 'initial',
+                overflow: 'visible',
+                right: 'initial'
+              });
+
+              // Position the modal.
+              this.$element.position(value);
+              break;
+
+            // Resizable support (must initialize first).
+            case 'resizable':
+              $content
+                .resizable()
+                .resizable(options.resizable ? 'enable' : 'disable');
+              break;
+
+            // Title support.
+            case 'title':
+              this.$dialog.find('.modal-title').text(value);
+              break;
+          }
+        }
+      },
+
+      /**
+       * Handler for $.fn.dialog('moveToTop').
+       */
+      moveToTop: function () {
+        unsupportedMethod('moveToTop');
+      },
+
+      /**
+       * Handler for $.fn.dialog('option').
+       */
+      option: function () {
+        var previousOptions = $.extend({}, this.options);
+
+        // Proxy to the original option method.
+        option.apply(this, arguments);
+
+        var options = Bootstrap.diffObjects(this.options, previousOptions);
+
+        // Map dialog options.
+        this.mapDialogOptions(options);
+
+        // Handle autoResize option (this is a drupal.dialog option).
+        if (this.options.autoResize && this.options.position) {
+          this.$element.position(this.options.position);
+        }
+
+        // If autoOpen is set and currently not shown, show it.
+        if (this.options.autoOpen && !this.isShown) {
+          this.show();
+        }
+      },
+
+      /**
+       * Handler for $.fn.dialog('open').
+       */
+      open: function () {
+        this.show.apply(this, arguments);
+      },
+
+      /**
+       * Handler for $.fn.dialog('widget').
+       */
+      widget: function () {
+        return this.$element;
+      }
+    };
+  });
+
+
+})(window.jQuery, window.Drupal, window.Drupal.bootstrap, window.Attributes);
diff --git a/js/modal.js b/js/modal.js
index 8f32e5b..9952d89 100644
--- a/js/modal.js
+++ b/js/modal.js
@@ -2,7 +2,7 @@
  * @file
  * Bootstrap Modals.
  */
-(function ($, Drupal, Bootstrap) {
+(function ($, Drupal, Bootstrap, Attributes) {
   "use strict";
 
   /**
@@ -28,25 +28,54 @@
   Bootstrap.replacePlugin('modal', function () {
     var Modal = this;
 
-    // Extract the arguments.
-    var args = Array.prototype.slice.call(arguments, 1);
-
     // Modal jQuery Plugin Definition.
-    return function (option, _relatedTarget) {
-      var ret = void(0);
+    return function () {
+      // Extract the arguments.
+      var args = Array.prototype.slice.call(arguments);
+      var method = args.shift();
+      var options = {};
+      if ($.isPlainObject(method)) {
+        options = method;
+        method = null;
+      }
+      var ret = void 0;
       this.each(function () {
         var $this   = $(this);
         var data    = $this.data('bs.modal');
-        var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option);
 
-        if (!data) $this.data('bs.modal', (data = new Modal(this, options)));
-        if (typeof option == 'string') ret = data[option].apply(data, args);
-        else if (options.show) data.show(_relatedTarget);
+        options = $.extend({}, Modal.DEFAULTS, data && data.options, $this.data(), options);
+        if (!data) {
+          // When initializing the Bootstrap Modal, only pass the "supported"
+          // options by intersecting the default options. This allows plugins
+          // like the jQuery UI bridge to properly detect when options have
+          // changed when they're set below as a global "option" method.
+          $this.data('bs.modal', (data = new Modal(this, Bootstrap.intersectObjects(options, Modal.DEFAULTS))));
+        }
+
+        // If no method or arguments, treat it like it's setting options.
+        if (!method && !args.length) {
+          method = 'option';
+          args.push(options);
+        }
+
+        if (method) {
+          if (typeof data[method] === 'function') {
+            try {
+              ret = data[method].apply(data, args);
+            }
+            catch (e) {
+              Drupal.throwError(e);
+            }
+          }
+          else {
+            Drupal.throwError(new Error('Unsupported bs.modal method: ' + method));
+          }
+        }
       });
 
       // If just one element and there was a result returned for the option passed,
       // then return the result. Otherwise, just return the jQuery object.
-      return this.length === 1 && ret !== void(0) ? ret : this;
+      return this.length === 1 && ret !== void 0 ? ret : this;
     }
   });
 
@@ -57,163 +86,310 @@
     /**
      * Theme function for a Bootstrap Modal.
      *
-     * @param {object}[variables]
-     *   An object with the following keys:
-     *   - title: The name of the tab.
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
      *
      * @return {string}
      *   The HTML for the modal.
      */
     bootstrapModal: function (variables) {
+      var output = '';
       var settings = drupalSettings.bootstrap || {};
       var defaults = {
+        attributes: {
+          class: ['modal'],
+          tabindex: -1,
+          role: 'dialog'
+        },
         body: '',
         closeButton: true,
         description: {
+          attributes: {
+            class: ['help-block']
+          },
           content: null,
           position: 'before'
         },
         footer: '',
         id: 'drupal-modal',
         size: settings.modal_size ? settings.modal_size : '',
-        title: Drupal.t('Loading...')
+        title: {
+          attributes: {
+            class: ['modal-title']
+          },
+          content: Drupal.t('Loading...'),
+          html: false,
+          tag: 'h4'
+        }
       };
       variables = $.extend(true, {}, defaults, variables);
-      var output = '';
 
-      // Build the modal wrapper.
-      var classes = ['modal'];
+      var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+      attributes.set('id', attributes.get('id', variables.id));
+
       if (settings.modal_animation) {
-        classes.push('fade');
+        attributes.addClass('fade');
       }
-      output += '<div id="' + variables.id + '" class="' + classes.join(' ') + '" tabindex="-1" role="dialog">';
+
+      // Build the modal wrapper.
+      output += '<div' + attributes + '>';
 
       // Build the modal-dialog wrapper.
-      var dialogClasses = ['modal-dialog'];
+      output += Drupal.theme.bootstrapModalDialog(_.omit(variables, 'attributes'));
+
+      // Close the modal wrapper.
+      output += '</div>';
+
+      // Return the constructed modal.
+      return output;
+    },
+
+    /**
+     * Theme function for a Bootstrap Modal dialog markup.
+     *
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
+     *
+     * @return {string}
+     *   The HTML for the modal close button.
+     */
+    bootstrapModalDialog: function (variables) {
+      var output = '';
+
+      var defaults = {
+        attributes: {
+          class: ['modal-dialog'],
+          role: 'document'
+        },
+        id: 'drupal-modal'
+      };
+      variables = $.extend(true, {}, defaults, variables);
+
+      var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+      attributes.set('id', attributes.get('id', variables.id + '--dialog'));
+
       if (variables.size) {
-        // @todo This should really be a clean CSS class method instead.
-        dialogClasses.push(Drupal.checkPlain(variables.size));
+        attributes.addClass(variables.size);
       }
-      output += '<div class="' + dialogClasses.join(' ') + '" role="document">';
+      output += '<div' + attributes + '>';
 
       // Build the modal-content wrapper.
-      output += '<div class="modal-content">';
+      output += Drupal.theme.bootstrapModalContent(_.omit(variables, 'attributes'));
+
+      // Close the modal-dialog wrapper.
+      output += '</div>';
+      return output;
+    },
+
+    /**
+     * Theme function for a Bootstrap Modal content markup.
+     *
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
+     *
+     * @return {string}
+     *   The HTML for the modal close button.
+     */
+    bootstrapModalContent: function (variables) {
+      var output = '';
+
+      var defaults = {
+        attributes: {
+          class: ['modal-content']
+        },
+        id: 'drupal-modal'
+      };
+      variables = $.extend(true, {}, defaults, variables);
+
+      var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+      attributes.set('id', attributes.get('id', variables.id + '--content'));
+
+      // Build the modal-content wrapper.
+      output += '<div' + attributes + '>';
+      variables = _.omit(variables, 'attributes');
 
       // Build the header wrapper and title.
-      output += Drupal.theme.bootstrapModalHeader(variables.title, variables.closeButton);
+      output += Drupal.theme.bootstrapModalHeader(variables);
 
       // Build the body.
-      output += Drupal.theme.bootstrapModalBody(variables.id + '--body', variables.body, variables.description);
+      output += Drupal.theme.bootstrapModalBody(variables);
 
       // Build the footer.
-      output += Drupal.theme.bootstrapModalFooter(variables.footer);
+      output += Drupal.theme.bootstrapModalFooter(variables);
 
       // Close the modal-content wrapper.
       output += '</div>';
 
-      // Close the modal-dialog wrapper.
-      output += '</div>';
-
-      // Close the modal wrapper.
-      output += '</div>';
-
-      // Return the constructed modal.
       return output;
     },
 
     /**
      * Theme function for a Bootstrap Modal body markup.
      *
-     * @param {string} id
-     *   A unique ID for the modal body div.
-     * @param {string} body
-     *   The HTML markup to place in the body.
-     * @param {string|object} description
-     *   A description to show. Can either be a string or an object with the
-     *   following key/value pairs:
-     *   - content: The description value.
-     *   - position: (optional) A display setting that can have these values:
-     *     - before: The description is displayed before the body. This is the
-     *       default value.
-     *     - after: The description is display after the body.
-     *     - invisible: The description is displayed after the element, hidden
-     *       visually but available to screen readers.
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
      *
      * @return {string}
      *   The HTML for the modal close button.
      */
-    bootstrapModalBody: function (id, body, description) {
+    bootstrapModalBody: function (variables) {
       var output = '';
-      output += '<div id="' + id + '" class="modal-body">';
-      if (!description || !$.isPlainObject(description)) {
-        description = { content: description};
+
+      var defaults = {
+        attributes: {
+          class: ['modal-body']
+        },
+        body: '',
+        description: {
+          attributes: {
+            class: ['help-block']
+          },
+          content: null,
+          position: 'before'
+        },
+        id: 'drupal-modal'
+      };
+      variables = $.extend(true, {}, defaults, variables);
+
+      var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+      attributes.set('id', attributes.get('id', variables.id + '--body'));
+
+      output += '<div' + attributes + '>';
+
+      if (typeof variables.description === 'string') {
+        variables.description = $.extend({}, defaults.description, { content: variables.description });
       }
-      description = $.extend({ position: 'before' }, description);
 
-      var descriptionClasses = ['help-block'];
+      var description = variables.description;
+      description.attributes = Attributes.create(defaults.description.attributes).merge(description.attributes);
+
       if (description.content && description.position === 'invisible') {
-        descriptionClasses.push('sr-only');
+        description.attributes.addClass('sr-only');
       }
+
       if (description.content && description.position === 'before') {
-        output += '<p class="' + descriptionClasses.join(' ') + '">' + description.content + '</p>';
+        output += '<p' + description.attributes + '>' + description.content + '</p>';
       }
-      output += body;
+
+      output += variables.body;
+
       if (description.content && (description.position === 'after' || description.position === 'invisible')) {
-        output += '<p class="' + descriptionClasses.join(' ') + '">' + description.content + '</p>';
+        output += '<p' + description.attributes + '>' + description.content + '</p>';
       }
+
       output += '</div>';
+
       return output;
     },
 
     /**
      * Theme function for a Bootstrap Modal close button.
      *
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
+     *
      * @return {string}
      *   The HTML for the modal close button.
      */
-    bootstrapModalClose: function () {
-      return '<button type="button" class="close" data-dismiss="modal" aria-label="' + Drupal.t('Close') + '"><span aria-hidden="true">&times;</span></button>';
+    bootstrapModalClose: function (variables) {
+      var defaults = {
+        attributes: {
+          'aria-label': Drupal.t('Close'),
+          class: ['close'],
+          'data-dismiss': 'modal',
+          type: 'button'
+        }
+      };
+      variables = $.extend(true, {}, defaults, variables);
+      var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+      return '<button' + attributes + '><span aria-hidden="true">&times;</span></button>';
     },
 
     /**
      * Theme function for a Bootstrap Modal footer.
      *
-     * @param {string} [footer]
-     *   The HTML markup to place in the footer.
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
      * @param {boolean} [force]
-     *   Flag to force the rendering of the footer.
+     *   Flag to force rendering the footer, regardless if there's content.
      *
      * @return {string}
      *   The HTML for the modal footer.
      */
-    bootstrapModalFooter: function (footer, force) {
-      return footer || force ? '<div class="modal-footer">' + (footer || '') + '</div>' : '';
+    bootstrapModalFooter: function (variables, force) {
+      var output = '';
+      var defaults = {
+        attributes: {
+          class: ['modal-footer']
+        },
+        footer: '',
+        id: 'drupal-modal'
+      };
+
+      variables = $.extend(true, {}, defaults, variables);
+
+      if (force || variables.footer) {
+        var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+        attributes.set('id', attributes.get('id', variables.id + '--footer'));
+        output += '<div' + attributes + '>';
+        output += variables.footer;
+        output += '</div>';
+      }
+
+      return output;
     },
 
     /**
      * Theme function for a Bootstrap Modal header.
      *
-     * @param {string} [title]
-     *   The title for the header.
-     * @param {boolean} [closeButton]
-     *   Flag indicating whether or not to show the close button in the header.
+     * @param {Object} [variables]
+     *   An object containing key/value pairs of variables.
      *
      * @return {string}
      *   The HTML for the modal header.
      */
-    bootstrapModalHeader: function (title, closeButton) {
+    bootstrapModalHeader: function (variables) {
       var output = '';
+
+      var defaults = {
+        attributes: {
+          class: ['modal-header']
+        },
+        closeButton: true,
+        id: 'drupal-modal',
+        title: {
+          attributes: {
+            class: ['modal-title']
+          },
+          content: Drupal.t('Loading...'),
+          html: false,
+          tag: 'h4'
+        }
+      };
+      variables = $.extend(true, {}, defaults, variables);
+
+      var title = variables.title;
       if (title) {
-        closeButton = closeButton !== void(0) ? closeButton : true;
-        output += '<div class="modal-header">';
-        if (closeButton) {
-          output += Drupal.theme.bootstrapModalClose();
+        var attributes = Attributes.create(defaults.attributes).merge(variables.attributes);
+        attributes.set('id', attributes.get('id', variables.id + '--header'));
+
+        if (typeof title === 'string') {
+          title = $.extend({}, defaults.title, { content: title });
+        }
+
+        output += '<div' + attributes + '>';
+
+        if (variables.closeButton) {
+          output += Drupal.theme.bootstrapModalClose(_.omit(variables, 'attributes'));
         }
-        output += '<h4 class="modal-title">' + Drupal.checkPlain(title) + '</h4>';
+
+        output += '<' + Drupal.checkPlain(title.tag) + Attributes.create(defaults.title.attributes).merge(title.attributes) + '>' + (title.html ? title.content : Drupal.checkPlain(title.content)) + '</' + Drupal.checkPlain(title.tag) + '>';
+
         output += '</div>';
       }
+
       return output;
     }
   })
 
-})(window.jQuery, window.Drupal, window.Drupal.bootstrap);
+})(window.jQuery, window.Drupal, window.Drupal.bootstrap, window.Attributes);
diff --git a/js/theme-settings.js b/js/theme-settings.js
index 64ec490..b66bb28 100644
--- a/js/theme-settings.js
+++ b/js/theme-settings.js
@@ -62,7 +62,12 @@
       $context.find('#edit-javascript').drupalSetSummary(function () {
         var summary = [];
         if ($context.find('input[name="modal_enabled"]').is(':checked')) {
-          summary.push(Drupal.t('Modals'));
+          if ($context.find('input[name="modal_jquery_ui_bridge"]').is(':checked')) {
+            summary.push(Drupal.t('Modals (Bridged)'));
+          }
+          else {
+            summary.push(Drupal.t('Modals'));
+          }
         }
         if ($context.find('input[name="popover_enabled"]').is(':checked')) {
           summary.push(Drupal.t('Popovers'));
diff --git a/js/theme.js b/js/theme.js
index 7e3de0a..16dc7a4 100644
--- a/js/theme.js
+++ b/js/theme.js
@@ -2,7 +2,7 @@
  * @file
  * Theme hooks for the Drupal Bootstrap base theme.
  */
-(function ($, Drupal, Bootstrap) {
+(function ($, Drupal, Bootstrap, Attributes) {
 
   /**
    * Fallback for theming an icon if the Icon API module is not installed.
@@ -24,7 +24,7 @@
        */
       icon: function (bundle, icon, attributes) {
         if (!Drupal.icon.bundles[bundle]) return '';
-        attributes = Attributes(attributes).addClass('icon').set('aria-hidden', 'true');
+        attributes = Attributes.create(attributes).addClass('icon').set('aria-hidden', 'true');
         icon = Drupal.icon.bundles[bundle](icon, attributes);
         return '<span' + attributes + '></span>';
       }
@@ -75,7 +75,7 @@
      * @returns {string}
      */
     button: function (attributes) {
-      attributes = Attributes(attributes).addClass('btn');
+      attributes = Attributes.create(attributes).addClass('btn');
       var context = attributes.get('context', 'default');
       var label = attributes.get('value', '');
       attributes.remove('context').remove('value');
@@ -110,7 +110,7 @@
      * @returns {string}
      */
     'btn-block': function (attributes) {
-      return Drupal.theme('button', Attributes(attributes).addClass('btn-block'));
+      return Drupal.theme('button', Attributes.create(attributes).addClass('btn-block'));
     },
 
     /**
@@ -124,7 +124,7 @@
      * @returns {string}
      */
     'btn-lg': function (attributes) {
-      return Drupal.theme('button', Attributes(attributes).addClass('btn-lg'));
+      return Drupal.theme('button', Attributes.create(attributes).addClass('btn-lg'));
     },
 
     /**
@@ -138,7 +138,7 @@
      * @returns {string}
      */
     'btn-sm': function (attributes) {
-      return Drupal.theme('button', Attributes(attributes).addClass('btn-sm'));
+      return Drupal.theme('button', Attributes.create(attributes).addClass('btn-sm'));
     },
 
     /**
@@ -152,7 +152,7 @@
      * @returns {string}
      */
     'btn-xs': function (attributes) {
-      return Drupal.theme('button', Attributes(attributes).addClass('btn-xs'));
+      return Drupal.theme('button', Attributes.create(attributes).addClass('btn-xs'));
     },
 
     /**
@@ -171,4 +171,4 @@
 
   });
 
-})(window.jQuery, window.Drupal, window.Drupal.bootstrap);
+})(window.jQuery, window.Drupal, window.Drupal.bootstrap, window.Attributes);
diff --git a/src/Plugin/Alter/LibraryInfo.php b/src/Plugin/Alter/LibraryInfo.php
index d91f539..7aba6c7 100644
--- a/src/Plugin/Alter/LibraryInfo.php
+++ b/src/Plugin/Alter/LibraryInfo.php
@@ -82,8 +82,16 @@ class LibraryInfo extends PluginBase implements AlterInterface {
     elseif ($extension === 'core') {
       // Replace core dialog/jQuery UI implementations with Bootstrap Modals.
       if ($this->theme->getSetting('modal_enabled')) {
-        $libraries['drupal.dialog']['override'] = 'bootstrap/drupal.dialog';
-        $libraries['drupal.dialog.ajax']['override'] = 'bootstrap/drupal.dialog.ajax';
+        // Replace dependencies if using bridge so jQuery UI is not loaded
+        // and remove dialog.jquery-ui.js since the dialog widget isn't loaded.
+        if ($this->theme->getSetting('modal_jquery_ui_bridge')) {
+          $libraries['drupal.dialog']['dependencies'] = ['bootstrap/modal.jquery.ui.bridge'];
+          unset($libraries['drupal.dialog']['js']['misc/dialog/dialog.jquery-ui.js']);
+        }
+        // Otherwise, just append the modal.
+        else {
+          $libraries['drupal.dialog']['dependencies'][] =  'bootstrap/modal';
+        }
       }
     }
   }
diff --git a/src/Plugin/Setting/JavaScript/Modals/ModalEnabled.php b/src/Plugin/Setting/JavaScript/Modals/ModalEnabled.php
index 1badd4b..ceb0562 100644
--- a/src/Plugin/Setting/JavaScript/Modals/ModalEnabled.php
+++ b/src/Plugin/Setting/JavaScript/Modals/ModalEnabled.php
@@ -21,7 +21,6 @@ use Drupal\Core\Form\FormStateInterface;
  *   id = "modal_enabled",
  *   type = "checkbox",
  *   title = @Translation("Enable Bootstrap Modals"),
- *   description = @Translation("Enabling this will replace core's jQuery UI Dialog implementations with modals from the Bootstrap Framework."),
  *   defaultValue = 1,
  *   weight = -1,
  *   groups = {
diff --git a/src/Plugin/Setting/JavaScript/Modals/ModalJqueryUIBridge.php b/src/Plugin/Setting/JavaScript/Modals/ModalJqueryUIBridge.php
new file mode 100644
index 0000000..51bfe9e
--- /dev/null
+++ b/src/Plugin/Setting/JavaScript/Modals/ModalJqueryUIBridge.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * @file
+ * Contains \Drupal\bootstrap\Plugin\Setting\JavaScript\Modals\ModalEnabled.
+ */
+
+namespace Drupal\bootstrap\Plugin\Setting\JavaScript\Modals;
+
+use Drupal\bootstrap\Annotation\BootstrapSetting;
+use Drupal\bootstrap\Plugin\Setting\SettingBase;
+use Drupal\bootstrap\Utility\Element;
+use Drupal\Core\Annotation\Translation;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * The "modal_jquery_ui_bridge" theme setting.
+ *
+ * @ingroup plugins_setting
+ *
+ * @BootstrapSetting(
+ *   id = "modal_jquery_ui_bridge",
+ *   type = "checkbox",
+ *   title = @Translation("jQuery UI Bridge"),
+ *   description = @Translation("Enabling this replaces the core/jquery.ui.dialog dependency in the core/drupal.dialog library with a bridge. This bridge adds support to Bootstrap Modals so that it may interpret jQuery UI Dialog functionality. It is highly recommended that this remain enabled unless you know what you're really doing."),
+ *   defaultValue = 1,
+ *   weight = 0,
+ *   groups = {
+ *     "javascript" = @Translation("JavaScript"),
+ *     "modals" = @Translation("Modals"),
+ *   },
+ * )
+ */
+class ModalJqueryUIBridge extends SettingBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alterFormElement(Element $form, FormStateInterface $form_state, $form_id = NULL) {
+    parent::alterFormElement($form, $form_state, $form_id);
+    $setting = $this->getSettingElement($form, $form_state);
+    $setting->setProperty('states', [
+      'visible' => [
+        ':input[name="modal_enabled"]' => ['checked' => TRUE],
+      ],
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    return ['rendered', 'library_info'];
+  }
+
+}
-- 
2.15.1

