Skip to content
Permalink
Browse files
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>
  • Loading branch information
Hiws and jacobmllr95 committed Sep 8, 2020
1 parent f847dae commit caa0f1a2e6d96637c216eb306c77a67254af1caf
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 39 deletions.
@@ -113,8 +113,8 @@ When creating custom variants, follow the Bootstrap v4 variant CSS class naming
become available to the various components that use that scheme (i.e. create a custom CSS class
`btn-purple` and `purple` becomes a valid variant to use on `<b-button>`).

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

```scss
// Base grayscale colors definitions
@@ -21,8 +21,8 @@ button will only appear when the user has entered a new tag value.
<template>
<div>
<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>
<b-form-tags input-id="tags-basic" v-model="value"></b-form-tags>
<p class="mt-2">Value: {{ value }}</p>
</div>
</template>

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

@@ -99,9 +98,8 @@ When the prop `remove-on-delete` is set, and the user presses <kbd>Backspace</kb
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">
<b-form-text id="tags-remove-on-delete-help" class="mt-2">
Press <kbd>Backspace</kbd> to remove the last tag entered
</b-form-text>
<p>Value: {{ value }}</p>
@@ -150,9 +148,8 @@ The focus and validation state styling of the component relies upon BootstrapVue
size="lg"
separator=" "
placeholder="Enter new tags separated by space"
class="mb-2"
></b-form-tags>
<p>Value: {{ value }}</p>
<p class="mt-2">Value: {{ value }}</p>
</div>
</template>

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

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`.
<!-- b-form-tags-tags-state-event.vue -->
```

## Limiting tags

If you want to limit the amount of tags the user is able to add use the `limit` prop. When
configured, adding more tags than the `limit` allows is only possible by the `v-model`.

When the limit of tags is reached, the user is still able to type but adding more tags is disabled.
A message is shown to give the user feedback about the reached limit. This message can be configured
by the `limit-tags-text` prop. Setting it to either an empty string (`''`) or `null` will disable
the feedback.

Removing tags is unaffected by the `limit` prop.

```html
<template>
<div>
<label for="tags-limit">Enter tags</label>
<b-form-tags input-id="tags-limit" v-model="value" :limit="limit" remove-on-delete></b-form-tags>
<p class="mt-2">Value: {{ value }}</p>
</div>
</template>

<script>
export default {
data() {
return {
value: [],
limit: 5
}
}
}
</script>

<!-- b-form-tags-limit.vue -->
```

## Custom rendering with default scoped slot

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:
| `invalidTags` | Array | Array of the invalid tag(s) the user has entered |
| `isDuplicate` | Boolean | `true` if the user input contains duplicate tag(s) |
| `duplicateTags` | Array | Array of the duplicate tag(s) the user has entered |
| `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 |
| `disableAddButton` | Boolean | Will be `true` if the tag(s) in the input cannot be added (all invalid and/or duplicates) |
| `disabled` | Boolean | `true` if the component is in the disabled state. Value of the `disabled` prop |
| `state` | Boolean | The contextual state of the component. Value of the `state` prop. Possible values are `true`, `false` or `null` |
| `size` | String | The value of the `size` prop |
| `limit` | String | <span class="badge badge-secondary">v2.17.0+</span> The value of the `limit` prop |
| `separator` | String | The value of the `separator` prop |
| `placeholder` | String | The value of the `placeholder` prop |
| `tagRemoveLabel` | String | Value of the `tag-remove-label` prop. Used as the `aria-label` attribute on the remove button of tags |
| `tagVariant` | String | The value of the `tag-variant` prop |
| `tagPills` | Boolean | The value of the `tag-pills` prop |
| `tagClass` | String, Array, or Object | The value of the `tag-variant` prop. Class (or classes) to apply to the tag elements |
| `addButtonText` | String | The value of the `add-button-text` prop |
| `addButtonVariant` | String | The value of the `add-button-variant` prop |
| `invalidTagText` | String | The value of the `invalid-tag-text` prop |
| `duplicateTagText` | String | The value of the `duplicate-tag-text` prop |
| `limitTagsText` | String | <span class="badge badge-secondary">v2.17.0+</span> The value of the `limit-tags-text` prop |

#### `inputAttrs` object properties

@@ -16,7 +16,7 @@ import {
requestAF,
select
} from '../../utils/dom'
import { isEvent, isFunction, isString } from '../../utils/inspect'
import { isEvent, isFunction, isNumber, isString } from '../../utils/inspect'
import { escapeRegExp, toString, trim, trimLeft } from '../../utils/string'
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
@@ -160,6 +160,14 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
type: String,
default: () => getComponentConfig(NAME, 'invalidTagText')
},
limitTagsText: {
type: String,
default: () => getComponentConfig(NAME, 'limitTagsText')
},
limit: {
type: Number
// default: null
},
separator: {
// Character (or characters) that trigger adding tags
type: [String, Array]
@@ -288,6 +296,10 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
},
hasInvalidTags() {
return this.invalidTags.length > 0
},
isLimitReached() {
const { limit } = this
return isNumber(limit) && limit >= 0 && this.tags.length >= limit
}
},
watch: {
@@ -328,7 +340,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
addTag(newTag) {
newTag = isString(newTag) ? newTag : this.newTag
/* istanbul ignore next */
if (this.disabled || trim(newTag) === '') {
if (this.disabled || trim(newTag) === '' || this.isLimitReached) {
// Early exit
return
}
@@ -530,25 +542,27 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
// Default User Interface render
defaultRender({
tags,
addTag,
removeTag,
inputType,
inputAttrs,
inputType,
inputHandlers,
inputClass,
tagClass,
tagVariant,
tagPills,
tagRemoveLabel,
invalidTagText,
duplicateTagText,
removeTag,
addTag,
isInvalid,
isDuplicate,
isLimitReached,
disableAddButton,
disabled,
placeholder,
inputClass,
tagRemoveLabel,
tagVariant,
tagPills,
tagClass,
addButtonText,
addButtonVariant,
disableAddButton
invalidTagText,
duplicateTagText,
limitTagsText
}) {
const h = this.$createElement

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

// Compute the `aria-describedby` attribute value
const ariaDescribedby = [
inputAttrs['aria-describedby'],
invalidFeedbackId,
duplicateFeedbackId
duplicateFeedbackId,
limitFeedbackId
]
.filter(identity)
.join(' ')
@@ -623,7 +640,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
invisible: disableAddButton
},
style: { fontSize: '90%' },
props: { variant: addButtonVariant, disabled: disableAddButton },
props: { variant: addButtonVariant, disabled: disableAddButton || isLimitReached },
on: { click: () => addTag() }
},
[this.normalizeSlot('add-button-text') || addButtonText]
@@ -663,7 +680,7 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({

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

// Limit tags feedback if needed (warning, not error)
let $limit = h()
if (limitFeedbackId) {
$limit = h(
BFormText,
{
key: '_tags_limit_feedback_',
props: { id: limitFeedbackId }
},
[limitTagsText]
)
}

$feedback = h(
'div',
{
key: '_tags_feedback_',
attrs: { 'aria-live': 'polite', 'aria-atomic': 'true' }
},
[$invalid, $duplicate]
[$invalid, $duplicate, $limit]
)
}
// Return the content
@@ -712,29 +742,31 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
const scope = {
// Array of tags (shallow copy to prevent mutations)
tags: this.tags.slice(),
// Methods
removeTag: this.removeTag,
addTag: this.addTag,
// We don't include this in the attrs, as users may want to override this
inputType: this.computedInputType,
// <input> v-bind:inputAttrs
inputAttrs: this.computedInputAttrs,
// We don't include this in the attrs, as users may want to override this
inputType: this.computedInputType,
// <input> v-on:inputHandlers
inputHandlers: this.computedInputHandlers,
// Methods
removeTag: this.removeTag,
addTag: this.addTag,
// <input> :id="inputId"
inputId: this.computedInputId,
// Invalid/Duplicate state information
invalidTags: this.invalidTags.slice(),
isInvalid: this.hasInvalidTags,
duplicateTags: this.duplicateTags.slice(),
invalidTags: this.invalidTags.slice(),
isDuplicate: this.hasDuplicateTags,
duplicateTags: this.duplicateTags.slice(),
isLimitReached: this.isLimitReached,
// If the 'Add' button should be disabled
disableAddButton: this.disableAddButton,
// Pass-though values
state: this.state,
separator: this.separator,
disabled: this.disabled,
state: this.state,
size: this.size,
limit: this.limit,
separator: this.separator,
placeholder: this.placeholder,
inputClass: this.inputClass,
tagRemoveLabel: this.tagRemoveLabel,
@@ -744,7 +776,8 @@ export const BFormTags = /*#__PURE__*/ Vue.extend({
addButtonText: this.addButtonText,
addButtonVariant: this.addButtonVariant,
invalidTagText: this.invalidTagText,
duplicateTagText: this.duplicateTagText
duplicateTagText: this.duplicateTagText,
limitTagsText: this.limitTagsText
}

// Generate the user interface
@@ -601,4 +601,59 @@ describe('form-tags', () => {

wrapper.destroy()
})

it('`limit` prop works', async () => {
const wrapper = mount(BFormTags, {
propsData: {
value: ['apple', 'orange'],
limit: 3
}
})

expect(wrapper.element.tagName).toBe('DIV')
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
expect(wrapper.vm.newTag).toEqual('')

const $input = wrapper.find('input')
expect($input.exists()).toBe(true)
expect($input.element.value).toBe('')

const $button = wrapper.find('button.b-form-tags-button')
expect($button.exists()).toBe(true)
expect($button.classes()).toContain('invisible')

expect(wrapper.find('small.form-text').exists()).toBe(false)

// Add new tag
$input.element.value = 'pear'
await $input.trigger('input')
expect(wrapper.vm.newTag).toEqual('pear')
expect(wrapper.vm.tags).toEqual(['apple', 'orange'])
expect($button.classes()).not.toContain('invisible')

await $button.trigger('click')
expect($button.classes()).toContain('invisible')
expect(wrapper.vm.newTag).toEqual('')
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])

const $feedback = wrapper.find('small.form-text')
expect($feedback.exists()).toBe(true)
expect($feedback.text()).toContain('Tag limit reached')

// Attempt to add new tag
$input.element.value = 'lemon'
await $input.trigger('input')
expect(wrapper.vm.newTag).toEqual('lemon')
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])
expect($button.classes()).not.toContain('invisible')

await $button.trigger('click')
expect($button.classes()).not.toContain('invisible')
expect(wrapper.vm.newTag).toEqual('lemon')
expect(wrapper.vm.tags).toEqual(['apple', 'orange', 'pear'])
expect($feedback.exists()).toBe(true)
expect($feedback.text()).toContain('Tag limit reached')

wrapper.destroy()
})
})

0 comments on commit caa0f1a

Please sign in to comment.