Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/components/modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -999,9 +999,18 @@ focus a form control when the modal opens. Note that the `autofocus` prop will n
`b-modal` if the `static` prop is used without the `lazy` prop set, as `autofocus` happens when the
`b-form-*` controls are _mounted in the DOM_.

**Note:** it is **not recommended** to autofocus an input inside a modal for accessibility reasons,
as screen reader users will not know the context of where the input is. It is best to let
`<b-modal>` focus the modal's container and then allow the user to tab into the input.
If you want to auto focus one of the _built-in_ modal buttons (`ok`, `cancel` or the header `close`
button, you can set the prop `auto-focus-button` to one of the values `'ok'`, `'cancel'` or
`'close'` and `<b-modal>` will focus the specified button if it exists. This feature is also
available for modal message boxes.

<p class="alert alert-warning">
<strong>Note:</strong> it is <strong>not recommended</strong> to autofocus an input or control
inside of a modal for accessibility reasons, as screen reader users will not know the context of
where the input is (the announcement of the modal may not be spoken). It is best to let
<code>&lt;b-modal&gt;</code> focus the modal's container, allowing the modal information to be
spoken to the user, and then allow the user to tab into the input.
</p>

### Returning focus to the triggering element

Expand Down
90 changes: 66 additions & 24 deletions src/components/modal/modal.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import Vue from '../../utils/vue'
import { modalManager } from './helpers/modal-manager'
import { BvModalEvent } from './helpers/bv-modal-event.class'
import idMixin from '../../mixins/id'
import listenOnRootMixin from '../../mixins/listen-on-root'
import normalizeSlotMixin from '../../mixins/normalize-slot'
import scopedStyleAttrsMixin from '../../mixins/scoped-style-attrs'
import BVTransition from '../../utils/bv-transition'
import KeyCodes from '../../utils/key-codes'
import observeDom from '../../utils/observe-dom'
import { BTransporterSingle } from '../../utils/transporter'
import { isBrowser } from '../../utils/env'
import { isString } from '../../utils/inspect'
import { arrayIncludes } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import {
contains,
eventOff,
eventOn,
isVisible,
requestAF,
select,
selectAll
} from '../../utils/dom'
import { isBrowser } from '../../utils/env'
import { stripTags } from '../../utils/html'
import { contains, eventOff, eventOn, isVisible, select, selectAll } from '../../utils/dom'
import { isString, isUndefinedOrNull } from '../../utils/inspect'
import { BTransporterSingle } from '../../utils/transporter'
import idMixin from '../../mixins/id'
import listenOnRootMixin from '../../mixins/listen-on-root'
import normalizeSlotMixin from '../../mixins/normalize-slot'
import scopedStyleAttrsMixin from '../../mixins/scoped-style-attrs'
import { BButton } from '../button/button'
import { BButtonClose } from '../button/button-close'
import { modalManager } from './helpers/modal-manager'
import { BvModalEvent } from './helpers/bv-modal-event.class'

// --- Constants ---

Expand Down Expand Up @@ -255,6 +264,14 @@ export const props = {
static: {
type: Boolean,
default: false
},
autoFocusButton: {
type: String,
default: null,
validator: val => {
/* istanbul ignore next */
return isUndefinedOrNull(val) || arrayIncludes(['ok', 'cancel', 'close'], val)
}
}
}

Expand Down Expand Up @@ -576,10 +593,18 @@ export const BModal = /*#__PURE__*/ Vue.extend({
this.checkModalOverflow()
this.isShow = true
this.isTransitioning = false
this.$nextTick(() => {
// We use `requestAF()` to allow transition hooks to complete
// before passing control over to the other handlers
// This will allow users to not have to use `$nextTick()` or `requestAF()`
// when trying to pre-focus an element
requestAF(() => {
this.emitEvent(this.buildEvent('shown'))
this.focusFirst()
this.setEnforceFocus(true)
this.$nextTick(() => {
// Delayed in a `$nextTick()` to allow users time to pre-focus
// an element if the wish
this.focusFirst()
})
})
},
onBeforeLeave() {
Expand Down Expand Up @@ -731,18 +756,32 @@ export const BModal = /*#__PURE__*/ Vue.extend({
focusFirst() {
// Don't try and focus if we are SSR
if (isBrowser) {
const modal = this.$refs.modal
const content = this.$refs.content
const activeElement = this.getActiveElement()
// If the modal contains the activeElement, we don't do anything
if (modal && content && !(activeElement && contains(content, activeElement))) {
// Make sure top of modal is showing (if longer than the viewport)
// and focus the modal content wrapper
this.$nextTick(() => {
modal.scrollTop = 0
content.focus()
})
}
requestAF(() => {
const modal = this.$refs.modal
const content = this.$refs.content
const activeElement = this.getActiveElement()
// If the modal contains the activeElement, we don't do anything
if (modal && content && !(activeElement && contains(content, activeElement))) {
const ok = this.$refs['ok-button']
const cancel = this.$refs['cancel-button']
const close = this.$refs['close-button']
// Focus the appropriate button or modal content wrapper
const autoFocus = this.autoFocusButton
const el =
autoFocus === 'ok' && ok
? ok.$el || ok
: autoFocus === 'cancel' && cancel
? cancel.$el || cancel
: autoFocus === 'close' && close
? close.$el || close
: content
// Make sure top of modal is showing (if longer than the viewport)
if (el === content) {
modal.scrollTop = 0
}
attemptFocus(el)
}
})
}
},
returnFocusTo() {
Expand Down Expand Up @@ -777,6 +816,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
closeButton = h(
BButtonClose,
{
ref: 'close-button',
props: {
disabled: this.isTransitioning,
ariaLabel: this.headerCloseLabel,
Expand Down Expand Up @@ -840,6 +880,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
cancelButton = h(
BButton,
{
ref: 'cancel-button',
props: {
variant: this.cancelVariant,
size: this.buttonSize,
Expand All @@ -857,6 +898,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
const okButton = h(
BButton,
{
ref: 'ok-button',
props: {
variant: this.okVariant,
size: this.buttonSize,
Expand Down
12 changes: 12 additions & 0 deletions src/components/modal/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1049,7 +1049,9 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()

// Modal should now be open
expect($modal.element.style.display).toEqual('block')
Expand All @@ -1065,7 +1067,9 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()

// Modal should now be closed
expect($modal.element.style.display).toEqual('none')
Expand Down Expand Up @@ -1103,7 +1107,9 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()

const $button = wrapper.find('button.trigger')
expect($button.exists()).toBe(true)
Expand Down Expand Up @@ -1131,7 +1137,9 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()

// Modal should now be open
expect($modal.element.style.display).toEqual('block')
Expand All @@ -1148,7 +1156,9 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()

// Modal should now be closed
expect($modal.element.style.display).toEqual('none')
Expand Down Expand Up @@ -1181,7 +1191,9 @@ describe('modal', () => {
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()

const $button = wrapper.find('button.trigger')
expect($button.exists()).toBe(true)
Expand Down