feat: add AppTagInput multi-select tag component
This commit is contained in:
177
src/components/AppTagInput.vue
Normal file
177
src/components/AppTagInput.vue
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { X, Plus } from 'lucide-vue-next'
|
||||||
|
import { useTagsStore } from '../stores/tags'
|
||||||
|
import { computeDropdownPosition } from '../utils/dropdown'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tagsStore = useTagsStore()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const triggerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const panelRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const panelStyle = ref<Record<string, string>>({})
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const selectedTags = computed(() => {
|
||||||
|
return tagsStore.tags.filter(t => t.id && props.modelValue.includes(t.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTags = computed(() => {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return tagsStore.tags.filter(t => {
|
||||||
|
if (t.id && props.modelValue.includes(t.id)) return false
|
||||||
|
if (q && !t.name.toLowerCase().includes(q)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreateOption = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return false
|
||||||
|
return !tagsStore.tags.some(t => t.name.toLowerCase() === searchQuery.value.trim().toLowerCase())
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTag(tagId: number) {
|
||||||
|
const current = [...props.modelValue]
|
||||||
|
const index = current.indexOf(tagId)
|
||||||
|
if (index >= 0) {
|
||||||
|
current.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
current.push(tagId)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', current)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tagId: number) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndAdd() {
|
||||||
|
const name = searchQuery.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
const colors = ['#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#F59E0B', '#6B7280']
|
||||||
|
const color = colors[tagsStore.tags.length % colors.length]
|
||||||
|
const id = await tagsStore.createTag({ name, color })
|
||||||
|
if (id) {
|
||||||
|
emit('update:modelValue', [...props.modelValue, id])
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
if (!triggerRef.value) return
|
||||||
|
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 200, estimatedHeight: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
isOpen.value = true
|
||||||
|
searchQuery.value = ''
|
||||||
|
updatePosition()
|
||||||
|
nextTick(() => inputRef.value?.focus())
|
||||||
|
document.addEventListener('click', onClickOutside, true)
|
||||||
|
document.addEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.addEventListener('resize', onScrollOrResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false
|
||||||
|
document.removeEventListener('click', onClickOutside, true)
|
||||||
|
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.removeEventListener('resize', onScrollOrResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScrollOrResize() {
|
||||||
|
if (isOpen.value) updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onClickOutside, true)
|
||||||
|
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.removeEventListener('resize', onScrollOrResize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="triggerRef" class="relative">
|
||||||
|
<!-- Selected tags + add button -->
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="tag in selectedTags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-primary"
|
||||||
|
:style="{ backgroundColor: tag.color + '22', borderColor: tag.color + '44', border: '1px solid' }"
|
||||||
|
>
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
{{ tag.name }}
|
||||||
|
<button @click.stop="removeTag(tag.id!)" class="ml-0.5 hover:text-status-error">
|
||||||
|
<X class="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="isOpen ? close() : open()"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[0.6875rem] text-text-tertiary border border-border-subtle hover:text-text-secondary hover:border-border-visible transition-colors"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3" />
|
||||||
|
Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="panelRef"
|
||||||
|
:style="panelStyle"
|
||||||
|
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
|
||||||
|
>
|
||||||
|
<div class="px-2 pt-2 pb-1">
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
||||||
|
placeholder="Search or create tag..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[160px] overflow-y-auto py-1">
|
||||||
|
<div
|
||||||
|
v-for="tag in filteredTags"
|
||||||
|
:key="tag.id"
|
||||||
|
@click="toggleTag(tag.id!); searchQuery = ''"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
<span class="text-[0.75rem] text-text-primary">{{ tag.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showCreateOption"
|
||||||
|
@click="createAndAdd"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-elevated transition-colors text-accent-text"
|
||||||
|
>
|
||||||
|
<Plus class="w-3 h-3" />
|
||||||
|
<span class="text-[0.75rem]">Create "{{ searchQuery.trim() }}"</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredTags.length === 0 && !showCreateOption" class="px-3 py-3 text-center text-[0.75rem] text-text-tertiary">
|
||||||
|
No tags found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user