11// Tagged input form control
22// Based loosely on https://adamwathan.me/renderless-components-in-vuejs/
33import Vue from '../../utils/vue'
4- import identity from '../../utils/identity'
54import KeyCodes from '../../utils/key-codes'
5+ import identity from '../../utils/identity'
66import looseEqual from '../../utils/loose-equal'
77import { arrayIncludes , concat } from '../../utils/array'
88import { getComponentConfig } from '../../utils/config'
@@ -11,10 +11,10 @@ import { isEvent, isFunction, isString } from '../../utils/inspect'
1111import { escapeRegExp , toString , trim , trimLeft } from '../../utils/string'
1212import idMixin from '../../mixins/id'
1313import normalizeSlotMixin from '../../mixins/normalize-slot'
14- import { BFormTag } from './form-tag '
14+ import { BButton } from '../button/button '
1515import { BFormInvalidFeedback } from '../form/form-invalid-feedback'
1616import { 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
2727const 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