Skip to content
Permalink
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>
  • Loading branch information
tmorehouse and jacobmllr95 committed Feb 17, 2020
1 parent da5e473 commit 2328630542defc395912165a964a95107f8a4ba9
Showing 6 changed files with 135 additions and 44 deletions.
@@ -260,12 +260,12 @@ Note the `<router-link>` prop `tag` is referred to as `router-tag` in `bootstrap
## Accessibility

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

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

## See also

@@ -22,7 +22,8 @@ button will only appear when the user has entered a new tag value.
```html
<template>
<div>
<b-form-tags v-model="value" class="mb-2"></b-form-tags>
<label for="tags-basic">Type a new tag and press enter</label>
<b-form-tags input-id="tags-basic" v-model="value" class="mb-2"></b-form-tags>
<p>Value: {{ value }}</p>
</div>
</template>
@@ -57,7 +58,9 @@ are typed:
```html
<template>
<div>
<label for="tags-separators">Enter tags separated by space, comma or semicolon</label>
<b-form-tags
input-id="tags-separators"
v-model="value"
separator=" ,;"
placeholder="Enter new tags separated by space, comma or semicolon"
@@ -81,22 +84,28 @@ are typed:
<!-- form-tags-separator.vue -->
```

## Last tag removal via delete keypress
## Last tag removal via backspace keypress

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

```html
<template>
<div>
<label for="tags-remove-on-delete">Enter new tags separated by space</label>
<b-form-tags
input-id="tags-remove-on-delete"
:input-attrs="{ 'aria-describedby': 'tags-remove-on-delete-help' }"
v-model="value"
separator=" "
placeholder="Enter new tags separated by space"
remove-on-delete
no-add-on-enter
class="mb-2"
></b-form-tags>
<b-form-text id="tags-remove-on-delete-help">
Press <kbd>BACKSPACE</kbd> to remove the last tag entered
</b-form-text>
<p>Value: {{ value }}</p>
</div>
</template>
@@ -134,7 +143,9 @@ The focus and validation state styling of the component relies upon BootstrapVue
```html
<template>
<div>
<label for="tags-pills">Enter tags</label>
<b-form-tags
input-id="tags-pills"
v-model="value"
tag-variant="primary"
tag-pills
@@ -186,8 +197,10 @@ not validated.
```html
<template>
<div>
<b-form-group :state="state" label="Tags validation example">
<b-form-group :state="state" label="Tags validation example" label-for="tags-validation">
<b-form-tags
input-id="tags-validation"
:input-attrs="{ 'aria-describedby': 'tags-validation-help' }"
v-model="tags"
:state="state"
:tag-validator="tagValidator"
@@ -198,8 +211,10 @@ not validated.
You must provide at least 3 tags and no more than 8
</template>
<template v-slot:description>
Tags must be 3 to 5 characters in length and all lower
case. Enter tags separated by spaces or press enter.
<div id="tags-validation-help">
Tags must be 3 to 5 characters in length and all lower
case. Enter tags separated by spaces or press enter.
</div>
</template>
</b-form-group>
</div>
@@ -260,7 +275,9 @@ to either an empty string (`''`) or `null`.
```html
<template>
<div>
<label for="tags-state-event">Enter tags</label>
<b-form-tags
input-id="tags-state-event"
v-model="tags"
:tag-validator="validator"
placeholder="Enter tags (3-5 characters) separated by space"
@@ -1,4 +1,5 @@
import Vue from '../../utils/vue'
import KeyCodes from '../../utils/key-codes'
import { getComponentConfig } from '../../utils/config'
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
@@ -37,32 +38,49 @@ export const BFormTag = /*#__PURE__*/ Vue.extend({
}
},
methods: {
onClick() {
this.$emit('remove')
onDelete(evt) {
const { type, keyCode } = evt
if (
!this.disabled &&
(type === 'click' || (type === 'keydown' && keyCode === KeyCodes.DELETE))
) {
this.$emit('remove')
}
}
},
render(h) {
const tagId = this.safeId()
const tagLabelId = this.safeId('_taglabel_')
let $remove = h()
if (!this.disabled) {
$remove = h(BButtonClose, {
staticClass: 'b-form-tag-remove ml-1',
props: { ariaLabel: this.removeLabel },
attrs: { 'aria-controls': tagId },
on: { click: this.onClick }
attrs: {
'aria-controls': tagId,
'aria-describedby': tagLabelId,
'aria-keyshortcuts': 'Delete'
},
on: {
click: this.onDelete,
keydown: this.onDelete
}
})
}
const $tag = h(
'span',
{ staticClass: 'b-form-tag-content flex-grow-1 text-truncate' },
{
staticClass: 'b-form-tag-content flex-grow-1 text-truncate',
attrs: { id: tagLabelId }
},
this.normalizeSlot('default') || this.title || [h()]
)
return h(
BBadge,
{
staticClass: 'b-form-tag d-inline-flex align-items-baseline mw-100',
class: { disabled: this.disabled },
attrs: { id: tagId, title: this.title || null },
attrs: { id: tagId, title: this.title || null, 'aria-labelledby': tagLabelId },
props: { tag: this.tag, variant: this.variant, pill: this.pill }
},
[$tag, $remove]
@@ -1,8 +1,8 @@
// Tagged input form control
// Based loosely on https://adamwathan.me/renderless-components-in-vuejs/
import Vue from '../../utils/vue'
import identity from '../../utils/identity'
import KeyCodes from '../../utils/key-codes'
import identity from '../../utils/identity'
import looseEqual from '../../utils/loose-equal'
import { arrayIncludes, concat } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
@@ -11,10 +11,10 @@ import { isEvent, isFunction, isString } from '../../utils/inspect'
import { escapeRegExp, toString, trim, trimLeft } from '../../utils/string'
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
import { BFormTag } from './form-tag'
import { BButton } from '../button/button'
import { BFormInvalidFeedback } from '../form/form-invalid-feedback'
import { BFormText } from '../form/form-text'
import { BButton } from '../button/button'
import { BFormTag } from './form-tag'

// --- Constants ---

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

// KeyCode constants
const { ENTER, BACKSPACE, DELETE } = KeyCodes

// --- Utility methods ---

// Escape special chars in string and replace
@@ -132,6 +135,10 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
type: String,
default: () => getComponentConfig(NAME, 'tagRemoveLabel')
},
tagRemovedLabel: {
type: String,
default: () => getComponentConfig(NAME, 'tagRemovedLabel')
},
tagValidator: {
type: Function,
default: null
@@ -182,6 +189,8 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
hasFocus: false,
newTag: '',
tags: [],
// Tags that were removed
removedTags: [],
// Populated when tags are parsed
tagsState: cleanTagsState()
}
@@ -263,11 +272,16 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
value(newVal) {
this.tags = cleanTags(newVal)
},
tags(newVal) {
tags(newVal, oldVal) {
// Update the `v-model` (if it differs from the value prop)
if (!looseEqual(newVal, this.value)) {
this.$emit('input', newVal)
}
if (!looseEqual(newVal, oldVal)) {
newVal = concat(newVal).filter(identity)
oldVal = concat(oldVal).filter(identity)
this.removedTags = oldVal.filter(old => !arrayIncludes(newVal, old))
}
},
tagsState(newVal, oldVal) {
// Emit a tag-state event when the `tagsState` object changes
@@ -336,7 +350,9 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
// Or emit cancelable `BvEvent`
this.tags = this.tags.filter(t => t !== tag)
// Return focus to the input (if possible)
this.focus()
this.$nextTick(() => {
this.focus()
})
},
// --- Input element event handlers ---
onInputInput(evt) {
@@ -383,20 +399,26 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
const keyCode = evt.keyCode
const value = evt.target.value || ''
/* istanbul ignore else: testing to be added later */
if (!this.noAddOnEnter && keyCode === KeyCodes.ENTER) {
if (!this.noAddOnEnter && keyCode === ENTER) {
// Attempt to add the tag when user presses enter
evt.preventDefault()
this.addTag()
} else if (this.removeOnDelete && keyCode === KeyCodes.BACKSPACE && value === '') {
// Remove the last tag if the user pressed backspace and the input is empty
} else if (
this.removeOnDelete &&
(keyCode === BACKSPACE || keyCode === DELETE) &&
value === ''
) {
// Remove the last tag if the user pressed backspace/delete and the input is empty
evt.preventDefault()
this.tags.pop()
this.tags = this.tags.slice(0, -1)
}
},
// --- Wrapper event handlers ---
onClick(evt) {
if (!this.disabled && isEvent(evt) && evt.target === evt.currentTarget) {
this.$nextTick(this.focus)
this.$nextTick(() => {
this.focus()
})
}
},
onFocusin() {
@@ -512,7 +534,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
staticClass: 'mt-1 mr-1',
class: tagClass,
props: {
// 'BFormTag' will auto generate an ID
// `BFormTag` will auto generate an ID
// so we do not need to set the ID prop
tag: 'li',
title: tag,
@@ -591,10 +613,14 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
'li',
{
key: '__li-input__',
staticClass: 'd-inline-flex flex-grow-1 mt-1',
attrs: { role: 'group', 'aria-live': 'off', 'aria-controls': tagListId }
staticClass: 'flex-grow-1 mt-1',
attrs: {
role: 'none',
'aria-live': 'off',
'aria-controls': tagListId
}
},
[$input, $button]
[h('div', { staticClass: 'd-flex', attrs: { role: 'group' } }, [$input, $button])]
)

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

// Generate the `aria-live` region for the current value(s)
const $output = h(
'output',
{
staticClass: 'sr-only',
attrs: {
id: this.safeId('_selected-tags_'),
role: 'status',
for: this.computedInputId,
'aria-live': this.hasFocus ? 'polite' : 'off',
'aria-atomic': 'true',
'aria-relevant': 'additions text'
}
},
this.tags.join(', ')
)

// Removed tag live region
const $removed = h(
'div',
{
staticClass: 'sr-only',
attrs: {
id: this.safeId('_removed-tags_'),
role: 'status',
'aria-live': this.hasFocus ? 'assertive' : 'off',
'aria-atomic': 'true'
}
},
this.removedTags.length > 0 ? `(${this.tagRemovedLabel}) ${this.removedTags.join(', ')}` : ''
)

// Add hidden inputs for form submission
let $hidden = h()
if (this.name && !this.disabled) {
@@ -740,15 +789,16 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
attrs: {
id: this.safeId(),
role: 'group',
tabindex: this.disabled || this.noOuterFocus ? null : '-1'
tabindex: this.disabled || this.noOuterFocus ? null : '-1',
'aria-describedby': this.safeId('_selected_')
},
on: {
focusin: this.onFocusin,
focusout: this.onFocusout,
click: this.onClick
}
},
concat($content, $hidden)
concat($output, $removed, $content, $hidden)
)
}
})

0 comments on commit 2328630

Please sign in to comment.