Добавлен компонент тегов
This commit is contained in:
316
resources/js/Components/Input/TagsInput.vue
Normal file
316
resources/js/Components/Input/TagsInput.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user