Skip to content

Commit 2328630

Browse files
fix(b-form-tags): improve accessibility for screen reader users (#4775)
* fix(b-form-tag): improve accessibility of individual tags * Update form-tag.js * Update form-tags.js * Update form-tags.js * Update form-tags.js * Update form-tags.js * Update config-defaults.js * Update package.json * Update form-tags.js * Update package.json * Update form-tags.js * Update form-tags.js * Update form-tags.js * Update form-tags.js * Update form-tags.js * Update form-tags.js * Update form-tag.js * Update form-tag.js * Update form-tag.js * Update form-tag.js * Update form-tags.js * Update form-tag.js * Update form-tags.js * Update form-tags.js * Update README.md * Update form-tags.js * Update README.md * Update package.json * Update form-tags.js * Update README.md * Prettify * Update README.md * Update README.md Co-authored-by: Jacob Müller <jacob.mueller.elz@gmail.com>
1 parent da5e473 commit 2328630

6 files changed

Lines changed: 135 additions & 44 deletions

File tree

src/components/button/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,12 @@ Note the `<router-link>` prop `tag` is referred to as `router-tag` in `bootstrap
260260
## Accessibility
261261

262262
When the `href` prop is set to `'#'`, `<b-button>` will render a link (`<a>`) element with attribute
263-
`role="button"` set and apropriate keydown listeners (<kbd>Enter</kbd> and <kbd>Space</kbd>) so that
264-
the link acts like a native HTML `<button>` for screen reader and keyboard-only users. When disabled,
265-
the `aria-disabled="true"` attribute will be set on the `<a>` element.
263+
`role="button"` set and appropriate keydown listeners (<kbd>Enter</kbd> and <kbd>Space</kbd>) so
264+
that the link acts like a native HTML `<button>` for screen reader and keyboard-only users. When
265+
disabled, the `aria-disabled="true"` attribute will be set on the `<a>` element.
266266

267267
When the `href` is set to any other value (or the `to` prop is used), `role="button"` will not be
268-
added, nor will the keyboad event listeners be enabled.
268+
added, nor will the keyboard event listeners be enabled.
269269

270270
## See also
271271

src/components/form-tags/README.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ button will only appear when the user has entered a new tag value.
2222
```html
2323
<template>
2424
<div>
25-
<b-form-tags v-model="value" class="mb-2"></b-form-tags>
25+
<label for="tags-basic">Type a new tag and press enter</label>
26+
<b-form-tags input-id="tags-basic" v-model="value" class="mb-2"></b-form-tags>
2627
<p>Value: {{ value }}</p>
2728
</div>
2829
</template>
@@ -57,7 +58,9 @@ are typed:
5758
```html
5859
<template>
5960
<div>
61+
<label for="tags-separators">Enter tags separated by space, comma or semicolon</label>
6062
<b-form-tags
63+
input-id="tags-separators"
6164
v-model="value"
6265
separator=" ,;"
6366
placeholder="Enter new tags separated by space, comma or semicolon"
@@ -81,22 +84,28 @@ are typed:
8184
<!-- form-tags-separator.vue -->
8285
```
8386

84-
## Last tag removal via delete keypress
87+
## Last tag removal via backspace keypress
8588

86-
When the prop `remove-on-delete` is set, and the user presses <kbd>DEL</kbd> _and_ the input value
87-
is empty, the last tag in the tag list will be removed.
89+
When the prop `remove-on-delete` is set, and the user presses <kbd>BACKSPACE</kbd> (or
90+
<kbd>DEL</kbd>) _and_ the input value is empty, the last tag in the tag list will be removed.
8891

8992
```html
9093
<template>
9194
<div>
95+
<label for="tags-remove-on-delete">Enter new tags separated by space</label>
9296
<b-form-tags
97+
input-id="tags-remove-on-delete"
98+
:input-attrs="{ 'aria-describedby': 'tags-remove-on-delete-help' }"
9399
v-model="value"
94100
separator=" "
95101
placeholder="Enter new tags separated by space"
96102
remove-on-delete
97103
no-add-on-enter
98104
class="mb-2"
99105
></b-form-tags>
106+
<b-form-text id="tags-remove-on-delete-help">
107+
Press <kbd>BACKSPACE</kbd> to remove the last tag entered
108+
</b-form-text>
100109
<p>Value: {{ value }}</p>
101110
</div>
102111
</template>
@@ -134,7 +143,9 @@ The focus and validation state styling of the component relies upon BootstrapVue
134143
```html
135144
<template>
136145
<div>
146+
<label for="tags-pills">Enter tags</label>
137147
<b-form-tags
148+
input-id="tags-pills"
138149
v-model="value"
139150
tag-variant="primary"
140151
tag-pills
@@ -186,8 +197,10 @@ not validated.
186197
```html
187198
<template>
188199
<div>
189-
<b-form-group :state="state" label="Tags validation example">
200+
<b-form-group :state="state" label="Tags validation example" label-for="tags-validation">
190201
<b-form-tags
202+
input-id="tags-validation"
203+
:input-attrs="{ 'aria-describedby': 'tags-validation-help' }"
191204
v-model="tags"
192205
:state="state"
193206
:tag-validator="tagValidator"
@@ -198,8 +211,10 @@ not validated.
198211
You must provide at least 3 tags and no more than 8
199212
</template>
200213
<template v-slot:description>
201-
Tags must be 3 to 5 characters in length and all lower
202-
case. Enter tags separated by spaces or press enter.
214+
<div id="tags-validation-help">
215+
Tags must be 3 to 5 characters in length and all lower
216+
case. Enter tags separated by spaces or press enter.
217+
</div>
203218
</template>
204219
</b-form-group>
205220
</div>
@@ -260,7 +275,9 @@ to either an empty string (`''`) or `null`.
260275
```html
261276
<template>
262277
<div>
278+
<label for="tags-state-event">Enter tags</label>
263279
<b-form-tags
280+
input-id="tags-state-event"
264281
v-model="tags"
265282
:tag-validator="validator"
266283
placeholder="Enter tags (3-5 characters) separated by space"

src/components/form-tags/form-tag.js

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Vue from '../../utils/vue'
2+
import KeyCodes from '../../utils/key-codes'
23
import { getComponentConfig } from '../../utils/config'
34
import idMixin from '../../mixins/id'
45
import normalizeSlotMixin from '../../mixins/normalize-slot'
@@ -37,32 +38,49 @@ export const BFormTag = /*#__PURE__*/ Vue.extend({
3738
}
3839
},
3940
methods: {
40-
onClick() {
41-
this.$emit('remove')
41+
onDelete(evt) {
42+
const { type, keyCode } = evt
43+
if (
44+
!this.disabled &&
45+
(type === 'click' || (type === 'keydown' && keyCode === KeyCodes.DELETE))
46+
) {
47+
this.$emit('remove')
48+
}
4249
}
4350
},
4451
render(h) {
4552
const tagId = this.safeId()
53+
const tagLabelId = this.safeId('_taglabel_')
4654
let $remove = h()
4755
if (!this.disabled) {
4856
$remove = h(BButtonClose, {
4957
staticClass: 'b-form-tag-remove ml-1',
5058
props: { ariaLabel: this.removeLabel },
51-
attrs: { 'aria-controls': tagId },
52-
on: { click: this.onClick }
59+
attrs: {
60+
'aria-controls': tagId,
61+
'aria-describedby': tagLabelId,
62+
'aria-keyshortcuts': 'Delete'
63+
},
64+
on: {
65+
click: this.onDelete,
66+
keydown: this.onDelete
67+
}
5368
})
5469
}
5570
const $tag = h(
5671
'span',
57-
{ staticClass: 'b-form-tag-content flex-grow-1 text-truncate' },
72+
{
73+
staticClass: 'b-form-tag-content flex-grow-1 text-truncate',
74+
attrs: { id: tagLabelId }
75+
},
5876
this.normalizeSlot('default') || this.title || [h()]
5977
)
6078
return h(
6179
BBadge,
6280
{
6381
staticClass: 'b-form-tag d-inline-flex align-items-baseline mw-100',
6482
class: { disabled: this.disabled },
65-
attrs: { id: tagId, title: this.title || null },
83+
attrs: { id: tagId, title: this.title || null, 'aria-labelledby': tagLabelId },
6684
props: { tag: this.tag, variant: this.variant, pill: this.pill }
6785
},
6886
[$tag, $remove]

src/components/form-tags/form-tags.js

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Tagged input form control
22
// Based loosely on https://adamwathan.me/renderless-components-in-vuejs/
33
import Vue from '../../utils/vue'
4-
import identity from '../../utils/identity'
54
import KeyCodes from '../../utils/key-codes'
5+
import identity from '../../utils/identity'
66
import looseEqual from '../../utils/loose-equal'
77
import { arrayIncludes, concat } from '../../utils/array'
88
import { getComponentConfig } from '../../utils/config'
@@ -11,10 +11,10 @@ import { isEvent, isFunction, isString } from '../../utils/inspect'
1111
import { escapeRegExp, toString, trim, trimLeft } from '../../utils/string'
1212
import idMixin from '../../mixins/id'
1313
import normalizeSlotMixin from '../../mixins/normalize-slot'
14-
import { BFormTag } from './form-tag'
14+
import { BButton } from '../button/button'
1515
import { BFormInvalidFeedback } from '../form/form-invalid-feedback'
1616
import { BFormText } from '../form/form-text'
17-
import { BButton } from '../button/button'
17+
import { BFormTag } from './form-tag'
1818

1919
// --- Constants ---
2020

@@ -26,6 +26,9 @@ const TYPES = ['text', 'email', 'tel', 'url', 'number']
2626
// Pre-compiled regular expressions for performance reasons
2727
const RX_SPACES = /[\s\uFEFF\xA0]+/g
2828

29+
// KeyCode constants
30+
const { ENTER, BACKSPACE, DELETE } = KeyCodes
31+
2932
// --- Utility methods ---
3033

3134
// Escape special chars in string and replace
@@ -132,6 +135,10 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
132135
type: String,
133136
default: () => getComponentConfig(NAME, 'tagRemoveLabel')
134137
},
138+
tagRemovedLabel: {
139+
type: String,
140+
default: () => getComponentConfig(NAME, 'tagRemovedLabel')
141+
},
135142
tagValidator: {
136143
type: Function,
137144
default: null
@@ -182,6 +189,8 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
182189
hasFocus: false,
183190
newTag: '',
184191
tags: [],
192+
// Tags that were removed
193+
removedTags: [],
185194
// Populated when tags are parsed
186195
tagsState: cleanTagsState()
187196
}
@@ -263,11 +272,16 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
263272
value(newVal) {
264273
this.tags = cleanTags(newVal)
265274
},
266-
tags(newVal) {
275+
tags(newVal, oldVal) {
267276
// Update the `v-model` (if it differs from the value prop)
268277
if (!looseEqual(newVal, this.value)) {
269278
this.$emit('input', newVal)
270279
}
280+
if (!looseEqual(newVal, oldVal)) {
281+
newVal = concat(newVal).filter(identity)
282+
oldVal = concat(oldVal).filter(identity)
283+
this.removedTags = oldVal.filter(old => !arrayIncludes(newVal, old))
284+
}
271285
},
272286
tagsState(newVal, oldVal) {
273287
// Emit a tag-state event when the `tagsState` object changes
@@ -336,7 +350,9 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
336350
// Or emit cancelable `BvEvent`
337351
this.tags = this.tags.filter(t => t !== tag)
338352
// Return focus to the input (if possible)
339-
this.focus()
353+
this.$nextTick(() => {
354+
this.focus()
355+
})
340356
},
341357
// --- Input element event handlers ---
342358
onInputInput(evt) {
@@ -383,20 +399,26 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
383399
const keyCode = evt.keyCode
384400
const value = evt.target.value || ''
385401
/* istanbul ignore else: testing to be added later */
386-
if (!this.noAddOnEnter && keyCode === KeyCodes.ENTER) {
402+
if (!this.noAddOnEnter && keyCode === ENTER) {
387403
// Attempt to add the tag when user presses enter
388404
evt.preventDefault()
389405
this.addTag()
390-
} else if (this.removeOnDelete && keyCode === KeyCodes.BACKSPACE && value === '') {
391-
// Remove the last tag if the user pressed backspace and the input is empty
406+
} else if (
407+
this.removeOnDelete &&
408+
(keyCode === BACKSPACE || keyCode === DELETE) &&
409+
value === ''
410+
) {
411+
// Remove the last tag if the user pressed backspace/delete and the input is empty
392412
evt.preventDefault()
393-
this.tags.pop()
413+
this.tags = this.tags.slice(0, -1)
394414
}
395415
},
396416
// --- Wrapper event handlers ---
397417
onClick(evt) {
398418
if (!this.disabled && isEvent(evt) && evt.target === evt.currentTarget) {
399-
this.$nextTick(this.focus)
419+
this.$nextTick(() => {
420+
this.focus()
421+
})
400422
}
401423
},
402424
onFocusin() {
@@ -512,7 +534,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
512534
staticClass: 'mt-1 mr-1',
513535
class: tagClass,
514536
props: {
515-
// 'BFormTag' will auto generate an ID
537+
// `BFormTag` will auto generate an ID
516538
// so we do not need to set the ID prop
517539
tag: 'li',
518540
title: tag,
@@ -591,10 +613,14 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
591613
'li',
592614
{
593615
key: '__li-input__',
594-
staticClass: 'd-inline-flex flex-grow-1 mt-1',
595-
attrs: { role: 'group', 'aria-live': 'off', 'aria-controls': tagListId }
616+
staticClass: 'flex-grow-1 mt-1',
617+
attrs: {
618+
role: 'none',
619+
'aria-live': 'off',
620+
'aria-controls': tagListId
621+
}
596622
},
597-
[$input, $button]
623+
[h('div', { staticClass: 'd-flex', attrs: { role: 'group' } }, [$input, $button])]
598624
)
599625

600626
// Wrap in an unordered list element (we use a list for accessibility)
@@ -603,16 +629,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
603629
{
604630
key: '_tags_list_',
605631
staticClass: 'list-unstyled mt-n1 mb-0 d-flex flex-wrap align-items-center',
606-
attrs: {
607-
id: tagListId,
608-
// Don't interrupt the user abruptly
609-
// Although maybe this should be 'assertive'
610-
// to provide immediate feedback of the tag added/removed
611-
'aria-live': 'polite',
612-
// Only read elements that have been added or removed
613-
'aria-atomic': 'false',
614-
'aria-relevant': 'additions removals'
615-
}
632+
attrs: { id: tagListId }
616633
},
617634
// `concat()` is faster than array spread when args are known to be arrays
618635
concat($tags, $field)
@@ -707,6 +724,38 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
707724
// Generate the user interface
708725
const $content = this.normalizeSlot('default', scope) || this.defaultRender(scope)
709726

727+
// Generate the `aria-live` region for the current value(s)
728+
const $output = h(
729+
'output',
730+
{
731+
staticClass: 'sr-only',
732+
attrs: {
733+
id: this.safeId('_selected-tags_'),
734+
role: 'status',
735+
for: this.computedInputId,
736+
'aria-live': this.hasFocus ? 'polite' : 'off',
737+
'aria-atomic': 'true',
738+
'aria-relevant': 'additions text'
739+
}
740+
},
741+
this.tags.join(', ')
742+
)
743+
744+
// Removed tag live region
745+
const $removed = h(
746+
'div',
747+
{
748+
staticClass: 'sr-only',
749+
attrs: {
750+
id: this.safeId('_removed-tags_'),
751+
role: 'status',
752+
'aria-live': this.hasFocus ? 'assertive' : 'off',
753+
'aria-atomic': 'true'
754+
}
755+
},
756+
this.removedTags.length > 0 ? `(${this.tagRemovedLabel}) ${this.removedTags.join(', ')}` : ''
757+
)
758+
710759
// Add hidden inputs for form submission
711760
let $hidden = h()
712761
if (this.name && !this.disabled) {
@@ -740,15 +789,16 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
740789
attrs: {
741790
id: this.safeId(),
742791
role: 'group',
743-
tabindex: this.disabled || this.noOuterFocus ? null : '-1'
792+
tabindex: this.disabled || this.noOuterFocus ? null : '-1',
793+
'aria-describedby': this.safeId('_selected_')
744794
},
745795
on: {
746796
focusin: this.onFocusin,
747797
focusout: this.onFocusout,
748798
click: this.onClick
749799
}
750800
},
751-
concat($content, $hidden)
801+
concat($output, $removed, $content, $hidden)
752802
)
753803
}
754804
})

0 commit comments

Comments
 (0)