Add CategoryInput component for category management in CreateProductDialog

- Introduced a new CategoryInput component to facilitate category selection with suggestions and popular categories.
- Updated CreateProductDialog to integrate the CategoryInput, enhancing the user experience for adding product categories.
- Improved accessibility and usability by allowing users to add categories via keyboard shortcuts and providing visual feedback for selected categories.

These changes enhance the product creation process by streamlining category management.
This commit is contained in:
padreug 2025-09-26 00:08:25 +02:00
parent 4d3962e941
commit a75982f8ef
2 changed files with 297 additions and 5 deletions

View file

@ -0,0 +1,286 @@
<template>
<div class="space-y-3">
<!-- Category Input with Suggestions -->
<div class="relative">
<div class="flex gap-2">
<Input
v-model="currentInput"
:placeholder="placeholder"
:disabled="disabled"
@keydown.enter.prevent="addCategory"
@keydown.comma.prevent="addCategory"
@input="handleInput"
@focus="showSuggestions = true"
class="flex-1"
/>
<Button
type="button"
@click="addCategory"
:disabled="disabled || !canAdd"
size="sm"
>
Add
</Button>
</div>
<!-- Category Suggestions Dropdown -->
<div
v-if="showSuggestions && filteredSuggestions.length > 0"
class="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-popover border border-border rounded-md shadow-lg"
role="listbox"
aria-label="Category suggestions"
>
<button
v-for="(suggestion, index) in filteredSuggestions"
:key="suggestion.category"
type="button"
@click="selectSuggestion(suggestion)"
@keydown.enter="selectSuggestion(suggestion)"
class="w-full px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground text-sm flex items-center justify-between"
role="option"
:aria-selected="index === selectedSuggestionIndex"
>
<span>{{ suggestion.category }}</span>
<Badge variant="secondary" class="text-xs">
{{ suggestion.count }}
</Badge>
</button>
</div>
</div>
<!-- Selected Categories -->
<div v-if="modelValue.length > 0" class="space-y-2">
<div class="text-sm font-medium text-foreground">Selected Categories:</div>
<div class="flex flex-wrap gap-2">
<Badge
v-for="(category, index) in modelValue"
:key="category"
variant="secondary"
class="flex items-center gap-1 pl-2 pr-1 py-1"
>
<span>{{ category }}</span>
<Button
type="button"
@click="removeCategory(index)"
:disabled="disabled"
variant="ghost"
size="sm"
class="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
:aria-label="`Remove ${category} category`"
>
<X class="h-3 w-3" />
</Button>
</Badge>
</div>
</div>
<!-- Popular Categories Section -->
<div v-if="showPopularCategories && popularCategories.length > 0" class="space-y-2">
<div class="text-sm font-medium text-foreground">Popular Categories:</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="category in popularCategories"
:key="category.category"
type="button"
@click="selectSuggestion(category)"
:disabled="disabled || modelValue.includes(category.category)"
variant="outline"
size="sm"
class="h-6 px-2 text-xs"
>
{{ category.category }} ({{ category.count }})
</Button>
</div>
</div>
<!-- Helper Text -->
<div class="text-xs text-muted-foreground">
Press Enter or comma to add a category. Maximum {{ maxCategories }} categories.
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-vue-next'
import { useMarketStore } from '@/modules/market/stores/market'
interface CategorySuggestion {
category: string
count: number
}
interface Props {
modelValue: string[]
disabled?: boolean
placeholder?: string
maxCategories?: number
showPopularCategories?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string[]): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
placeholder: 'Enter category name...',
maxCategories: 10,
showPopularCategories: true
})
const emit = defineEmits<Emits>()
const marketStore = useMarketStore()
// Local state
const currentInput = ref('')
const showSuggestions = ref(false)
const selectedSuggestionIndex = ref(-1)
// Get existing categories from the market store
const existingCategories = computed<CategorySuggestion[]>(() => {
const categoryMap = new Map<string, number>()
marketStore.products.forEach(product => {
product.categories?.forEach(cat => {
if (cat && cat.trim()) {
const normalizedCat = cat.toLowerCase().trim()
categoryMap.set(normalizedCat, (categoryMap.get(normalizedCat) || 0) + 1)
}
})
})
return Array.from(categoryMap.entries())
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count) // Sort by popularity
})
// Filter suggestions based on current input
const filteredSuggestions = computed(() => {
if (!currentInput.value.trim()) return []
const inputLower = currentInput.value.toLowerCase().trim()
return existingCategories.value
.filter(suggestion =>
suggestion.category.toLowerCase().includes(inputLower) &&
!props.modelValue.includes(suggestion.category)
)
.slice(0, 8) // Limit to 8 suggestions
})
// Popular categories (top 6 most used)
const popularCategories = computed(() => {
return existingCategories.value
.filter(cat => !props.modelValue.includes(cat.category))
.slice(0, 6)
})
// Check if we can add the current input
const canAdd = computed(() => {
const trimmed = currentInput.value.trim().toLowerCase()
return trimmed.length > 0 &&
!props.modelValue.some(cat => cat.toLowerCase() === trimmed) &&
props.modelValue.length < props.maxCategories
})
// Methods
const addCategory = () => {
if (!canAdd.value) return
const category = currentInput.value.trim()
const newCategories = [...props.modelValue, category]
emit('update:modelValue', newCategories)
currentInput.value = ''
showSuggestions.value = false
}
const selectSuggestion = (suggestion: CategorySuggestion) => {
if (props.modelValue.includes(suggestion.category)) return
if (props.modelValue.length >= props.maxCategories) return
const newCategories = [...props.modelValue, suggestion.category]
emit('update:modelValue', newCategories)
currentInput.value = ''
showSuggestions.value = false
}
const removeCategory = (index: number) => {
const newCategories = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', newCategories)
}
const handleInput = () => {
showSuggestions.value = true
selectedSuggestionIndex.value = -1
}
// Hide suggestions when clicking outside
const hideSuggestions = () => {
// Delay to allow click events on suggestions to fire
setTimeout(() => {
showSuggestions.value = false
}, 200)
}
// Handle keyboard navigation in suggestions
const handleKeydown = (event: KeyboardEvent) => {
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedSuggestionIndex.value = Math.min(
selectedSuggestionIndex.value + 1,
filteredSuggestions.value.length - 1
)
break
case 'ArrowUp':
event.preventDefault()
selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, 0)
break
case 'Enter':
event.preventDefault()
if (selectedSuggestionIndex.value >= 0) {
selectSuggestion(filteredSuggestions.value[selectedSuggestionIndex.value])
} else {
addCategory()
}
break
case 'Escape':
showSuggestions.value = false
break
}
}
// Watch for keyboard navigation
watch(currentInput, () => {
selectedSuggestionIndex.value = -1
})
// Setup global event listeners
nextTick(() => {
document.addEventListener('click', hideSuggestions)
document.addEventListener('keydown', handleKeydown)
})
</script>
<style scoped>
/* Add smooth transitions */
.category-suggestions-enter-active,
.category-suggestions-leave-active {
transition: all 0.2s ease;
}
.category-suggestions-enter-from,
.category-suggestions-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View file

@ -104,14 +104,19 @@
</div>
<!-- Categories -->
<FormField name="categories">
<FormField v-slot="{ componentField }" name="categories">
<FormItem>
<FormLabel>Categories</FormLabel>
<FormDescription>Add categories to help customers find your product</FormDescription>
<div class="text-center py-8 border-2 border-dashed rounded-lg">
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">Category management coming soon</p>
</div>
<FormControl>
<CategoryInput
v-bind="componentField"
:disabled="isCreating"
placeholder="Enter category (e.g., electronics, clothing, books...)"
:max-categories="10"
:show-popular-categories="true"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
@ -202,6 +207,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import CategoryInput from './CategoryInput.vue'
import {
FormControl,
FormDescription,