Tags input
This component is a Vue version of Tags, using the existing tags CSS hooks and the same add/remove interaction pattern.
Live example
Usage
vue
<script setup>
import { ref } from 'vue'
import { TagsInput } from '@tickster/ui-framework/library.mjs'
const tags = ref(['festival', 'vip'])
</script>
<template>
<tags-input v-model="tags1" name="tags" />
</template>vue
<script setup>
import { ref } from 'vue'
import { TagsInput } from '@tickster/ui-framework/library.mjs'
const tags = ref(['festival', 'vip'])
</script>
<template>
<tags-input v-model="tags2" label="Tags" placeholder="Add a tag" name="tags" />
</template>Component source
vue
<script setup>
import { computed, ref, useId, watch } from 'vue'
defineOptions({
name: 'TagsInput',
})
const props = defineProps({
modelValue: {
type: [Array, String],
default: () => [],
},
label: {
type: String,
default: '',
},
valueId: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
hideLabel: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: 'No tags added',
},
removeTagLabel: {
type: String,
default: 'Remove tag',
},
inputType: {
type: String,
default: 'text',
validator: (value) => ['text', 'number'].includes(value),
},
})
const emit = defineEmits({
'update:modelValue': (value) => Array.isArray(value),
change: (payload) => Array.isArray(payload),
'tags:change': (payload) => Array.isArray(payload),
})
const generatedId = useId()
const inputValue = ref('')
const tags = ref(normalizeTags(props.modelValue))
const inputId = computed(() => (props.valueId ? `input-${props.valueId}` : `tags-input-${generatedId}`))
const hiddenInputId = computed(() => props.valueId || null)
const hiddenInputValue = computed(() => tags.value.join(','))
watch(() => props.modelValue, (nextValue) => {
const normalizedTags = normalizeTags(nextValue)
if (!arraysEqual(tags.value, normalizedTags)) {
tags.value = normalizedTags
}
})
function normalizeTags(value) {
if (Array.isArray(value)) {
return value
.map((tag) => normalizeTagValue(tag, props.inputType))
.filter((tag) => tag !== '')
}
if (typeof value === 'string') {
return value
.split(',')
.map((tag) => normalizeTagValue(tag, 'text'))
.filter((tag) => tag !== '')
}
return []
}
function normalizeTagValue(value, inputType) {
if (value == null) {
return ''
}
if (inputType === 'number') {
const numericValue = Number.parseInt(value, 10)
return Number.isNaN(numericValue) ? '' : `${numericValue}`
}
return String(value).trim().replaceAll(',', '')
}
function arraysEqual(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function shouldCommitTag(event) {
return event.type === 'blur'
|| [' ', ',', 'Enter', 'Tab'].includes(event.data)
|| [' ', ',', 'Enter', 'Tab'].includes(event.key)
}
function commitTag(event) {
if (props.disabled || !shouldCommitTag(event) || event.isComposing) {
return
}
const normalizedValue = normalizeTagValue(inputValue.value, props.inputType)
if (normalizedValue !== '' && !tags.value.includes(normalizedValue)) {
const nextTags = [...tags.value, normalizedValue]
tags.value = nextTags
emit('update:modelValue', nextTags)
emit('change', nextTags)
emit('tags:change', nextTags)
}
inputValue.value = ''
}
function removeTag(tag) {
if (props.disabled || !tags.value.includes(tag)) {
return
}
const nextTags = tags.value.filter((value) => value !== tag)
tags.value = nextTags
emit('update:modelValue', nextTags)
emit('change', nextTags)
emit('tags:change', nextTags)
}
function getRemoveLabel(tag) {
return `${props.removeTagLabel} ${tag}`
}
</script>
<template>
<div class="c-form-control">
<label
v-if="props.label"
:for="inputId"
class="c-label"
:class="props.hideLabel ? 'u-hidden--visually' : ''">
{{ props.label }}
</label>
<input
:id="inputId"
v-model="inputValue"
class="c-tags-input u-margin-bottom--small"
:type="props.inputType"
:name="props.name ? `${props.name}__input` : null"
:placeholder="props.placeholder"
:disabled="props.disabled"
@input="commitTag($event)"
@blur="commitTag($event)"
@keydown.enter.prevent="commitTag($event)"
@keydown.tab="commitTag($event)" />
<input
v-if="props.name || hiddenInputId"
type="hidden"
:id="hiddenInputId"
:name="props.name || null"
:value="hiddenInputValue">
<span v-if="tags.length === 0" class="c-note">{{ props.emptyText }}</span>
<div v-else class="c-tags-container">
<span v-for="tag in tags" :key="tag" class="c-pill c-pill--removable">
<span>{{ tag }}</span>
<button type="button" class="c-pill__button" :disabled="props.disabled" :aria-label="getRemoveLabel(tag)" @click="removeTag(tag)">
<span aria-hidden="true">×</span>
</button>
</span>
</div>
</div>
</template>