Добавлен компонент тегов

This commit is contained in:
brusnitsyn
2025-11-05 16:30:01 +09:00
parent c12ed783d9
commit 3e62e0f36d

View File

@@ -0,0 +1,316 @@
<script setup>
import { computed, ref, watch, nextTick } from "vue";
import Badge from "../Badge/Badge.vue";
const emits = defineEmits([
'update:modelValue',
'click',
'add',
'remove'
])
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
tag: {
type: [String, Object],
default: 'button'
},
href: {
type: String,
default: null
},
icon: {
type: Boolean,
default: false
},
block: {
type: Boolean,
default: false
},
variant: {
type: String,
default: 'default'
},
loading: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
textAlign: {
type: String,
default: 'left'
},
iconLeft: {
type: Boolean,
default: false
},
iconRight: {
type: Boolean,
default: false
},
maxWidth: {
type: [String, Number],
default: 'none'
},
placeholder: {
type: String,
default: 'Добавить тег...'
},
maxTags: {
type: Number,
default: null
},
label: {
type: String,
default: null
}
})
const inputRef = ref(null)
const inputValue = ref('')
const baseClasses = [
'relative', 'block', 'w-full', 'appearance-none', 'rounded-lg', 'text-base/6',
'sm:text-sm/6', 'border', 'px-[calc(--spacing(3.5)-1px)]', 'py-[calc(--spacing(2.5)-1px)]',
'sm:px-[calc(--spacing(3)-1px)]', 'sm:py-[calc(--spacing(1.5)-1px)]',
'text-zinc-950', 'placeholder:text-zinc-500', 'dark:text-white',
'border-zinc-950/10', 'hover:border-zinc-950/20', 'dark:border-white/10',
'dark:hover:border-white/20', 'bg-transparent', 'dark:bg-white/5',
'focus:outline-hidden', 'data-[disabled=true]:border-zinc-950/20',
'dark:data-[disabled=true]:border-white/15', 'data-[disabled=true]:text-zinc-950/35',
'dark:data-[disabled=true]:text-white/35', 'dark:data-[disabled=true]:bg-white/2.5',
'dark:data-hover:data-disabled:border-white/15'
]
const textContainerClasses = computed(() => {
let base = ['flex', 'flex-row', 'gap-x-2', 'relative']
if (props.textAlign) {
const align = {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end',
}
const textAlign = align[props.textAlign]
base = base.concat([textAlign])
}
return base
})
const classes = computed(() => {
let base = [...baseClasses]
if (props.block) {
base = base.concat(['w-full'])
}
return base
})
const textClasses = computed(() => {
let base = ['min-w-0', 'w-full']
if (props.maxWidth === 'none') {
if (props.iconLeft && props.iconRight) {
base = base.concat(['max-w-[calc(100%-8rem)]'])
} else if (props.iconLeft || props.iconRight) {
base = base.concat(['max-w-[calc(100%-1.5rem)]'])
}
} else if (props.maxWidth) {
base = base.concat([`max-w-[${props.maxWidth}]`])
}
base = base.concat(['truncate'])
return base
})
const canAddMoreTags = computed(() => {
return !props.maxTags || props.modelValue.length < props.maxTags
})
const handleClick = (event) => {
if (props.loading || props.disabled) {
event.preventDefault()
return
}
emits('click', event)
}
const addTag = (tag) => {
if (!tag || !tag.trim()) return
const trimmedTag = tag.trim()
if (!props.modelValue.includes(trimmedTag) && canAddMoreTags.value) {
const newTags = [...props.modelValue, trimmedTag]
emits('update:modelValue', newTags)
emits('add', trimmedTag)
}
inputValue.value = ''
}
const removeTag = (tag) => {
const newTags = props.modelValue.filter(t => t !== tag)
emits('update:modelValue', newTags)
emits('remove', tag)
}
const handleInputKeydown = (event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
addTag(inputValue.value)
} else if (event.key === 'Backspace' && inputValue.value === '' && props.modelValue.length > 0) {
removeTag(props.modelValue[props.modelValue.length - 1])
}
}
const handleInputBlur = () => {
if (inputValue.value.trim()) {
addTag(inputValue.value)
}
}
const focusInput = () => {
if (inputRef.value) {
inputRef.value.focus()
}
}
watch(() => props.modelValue, () => {
nextTick(() => {
if (inputRef.value) {
inputRef.value.style.width = 'auto'
inputRef.value.style.width = `${inputRef.value.scrollWidth}px`
}
})
}, { deep: true })
</script>
<template>
<div class="flex flex-col">
<span v-if="label" class="text-sm mb-0.5">
{{ label }}
</span>
<component
:is="tag"
:href="href"
@click="handleClick"
:class="classes"
:disabled="loading || disabled"
:data-disabled="loading || disabled"
v-bind="$attrs"
>
<div :class="textContainerClasses">
<!-- Спиннер загрузки -->
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<div class="flex items-center gap-x-2">
<div class="flex space-x-1">
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-1.5 h-1.5 bg-current rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
</div>
</div>
<!-- Основной контент (скрывается при loading) -->
<div
:class="[
'flex flex-wrap gap-2 items-center transition-opacity duration-200 w-full min-h-6',
loading ? 'opacity-0' : 'opacity-100'
]"
>
<!-- Иконка слева -->
<div
v-if="($slots.iconLeft || (iconLeft && $slots.icon))"
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
>
<slot name="iconLeft">
<slot name="icon" />
</slot>
</div>
<!-- Теги -->
<Badge v-for="tagItem in modelValue"
:key="tagItem" variant="primary">
{{ tagItem }}
<template #suffix>
<button
type="button"
@click.stop="removeTag(tagItem)"
class="size-4 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-600 flex items-center justify-center transition-colors"
:disabled="disabled"
>
<svg class="size-3 text-zinc-500 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
</Badge>
<!-- <div-->
<!-- v-for="tagItem in modelValue"-->
<!-- :key="tagItem"-->
<!-- class="inline-flex items-center gap-1 px-2 py-1 bg-zinc-100 dark:bg-zinc-700 rounded-md text-sm border border-zinc-200 dark:border-zinc-600"-->
<!-- >-->
<!-- <span class="text-zinc-800 dark:text-zinc-200 text-sm">{{ tagItem }}</span>-->
<!-- <button-->
<!-- type="button"-->
<!-- @click.stop="removeTag(tagItem)"-->
<!-- class="size-4 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-600 flex items-center justify-center transition-colors"-->
<!-- :disabled="disabled"-->
<!-- >-->
<!-- <svg class="size-3 text-zinc-500 dark:text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">-->
<!-- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />-->
<!-- </svg>-->
<!-- </button>-->
<!-- </div>-->
<!-- Поле ввода -->
<input
ref="inputRef"
v-model="inputValue"
@keydown="handleInputKeydown"
@blur="handleInputBlur"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled || !canAddMoreTags"
:data-disabled="disabled || !canAddMoreTags"
:class="[
'flex-1 min-w-20 bg-transparent outline-none placeholder-zinc-500 dark:placeholder-zinc-400',
'disabled:cursor-not-allowed disabled:opacity-50'
]"
type="text"
/>
<!-- Иконка справа -->
<div
v-if="$slots.iconRight || (iconRight && $slots.icon)"
class="shrink-0 size-6 stroke-zinc-500 group-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400"
>
<slot name="iconRight">
<slot name="icon" />
</slot>
</div>
</div>
</div>
</component>
</div>
</template>
<style scoped>
input {
min-width: 4rem;
}
</style>