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 & 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="<div id="modal-test"><div class="contents"<div<div id="close" data-dismiss="modal"/></div></div>"/>') - .appendTo('#qunit-fixture') + var $toggleBtn = $('<button data-toggle="modal" data-target="<div id="modal-test"><div class="contents"<div<div id="close" data-dismiss="modal"/></div></div>"/>') + .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="<div><image src="missing.png" onerror="$('#qunit-fixture button.control').trigger('click')"></div>"/>') - .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="<div><image src="missing.png" onerror="$('#qunit-fixture button.control').trigger('click')"></div>"/>') + .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 & 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="<div id="modal-test"><div class="contents"<div<div id="close" data-dismiss="modal"/></div></div>"/>') - .appendTo('#qunit-fixture') + try { + var $toggleBtn = $('<button data-toggle="modal" data-target="<div id="modal-test"><div class="contents"<div<div id="close" data-dismiss="modal"/></div></div>"/>') + .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="<div><image src="missing.png" onerror="$('#qunit-fixture button.control').trigger('click')"></div>"/>') - .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="<div><image src="missing.png" onerror="$('#qunit-fixture button.control').trigger('click')"></div>"/>') + .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 & 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