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 numberOriginal file lineDiff line numberDiff line change
@@ -1020,7 +1020,7 @@ emitted.
1020
`<b-modal>` provides several accessibility features, including auto focus, return focus, keyboard
1020
`<b-modal>` provides several accessibility features, including auto focus, return focus, keyboard
1021
(tab) _focus containment_, and automated `aria-*` attributes.
1021
(tab) _focus containment_, and automated `aria-*` attributes.
1022

1022

1023-
### ARIA attributes
1023+
### Modal ARIA attributes
1024

1024

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

1160

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+
1161
<!-- Component reference added automatically from component package.json -->
1172
<!-- Component reference added automatically from component package.json -->

src/directives/modal/modal.js

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

14

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

33

34
const setRole = trigger => {
34
const setRole = trigger => {
35-
// Only set a role if the trigger element doesn't have one
35+
// Ensure accessibility on non button elements
36-
if (trigger && trigger.tagName !== 'BUTTON' && !hasAttr(trigger, 'role')) {
36+
if (trigger && trigger.tagName !== 'BUTTON') {
37-
setAttr(trigger, 'role', '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+
}
38
}
45
}
39
}
46
}
40

47

src/directives/modal/modal.spec.js

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

26

27
expect(wrapper.isVueInstance()).toBe(true)
27
expect(wrapper.isVueInstance()).toBe(true)
28
expect(wrapper.is('button')).toBe(true)
28
expect(wrapper.is('button')).toBe(true)
29+
expect(wrapper.find('button').attributes('tabindex')).not.toBeDefined()
30+
expect(wrapper.find('button').attributes('role')).not.toBeDefined()
29
expect(spy).not.toHaveBeenCalled()
31
expect(spy).not.toHaveBeenCalled()
30

32

31
const $button = wrapper.find('button')
33
const $button = wrapper.find('button')
@@ -36,6 +38,48 @@ describe('v-b-modal directive', () => {
36
wrapper.destroy()
38
wrapper.destroy()
37
})
39
})
38

40

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+
39
it('works on non-buttons', async () => {
83
it('works on non-buttons', async () => {
40
const localVue = new CreateLocalVue()
84
const localVue = new CreateLocalVue()
41
const spy = jest.fn()
85
const spy = jest.fn()
@@ -55,7 +99,7 @@ describe('v-b-modal directive', () => {
55
beforeDestroy() {
99
beforeDestroy() {
56
this.$root.$off(EVENT_SHOW, spy)
100
this.$root.$off(EVENT_SHOW, spy)
57
},
101
},
58-
template: '<span tabindex="0" v-b-modal.test>{{ text }}</span>'
102+
template: '<span v-b-modal.test>{{ text }}</span>'
59
})
103
})
60
const wrapper = mount(App, {
104
const wrapper = mount(App, {
61
localVue: localVue
105
localVue: localVue
@@ -65,6 +109,7 @@ describe('v-b-modal directive', () => {
65
expect(wrapper.is('span')).toBe(true)
109
expect(wrapper.is('span')).toBe(true)
66
expect(spy).not.toHaveBeenCalled()
110
expect(spy).not.toHaveBeenCalled()
67
expect(wrapper.find('span').attributes('role')).toBe('button')
111
expect(wrapper.find('span').attributes('role')).toBe('button')
112+
expect(wrapper.find('span').attributes('tabindex')).toBe('0')
68
expect(wrapper.find('span').text()).toBe('span')
113
expect(wrapper.find('span').text()).toBe('span')
69

114

70
const $span = wrapper.find('span')
115
const $span = wrapper.find('span')
@@ -102,7 +147,7 @@ describe('v-b-modal directive', () => {
102
beforeDestroy() {
147
beforeDestroy() {
103
this.$root.$off(EVENT_SHOW, spy)
148
this.$root.$off(EVENT_SHOW, spy)
104
},
149
},
105-
template: '<span tabindex="0" v-b-modal.test>{{ text }}</span>'
150+
template: '<span v-b-modal.test>{{ text }}</span>'
106
})
151
})
107
const wrapper = mount(App, {
152
const wrapper = mount(App, {
108
localVue: localVue
153
localVue: localVue
@@ -112,6 +157,7 @@ describe('v-b-modal directive', () => {
112
expect(wrapper.is('span')).toBe(true)
157
expect(wrapper.is('span')).toBe(true)
113
expect(spy).not.toHaveBeenCalled()
158
expect(spy).not.toHaveBeenCalled()
114
expect(wrapper.find('span').attributes('role')).toBe('button')
159
expect(wrapper.find('span').attributes('role')).toBe('button')
160+
expect(wrapper.find('span').attributes('tabindex')).toBe('0')
115
expect(wrapper.find('span').text()).toBe('span')
161
expect(wrapper.find('span').text()).toBe('span')
116

162

117
const $span = wrapper.find('span')
163
const $span = wrapper.find('span')

0 commit comments

Comments
 (0)