Добавлен компонент тегов
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