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
normal.hidden#listname@team

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="tags" name="tags" />
</template>
vue
<script setup>
import { ref } from 'vue'
import { TagsInput } from '@tickster/ui-framework/library.mjs'

const tags = ref(['normal', '.hidden', '#listname', '@team'])
</script>

<template>
    <tags-input v-model="tags" name="tags" label="Tags" placeholder="Add a tag" />
</template>

Form submission ​

The name prop is applied to the hidden field containing the comma-separated tags. For example, name="tags" submits tags=festival,vip. The visible input is only for adding tags and has no name, so it is not submitted separately.

Tag prefixes ​

Tags starting with . use u-color-turquoise-base; tags starting with # use u-color-turquoise-lighter; tags starting with @ use u-color-turquoise-darker. The color applies to the whole pill, and the prefix is displayed separately using c-pill__prefix. Other tags keep the default appearance.

Prefixes are preserved in v-model, emitted values, and the hidden form field. This applies to text tags; input-type="number" keeps its existing numeric behavior.

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(','))
const tagColors = {
    '.': 'u-color-turquoise-base',
    '#': 'u-color-turquoise-lighter',
    '@': 'u-color-turquoise-darker',
}
const displayTags = computed(() => tags.value.map((value) => {
    const prefix = tagColors[value[0]] ? value[0] : ''

    return {
        value,
        prefix,
        text: prefix ? value.slice(1) : value,
        color: tagColors[prefix] || '',
    }
}))

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"
            :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 displayTags" :key="tag.value" class="c-pill c-pill--removable" :class="tag.color">
                <span v-if="tag.prefix" class="c-pill__prefix">{{ tag.prefix }}</span>
                <span>{{ tag.text }}</span>
                <button type="button" class="c-pill__button" :disabled="props.disabled" :aria-label="getRemoveLabel(tag.value)" @click="removeTag(tag.value)">
                    <span aria-hidden="true">&times;</span>
                </button>
            </span>
        </div>
    </div>
</template>