Skip to content

Commit caa0f1a

Browse files
Hiwsjacobmllr95
andauthored
feat(b-tags): add limit prop (#5543)
* Initial * update prop version * update form-tags.js * add limit prop test * Final tweaks * Update _variables.scss * Document `limit` prop * Update README.md Co-authored-by: Jacob Müller <jacob.mueller.elz@gmail.com>
1 parent f847dae commit caa0f1a

File tree

7 files changed

+177
-39
lines changed

7 files changed

+177
-39
lines changed

docs/markdown/reference/color-variants/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ When creating custom variants, follow the Bootstrap v4 variant CSS class naming
113113
become available to the various components that use that scheme (i.e. create a custom CSS class
114114
`btn-purple` and `purple` becomes a valid variant to use on `<b-button>`).
115115

116-
Alternatively, you can create new variant theme colors by supplying custom Bootstrap SCSS theme color
117-
maps. The default theme color map is (from `bootstrap/scss/_variables.scss`):
116+
Alternatively, you can create new variant theme colors by supplying custom Bootstrap SCSS theme
117+
color maps. The default theme color map is (from `bootstrap/scss/_variables.scss`):
118118

119119
```scss
120120
// Base grayscale colors definitions

src/components/form-tags/README.md

+47-9
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ button will only appear when the user has entered a new tag value.
2121
<template>
2222
<div>
2323
<label for="tags-basic">Type a new tag and press enter</label>
24-
<b-form-tags input-id="tags-basic" v-model="value" class="mb-2"></b-form-tags>
25-
<p>Value: {{ value }}</p>
24+
<b-form-tags input-id="tags-basic" v-model="value"></b-form-tags>
25+
<p class="mt-2">Value: {{ value }}</p>
2626
</div>
2727
</template>
2828

@@ -63,9 +63,8 @@ are typed:
6363
separator=" ,;"
6464
placeholder="Enter new tags separated by space, comma or semicolon"
6565
no-add-on-enter
66-
class="mb-2"
6766
></b-form-tags>
68-
<p>Value: {{ value }}</p>
67+
<p class="mt-2">Value: {{ value }}</p>
6968
</div>
7069
</template>
7170

@@ -99,9 +98,8 @@ When the prop `remove-on-delete` is set, and the user presses <kbd>Backspace</kb
9998
placeholder="Enter new tags separated by space"
10099
remove-on-delete
101100
no-add-on-enter
102-
class="mb-2"
103101
></b-form-tags>
104-
<b-form-text id="tags-remove-on-delete-help">
102+
<b-form-text id="tags-remove-on-delete-help" class="mt-2">
105103
Press <kbd>Backspace</kbd> to remove the last tag entered
106104
</b-form-text>
107105
<p>Value: {{ value }}</p>
@@ -150,9 +148,8 @@ The focus and validation state styling of the component relies upon BootstrapVue
150148
size="lg"
151149
separator=" "
152150
placeholder="Enter new tags separated by space"
153-
class="mb-2"
154151
></b-form-tags>
155-
<p>Value: {{ value }}</p>
152+
<p class="mt-2">Value: {{ value }}</p>
156153
</div>
157154
</template>
158155

@@ -186,7 +183,7 @@ duplicate tag, and will provide integrated feedback to the user.
186183
You can optionally provide a tag validator method via the `tag-validator` prop. The validator
187184
function will receive one argument which is the tag being added, and should return either `true` if
188185
the tag passes validation and can be added, or `false` if the tag fails validation (in which case it
189-
is not added to the array of tags). integrated feedback will be provided to the user listing the
186+
is not added to the array of tags). Integrated feedback will be provided to the user listing the
190187
invalid tag(s) that could not be added.
191188

192189
Tag validation occurs only for tags added via user input. Changes to the tags via the `v-model` are
@@ -318,6 +315,41 @@ to either an empty string (`''`) or `null`.
318315
<!-- b-form-tags-tags-state-event.vue -->
319316
```
320317

318+
## Limiting tags
319+
320+
If you want to limit the amount of tags the user is able to add use the `limit` prop. When
321+
configured, adding more tags than the `limit` allows is only possible by the `v-model`.
322+
323+
When the limit of tags is reached, the user is still able to type but adding more tags is disabled.
324+
A message is shown to give the user feedback about the reached limit. This message can be configured
325+
by the `limit-tags-text` prop. Setting it to either an empty string (`''`) or `null` will disable
326+
the feedback.
327+
328+
Removing tags is unaffected by the `limit` prop.
329+
330+
```html
331+
<template>
332+
<div>
333+
<label for="tags-limit">Enter tags</label>
334+
<b-form-tags input-id="tags-limit" v-model="value" :limit="limit" remove-on-delete></b-form-tags>
335+
<p class="mt-2">Value: {{ value }}</p>
336+
</div>
337+
</template>
338+
339+
<script>
340+
export default {
341+
data() {
342+
return {
343+
value: [],
344+
limit: 5
345+
}
346+
}
347+
}
348+
</script>
349+
350+
<!-- b-form-tags-limit.vue -->
351+
```
352+
321353
## Custom rendering with default scoped slot
322354

323355
If you fancy a different look and feel for the tags control, you can provide your own custom
@@ -344,17 +376,23 @@ The default slot scope properties are as follows:
344376
| `invalidTags` | Array | Array of the invalid tag(s) the user has entered |
345377
| `isDuplicate` | Boolean | `true` if the user input contains duplicate tag(s) |
346378
| `duplicateTags` | Array | Array of the duplicate tag(s) the user has entered |
379+
| `isLimitReached` | Boolean | <span class="badge badge-secondary">v2.17.0+</span> `true` if a `limit` is configured and the amount of tags has reached the limit |
347380
| `disableAddButton` | Boolean | Will be `true` if the tag(s) in the input cannot be added (all invalid and/or duplicates) |
348381
| `disabled` | Boolean | `true` if the component is in the disabled state. Value of the `disabled` prop |
349382
| `state` | Boolean | The contextual state of the component. Value of the `state` prop. Possible values are `true`, `false` or `null` |
350383
| `size` | String | The value of the `size` prop |
384+
| `limit` | String | <span class="badge badge-secondary">v2.17.0+</span> The value of the `limit` prop |
351385
| `separator` | String | The value of the `separator` prop |
352386
| `placeholder` | String | The value of the `placeholder` prop |
353387
| `tagRemoveLabel` | String | Value of the `tag-remove-label` prop. Used as the `aria-label` attribute on the remove button of tags |
354388
| `tagVariant` | String | The value of the `tag-variant` prop |
389+
| `tagPills` | Boolean | The value of the `tag-pills` prop |
355390
| `tagClass` | String, Array, or Object | The value of the `tag-variant` prop. Class (or classes) to apply to the tag elements |
356391
| `addButtonText` | String | The value of the `add-button-text` prop |
357392
| `addButtonVariant` | String | The value of the `add-button-variant` prop |
393+
| `invalidTagText` | String | The value of the `invalid-tag-text` prop |
394+
| `duplicateTagText` | String | The value of the `duplicate-tag-text` prop |
395+
| `limitTagsText` | String | <span class="badge badge-secondary">v2.17.0+</span> The value of the `limit-tags-text` prop |
358396

359397
#### `inputAttrs` object properties
360398

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

+60-27
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
requestAF,
1717
select
1818
} from '../../utils/dom'
19-
import { isEvent, isFunction, isString } from '../../utils/inspect'
19+
import { isEvent, isFunction, isNumber, isString } from '../../utils/inspect'
2020
import { escapeRegExp, toString, trim, trimLeft } from '../../utils/string'
2121
import idMixin from '../../mixins/id'
2222
import normalizeSlotMixin from '../../mixins/normalize-slot'
@@ -160,6 +160,14 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
160160
type: String,
161161
default: () => getComponentConfig(NAME, 'invalidTagText')
162162
},
163+
limitTagsText: {
164+
type: String,
165+
default: () => getComponentConfig(NAME, 'limitTagsText')
166+
},
167+
limit: {
168+
type: Number
169+
// default: null
170+
},
163171
separator: {
164172
// Character (or characters) that trigger adding tags
165173
type: [String, Array]
@@ -288,6 +296,10 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
288296
},
289297
hasInvalidTags() {
290298
return this.invalidTags.length > 0
299+
},
300+
isLimitReached() {
301+
const { limit } = this
302+
return isNumber(limit) && limit >= 0 && this.tags.length >= limit
291303
}
292304
},
293305
watch: {
@@ -328,7 +340,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
328340
addTag(newTag) {
329341
newTag = isString(newTag) ? newTag : this.newTag
330342
/* istanbul ignore next */
331-
if (this.disabled || trim(newTag) === '') {
343+
if (this.disabled || trim(newTag) === '' || this.isLimitReached) {
332344
// Early exit
333345
return
334346
}
@@ -530,25 +542,27 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
530542
// Default User Interface render
531543
defaultRender({
532544
tags,
533-
addTag,
534-
removeTag,
535-
inputType,
536545
inputAttrs,
546+
inputType,
537547
inputHandlers,
538-
inputClass,
539-
tagClass,
540-
tagVariant,
541-
tagPills,
542-
tagRemoveLabel,
543-
invalidTagText,
544-
duplicateTagText,
548+
removeTag,
549+
addTag,
545550
isInvalid,
546551
isDuplicate,
552+
isLimitReached,
553+
disableAddButton,
547554
disabled,
548555
placeholder,
556+
inputClass,
557+
tagRemoveLabel,
558+
tagVariant,
559+
tagPills,
560+
tagClass,
549561
addButtonText,
550562
addButtonVariant,
551-
disableAddButton
563+
invalidTagText,
564+
duplicateTagText,
565+
limitTagsText
552566
}) {
553567
const h = this.$createElement
554568

@@ -581,12 +595,15 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
581595
invalidTagText && isInvalid ? this.safeId('__invalid_feedback__') : null
582596
const duplicateFeedbackId =
583597
duplicateTagText && isDuplicate ? this.safeId('__duplicate_feedback__') : null
598+
const limitFeedbackId =
599+
limitTagsText && isLimitReached ? this.safeId('__limit_feedback__') : null
584600

585601
// Compute the `aria-describedby` attribute value
586602
const ariaDescribedby = [
587603
inputAttrs['aria-describedby'],
588604
invalidFeedbackId,
589-
duplicateFeedbackId
605+
duplicateFeedbackId,
606+
limitFeedbackId
590607
]
591608
.filter(identity)
592609
.join(' ')
@@ -623,7 +640,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
623640
invisible: disableAddButton
624641
},
625642
style: { fontSize: '90%' },
626-
props: { variant: addButtonVariant, disabled: disableAddButton },
643+
props: { variant: addButtonVariant, disabled: disableAddButton || isLimitReached },
627644
on: { click: () => addTag() }
628645
},
629646
[this.normalizeSlot('add-button-text') || addButtonText]
@@ -663,7 +680,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
663680

664681
// Assemble the feedback
665682
let $feedback = h()
666-
if (invalidTagText || duplicateTagText) {
683+
if (invalidTagText || duplicateTagText || limitTagsText) {
667684
// Add an aria live region for the invalid/duplicate tag
668685
// messages if the user has not disabled the messages
669686
const joiner = this.computedJoiner
@@ -694,13 +711,26 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
694711
)
695712
}
696713

714+
// Limit tags feedback if needed (warning, not error)
715+
let $limit = h()
716+
if (limitFeedbackId) {
717+
$limit = h(
718+
BFormText,
719+
{
720+
key: '_tags_limit_feedback_',
721+
props: { id: limitFeedbackId }
722+
},
723+
[limitTagsText]
724+
)
725+
}
726+
697727
$feedback = h(
698728
'div',
699729
{
700730
key: '_tags_feedback_',
701731
attrs: { 'aria-live': 'polite', 'aria-atomic': 'true' }
702732
},
703-
[$invalid, $duplicate]
733+
[$invalid, $duplicate, $limit]
704734
)
705735
}
706736
// Return the content
@@ -712,29 +742,31 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
712742
const scope = {
713743
// Array of tags (shallow copy to prevent mutations)
714744
tags: this.tags.slice(),
715-
// Methods
716-
removeTag: this.removeTag,
717-
addTag: this.addTag,
718-
// We don't include this in the attrs, as users may want to override this
719-
inputType: this.computedInputType,
720745
// <input> v-bind:inputAttrs
721746
inputAttrs: this.computedInputAttrs,
747+
// We don't include this in the attrs, as users may want to override this
748+
inputType: this.computedInputType,
722749
// <input> v-on:inputHandlers
723750
inputHandlers: this.computedInputHandlers,
751+
// Methods
752+
removeTag: this.removeTag,
753+
addTag: this.addTag,
724754
// <input> :id="inputId"
725755
inputId: this.computedInputId,
726756
// Invalid/Duplicate state information
727-
invalidTags: this.invalidTags.slice(),
728757
isInvalid: this.hasInvalidTags,
729-
duplicateTags: this.duplicateTags.slice(),
758+
invalidTags: this.invalidTags.slice(),
730759
isDuplicate: this.hasDuplicateTags,
760+
duplicateTags: this.duplicateTags.slice(),
761+
isLimitReached: this.isLimitReached,
731762
// If the 'Add' button should be disabled
732763
disableAddButton: this.disableAddButton,
733764
// Pass-though values
734-
state: this.state,
735-
separator: this.separator,
736765
disabled: this.disabled,
766+
state: this.state,
737767
size: this.size,
768+
limit: this.limit,
769+
separator: this.separator,
738770
placeholder: this.placeholder,
739771
inputClass: this.inputClass,
740772
tagRemoveLabel: this.tagRemoveLabel,
@@ -744,7 +776,8 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
744776
addButtonText: this.addButtonText,
745777
addButtonVariant: this.addButtonVariant,
746778
invalidTagText: this.invalidTagText,
747-
duplicateTagText: this.duplicateTagText
779+
duplicateTagText: this.duplicateTagText,
780+
limitTagsText: this.limitTagsText
748781
}
749782

750783
// Generate the user interface

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

+55
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,59 @@ describe('form-tags', () => {
601601

602602
wrapper.destroy()
603603
})
604+
605+
it('`limit` prop works', async () => {
606+
const wrapper = mount(BFormTags, {
607+
propsData: {
608+
value: ['apple', 'orange'],
609+
limit: 3
610+
}
611+
})
612+
613+
expect(wrapper.element.tagName).toBe('DIV')
614+
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
615+
expect(wrapper.vm.newTag).toEqual('')
616+
617+
const $input = wrapper.find('input')
618+
expect($input.exists()).toBe(true)
619+
expect($input.element.value).toBe('')
620+
621+
const $button = wrapper.find('button.b-form-tags-button')
622+
expect($button.exists()).toBe(true)
623+
expect($button.classes()).toContain('invisible')
624+
625+
expect(wrapper.find('small.form-text').exists()).toBe(false)
626+
627+
// Add new tag
628+
$input.element.value = 'pear'
629+
await $input.trigger('input')
630+
expect(wrapper.vm.newTag).toEqual('pear')
631+
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
632+
expect($button.classes()).not.toContain('invisible')
633+
634+
await $button.trigger('click')
635+
expect($button.classes()).toContain('invisible')
636+
expect(wrapper.vm.newTag).toEqual('')
637+
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])
638+
639+
const $feedback = wrapper.find('small.form-text')
640+
expect($feedback.exists()).toBe(true)
641+
expect($feedback.text()).toContain('Tag limit reached')
642+
643+
// Attempt to add new tag
644+
$input.element.value = 'lemon'
645+
await $input.trigger('input')
646+
expect(wrapper.vm.newTag).toEqual('lemon')
647+
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])
648+
expect($button.classes()).not.toContain('invisible')
649+
650+
await $button.trigger('click')
651+
expect($button.classes()).not.toContain('invisible')
652+
expect(wrapper.vm.newTag).toEqual('lemon')
653+
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])
654+
expect($feedback.exists()).toBe(true)
655+
expect($feedback.text()).toContain('Tag limit reached')
656+
657+
wrapper.destroy()
658+
})
604659
})

0 commit comments

Comments
 (0)