Skip to content

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

festivalvip
festivalvip

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">&times;</span>
                </button>
            </span>
        </div>
    </div>
</template>