diff --git a/build/vnu-jar.js b/build/vnu-jar.js index 5fd214a9d07fcc2c156edf1d792daa589fb5d4de..f186d6bd7b8e7dd714f34156ce857938d83d849f 100644 --- a/build/vnu-jar.js +++ b/build/vnu-jar.js @@ -21,6 +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 = [ + '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 0004fe8bbefc38d596f2a09ec38b2259bdb58088..994c5bf817dc1f27669f1924644c506b4f95dc46 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 1156ce0c7009b13698d2b292fe1063fcb7230096..a3e07b2e0b73fc50851a0ff5c13adae44713e5c4 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 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 29665aa4ae19294d6f3e644f678bf82f21502193..5ed27d2c869024c62daa48e265d1e9507513c4a0 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 86996ecda4314469fdc6961f4badb7ffbfcb284b..09df5f7f670fc317028a0a812f833823cb2c490d 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> @@ -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> @@ -474,6 +467,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 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"). + ### 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 +636,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 +660,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 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> <tr> <td>focus</td> <td>boolean</td>