Skip to content
Permalink
Browse files

feat(modal): add prop for auto focusing one of the built in-buttons o…

…nce `shown` (closes #3945) (#3979)

* feat(modal): add prop for auto focusing one of the built in buttons on shown

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.js

* Update modal.spec.js

* Update modal.spec.js

* Update modal.spec.js

* Update README.md

* Update README.md

* Update modal.js

* Update modal.js
  • Loading branch information...
tmorehouse authored and jackmu95 committed Aug 30, 2019
1 parent 7418f08 commit 6f2827e2e70683bbd8cb48e45a8daa4171cb43e2
Showing with 90 additions and 27 deletions.
  1. +12 −3 src/components/modal/README.md
  2. +66 −24 src/components/modal/modal.js
  3. +12 −0 src/components/modal/modal.spec.js
@@ -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

@@ -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 ---

@@ -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)
}
}
}

@@ -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() {
@@ -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() {
@@ -777,6 +816,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
closeButton = h(
BButtonClose,
{
ref: 'close-button',
props: {
disabled: this.isTransitioning,
ariaLabel: this.headerCloseLabel,
@@ -840,6 +880,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
cancelButton = h(
BButton,
{
ref: 'cancel-button',
props: {
variant: this.cancelVariant,
size: this.buttonSize,
@@ -857,6 +898,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({
const okButton = h(
BButton,
{
ref: 'ok-button',
props: {
variant: this.okVariant,
size: this.buttonSize,
@@ -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')
@@ -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')
@@ -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)
@@ -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')
@@ -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')
@@ -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)

0 comments on commit 6f2827e

Please sign in to comment.
You can’t perform that action at this time.