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:
parent
4d3962e941
commit
a75982f8ef
2 changed files with 297 additions and 5 deletions
286
src/modules/market/components/CategoryInput.vue
Normal file
286
src/modules/market/components/CategoryInput.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue