From 7f439071ac7ab543309a0f309aa453b14bf21471 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Sat, 17 Feb 2018 21:03:12 -0600
Subject: [PATCH 01/23] fixes #22402

---
 build/vnu-jar.js             |   2 +
 docs/4.0/components/modal.md |  51 ++++++---
 js/src/modal.js              |  58 ++++++++++
 js/tests/unit/modal.js       | 211 +++++++++++++++++++++++++++++++++++
 package.json                 |   2 +-
 5 files changed, 306 insertions(+), 18 deletions(-)

diff --git a/build/vnu-jar.js b/build/vnu-jar.js
index 9a1d9fa12c..78fe6cc8dc 100644
--- a/build/vnu-jar.js
+++ b/build/vnu-jar.js
@@ -23,6 +23,8 @@ childProcess.exec('java -version', (error, stdout, stderr) => {
   // vnu-jar accepts multiple ignores joined with a `|`.
   // Also note that the ignores are regular expressions.
   const ignores = [
+    // docs/components/modal  contains multiple elements with autofocus
+    'A document must not include more than one “autofocus” attribute.*',
     // "autocomplete" is included in <button> and checkboxes and radio <input>s due to
     // Firefox's non-standard autocomplete behavior - see https://bugzilla.mozilla.org/show_bug.cgi?id=654072
     'Attribute “autocomplete” is only allowed when the input type is.*',
diff --git a/docs/4.0/components/modal.md b/docs/4.0/components/modal.md
index a3468bd8a2..9ed3cf81b7 100644
--- a/docs/4.0/components/modal.md
+++ b/docs/4.0/components/modal.md
@@ -15,13 +15,6 @@ Before getting started with Bootstrap's modal component, be sure to read the fol
 - Bootstrap only supports one modal window at a time. Nested modals aren't supported as we believe them to be poor user experiences.
 - Modals use `position: fixed`, which can sometimes be a bit particular about its rendering. Whenever possible, place your modal HTML in a top-level position to avoid potential interference from other elements. You'll likely run into issues when nesting a `.modal` within another fixed element.
 - Once again, due to `position: fixed`, there are some caveats with using modals on mobile devices. [See our browser support docs]({{ site.baseurl }}/docs/{{ site.docs_version }}/getting-started/browsers-devices/#modals-and-dropdowns-on-mobile) for details.
-- Due to how HTML5 defines its semantics, [the `autofocus` HTML attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autofocus) has no effect in Bootstrap modals. To achieve the same effect, use some custom JavaScript:
-
-{% highlight js %}
-$('#myModal').on('shown.bs.modal', function () {
-  $('#myInput').trigger('focus')
-})
-{% endhighlight %}
 
 Keep reading for demos and usage guidelines.
 
@@ -67,8 +60,8 @@ Below is a _static_ modal example (meaning its `position` and `display` have bee
         <p>Modal body text goes here.</p>
       </div>
       <div class="modal-footer">
-        <button type="button" class="btn btn-primary">Save changes</button>
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -93,7 +86,7 @@ Toggle a working modal demo by clicking the button below. It will slide down and
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -126,7 +119,7 @@ Toggle a working modal demo by clicking the button below. It will slide down and
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -168,7 +161,7 @@ When modals become too long for the user's viewport or device, they scroll indep
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -201,7 +194,7 @@ When modals become too long for the user's viewport or device, they scroll indep
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -226,7 +219,7 @@ Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal.
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -259,7 +252,7 @@ Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal.
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -287,7 +280,7 @@ Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal.
         <p><a href="#" class="tooltip-test" title="Tooltip" data-container="#exampleModalPopovers">This link</a> and <a href="#" class="tooltip-test" title="Tooltip" data-container="#exampleModalPopovers">that link</a> have tooltips on hover.</p>
       </div>
       <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+        <button type="button" class="btn btn-secondary" data-dismiss="modal" autofocus>Close</button>
         <button type="button" class="btn btn-primary">Save changes</button>
       </div>
     </div>
@@ -350,7 +343,7 @@ Utilize the Bootstrap grid system within a modal by nesting `.container-fluid` w
         </div>
       </div>
       <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+        <button type="button" class="btn btn-secondary" data-dismiss="modal" autofocus>Close</button>
         <button type="button" class="btn btn-primary">Save changes</button>
       </div>
     </div>
@@ -418,7 +411,7 @@ Below is a live demo followed by example HTML and JavaScript. For more informati
         <form>
           <div class="form-group">
             <label for="recipient-name" class="col-form-label">Recipient:</label>
-            <input type="text" class="form-control" id="recipient-name">
+            <input type="text" class="form-control" id="recipient-name" autofocus>
           </div>
           <div class="form-group">
             <label for="message-text" class="col-form-label">Message:</label>
@@ -465,6 +458,10 @@ If the height of a modal changes while it is open, you should call `$('#myModal'
 
 Be sure to add `role="dialog"` and `aria-labelledby="..."`, referencing the modal title, to `.modal`, and `role="document"` to the `.modal-dialog` itself. Additionally, you may give a description of your modal dialog with `aria-describedby` on `.modal`.
 
+The `autofocus` attribute may be given to inputs and buttons in modal.  By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
+
+By default, keyboard left & right arrow-keys can be used to focus buttons within the modal (ie change between "close" and "save").
+
 ### Embedding YouTube videos
 
 Embedding YouTube videos in modals requires additional JavaScript not in Bootstrap to automatically stop playback and more. [See this helpful Stack Overflow post](https://stackoverflow.com/questions/18622508/bootstrap-3-and-youtube-in-modal) for more information.
@@ -567,6 +564,18 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
     </tr>
   </thead>
   <tbody>
+    <tr>
+      <td>autofocus</td>
+      <td>boolean or the string <code>'notTouch'</code></td>
+      <td>'notTouch'</td>
+      <td>Whether input with `autofocus` attribute should be given focus when modal is shown<br />
+          <ul class="list-unstyled">
+            <li><code>notTouch</code> will give focus when not a touch device</li>
+            <li><code>true</code> will give focus regardless</li>
+            <li><code>false</code> no autofocus</li>
+          </ul>
+      </td>
+    </tr>
     <tr>
       <td>backdrop</td>
       <td>boolean or the string <code>'static'</code></td>
@@ -579,6 +588,14 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>true</td>
       <td>Closes the modal when escape key is pressed</td>
     </tr>
+    <tr>
+      <td>keyboardBtnNav</td>
+      <td>boolean</td>
+      <td>true</td>
+      <td>Whether keyboard's arrow keys should move focus to/between `.btn` elements.
+        <span class="text-muted">(focus will not be taken from input elements)</span>
+      </td>
+    </tr>
     <tr>
       <td>focus</td>
       <td>boolean</td>
diff --git a/js/src/modal.js b/js/src/modal.js
index 9237944df6..54b92f0109 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -24,17 +24,23 @@ const Modal = (($) => {
   const TRANSITION_DURATION          = 300
   const BACKDROP_TRANSITION_DURATION = 150
   const ESCAPE_KEYCODE               = 27 // KeyboardEvent.which value for Escape (Esc) key
+  const LEFT_KEYCODE                 = 37
+  const RIGHT_KEYCODE                = 39
 
   const Default = {
+    autofocus: 'notTouch',      // true|false|notTouch
     backdrop : true,
     keyboard : true,
+    keyboardBtnNav: true,  // ability to use arrows to nav button focus
     focus    : true,
     show     : true
   }
 
   const DefaultType = {
+    autofocus: '(boolean|string)',
     backdrop : '(boolean|string)',
     keyboard : 'boolean',
+    keyboardBtnNav: 'boolean',
     focus    : 'boolean',
     show     : 'boolean'
   }
@@ -48,6 +54,7 @@ const Modal = (($) => {
     RESIZE            : `resize${EVENT_KEY}`,
     CLICK_DISMISS     : `click.dismiss${EVENT_KEY}`,
     KEYDOWN_DISMISS   : `keydown.dismiss${EVENT_KEY}`,
+    KEYDOWN_NAV       : `keydown.nav${EVENT_KEY}`,
     MOUSEUP_DISMISS   : `mouseup.dismiss${EVENT_KEY}`,
     MOUSEDOWN_DISMISS : `mousedown.dismiss${EVENT_KEY}`,
     CLICK_DATA_API    : `click${EVENT_KEY}${DATA_API_KEY}`
@@ -134,6 +141,7 @@ const Modal = (($) => {
       $(document.body).addClass(ClassName.OPEN)
 
       this._setEscapeEvent()
+      this._setKeyNavEvent()
       this._setResizeEvent()
 
       $(this._element).on(
@@ -179,6 +187,7 @@ const Modal = (($) => {
       }
 
       this._setEscapeEvent()
+      this._setKeyNavEvent()
       this._setResizeEvent()
 
       $(document).off(Event.FOCUSIN)
@@ -227,6 +236,11 @@ const Modal = (($) => {
       return config
     }
 
+    // Util worthy?
+    _isTouchDevice() {
+      return 'ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch
+    }
+
     _showElement(relatedTarget) {
       const transition = Util.supportsTransitionEnd() &&
         $(this._element).hasClass(ClassName.FADE)
@@ -259,6 +273,9 @@ const Modal = (($) => {
         if (this._config.focus) {
           this._element.focus()
         }
+        if (this._config.autofocus === true || this._config.autofocus === 'notTouch' && !this._isTouchDevice()) {
+          this._autofocus()
+        }
         this._isTransitioning = false
         $(this._element).trigger(shownEvent)
       }
@@ -272,6 +289,10 @@ const Modal = (($) => {
       }
     }
 
+    _autofocus() {
+      $(this._element).find(':input[autofocus]:not(:hidden)').eq(0).trigger('focus')
+    }
+
     _enforceFocus() {
       $(document)
         .off(Event.FOCUSIN) // Guard against infinite focus loop
@@ -297,6 +318,20 @@ const Modal = (($) => {
       }
     }
 
+    _setKeyNavEvent() {
+      if (this._isShown && this._config.keyboardBtnNav) {
+        $(this._element).on(Event.KEYDOWN_NAV, (event) => {
+          if (event.which === LEFT_KEYCODE) {
+            this._keyboardBtnNav('prev')
+          } else if (event.which === RIGHT_KEYCODE) {
+            this._keyboardBtnNav('next')
+          }
+        })
+      } else if (!this._isShown) {
+        $(this._element).off(Event.KEYDOWN_NAV)
+      }
+    }
+
     _setResizeEvent() {
       if (this._isShown) {
         $(window).on(Event.RESIZE, (event) => this.handleUpdate(event))
@@ -317,6 +352,29 @@ const Modal = (($) => {
       })
     }
 
+    _keyboardBtnNav(prevNext) {
+      const $focusable = $(this._element).find('.btn')
+      let curFocusIdx = $focusable.index(document.activeElement)
+      if ($(document.activeElement).is(':input:not(:button)')) {
+        // we're currently focused on an input, stay put
+        return
+      }
+      if (curFocusIdx < 0) {
+        // nothing currently focused
+        // "next" will focus first $focusable, "prev" will focus last $focusable
+        curFocusIdx = prevNext === 'next' ? -1 : 0
+      }
+      if (prevNext === 'prev') {
+        // eq() accepts negative index
+        $focusable.eq(curFocusIdx - 1).trigger('focus')
+      } else if (curFocusIdx === $focusable.length - 1) {
+        // last btn is focused, wrap back to first
+        $focusable.eq(0).trigger('focus')
+      } else {
+        $focusable.eq(curFocusIdx + 1).trigger('focus')
+      }
+    }
+
     _removeBackdrop() {
       if (this._backdrop) {
         $(this._backdrop).remove()
diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index c8b321a7c2..d0ba41378a 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -694,4 +694,215 @@ $(function () {
       .bootstrapModal('show')
       .bootstrapModal('hide')
   })
+
+  QUnit.test('right arrow should select first button when none focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.trigger($.Event('keydown', {
+        which: 39
+      }))
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'first button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should select next button', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').first().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should select first button when last focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').last().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'right arrow wrapped')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should not take focus from input', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<input type="text" />' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('input').trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is('input'), 'input still focused')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('left arrow should last button when none focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.trigger($.Event('keydown', {
+        which: 37
+      }))
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('left arrow should select prev button', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').last().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'first button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('left arrow should select last button when first focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').first().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should not take focus from input', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<input type="text" />' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('input').trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is('input'), 'input still focused')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test(':input[autofocus] should get focus', function (assert) {
+    assert.expect(2)
+    var done = assert.async()
+    $('body').append('<div id="modal-test" class="modal" data-autofocus="true"><div class="contents">' +
+      '<input id="my-input" type="text" autofocus />' +
+      '</div>' +
+      '</div>')
+    assert.notOk($(document.activeElement).is('#my-input'), 'input focused')
+    $('#modal-test').on('shown.bs.modal', function () {
+      assert.ok($(document.activeElement).is('#my-input'), 'input focused')
+      done()
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test(':input[autofocus] should not get focus (default)', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    $('body').append(
+      '<div id="modal-test" class="modal" data-autofocus="false"><div class="contents">' +
+      '<input id="my-input" type="text" autofocus />' +
+      '</div>' +
+      '</div>')
+    $('#modal-test')
+      .on('shown.bs.modal', function () {
+        assert.notOk($(document.activeElement).is('#my-input'), 'input does not have focus')
+        done()
+      })
+      .bootstrapModal('show')
+  })
 })
diff --git a/package.json b/package.json
index 5c2e2db42a..e6e40f7c81 100644
--- a/package.json
+++ b/package.json
@@ -180,7 +180,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "20 kB"
+      "maxSize": "21 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-- 
GitLab


From a04461bab3da7e6ca3634bfa5b3f865b5763a82c Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Mon, 19 Feb 2018 13:08:36 -0600
Subject: [PATCH 02/23] tweak unit test descriptions ("focus" vs "select"..   
 had "right" vs "left" in one spot) docs:  clarify that keyboardBtnNav affects
 left & right arrow keys only

---
 docs/4.0/components/modal.md |  2 +-
 js/tests/unit/modal.js       | 14 +++++++-------
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/docs/4.0/components/modal.md b/docs/4.0/components/modal.md
index 9ed3cf81b7..bc6ef77900 100644
--- a/docs/4.0/components/modal.md
+++ b/docs/4.0/components/modal.md
@@ -592,7 +592,7 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>keyboardBtnNav</td>
       <td>boolean</td>
       <td>true</td>
-      <td>Whether keyboard's arrow keys should move focus to/between `.btn` elements.
+      <td>Whether keyboard's left &amp; right arrow keys should move focus to/between `.btn` elements.
         <span class="text-muted">(focus will not be taken from input elements)</span>
       </td>
     </tr>
diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index d0ba41378a..7f31220406 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -695,7 +695,7 @@ $(function () {
       .bootstrapModal('hide')
   })
 
-  QUnit.test('right arrow should select first button when none focused', function (assert) {
+  QUnit.test('right arrow should focus first button when none focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -715,7 +715,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should select next button', function (assert) {
+  QUnit.test('right arrow should focus next button', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -738,7 +738,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should select first button when last focused', function (assert) {
+  QUnit.test('right arrow should focus first button when last focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -785,7 +785,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should last button when none focused', function (assert) {
+  QUnit.test('left arrow should focus last button when none focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -805,7 +805,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should select prev button', function (assert) {
+  QUnit.test('left arrow should focus prev button', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -828,7 +828,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should select last button when first focused', function (assert) {
+  QUnit.test('left arrow should focus last button when first focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -851,7 +851,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should not take focus from input', function (assert) {
+  QUnit.test('left arrow should not take focus from input', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
-- 
GitLab


From 28882c7791a8ce82e7a5a5817d86259969527f54 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Tue, 20 Feb 2018 20:23:47 -0600
Subject: [PATCH 03/23] don't focus or move focus via left/right arrow keys
 when there's a modifier key (alt, crtl, meta, shift) (unit tested)

---
 js/src/modal.js        |  4 ++++
 js/tests/unit/modal.js | 36 ++++++++++++++++++++++++++++++++++++
 2 files changed, 40 insertions(+)

diff --git a/js/src/modal.js b/js/src/modal.js
index 54b92f0109..c88e26aaac 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -321,6 +321,10 @@ const Modal = (($) => {
     _setKeyNavEvent() {
       if (this._isShown && this._config.keyboardBtnNav) {
         $(this._element).on(Event.KEYDOWN_NAV, (event) => {
+          if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+            // disregard if modifier
+            return
+          }
           if (event.which === LEFT_KEYCODE) {
             this._keyboardBtnNav('prev')
           } else if (event.which === RIGHT_KEYCODE) {
diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index 7f31220406..752f1b9a8a 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -875,6 +875,42 @@ $(function () {
       .bootstrapModal('show')
   })
 
+  QUnit.test('ignore left/right arrow keys with modifier key', function (assert) {
+    assert.expect(8)
+    var done = assert.async(8)
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      const keys = [37, 39]
+      const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']
+      // itterate over each combination of left/right and modifier
+      for (let i = 0, l = keys.length; i < l; i++) {
+        for (let i2 = 0, l2 = modifiers.length; i2 < l2; i2++) {
+          (function (keycode, modifier) {
+            setTimeout(function () {
+              // unfocus .btn if currently focused
+              $(document.activeElement).filter('.btn').blur()
+            }, 0)
+            setTimeout(function () {
+              $div.trigger($.Event('keydown', {
+                which: keycode,
+                [modifier]: true
+              }))
+            }, 0)
+            setTimeout(function () {
+              assert.ok(!$(document.activeElement).is('.btn'), 'button does not have focus: keycode = ' + keycode + ', modifier = ' + modifier)
+              done()
+            }, 0)
+          }(keys[i], modifiers[i2]))
+        }
+      }
+    })
+      .bootstrapModal('show')
+  })
+
   QUnit.test(':input[autofocus] should get focus', function (assert) {
     assert.expect(2)
     var done = assert.async()
-- 
GitLab


From b14a0bbcb39accee1878d6675fd124b8eb1ed967 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Wed, 21 Feb 2018 11:30:29 -0600
Subject: [PATCH 04/23] input[type=submit] & input[type=reset] should be
 treated as buttons and allow keyboard to move focus from (was already the
 case with input[type=button])

---
 js/src/modal.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index c88e26aaac..646df2f56c 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -359,8 +359,8 @@ const Modal = (($) => {
     _keyboardBtnNav(prevNext) {
       const $focusable = $(this._element).find('.btn')
       let curFocusIdx = $focusable.index(document.activeElement)
-      if ($(document.activeElement).is(':input:not(:button)')) {
-        // we're currently focused on an input, stay put
+      if ($(document.activeElement).is(':input:not(.btn)')) {
+        // we're currently focused on a non-btn input, stay put
         return
       }
       if (curFocusIdx < 0) {
-- 
GitLab


From 3b4178818786ea48fcaa0f7a43561e4c8b9e3e43 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Sat, 17 Feb 2018 21:03:12 -0600
Subject: [PATCH 05/23] fixes #22402

---
 build/vnu-jar.js                  |   2 +
 js/src/modal.js                   |  58 +++++
 js/tests/unit/modal.js            | 364 ++++++++++++++++++++++--------
 site/docs/4.1/components/modal.md |  60 +++--
 4 files changed, 362 insertions(+), 122 deletions(-)

diff --git a/build/vnu-jar.js b/build/vnu-jar.js
index 5fd214a9d0..ff32c5d3fe 100644
--- a/build/vnu-jar.js
+++ b/build/vnu-jar.js
@@ -21,6 +21,8 @@ childProcess.exec('java -version', (error, stdout, stderr) => {
   // vnu-jar accepts multiple ignores joined with a `|`.
   // Also note that the ignores are regular expressions.
   const ignores = [
+    // docs/components/modal  contains multiple elements with autofocus
+    'A document must not include more than one “autofocus” attribute.*',
     // "autocomplete" is included in <button> and checkboxes and radio <input>s due to
     // Firefox's non-standard autocomplete behavior - see https://bugzilla.mozilla.org/show_bug.cgi?id=654072
     'Attribute “autocomplete” is only allowed when the input type is.*',
diff --git a/js/src/modal.js b/js/src/modal.js
index 0004fe8bbe..fbc4aa3f89 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -21,17 +21,23 @@ const EVENT_KEY          = `.${DATA_KEY}`
 const DATA_API_KEY       = '.data-api'
 const JQUERY_NO_CONFLICT = $.fn[NAME]
 const ESCAPE_KEYCODE     = 27 // KeyboardEvent.which value for Escape (Esc) key
+  const LEFT_KEYCODE                 = 37
+  const RIGHT_KEYCODE                = 39
 
 const Default = {
+  autofocus: 'notTouch',      // true|false|notTouch
   backdrop : true,
   keyboard : true,
+  keyboardBtnNav: true,  // ability to use arrows to nav button focus
   focus    : true,
   show     : true
 }
 
 const DefaultType = {
+  autofocus: '(boolean|string)',
   backdrop : '(boolean|string)',
   keyboard : 'boolean',
+  keyboardBtnNav: 'boolean',
   focus    : 'boolean',
   show     : 'boolean'
 }
@@ -45,6 +51,7 @@ const Event = {
   RESIZE            : `resize${EVENT_KEY}`,
   CLICK_DISMISS     : `click.dismiss${EVENT_KEY}`,
   KEYDOWN_DISMISS   : `keydown.dismiss${EVENT_KEY}`,
+  KEYDOWN_NAV       : `keydown.nav${EVENT_KEY}`,
   MOUSEUP_DISMISS   : `mouseup.dismiss${EVENT_KEY}`,
   MOUSEDOWN_DISMISS : `mousedown.dismiss${EVENT_KEY}`,
   CLICK_DATA_API    : `click${EVENT_KEY}${DATA_API_KEY}`
@@ -130,6 +137,7 @@ class Modal {
     $(document.body).addClass(ClassName.OPEN)
 
     this._setEscapeEvent()
+    this._setKeyNavEvent()
     this._setResizeEvent()
 
     $(this._element).on(
@@ -174,6 +182,7 @@ class Modal {
     }
 
     this._setEscapeEvent()
+    this._setKeyNavEvent()
     this._setResizeEvent()
 
     $(document).off(Event.FOCUSIN)
@@ -234,6 +243,11 @@ class Modal {
     return config
   }
 
+    // Util worthy?
+    _isTouchDevice() {
+      return 'ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch
+    }
+
   _showElement(relatedTarget) {
     const transition = $(this._element).hasClass(ClassName.FADE)
 
@@ -265,6 +279,9 @@ class Modal {
       if (this._config.focus) {
         this._element.focus()
       }
+      if (this._config.autofocus === true || this._config.autofocus === 'notTouch' && !this._isTouchDevice()) {
+        this._autofocus()
+      }
       this._isTransitioning = false
       $(this._element).trigger(shownEvent)
     }
@@ -280,6 +297,10 @@ class Modal {
     }
   }
 
+  _autofocus() {
+    $(this._element).find(':input[autofocus]:not(:hidden)').eq(0).trigger('focus')
+  }
+
   _enforceFocus() {
     $(document)
       .off(Event.FOCUSIN) // Guard against infinite focus loop
@@ -305,6 +326,20 @@ class Modal {
     }
   }
 
+  _setKeyNavEvent() {
+    if (this._isShown && this._config.keyboardBtnNav) {
+      $(this._element).on(Event.KEYDOWN_NAV, (event) => {
+        if (event.which === LEFT_KEYCODE) {
+          this._keyboardBtnNav('prev')
+        } else if (event.which === RIGHT_KEYCODE) {
+          this._keyboardBtnNav('next')
+        }
+      })
+    } else if (!this._isShown) {
+      $(this._element).off(Event.KEYDOWN_NAV)
+    }
+  }
+
   _setResizeEvent() {
     if (this._isShown) {
       $(window).on(Event.RESIZE, (event) => this.handleUpdate(event))
@@ -325,6 +360,29 @@ class Modal {
     })
   }
 
+  _keyboardBtnNav(prevNext) {
+    const $focusable = $(this._element).find('.btn')
+    let curFocusIdx = $focusable.index(document.activeElement)
+    if ($(document.activeElement).is(':input:not(:button)')) {
+      // we're currently focused on an input, stay put
+      return
+    }
+    if (curFocusIdx < 0) {
+      // nothing currently focused
+      // "next" will focus first $focusable, "prev" will focus last $focusable
+      curFocusIdx = prevNext === 'next' ? -1 : 0
+    }
+    if (prevNext === 'prev') {
+      // eq() accepts negative index
+      $focusable.eq(curFocusIdx - 1).trigger('focus')
+    } else if (curFocusIdx === $focusable.length - 1) {
+      // last btn is focused, wrap back to first
+      $focusable.eq(0).trigger('focus')
+    } else {
+      $focusable.eq(curFocusIdx + 1).trigger('focus')
+    }
+  }
+
   _removeBackdrop() {
     if (this._backdrop) {
       $(this._backdrop).remove()
diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index 1156ce0c70..d0ba41378a 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -1,8 +1,6 @@
 $(function () {
   'use strict'
 
-  window.Util = typeof bootstrap !== 'undefined' ? bootstrap.Util : Util
-
   QUnit.module('modal plugin')
 
   QUnit.test('should be defined on jquery object', function (assert) {
@@ -15,9 +13,15 @@ $(function () {
       // Enable the scrollbar measurer
       $('<style type="text/css"> .modal-scrollbar-measure { position: absolute; top: -9999px; width: 50px; height: 50px; overflow: scroll; } </style>').appendTo('head')
       // Function to calculate the scrollbar width which is then compared to the padding or margin changes
-      $.fn.getScrollbarWidth = $.fn.modal.Constructor.prototype._getScrollbarWidth
-
-      // Simulate scrollbars
+      $.fn.getScrollbarWidth = function () {
+        var scrollDiv = document.createElement('div')
+        scrollDiv.className = 'modal-scrollbar-measure'
+        document.body.appendChild(scrollDiv)
+        var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
+        document.body.removeChild(scrollDiv)
+        return scrollbarWidth
+      }
+      // Simulate scrollbars in PhantomJS
       $('html').css('padding-right', '16px')
     },
     beforeEach: function () {
@@ -29,7 +33,6 @@ $(function () {
       $(document.body).removeClass('modal-open')
       $.fn.modal = $.fn.bootstrapModal
       delete $.fn.bootstrapModal
-      $('#qunit-fixture').html('')
     }
   })
 
@@ -517,6 +520,48 @@ $(function () {
       .bootstrapModal('show')
   })
 
+  QUnit.test('should adjust the inline margin of the navbar-toggler when opening and restore when closing', function (assert) {
+    assert.expect(2)
+    var done = assert.async()
+    var $element = $('<div class="navbar-toggler"></div>').appendTo('#qunit-fixture')
+    var originalMargin = $element.css('margin-right')
+
+    $('<div id="modal-test"/>')
+      .on('hidden.bs.modal', function () {
+        var currentMargin = $element.css('margin-right')
+        assert.strictEqual(currentMargin, originalMargin, 'navbar-toggler margin should be reset after closing')
+        $element.remove()
+        done()
+      })
+      .on('shown.bs.modal', function () {
+        var expectedMargin = parseFloat(originalMargin) + $(this).getScrollbarWidth() + 'px'
+        var currentMargin = $element.css('margin-right')
+        assert.strictEqual(currentMargin, expectedMargin, 'navbar-toggler margin should be adjusted while opening')
+        $(this).bootstrapModal('hide')
+      })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('should store the original margin of the navbar-toggler in data-margin-right before showing', function (assert) {
+    assert.expect(2)
+    var done = assert.async()
+    var $element = $('<div class="navbar-toggler"></div>').appendTo('#qunit-fixture')
+    var originalMargin = '0px'
+    $element.css('margin-right', originalMargin)
+
+    $('<div id="modal-test"/>')
+      .on('hidden.bs.modal', function () {
+        assert.strictEqual(typeof $element.data('margin-right'), 'undefined', 'data-margin-right should be cleared after closing')
+        $element.remove()
+        done()
+      })
+      .on('shown.bs.modal', function () {
+        assert.strictEqual($element.data('margin-right'), originalMargin, 'original navbar-toggler margin should be stored in data-margin-right')
+        $(this).bootstrapModal('hide')
+      })
+      .bootstrapModal('show')
+  })
+
   QUnit.test('should ignore values set via CSS when trying to restore body padding after closing', function (assert) {
     assert.expect(1)
     var done = assert.async()
@@ -602,40 +647,36 @@ $(function () {
     assert.expect(1)
     var done = assert.async()
 
-    try {
-      var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div id=&quot;modal-test&quot;&gt;&lt;div class=&quot;contents&quot;&lt;div&lt;div id=&quot;close&quot; data-dismiss=&quot;modal&quot;/&gt;&lt;/div&gt;&lt;/div&gt;"/>')
-        .appendTo('#qunit-fixture')
+    var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div id=&quot;modal-test&quot;&gt;&lt;div class=&quot;contents&quot;&lt;div&lt;div id=&quot;close&quot; data-dismiss=&quot;modal&quot;/&gt;&lt;/div&gt;&lt;/div&gt;"/>')
+      .appendTo('#qunit-fixture')
 
-      $toggleBtn.trigger('click')
-    } catch (e) {
+    $toggleBtn.trigger('click')
+    setTimeout(function () {
       assert.strictEqual($('#modal-test').length, 0, 'target has not been parsed and added to the document')
       done()
-    }
+    }, 1)
   })
 
   QUnit.test('should not execute js from target', function (assert) {
     assert.expect(0)
     var done = assert.async()
 
-    try {
-      // This toggle button contains XSS payload in its data-target
-      // Note: it uses the onerror handler of an img element to execute the js, because a simple script element does not work here
-      //       a script element works in manual tests though, so here it is likely blocked by the qunit framework
-      var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div&gt;&lt;image src=&quot;missing.png&quot; onerror=&quot;$(&apos;#qunit-fixture button.control&apos;).trigger(&apos;click&apos;)&quot;&gt;&lt;/div&gt;"/>')
-        .appendTo('#qunit-fixture')
-      // The XSS payload above does not have a closure over this function and cannot access the assert object directly
-      // However, it can send a click event to the following control button, which will then fail the assert
-      $('<button>')
-        .addClass('control')
-        .on('click', function () {
-          assert.notOk(true, 'XSS payload is not executed as js')
-        })
-        .appendTo('#qunit-fixture')
-
-      $toggleBtn.trigger('click')
-    } catch (e) {
-      done()
-    }
+    // This toggle button contains XSS payload in its data-target
+    // Note: it uses the onerror handler of an img element to execute the js, because a simple script element does not work here
+    //       a script element works in manual tests though, so here it is likely blocked by the qunit framework
+    var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div&gt;&lt;image src=&quot;missing.png&quot; onerror=&quot;$(&apos;#qunit-fixture button.control&apos;).trigger(&apos;click&apos;)&quot;&gt;&lt;/div&gt;"/>')
+      .appendTo('#qunit-fixture')
+    // The XSS payload above does not have a closure over this function and cannot access the assert object directly
+    // However, it can send a click event to the following control button, which will then fail the assert
+    $('<button>')
+      .addClass('control')
+      .on('click', function () {
+        assert.notOk(true, 'XSS payload is not executed as js')
+      })
+      .appendTo('#qunit-fixture')
+
+    $toggleBtn.trigger('click')
+    setTimeout(done, 500)
   })
 
   QUnit.test('should not try to open a modal which is already visible', function (assert) {
@@ -654,85 +695,214 @@ $(function () {
       .bootstrapModal('hide')
   })
 
-  QUnit.test('transition duration should be the modal-dialog duration before triggering shown event', function (assert) {
-    assert.expect(2)
+  QUnit.test('right arrow should select first button when none focused', function (assert) {
+    assert.expect(1)
     var done = assert.async()
-    var style = [
-      '<style>',
-      '  .modal.fade .modal-dialog {',
-      '    transition: -webkit-transform .3s ease-out;',
-      '    transition: transform .3s ease-out;',
-      '    transition: transform .3s ease-out,-webkit-transform .3s ease-out;',
-      '    -webkit-transform: translate(0,-50px);',
-      '    transform: translate(0,-50px);',
-      '  }',
-      '</style>'
-    ].join('')
-
-    var $style = $(style).appendTo('head')
-    var modalHTML = [
-      '<div class="modal fade" id="exampleModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">',
-      '  <div class="modal-dialog" role="document">',
-      '    <div class="modal-content">',
-      '      <div class="modal-body">...</div>',
-      '    </div>',
-      '  </div>',
-      '</div>'
-    ].join('')
-
-    var beginTimestamp = 0
-    var $modal = $(modalHTML).appendTo('#qunit-fixture')
-    var $modalDialog = $('.modal-dialog')
-    var transitionDuration  = Util.getTransitionDurationFromElement($modalDialog[0])
-
-    assert.strictEqual(transitionDuration, 300)
-
-    $modal.on('shown.bs.modal', function () {
-      var diff = Date.now() - beginTimestamp
-      assert.ok(diff < 400)
-      $style.remove()
-      done()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.trigger($.Event('keydown', {
+        which: 39
+      }))
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'first button selected')
+        done()
+      }, 0)
     })
       .bootstrapModal('show')
-
-    beginTimestamp = Date.now()
   })
 
-  QUnit.test('should dispose modal', function (assert) {
-    assert.expect(3)
+  QUnit.test('right arrow should select next button', function (assert) {
+    assert.expect(1)
     var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').first().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-    var $modal = $([
-      '<div id="modal-test">',
-      '  <div class="modal-dialog">',
-      '    <div class="modal-content">',
-      '      <div class="modal-body" />',
-      '    </div>',
-      '  </div>',
-      '</div>'
-    ].join('')).appendTo('#qunit-fixture')
-
-    $modal.on('shown.bs.modal', function () {
-      var spy = sinon.spy($.fn, 'off')
+  QUnit.test('right arrow should select first button when last focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').last().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'right arrow wrapped')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-      $(this).bootstrapModal('dispose')
+  QUnit.test('right arrow should not take focus from input', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<input type="text" />' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('input').trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is('input'), 'input still focused')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-      var modalDataApiEvent = []
-      $._data(document, 'events').click
-        .forEach(function (e) {
-          if (e.namespace === 'bs.data-api.modal') {
-            modalDataApiEvent.push(e)
-          }
-        })
+  QUnit.test('left arrow should last button when none focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.trigger($.Event('keydown', {
+        which: 37
+      }))
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-      assert.ok(typeof $(this).data('bs.modal') === 'undefined', 'modal data object was disposed')
+  QUnit.test('left arrow should select prev button', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').last().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'first button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-      assert.ok(spy.callCount === 4, '`jQuery.off` was called')
+  QUnit.test('left arrow should select last button when first focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').first().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-      assert.ok(modalDataApiEvent.length === 1, '`Event.CLICK_DATA_API` on `document` was not removed')
+  QUnit.test('right arrow should not take focus from input', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<input type="text" />' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('input').trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is('input'), 'input still focused')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
 
-      $.fn.off.restore()
+  QUnit.test(':input[autofocus] should get focus', function (assert) {
+    assert.expect(2)
+    var done = assert.async()
+    $('body').append('<div id="modal-test" class="modal" data-autofocus="true"><div class="contents">' +
+      '<input id="my-input" type="text" autofocus />' +
+      '</div>' +
+      '</div>')
+    assert.notOk($(document.activeElement).is('#my-input'), 'input focused')
+    $('#modal-test').on('shown.bs.modal', function () {
+      assert.ok($(document.activeElement).is('#my-input'), 'input focused')
       done()
-    }).bootstrapModal('show')
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test(':input[autofocus] should not get focus (default)', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    $('body').append(
+      '<div id="modal-test" class="modal" data-autofocus="false"><div class="contents">' +
+      '<input id="my-input" type="text" autofocus />' +
+      '</div>' +
+      '</div>')
+    $('#modal-test')
+      .on('shown.bs.modal', function () {
+        assert.notOk($(document.activeElement).is('#my-input'), 'input does not have focus')
+        done()
+      })
+      .bootstrapModal('show')
   })
 })
diff --git a/site/docs/4.1/components/modal.md b/site/docs/4.1/components/modal.md
index 86996ecda4..a500ffc9f0 100644
--- a/site/docs/4.1/components/modal.md
+++ b/site/docs/4.1/components/modal.md
@@ -15,13 +15,6 @@ Before getting started with Bootstrap's modal component, be sure to read the fol
 - Bootstrap only supports one modal window at a time. Nested modals aren't supported as we believe them to be poor user experiences.
 - Modals use `position: fixed`, which can sometimes be a bit particular about its rendering. Whenever possible, place your modal HTML in a top-level position to avoid potential interference from other elements. You'll likely run into issues when nesting a `.modal` within another fixed element.
 - Once again, due to `position: fixed`, there are some caveats with using modals on mobile devices. [See our browser support docs]({{ site.baseurl }}/docs/{{ site.docs_version }}/getting-started/browsers-devices/#modals-and-dropdowns-on-mobile) for details.
-- Due to how HTML5 defines its semantics, [the `autofocus` HTML attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autofocus) has no effect in Bootstrap modals. To achieve the same effect, use some custom JavaScript:
-
-{% highlight js %}
-$('#myModal').on('shown.bs.modal', function () {
-  $('#myInput').trigger('focus')
-})
-{% endhighlight %}
 
 {% include callout-info-prefersreducedmotion.md %}
 
@@ -70,7 +63,7 @@ Below is a _static_ modal example (meaning its `position` and `display` have bee
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -95,7 +88,7 @@ Toggle a working modal demo by clicking the button below. It will slide down and
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -128,7 +121,7 @@ Toggle a working modal demo by clicking the button below. It will slide down and
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -170,7 +163,7 @@ When modals become too long for the user's viewport or device, they scroll indep
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -203,7 +196,7 @@ When modals become too long for the user's viewport or device, they scroll indep
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -228,7 +221,7 @@ Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal.
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -261,7 +254,7 @@ Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal.
       </div>
       <div class="modal-footer">
         <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
-        <button type="button" class="btn btn-primary">Save changes</button>
+        <button type="button" class="btn btn-primary" autofocus>Save changes</button>
       </div>
     </div>
   </div>
@@ -289,7 +282,7 @@ Add `.modal-dialog-centered` to `.modal-dialog` to vertically center the modal.
         <p><a href="#" class="tooltip-test" title="Tooltip" data-container="#exampleModalPopovers">This link</a> and <a href="#" class="tooltip-test" title="Tooltip" data-container="#exampleModalPopovers">that link</a> have tooltips on hover.</p>
       </div>
       <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+        <button type="button" class="btn btn-secondary" data-dismiss="modal" autofocus>Close</button>
         <button type="button" class="btn btn-primary">Save changes</button>
       </div>
     </div>
@@ -352,7 +345,7 @@ Utilize the Bootstrap grid system within a modal by nesting `.container-fluid` w
         </div>
       </div>
       <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+        <button type="button" class="btn btn-secondary" data-dismiss="modal" autofocus>Close</button>
         <button type="button" class="btn btn-primary">Save changes</button>
       </div>
     </div>
@@ -420,7 +413,7 @@ Below is a live demo followed by example HTML and JavaScript. For more informati
         <form>
           <div class="form-group">
             <label for="recipient-name" class="col-form-label">Recipient:</label>
-            <input type="text" class="form-control" id="recipient-name">
+            <input type="text" class="form-control" id="recipient-name" autofocus>
           </div>
           <div class="form-group">
             <label for="message-text" class="col-form-label">Message:</label>
@@ -435,8 +428,7 @@ Below is a live demo followed by example HTML and JavaScript. For more informati
     </div>
   </div>
 </div>
-{% endcapture %}
-{% include example.html content=example %}
+{% endexample %}
 
 {% highlight js %}
 $('#exampleModal').on('show.bs.modal', function (event) {
@@ -450,12 +442,6 @@ $('#exampleModal').on('show.bs.modal', function (event) {
 })
 {% endhighlight %}
 
-### Change animation
-
-The `$modal-fade-transform` variable determines the transform state of `.modal-dialog` before the modal fade-in animation, the `$modal-show-transform` variable determines the transform of `.modal-dialog` at the end of the modal fade-in animation.
-
-If you want for example a zoom-in animation, you can set `$modal-fade-transform: scale(.8)`.
-
 ### Remove animation
 
 For modals that simply appear rather than fade in to view, remove the `.fade` class from your modal markup.
@@ -474,6 +460,10 @@ If the height of a modal changes while it is open, you should call `$('#myModal'
 
 Be sure to add `role="dialog"` and `aria-labelledby="..."`, referencing the modal title, to `.modal`, and `role="document"` to the `.modal-dialog` itself. Additionally, you may give a description of your modal dialog with `aria-describedby` on `.modal`.
 
+The `autofocus` attribute may be given to inputs and buttons in modal.  By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
+
+By default, keyboard left & right arrow-keys can be used to focus buttons within the modal (ie change between "close" and "save").
+
 ### Embedding YouTube videos
 
 Embedding YouTube videos in modals requires additional JavaScript not in Bootstrap to automatically stop playback and more. [See this helpful Stack Overflow post](https://stackoverflow.com/questions/18622508/bootstrap-3-and-youtube-in-modal) for more information.
@@ -639,6 +629,18 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
     </tr>
   </thead>
   <tbody>
+    <tr>
+      <td>autofocus</td>
+      <td>boolean or the string <code>'notTouch'</code></td>
+      <td>'notTouch'</td>
+      <td>Whether input with `autofocus` attribute should be given focus when modal is shown<br />
+          <ul class="list-unstyled">
+            <li><code>notTouch</code> will give focus when not a touch device</li>
+            <li><code>true</code> will give focus regardless</li>
+            <li><code>false</code> no autofocus</li>
+          </ul>
+      </td>
+    </tr>
     <tr>
       <td>backdrop</td>
       <td>boolean or the string <code>'static'</code></td>
@@ -651,6 +653,14 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>true</td>
       <td>Closes the modal when escape key is pressed</td>
     </tr>
+    <tr>
+      <td>keyboardBtnNav</td>
+      <td>boolean</td>
+      <td>true</td>
+      <td>Whether keyboard's arrow keys should move focus to/between `.btn` elements.
+        <span class="text-muted">(focus will not be taken from input elements)</span>
+      </td>
+    </tr>
     <tr>
       <td>focus</td>
       <td>boolean</td>
-- 
GitLab


From dab71df9163f41c000ea05748b76fdcbe3c81878 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Mon, 19 Feb 2018 13:08:36 -0600
Subject: [PATCH 06/23] tweak unit test descriptions ("focus" vs "select"..   
 had "right" vs "left" in one spot) docs:  clarify that keyboardBtnNav affects
 left & right arrow keys only

---
 js/tests/unit/modal.js            | 14 +++++++-------
 site/docs/4.1/components/modal.md |  2 +-
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index d0ba41378a..7f31220406 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -695,7 +695,7 @@ $(function () {
       .bootstrapModal('hide')
   })
 
-  QUnit.test('right arrow should select first button when none focused', function (assert) {
+  QUnit.test('right arrow should focus first button when none focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -715,7 +715,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should select next button', function (assert) {
+  QUnit.test('right arrow should focus next button', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -738,7 +738,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should select first button when last focused', function (assert) {
+  QUnit.test('right arrow should focus first button when last focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -785,7 +785,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should last button when none focused', function (assert) {
+  QUnit.test('left arrow should focus last button when none focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -805,7 +805,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should select prev button', function (assert) {
+  QUnit.test('left arrow should focus prev button', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -828,7 +828,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should select last button when first focused', function (assert) {
+  QUnit.test('left arrow should focus last button when first focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -851,7 +851,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should not take focus from input', function (assert) {
+  QUnit.test('left arrow should not take focus from input', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
diff --git a/site/docs/4.1/components/modal.md b/site/docs/4.1/components/modal.md
index a500ffc9f0..c4064b228c 100644
--- a/site/docs/4.1/components/modal.md
+++ b/site/docs/4.1/components/modal.md
@@ -657,7 +657,7 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>keyboardBtnNav</td>
       <td>boolean</td>
       <td>true</td>
-      <td>Whether keyboard's arrow keys should move focus to/between `.btn` elements.
+      <td>Whether keyboard's left &amp; right arrow keys should move focus to/between `.btn` elements.
         <span class="text-muted">(focus will not be taken from input elements)</span>
       </td>
     </tr>
-- 
GitLab


From 9d658b92b2642674e0d0ba65b8389d4c60ad1cb6 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Tue, 20 Feb 2018 20:23:47 -0600
Subject: [PATCH 07/23] don't focus or move focus via left/right arrow keys
 when there's a modifier key (alt, crtl, meta, shift) (unit tested)

---
 js/tests/unit/modal.js | 36 ++++++++++++++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index 7f31220406..752f1b9a8a 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -875,6 +875,42 @@ $(function () {
       .bootstrapModal('show')
   })
 
+  QUnit.test('ignore left/right arrow keys with modifier key', function (assert) {
+    assert.expect(8)
+    var done = assert.async(8)
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      const keys = [37, 39]
+      const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']
+      // itterate over each combination of left/right and modifier
+      for (let i = 0, l = keys.length; i < l; i++) {
+        for (let i2 = 0, l2 = modifiers.length; i2 < l2; i2++) {
+          (function (keycode, modifier) {
+            setTimeout(function () {
+              // unfocus .btn if currently focused
+              $(document.activeElement).filter('.btn').blur()
+            }, 0)
+            setTimeout(function () {
+              $div.trigger($.Event('keydown', {
+                which: keycode,
+                [modifier]: true
+              }))
+            }, 0)
+            setTimeout(function () {
+              assert.ok(!$(document.activeElement).is('.btn'), 'button does not have focus: keycode = ' + keycode + ', modifier = ' + modifier)
+              done()
+            }, 0)
+          }(keys[i], modifiers[i2]))
+        }
+      }
+    })
+      .bootstrapModal('show')
+  })
+
   QUnit.test(':input[autofocus] should get focus', function (assert) {
     assert.expect(2)
     var done = assert.async()
-- 
GitLab


From 63d2e9ee418d8f70627b44de0644a6b11a8c3782 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Wed, 21 Feb 2018 11:30:29 -0600
Subject: [PATCH 08/23] input[type=submit] & input[type=reset] should be
 treated as buttons and allow keyboard to move focus from (was already the
 case with input[type=button])

---
 js/src/modal.js | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index fbc4aa3f89..994c5bf817 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -21,8 +21,8 @@ const EVENT_KEY          = `.${DATA_KEY}`
 const DATA_API_KEY       = '.data-api'
 const JQUERY_NO_CONFLICT = $.fn[NAME]
 const ESCAPE_KEYCODE     = 27 // KeyboardEvent.which value for Escape (Esc) key
-  const LEFT_KEYCODE                 = 37
-  const RIGHT_KEYCODE                = 39
+const LEFT_KEYCODE       = 37
+const RIGHT_KEYCODE      = 39
 
 const Default = {
   autofocus: 'notTouch',      // true|false|notTouch
@@ -243,10 +243,10 @@ class Modal {
     return config
   }
 
-    // Util worthy?
-    _isTouchDevice() {
-      return 'ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch
-    }
+  // Util worthy?
+  _isTouchDevice() {
+    return 'ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch
+  }
 
   _showElement(relatedTarget) {
     const transition = $(this._element).hasClass(ClassName.FADE)
-- 
GitLab


From 5ee6f86857c5c1e7173c8cae44bfde5d8e2c87ef Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Thu, 8 Nov 2018 22:25:47 -0600
Subject: [PATCH 09/23] rebase fixes

---
 js/tests/unit/modal.js            | 236 +++++++++++++++---------------
 package.json                      |   2 +-
 site/docs/4.1/components/modal.md |   9 +-
 3 files changed, 130 insertions(+), 117 deletions(-)

diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index 752f1b9a8a..a3e07b2e0b 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -1,6 +1,8 @@
 $(function () {
   'use strict'
 
+  window.Util = typeof bootstrap !== 'undefined' ? bootstrap.Util : Util
+
   QUnit.module('modal plugin')
 
   QUnit.test('should be defined on jquery object', function (assert) {
@@ -13,15 +15,9 @@ $(function () {
       // Enable the scrollbar measurer
       $('<style type="text/css"> .modal-scrollbar-measure { position: absolute; top: -9999px; width: 50px; height: 50px; overflow: scroll; } </style>').appendTo('head')
       // Function to calculate the scrollbar width which is then compared to the padding or margin changes
-      $.fn.getScrollbarWidth = function () {
-        var scrollDiv = document.createElement('div')
-        scrollDiv.className = 'modal-scrollbar-measure'
-        document.body.appendChild(scrollDiv)
-        var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
-        document.body.removeChild(scrollDiv)
-        return scrollbarWidth
-      }
-      // Simulate scrollbars in PhantomJS
+      $.fn.getScrollbarWidth = $.fn.modal.Constructor.prototype._getScrollbarWidth
+
+      // Simulate scrollbars
       $('html').css('padding-right', '16px')
     },
     beforeEach: function () {
@@ -33,6 +29,7 @@ $(function () {
       $(document.body).removeClass('modal-open')
       $.fn.modal = $.fn.bootstrapModal
       delete $.fn.bootstrapModal
+      $('#qunit-fixture').html('')
     }
   })
 
@@ -520,48 +517,6 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('should adjust the inline margin of the navbar-toggler when opening and restore when closing', function (assert) {
-    assert.expect(2)
-    var done = assert.async()
-    var $element = $('<div class="navbar-toggler"></div>').appendTo('#qunit-fixture')
-    var originalMargin = $element.css('margin-right')
-
-    $('<div id="modal-test"/>')
-      .on('hidden.bs.modal', function () {
-        var currentMargin = $element.css('margin-right')
-        assert.strictEqual(currentMargin, originalMargin, 'navbar-toggler margin should be reset after closing')
-        $element.remove()
-        done()
-      })
-      .on('shown.bs.modal', function () {
-        var expectedMargin = parseFloat(originalMargin) + $(this).getScrollbarWidth() + 'px'
-        var currentMargin = $element.css('margin-right')
-        assert.strictEqual(currentMargin, expectedMargin, 'navbar-toggler margin should be adjusted while opening')
-        $(this).bootstrapModal('hide')
-      })
-      .bootstrapModal('show')
-  })
-
-  QUnit.test('should store the original margin of the navbar-toggler in data-margin-right before showing', function (assert) {
-    assert.expect(2)
-    var done = assert.async()
-    var $element = $('<div class="navbar-toggler"></div>').appendTo('#qunit-fixture')
-    var originalMargin = '0px'
-    $element.css('margin-right', originalMargin)
-
-    $('<div id="modal-test"/>')
-      .on('hidden.bs.modal', function () {
-        assert.strictEqual(typeof $element.data('margin-right'), 'undefined', 'data-margin-right should be cleared after closing')
-        $element.remove()
-        done()
-      })
-      .on('shown.bs.modal', function () {
-        assert.strictEqual($element.data('margin-right'), originalMargin, 'original navbar-toggler margin should be stored in data-margin-right')
-        $(this).bootstrapModal('hide')
-      })
-      .bootstrapModal('show')
-  })
-
   QUnit.test('should ignore values set via CSS when trying to restore body padding after closing', function (assert) {
     assert.expect(1)
     var done = assert.async()
@@ -647,36 +602,40 @@ $(function () {
     assert.expect(1)
     var done = assert.async()
 
-    var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div id=&quot;modal-test&quot;&gt;&lt;div class=&quot;contents&quot;&lt;div&lt;div id=&quot;close&quot; data-dismiss=&quot;modal&quot;/&gt;&lt;/div&gt;&lt;/div&gt;"/>')
-      .appendTo('#qunit-fixture')
+    try {
+      var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div id=&quot;modal-test&quot;&gt;&lt;div class=&quot;contents&quot;&lt;div&lt;div id=&quot;close&quot; data-dismiss=&quot;modal&quot;/&gt;&lt;/div&gt;&lt;/div&gt;"/>')
+        .appendTo('#qunit-fixture')
 
-    $toggleBtn.trigger('click')
-    setTimeout(function () {
+      $toggleBtn.trigger('click')
+    } catch (e) {
       assert.strictEqual($('#modal-test').length, 0, 'target has not been parsed and added to the document')
       done()
-    }, 1)
+    }
   })
 
   QUnit.test('should not execute js from target', function (assert) {
     assert.expect(0)
     var done = assert.async()
 
-    // This toggle button contains XSS payload in its data-target
-    // Note: it uses the onerror handler of an img element to execute the js, because a simple script element does not work here
-    //       a script element works in manual tests though, so here it is likely blocked by the qunit framework
-    var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div&gt;&lt;image src=&quot;missing.png&quot; onerror=&quot;$(&apos;#qunit-fixture button.control&apos;).trigger(&apos;click&apos;)&quot;&gt;&lt;/div&gt;"/>')
-      .appendTo('#qunit-fixture')
-    // The XSS payload above does not have a closure over this function and cannot access the assert object directly
-    // However, it can send a click event to the following control button, which will then fail the assert
-    $('<button>')
-      .addClass('control')
-      .on('click', function () {
-        assert.notOk(true, 'XSS payload is not executed as js')
-      })
-      .appendTo('#qunit-fixture')
-
-    $toggleBtn.trigger('click')
-    setTimeout(done, 500)
+    try {
+      // This toggle button contains XSS payload in its data-target
+      // Note: it uses the onerror handler of an img element to execute the js, because a simple script element does not work here
+      //       a script element works in manual tests though, so here it is likely blocked by the qunit framework
+      var $toggleBtn = $('<button data-toggle="modal" data-target="&lt;div&gt;&lt;image src=&quot;missing.png&quot; onerror=&quot;$(&apos;#qunit-fixture button.control&apos;).trigger(&apos;click&apos;)&quot;&gt;&lt;/div&gt;"/>')
+        .appendTo('#qunit-fixture')
+      // The XSS payload above does not have a closure over this function and cannot access the assert object directly
+      // However, it can send a click event to the following control button, which will then fail the assert
+      $('<button>')
+        .addClass('control')
+        .on('click', function () {
+          assert.notOk(true, 'XSS payload is not executed as js')
+        })
+        .appendTo('#qunit-fixture')
+
+      $toggleBtn.trigger('click')
+    } catch (e) {
+      done()
+    }
   })
 
   QUnit.test('should not try to open a modal which is already visible', function (assert) {
@@ -695,7 +654,90 @@ $(function () {
       .bootstrapModal('hide')
   })
 
-  QUnit.test('right arrow should focus first button when none focused', function (assert) {
+  QUnit.test('transition duration should be the modal-dialog duration before triggering shown event', function (assert) {
+    assert.expect(2)
+    var done = assert.async()
+    var style = [
+      '<style>',
+      '  .modal.fade .modal-dialog {',
+      '    transition: -webkit-transform .3s ease-out;',
+      '    transition: transform .3s ease-out;',
+      '    transition: transform .3s ease-out,-webkit-transform .3s ease-out;',
+      '    -webkit-transform: translate(0,-50px);',
+      '    transform: translate(0,-50px);',
+      '  }',
+      '</style>'
+    ].join('')
+
+    var $style = $(style).appendTo('head')
+    var modalHTML = [
+      '<div class="modal fade" id="exampleModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">',
+      '  <div class="modal-dialog" role="document">',
+      '    <div class="modal-content">',
+      '      <div class="modal-body">...</div>',
+      '    </div>',
+      '  </div>',
+      '</div>'
+    ].join('')
+
+    var beginTimestamp = 0
+    var $modal = $(modalHTML).appendTo('#qunit-fixture')
+    var $modalDialog = $('.modal-dialog')
+    var transitionDuration  = Util.getTransitionDurationFromElement($modalDialog[0])
+
+    assert.strictEqual(transitionDuration, 300)
+
+    $modal.on('shown.bs.modal', function () {
+      var diff = Date.now() - beginTimestamp
+      assert.ok(diff < 400)
+      $style.remove()
+      done()
+    })
+      .bootstrapModal('show')
+
+    beginTimestamp = Date.now()
+  })
+
+  QUnit.test('should dispose modal', function (assert) {
+    assert.expect(3)
+    var done = assert.async()
+
+    var $modal = $([
+      '<div id="modal-test">',
+      '  <div class="modal-dialog">',
+      '    <div class="modal-content">',
+      '      <div class="modal-body" />',
+      '    </div>',
+      '  </div>',
+      '</div>'
+    ].join('')).appendTo('#qunit-fixture')
+
+    $modal.on('shown.bs.modal', function () {
+      var spy = sinon.spy($.fn, 'off')
+
+      $(this).bootstrapModal('dispose')
+
+      var modalDataApiEvent = []
+      $._data(document, 'events').click
+        .forEach(function (e) {
+          if (e.namespace === 'bs.data-api.modal') {
+            modalDataApiEvent.push(e)
+          }
+        })
+
+      assert.ok(typeof $(this).data('bs.modal') === 'undefined', 'modal data object was disposed')
+
+      assert.ok(spy.callCount === 4, '`jQuery.off` was called')
+
+      assert.ok(modalDataApiEvent.length === 1, '`Event.CLICK_DATA_API` on `document` was not removed')
+
+      $.fn.off.restore()
+      done()
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should select first button when none focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -715,7 +757,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should focus next button', function (assert) {
+  QUnit.test('right arrow should select next button', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -738,7 +780,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should focus first button when last focused', function (assert) {
+  QUnit.test('right arrow should select first button when last focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -785,7 +827,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should focus last button when none focused', function (assert) {
+  QUnit.test('left arrow should last button when none focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -805,7 +847,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should focus prev button', function (assert) {
+  QUnit.test('left arrow should select prev button', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -828,7 +870,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should focus last button when first focused', function (assert) {
+  QUnit.test('left arrow should select last button when first focused', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -851,7 +893,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('left arrow should not take focus from input', function (assert) {
+  QUnit.test('right arrow should not take focus from input', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -875,42 +917,6 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('ignore left/right arrow keys with modifier key', function (assert) {
-    assert.expect(8)
-    var done = assert.async(8)
-    var $div = $('<div id="modal-test"><div class="contents">' +
-      '<button class="btn">button 1</button>' +
-      '<button class="btn">button 2</button>' +
-      '</div>' +
-      '</div>')
-    $div.on('shown.bs.modal', function () {
-      const keys = [37, 39]
-      const modifiers = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']
-      // itterate over each combination of left/right and modifier
-      for (let i = 0, l = keys.length; i < l; i++) {
-        for (let i2 = 0, l2 = modifiers.length; i2 < l2; i2++) {
-          (function (keycode, modifier) {
-            setTimeout(function () {
-              // unfocus .btn if currently focused
-              $(document.activeElement).filter('.btn').blur()
-            }, 0)
-            setTimeout(function () {
-              $div.trigger($.Event('keydown', {
-                which: keycode,
-                [modifier]: true
-              }))
-            }, 0)
-            setTimeout(function () {
-              assert.ok(!$(document.activeElement).is('.btn'), 'button does not have focus: keycode = ' + keycode + ', modifier = ' + modifier)
-              done()
-            }, 0)
-          }(keys[i], modifiers[i2]))
-        }
-      }
-    })
-      .bootstrapModal('show')
-  })
-
   QUnit.test(':input[autofocus] should get focus', function (assert) {
     assert.expect(2)
     var done = assert.async()
diff --git a/package.json b/package.json
index 29665aa4ae..5ed27d2c86 100644
--- a/package.json
+++ b/package.json
@@ -190,7 +190,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "22 kB"
+      "maxSize": "23 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
diff --git a/site/docs/4.1/components/modal.md b/site/docs/4.1/components/modal.md
index c4064b228c..34f86c6900 100644
--- a/site/docs/4.1/components/modal.md
+++ b/site/docs/4.1/components/modal.md
@@ -428,7 +428,8 @@ Below is a live demo followed by example HTML and JavaScript. For more informati
     </div>
   </div>
 </div>
-{% endexample %}
+{% endcapture %}
+{% include example.html content=example %}
 
 {% highlight js %}
 $('#exampleModal').on('show.bs.modal', function (event) {
@@ -442,6 +443,12 @@ $('#exampleModal').on('show.bs.modal', function (event) {
 })
 {% endhighlight %}
 
+### Change animation
+
+The `$modal-fade-transform` variable determines the transform state of `.modal-dialog` before the modal fade-in animation, the `$modal-show-transform` variable determines the transform of `.modal-dialog` at the end of the modal fade-in animation.
+
+If you want for example a zoom-in animation, you can set `$modal-fade-transform: scale(.8)`.
+
 ### Remove animation
 
 For modals that simply appear rather than fade in to view, remove the `.fade` class from your modal markup.
-- 
GitLab


From 981485082b2f5cb2b98d0354b34a70dd86886449 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Thu, 8 Nov 2018 23:06:42 -0600
Subject: [PATCH 10/23] recommit modal.js & unit/modal.js updates

---
 js/src/modal.js        |  58 +++++++++++
 js/tests/unit/modal.js | 214 ++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 271 insertions(+), 1 deletion(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index 0004fe8bbe..994c5bf817 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -21,17 +21,23 @@ const EVENT_KEY          = `.${DATA_KEY}`
 const DATA_API_KEY       = '.data-api'
 const JQUERY_NO_CONFLICT = $.fn[NAME]
 const ESCAPE_KEYCODE     = 27 // KeyboardEvent.which value for Escape (Esc) key
+const LEFT_KEYCODE       = 37
+const RIGHT_KEYCODE      = 39
 
 const Default = {
+  autofocus: 'notTouch',      // true|false|notTouch
   backdrop : true,
   keyboard : true,
+  keyboardBtnNav: true,  // ability to use arrows to nav button focus
   focus    : true,
   show     : true
 }
 
 const DefaultType = {
+  autofocus: '(boolean|string)',
   backdrop : '(boolean|string)',
   keyboard : 'boolean',
+  keyboardBtnNav: 'boolean',
   focus    : 'boolean',
   show     : 'boolean'
 }
@@ -45,6 +51,7 @@ const Event = {
   RESIZE            : `resize${EVENT_KEY}`,
   CLICK_DISMISS     : `click.dismiss${EVENT_KEY}`,
   KEYDOWN_DISMISS   : `keydown.dismiss${EVENT_KEY}`,
+  KEYDOWN_NAV       : `keydown.nav${EVENT_KEY}`,
   MOUSEUP_DISMISS   : `mouseup.dismiss${EVENT_KEY}`,
   MOUSEDOWN_DISMISS : `mousedown.dismiss${EVENT_KEY}`,
   CLICK_DATA_API    : `click${EVENT_KEY}${DATA_API_KEY}`
@@ -130,6 +137,7 @@ class Modal {
     $(document.body).addClass(ClassName.OPEN)
 
     this._setEscapeEvent()
+    this._setKeyNavEvent()
     this._setResizeEvent()
 
     $(this._element).on(
@@ -174,6 +182,7 @@ class Modal {
     }
 
     this._setEscapeEvent()
+    this._setKeyNavEvent()
     this._setResizeEvent()
 
     $(document).off(Event.FOCUSIN)
@@ -234,6 +243,11 @@ class Modal {
     return config
   }
 
+  // Util worthy?
+  _isTouchDevice() {
+    return 'ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch
+  }
+
   _showElement(relatedTarget) {
     const transition = $(this._element).hasClass(ClassName.FADE)
 
@@ -265,6 +279,9 @@ class Modal {
       if (this._config.focus) {
         this._element.focus()
       }
+      if (this._config.autofocus === true || this._config.autofocus === 'notTouch' && !this._isTouchDevice()) {
+        this._autofocus()
+      }
       this._isTransitioning = false
       $(this._element).trigger(shownEvent)
     }
@@ -280,6 +297,10 @@ class Modal {
     }
   }
 
+  _autofocus() {
+    $(this._element).find(':input[autofocus]:not(:hidden)').eq(0).trigger('focus')
+  }
+
   _enforceFocus() {
     $(document)
       .off(Event.FOCUSIN) // Guard against infinite focus loop
@@ -305,6 +326,20 @@ class Modal {
     }
   }
 
+  _setKeyNavEvent() {
+    if (this._isShown && this._config.keyboardBtnNav) {
+      $(this._element).on(Event.KEYDOWN_NAV, (event) => {
+        if (event.which === LEFT_KEYCODE) {
+          this._keyboardBtnNav('prev')
+        } else if (event.which === RIGHT_KEYCODE) {
+          this._keyboardBtnNav('next')
+        }
+      })
+    } else if (!this._isShown) {
+      $(this._element).off(Event.KEYDOWN_NAV)
+    }
+  }
+
   _setResizeEvent() {
     if (this._isShown) {
       $(window).on(Event.RESIZE, (event) => this.handleUpdate(event))
@@ -325,6 +360,29 @@ class Modal {
     })
   }
 
+  _keyboardBtnNav(prevNext) {
+    const $focusable = $(this._element).find('.btn')
+    let curFocusIdx = $focusable.index(document.activeElement)
+    if ($(document.activeElement).is(':input:not(:button)')) {
+      // we're currently focused on an input, stay put
+      return
+    }
+    if (curFocusIdx < 0) {
+      // nothing currently focused
+      // "next" will focus first $focusable, "prev" will focus last $focusable
+      curFocusIdx = prevNext === 'next' ? -1 : 0
+    }
+    if (prevNext === 'prev') {
+      // eq() accepts negative index
+      $focusable.eq(curFocusIdx - 1).trigger('focus')
+    } else if (curFocusIdx === $focusable.length - 1) {
+      // last btn is focused, wrap back to first
+      $focusable.eq(0).trigger('focus')
+    } else {
+      $focusable.eq(curFocusIdx + 1).trigger('focus')
+    }
+  }
+
   _removeBackdrop() {
     if (this._backdrop) {
       $(this._backdrop).remove()
diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index 1156ce0c70..50041934f6 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -733,6 +733,218 @@ $(function () {
 
       $.fn.off.restore()
       done()
-    }).bootstrapModal('show')
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should focus first button when none focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.trigger($.Event('keydown', {
+        which: 39
+      }))
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'first button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should focus next button', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').first().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should focus first button when last focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').last().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'right arrow wrapped')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should not take focus from input', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<input type="text" />' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('input').trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 39
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is('input'), 'input still focused')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('left arrow should focus last button when none focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.trigger($.Event('keydown', {
+        which: 37
+      }))
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('left arrow should focus prev button', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').last().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').first()), 'first button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('left arrow should focus last button when first focused', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('.btn').first().trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is($div.find('.btn').last()), 'last button selected')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test('right arrow should not take focus from input', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    var $div = $('<div id="modal-test"><div class="contents">' +
+      '<input type="text" />' +
+      '<button class="btn">button 1</button>' +
+      '<button class="btn">button 2</button>' +
+      '</div>' +
+      '</div>')
+    $div.on('shown.bs.modal', function () {
+      $div.find('input').trigger('focus')
+      setTimeout(function () {
+        $div.trigger($.Event('keydown', {
+          which: 37
+        }))
+      }, 0)
+      setTimeout(function () {
+        assert.ok($(document.activeElement).is('input'), 'input still focused')
+        done()
+      }, 0)
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test(':input[autofocus] should get focus', function (assert) {
+    assert.expect(2)
+    var done = assert.async()
+    $('body').append('<div id="modal-test" class="modal" data-autofocus="true"><div class="contents">' +
+      '<input id="my-input" type="text" autofocus />' +
+      '</div>' +
+      '</div>')
+    assert.notOk($(document.activeElement).is('#my-input'), 'input focused')
+    $('#modal-test').on('shown.bs.modal', function () {
+      assert.ok($(document.activeElement).is('#my-input'), 'input focused')
+      done()
+    })
+      .bootstrapModal('show')
+  })
+
+  QUnit.test(':input[autofocus] should not get focus (default)', function (assert) {
+    assert.expect(1)
+    var done = assert.async()
+    $('body').append(
+      '<div id="modal-test" class="modal" data-autofocus="false"><div class="contents">' +
+      '<input id="my-input" type="text" autofocus />' +
+      '</div>' +
+      '</div>')
+    $('#modal-test')
+      .on('shown.bs.modal', function () {
+        assert.notOk($(document.activeElement).is('#my-input'), 'input does not have focus')
+        done()
+      })
+      .bootstrapModal('show')
   })
 })
-- 
GitLab


From 18c51ffc789aefc8e62bdcdec93c77571f2fe7d0 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Thu, 8 Nov 2018 23:10:39 -0600
Subject: [PATCH 11/23] recommit modal.md update

---
 site/docs/4.1/components/modal.md | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/site/docs/4.1/components/modal.md b/site/docs/4.1/components/modal.md
index 7cc8b29fd8..645d9ab648 100644
--- a/site/docs/4.1/components/modal.md
+++ b/site/docs/4.1/components/modal.md
@@ -467,11 +467,7 @@ If the height of a modal changes while it is open, you should call `$('#myModal'
 
 Be sure to add `role="dialog"` and `aria-labelledby="..."`, referencing the modal title, to `.modal`, and `role="document"` to the `.modal-dialog` itself. Additionally, you may give a description of your modal dialog with `aria-describedby` on `.modal`.
 
-The `autofocus` attribute may be given to inputs and buttons in modal.  By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
-
-By default, keyboard left & right arrow-keys can be used to focus buttons within the modal (ie change between "close" and "save").
-
-The `autofocus` attribute may be given to inputs and buttons in modal.  By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
+The `autofocus` attribute may be given to inputs and buttons in modals.  By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
 
 By default, keyboard left & right arrow-keys can be used to focus buttons within the modal (ie change between "close" and "save").
 
@@ -668,7 +664,7 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>keyboardBtnNav</td>
       <td>boolean</td>
       <td>true</td>
-      <td>Whether keyboard's left &amp; right arrow keys should move focus to/between `.btn` elements.
+      <td>Whether keyboard's left and right arrow keys should move focus to/between `.btn` elements.
         <span class="text-muted">(focus will not be taken from input elements)</span>
       </td>
     </tr>
-- 
GitLab


From 9b14acd9cc12814e0464f4d187fffd653a3d1b54 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Tue, 13 Nov 2018 21:01:22 -0600
Subject: [PATCH 12/23] shorten `keyboardBtnNav` option to `keyboardNav`

---
 js/src/modal.js                   | 12 ++++++------
 site/docs/4.1/components/modal.md |  2 +-
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index 994c5bf817..4186456559 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -28,7 +28,7 @@ const Default = {
   autofocus: 'notTouch',      // true|false|notTouch
   backdrop : true,
   keyboard : true,
-  keyboardBtnNav: true,  // ability to use arrows to nav button focus
+  keyboardNav: true,  // ability to use arrows to nav button focus
   focus    : true,
   show     : true
 }
@@ -37,7 +37,7 @@ const DefaultType = {
   autofocus: '(boolean|string)',
   backdrop : '(boolean|string)',
   keyboard : 'boolean',
-  keyboardBtnNav: 'boolean',
+  keyboardNav: 'boolean',
   focus    : 'boolean',
   show     : 'boolean'
 }
@@ -327,12 +327,12 @@ class Modal {
   }
 
   _setKeyNavEvent() {
-    if (this._isShown && this._config.keyboardBtnNav) {
+    if (this._isShown && this._config.keyboardNav) {
       $(this._element).on(Event.KEYDOWN_NAV, (event) => {
         if (event.which === LEFT_KEYCODE) {
-          this._keyboardBtnNav('prev')
+          this._keyboardNav('prev')
         } else if (event.which === RIGHT_KEYCODE) {
-          this._keyboardBtnNav('next')
+          this._keyboardNav('next')
         }
       })
     } else if (!this._isShown) {
@@ -360,7 +360,7 @@ class Modal {
     })
   }
 
-  _keyboardBtnNav(prevNext) {
+  _keyboardNav(prevNext) {
     const $focusable = $(this._element).find('.btn')
     let curFocusIdx = $focusable.index(document.activeElement)
     if ($(document.activeElement).is(':input:not(:button)')) {
diff --git a/site/docs/4.1/components/modal.md b/site/docs/4.1/components/modal.md
index 645d9ab648..d2efa915b8 100644
--- a/site/docs/4.1/components/modal.md
+++ b/site/docs/4.1/components/modal.md
@@ -661,7 +661,7 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>Closes the modal when escape key is pressed</td>
     </tr>
     <tr>
-      <td>keyboardBtnNav</td>
+      <td>keyboardNav</td>
       <td>boolean</td>
       <td>true</td>
       <td>Whether keyboard's left and right arrow keys should move focus to/between `.btn` elements.
-- 
GitLab


From d2abba9800eb89de34707f0dedfff74b3f30bdfa Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Tue, 13 Nov 2018 21:49:51 -0600
Subject: [PATCH 13/23] use VanillaJS  querySelectorAll vs jQuery selector

---
 js/src/modal.js | 5 +++--
 package.json    | 2 +-
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index 4186456559..ba4fb36394 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -298,7 +298,8 @@ class Modal {
   }
 
   _autofocus() {
-    $(this._element).find(':input[autofocus]:not(:hidden)').eq(0).trigger('focus')
+    const selector = 'input[autofocus], select[autofocus], textarea[autofocus], button[autofocus]'
+    $(this._element.querySelectorAll(selector)).filter(':not(:hidden)').eq(0).trigger('focus')
   }
 
   _enforceFocus() {
@@ -361,7 +362,7 @@ class Modal {
   }
 
   _keyboardNav(prevNext) {
-    const $focusable = $(this._element).find('.btn')
+    const $focusable = $(this._element.querySelectorAll('.btn')).filter(':not(:hidden)')
     let curFocusIdx = $focusable.index(document.activeElement)
     if ($(document.activeElement).is(':input:not(:button)')) {
       // we're currently focused on an input, stay put
diff --git a/package.json b/package.json
index 9e3a7bbf0f..ad80876135 100644
--- a/package.json
+++ b/package.json
@@ -190,7 +190,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "23 kB"
+      "maxSize": "23.1 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-- 
GitLab


From ceac7adc8bd32d84d73cb3f05590a4be2f9d5cc2 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Tue, 13 Nov 2018 22:04:23 -0600
Subject: [PATCH 14/23] move css selectors to Selectors obj

---
 js/src/modal.js | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index ba4fb36394..1435da9f9d 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -66,6 +66,8 @@ const ClassName = {
 }
 
 const Selector = {
+  AUTOFOCUSABLE      : 'input[autofocus], select[autofocus], textarea[autofocus], button[autofocus]',
+  BTN                : '.btn',
   DIALOG             : '.modal-dialog',
   DATA_TOGGLE        : '[data-toggle="modal"]',
   DATA_DISMISS       : '[data-dismiss="modal"]',
@@ -298,8 +300,7 @@ class Modal {
   }
 
   _autofocus() {
-    const selector = 'input[autofocus], select[autofocus], textarea[autofocus], button[autofocus]'
-    $(this._element.querySelectorAll(selector)).filter(':not(:hidden)').eq(0).trigger('focus')
+    $(this._element.querySelectorAll(Selector.AUTOFOCUSABLE)).filter(':not(:hidden)').eq(0).trigger('focus')
   }
 
   _enforceFocus() {
@@ -362,7 +363,7 @@ class Modal {
   }
 
   _keyboardNav(prevNext) {
-    const $focusable = $(this._element.querySelectorAll('.btn')).filter(':not(:hidden)')
+    const $focusable = $(this._element.querySelectorAll(Selector.BTN)).filter(':not(:hidden)')
     let curFocusIdx = $focusable.index(document.activeElement)
     if ($(document.activeElement).is(':input:not(:button)')) {
       // we're currently focused on an input, stay put
-- 
GitLab


From bb8d1a13f3ce4f228c22c17feddc145339ec5c0c Mon Sep 17 00:00:00 2001
From: XhmikosR <xhmikosr@gmail.com>
Date: Wed, 14 Nov 2018 09:51:38 +0200
Subject: [PATCH 15/23] Update modal.md

---
 site/docs/4.1/components/modal.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/site/docs/4.1/components/modal.md b/site/docs/4.1/components/modal.md
index d2efa915b8..53c4bcdb93 100644
--- a/site/docs/4.1/components/modal.md
+++ b/site/docs/4.1/components/modal.md
@@ -467,7 +467,7 @@ If the height of a modal changes while it is open, you should call `$('#myModal'
 
 Be sure to add `role="dialog"` and `aria-labelledby="..."`, referencing the modal title, to `.modal`, and `role="document"` to the `.modal-dialog` itself. Additionally, you may give a description of your modal dialog with `aria-describedby` on `.modal`.
 
-The `autofocus` attribute may be given to inputs and buttons in modals.  By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
+The `autofocus` attribute may be given to inputs and buttons in modals. By default, when not on a touch-device, focus will be given to the autofocus input/button when the modal is shown.
 
 By default, keyboard left & right arrow-keys can be used to focus buttons within the modal (ie change between "close" and "save").
 
@@ -640,7 +640,7 @@ Options can be passed via data attributes or JavaScript. For data attributes, ap
       <td>autofocus</td>
       <td>boolean or the string <code>'notTouch'</code></td>
       <td>'notTouch'</td>
-      <td>Whether input with `autofocus` attribute should be given focus when modal is shown<br />
+      <td>Whether input with `autofocus` attribute should be given focus when modal is shown<br>
           <ul class="list-unstyled">
             <li><code>notTouch</code> will give focus when not a touch device</li>
             <li><code>true</code> will give focus regardless</li>
-- 
GitLab


From f5d29068e74537ca471953266d179e6b8ada27ac Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Wed, 14 Nov 2018 08:57:27 -0600
Subject: [PATCH 16/23] modal.js : create and use _touchSupported property
 (same as carousel.js)

---
 js/src/modal.js | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index 1435da9f9d..7cb70a1967 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -92,6 +92,7 @@ class Modal {
     this._ignoreBackdropClick = false
     this._isTransitioning     = false
     this._scrollbarWidth      = 0
+    this._touchSupported      = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
   }
 
   // Getters
@@ -245,11 +246,6 @@ class Modal {
     return config
   }
 
-  // Util worthy?
-  _isTouchDevice() {
-    return 'ontouchstart' in window || window.DocumentTouch && document instanceof window.DocumentTouch
-  }
-
   _showElement(relatedTarget) {
     const transition = $(this._element).hasClass(ClassName.FADE)
 
@@ -281,7 +277,7 @@ class Modal {
       if (this._config.focus) {
         this._element.focus()
       }
-      if (this._config.autofocus === true || this._config.autofocus === 'notTouch' && !this._isTouchDevice()) {
+      if (this._config.autofocus === true || this._config.autofocus === 'notTouch' && !this._touchSupported) {
         this._autofocus()
       }
       this._isTransitioning = false
-- 
GitLab


From cf7729fcccb102e7dc2224790691fe94dac65a98 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Wed, 14 Nov 2018 09:27:29 -0600
Subject: [PATCH 17/23] update unit test descriptions

---
 js/tests/unit/modal.js | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/js/tests/unit/modal.js b/js/tests/unit/modal.js
index 50041934f6..8cf4d3d54c 100644
--- a/js/tests/unit/modal.js
+++ b/js/tests/unit/modal.js
@@ -893,7 +893,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test('right arrow should not take focus from input', function (assert) {
+  QUnit.test('left arrow should not take focus from input', function (assert) {
     assert.expect(1)
     var done = assert.async()
     var $div = $('<div id="modal-test"><div class="contents">' +
@@ -917,7 +917,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test(':input[autofocus] should get focus', function (assert) {
+  QUnit.test(':input[autofocus] should get focus when autofocus opt = true', function (assert) {
     assert.expect(2)
     var done = assert.async()
     $('body').append('<div id="modal-test" class="modal" data-autofocus="true"><div class="contents">' +
@@ -932,7 +932,7 @@ $(function () {
       .bootstrapModal('show')
   })
 
-  QUnit.test(':input[autofocus] should not get focus (default)', function (assert) {
+  QUnit.test(':input[autofocus] should not get focus when autofocus opt = false', function (assert) {
     assert.expect(1)
     var done = assert.async()
     $('body').append(
-- 
GitLab


From f06e04dc21c1821cab4b1ef97af2fc2d9b371529 Mon Sep 17 00:00:00 2001
From: XhmikosR <xhmikosr@gmail.com>
Date: Thu, 22 Nov 2018 14:04:08 +0200
Subject: [PATCH 18/23] Update vnu-jar.js

---
 build/vnu-jar.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/build/vnu-jar.js b/build/vnu-jar.js
index a6f110ebb1..a29591fe8b 100644
--- a/build/vnu-jar.js
+++ b/build/vnu-jar.js
@@ -21,7 +21,7 @@ childProcess.exec('java -version', (error, stdout, stderr) => {
   // vnu-jar accepts multiple ignores joined with a `|`.
   // Also note that the ignores are regular expressions.
   const ignores = [
-    // docs/components/modal  contains multiple elements with autofocus
+    // docs/components/modal contains multiple elements with autofocus
     'A document must not include more than one “autofocus” attribute.*',
     // "autocomplete" is included in <button> and checkboxes and radio <input>s due to
     // Firefox's non-standard autocomplete behavior - see https://bugzilla.mozilla.org/show_bug.cgi?id=654072
-- 
GitLab


From 5f7424eda1707dc178c815616d37bf772fe57737 Mon Sep 17 00:00:00 2001
From: XhmikosR <xhmikosr@gmail.com>
Date: Thu, 22 Nov 2018 14:05:48 +0200
Subject: [PATCH 19/23] Update modal.js

---
 js/src/modal.js | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index 03cc6057d0..426d269d39 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -26,20 +26,20 @@ const RIGHT_KEYCODE      = 39
 
 const Default = {
   autofocus: 'notTouch',      // true|false|notTouch
-  backdrop : true,
-  keyboard : true,
+  backdrop: true,
+  keyboard: true,
   keyboardNav: true,  // ability to use arrows to nav button focus
-  focus    : true,
-  show     : true
+  focus: true,
+  show: true
 }
 
 const DefaultType = {
   autofocus: '(boolean|string)',
-  backdrop : '(boolean|string)',
-  keyboard : 'boolean',
+  backdrop: '(boolean|string)',
+  keyboard: 'boolean',
   keyboardNav: 'boolean',
-  focus    : 'boolean',
-  show     : 'boolean'
+  focus: 'boolean',
+  show: 'boolean'
 }
 
 const Event = {
-- 
GitLab


From 186ce1fb7b4aa47c43a2b71dc7e73ac7f9563c61 Mon Sep 17 00:00:00 2001
From: XhmikosR <xhmikosr@gmail.com>
Date: Fri, 23 Nov 2018 14:44:07 +0200
Subject: [PATCH 20/23] Update modal.js

---
 js/src/modal.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/js/src/modal.js b/js/src/modal.js
index 97d1f50f51..daf703eb51 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -25,7 +25,7 @@ const LEFT_KEYCODE       = 37
 const RIGHT_KEYCODE      = 39
 
 const Default = {
-  autofocus: 'notTouch',      // true|false|notTouch
+  autofocus: 'notTouch', // true|false|notTouch
   backdrop: true,
   keyboard: true,
   keyboardNav: true,  // ability to use arrows to nav button focus
-- 
GitLab


From 93c3be2ee5b41b3773ed796e5b9a4b9f19e60993 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Wed, 28 Nov 2018 09:42:07 -0600
Subject: [PATCH 21/23] move touch enabled logic to util.js  (used by
 carousel.js, dropdown.js, modal.js, & tooltip.js)

---
 js/src/carousel.js | 2 +-
 js/src/dropdown.js | 4 ++--
 js/src/modal.js    | 2 +-
 js/src/tooltip.js  | 4 ++--
 js/src/util.js     | 4 ++++
 5 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/js/src/carousel.js b/js/src/carousel.js
index 734e155965..760042bf11 100644
--- a/js/src/carousel.js
+++ b/js/src/carousel.js
@@ -113,7 +113,7 @@ class Carousel {
     this._config            = this._getConfig(config)
     this._element           = element
     this._indicatorsElement = this._element.querySelector(Selector.INDICATORS)
-    this._touchSupported    = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+    this._touchSupported    = Util.isTouchSupported()
     this._pointerEvent      = Boolean(window.PointerEvent || window.MSPointerEvent)
 
     this._addEventListeners()
diff --git a/js/src/dropdown.js b/js/src/dropdown.js
index d8fe5fdba1..2fa0a8bb79 100644
--- a/js/src/dropdown.js
+++ b/js/src/dropdown.js
@@ -180,7 +180,7 @@ class Dropdown {
     // empty mouseover listeners to the body's immediate children;
     // only needed because of broken event delegation on iOS
     // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
-    if ('ontouchstart' in document.documentElement &&
+    if (Util.isTouchSupported() &&
         $(parent).closest(Selector.NAVBAR_NAV).length === 0) {
       $(document.body).children().on('mouseover', null, $.noop)
     }
@@ -418,7 +418,7 @@ class Dropdown {
 
       // If this is a touch-enabled device we remove the extra
       // empty mouseover listeners we added for iOS support
-      if ('ontouchstart' in document.documentElement) {
+      if (Util.isTouchSupported()) {
         $(document.body).children().off('mouseover', null, $.noop)
       }
 
diff --git a/js/src/modal.js b/js/src/modal.js
index daf703eb51..3c1cbc112f 100644
--- a/js/src/modal.js
+++ b/js/src/modal.js
@@ -92,7 +92,7 @@ class Modal {
     this._ignoreBackdropClick = false
     this._isTransitioning     = false
     this._scrollbarWidth      = 0
-    this._touchSupported      = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+    this._touchSupported      = Util.isTouchSupported()
   }
 
   // Getters
diff --git a/js/src/tooltip.js b/js/src/tooltip.js
index f428a79ebd..8a7dead6b2 100644
--- a/js/src/tooltip.js
+++ b/js/src/tooltip.js
@@ -312,7 +312,7 @@ class Tooltip {
       // empty mouseover listeners to the body's immediate children;
       // only needed because of broken event delegation on iOS
       // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
-      if ('ontouchstart' in document.documentElement) {
+      if (Util.isTouchSupported()) {
         $(document.body).children().on('mouseover', null, $.noop)
       }
 
@@ -372,7 +372,7 @@ class Tooltip {
 
     // If this is a touch-enabled device we remove the extra
     // empty mouseover listeners we added for iOS support
-    if ('ontouchstart' in document.documentElement) {
+    if (Util.isTouchSupported()) {
       $(document.body).children().off('mouseover', null, $.noop)
     }
 
diff --git a/js/src/util.js b/js/src/util.js
index e9665d24fd..75528ac419 100644
--- a/js/src/util.js
+++ b/js/src/util.js
@@ -109,6 +109,10 @@ const Util = {
     return (parseFloat(transitionDuration) + parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER
   },
 
+  isTouchSupported() {
+    return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+  },
+
   reflow(element) {
     return element.offsetHeight
   },
-- 
GitLab


From d21014da536d3fd114e866f48877f8929252db09 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Thu, 20 Dec 2018 10:59:18 -0600
Subject: [PATCH 22/23] increase dist/js/bootstrap.bundle.js maxSize  (upstream
 updates increased size)

---
 package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 2f8bff561a..36cba2ea5c 100644
--- a/package.json
+++ b/package.json
@@ -185,7 +185,7 @@
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "45 kB"
+      "maxSize": "45.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-- 
GitLab


From 3366a0849a4ca27ccba632bcdc87717aa0666d80 Mon Sep 17 00:00:00 2001
From: Brad Kent <bkfake-github@yahoo.com>
Date: Wed, 16 Jan 2019 15:17:08 -0600
Subject: [PATCH 23/23] package.json maxSize updates

---
 package.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index f201124875..3a468e1fa0 100644
--- a/package.json
+++ b/package.json
@@ -186,11 +186,11 @@
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "21 kB"
+      "maxSize": "21.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "22.5 kB"
+      "maxSize": "23.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-- 
GitLab