Skip to content

Commit f54ca29

Browse files
authored
fix(v-b-modal): ensure trigger element is keyboard accessible if not a link or button, for A11Y (#4365)
1 parent fbbcffb commit f54ca29

File tree

3 files changed

+71
-7
lines changed

3 files changed

+71
-7
lines changed

src/components/modal/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1020,7 +1020,7 @@ emitted.
10201020
`<b-modal>` provides several accessibility features, including auto focus, return focus, keyboard
10211021
(tab) _focus containment_, and automated `aria-*` attributes.
10221022

1023-
### ARIA attributes
1023+
### Modal ARIA attributes
10241024

10251025
The `aria-labelledby` and `aria-describedby` attributes will appear on the modal automatically in
10261026
most cases.
@@ -1158,4 +1158,15 @@ content and can make some of your elements unreachable via keyboard navigation.
11581158
In some circumstances, you may need to disable the enforce focus feature. You can do this by setting
11591159
the prop `no-enforce-focus`, although this is highly discouraged.
11601160

1161+
### `v-b-modal` directive accessibility
1162+
1163+
Notes on `v-b-modal` directive accessibility:
1164+
1165+
- If the element is anything other than a `<button>` (or component that renders a `<button>`), the
1166+
ARIA `role` will be set to `button`, and a keydown event listeners for <kbd>ENTER</kbd> and
1167+
<kbd>SPACE</kbd> will be added, along with a `click` listener.
1168+
- If the element is anything other than a `<button>` or `<a>` (or a component that renders either),
1169+
then a `tabindex` of `0` will be added to the element to ensure accessibility, unless there is
1170+
already a `tabindex` set.
1171+
11611172
<!-- Component reference added automatically from component package.json -->

src/directives/modal/modal.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import KeyCodes from '../../utils/key-codes'
12
import {
23
eventOn,
34
eventOff,
@@ -10,7 +11,6 @@ import {
1011
} from '../../utils/dom'
1112
import { isString } from '../../utils/inspect'
1213
import { keys } from '../../utils/object'
13-
import KeyCodes from '../../utils/key-codes'
1414

1515
// Emitted show event for modal
1616
const EVENT_SHOW = 'bv::show::modal'
@@ -32,9 +32,16 @@ const getTriggerElement = el => {
3232
}
3333

3434
const setRole = trigger => {
35-
// Only set a role if the trigger element doesn't have one
36-
if (trigger && trigger.tagName !== 'BUTTON' && !hasAttr(trigger, 'role')) {
37-
setAttr(trigger, 'role', 'button')
35+
// Ensure accessibility on non button elements
36+
if (trigger && trigger.tagName !== 'BUTTON') {
37+
// Only set a role if the trigger element doesn't have one
38+
if (!hasAttr(trigger, 'role')) {
39+
setAttr(trigger, 'role', 'button')
40+
}
41+
// Add a tabindex is not a button or link, and tabindex is not provided
42+
if (trigger.tagName !== 'A' && !hasAttr(trigger, 'tabindex')) {
43+
setAttr(trigger, 'tabindex', '0')
44+
}
3845
}
3946
}
4047

src/directives/modal/modal.spec.js

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe('v-b-modal directive', () => {
2626

2727
expect(wrapper.isVueInstance()).toBe(true)
2828
expect(wrapper.is('button')).toBe(true)
29+
expect(wrapper.find('button').attributes('tabindex')).not.toBeDefined()
30+
expect(wrapper.find('button').attributes('role')).not.toBeDefined()
2931
expect(spy).not.toHaveBeenCalled()
3032

3133
const $button = wrapper.find('button')
@@ -36,6 +38,48 @@ describe('v-b-modal directive', () => {
3638
wrapper.destroy()
3739
})
3840

41+
it('works on links', async () => {
42+
const localVue = new CreateLocalVue()
43+
const spy = jest.fn()
44+
45+
const App = localVue.extend({
46+
directives: {
47+
bModal: VBModal
48+
},
49+
data() {
50+
return {
51+
text: 'link'
52+
}
53+
},
54+
mounted() {
55+
this.$root.$on(EVENT_SHOW, spy)
56+
},
57+
beforeDestroy() {
58+
this.$root.$off(EVENT_SHOW, spy)
59+
},
60+
template: '<a href="#" v-b-modal.test>{{ text }}</a>'
61+
})
62+
const wrapper = mount(App, {
63+
localVue: localVue
64+
})
65+
66+
expect(wrapper.isVueInstance()).toBe(true)
67+
expect(wrapper.is('a')).toBe(true)
68+
expect(spy).not.toHaveBeenCalled()
69+
expect(wrapper.find('a').attributes('role')).toBe('button')
70+
expect(wrapper.find('a').attributes('tabindex')).not.toBeDefined()
71+
expect(wrapper.find('a').text()).toBe('link')
72+
73+
const $link = wrapper.find('a')
74+
$link.trigger('click')
75+
expect(spy).toHaveBeenCalledTimes(1)
76+
expect(spy).toBeCalledWith('test', $link.element)
77+
expect(wrapper.find('a').attributes('role')).toBe('button')
78+
expect(wrapper.find('a').attributes('tabindex')).not.toBeDefined()
79+
80+
wrapper.destroy()
81+
})
82+
3983
it('works on non-buttons', async () => {
4084
const localVue = new CreateLocalVue()
4185
const spy = jest.fn()
@@ -55,7 +99,7 @@ describe('v-b-modal directive', () => {
5599
beforeDestroy() {
56100
this.$root.$off(EVENT_SHOW, spy)
57101
},
58-
template: '<span tabindex="0" v-b-modal.test>{{ text }}</span>'
102+
template: '<span v-b-modal.test>{{ text }}</span>'
59103
})
60104
const wrapper = mount(App, {
61105
localVue: localVue
@@ -65,6 +109,7 @@ describe('v-b-modal directive', () => {
65109
expect(wrapper.is('span')).toBe(true)
66110
expect(spy).not.toHaveBeenCalled()
67111
expect(wrapper.find('span').attributes('role')).toBe('button')
112+
expect(wrapper.find('span').attributes('tabindex')).toBe('0')
68113
expect(wrapper.find('span').text()).toBe('span')
69114

70115
const $span = wrapper.find('span')
@@ -102,7 +147,7 @@ describe('v-b-modal directive', () => {
102147
beforeDestroy() {
103148
this.$root.$off(EVENT_SHOW, spy)
104149
},
105-
template: '<span tabindex="0" v-b-modal.test>{{ text }}</span>'
150+
template: '<span v-b-modal.test>{{ text }}</span>'
106151
})
107152
const wrapper = mount(App, {
108153
localVue: localVue
@@ -112,6 +157,7 @@ describe('v-b-modal directive', () => {
112157
expect(wrapper.is('span')).toBe(true)
113158
expect(spy).not.toHaveBeenCalled()
114159
expect(wrapper.find('span').attributes('role')).toBe('button')
160+
expect(wrapper.find('span').attributes('tabindex')).toBe('0')
115161
expect(wrapper.find('span').text()).toBe('span')
116162

117163
const $span = wrapper.find('span')

0 commit comments

Comments
 (0)