Implement AND/OR filter logic in useCategoryFilter and update MarketPage UI

- Added support for AND/OR filtering modes in the `useCategoryFilter` composable, allowing users to filter products based on all or any selected categories.
- Introduced reactive state management for filter mode and updated the filtering logic to accommodate the new functionality.
- Enhanced the MarketPage UI with a toggle for selecting filter modes, improving user experience and accessibility.
- Updated ARIA attributes for better screen reader support in the filter mode selection.

These changes significantly enhance the category filtering capabilities, providing users with more control over product visibility.

Refactor CreateProductDialog and MarketPage for improved category handling

- Updated CreateProductDialog to utilize `model-value` and `@update:model-value` for the CategoryInput component, enhancing reactivity in category selection.
- Enhanced MarketPage filtering logic to support AND/OR modes, allowing for more flexible product filtering based on selected categories.
- Improved category normalization and matching logic to ensure accurate filtering results.

These changes streamline the category management and filtering processes, providing users with a more intuitive experience when creating and finding products.
This commit is contained in:
padreug 2025-09-26 00:03:23 +02:00
parent bb761abe75
commit 39ecba581f
3 changed files with 106 additions and 24 deletions

View file

@ -105,13 +105,14 @@
</div>
<!-- Categories -->
<FormField v-slot="{ componentField }" name="categories">
<FormField v-slot="{ value, handleChange }" name="categories">
<FormItem>
<FormLabel>Categories</FormLabel>
<FormDescription>Add categories to help customers find your product</FormDescription>
<FormControl>
<CategoryInput
v-bind="componentField"
:model-value="value || []"
@update:model-value="handleChange"
:disabled="isCreating"
placeholder="Enter category (e.g., electronics, clothing, books...)"
:max-categories="10"

View file

@ -11,6 +11,7 @@ export interface CategoryFilterOptions {
caseSensitive?: boolean
includeEmpty?: boolean
minCount?: number
mode?: 'any' | 'all' // OR vs AND logic
}
/**
@ -24,6 +25,9 @@ export function useCategoryFilter(
// Use Set for O(1) lookups instead of array
const selectedCategories = ref<Set<string>>(new Set())
// Filter mode state (reactive)
const filterMode = ref<'any' | 'all'>(options.mode || 'any')
// Computed property for all available categories with counts
const allCategories = computed<CategoryItem[]>(() => {
const categoryMap = new Map<string, number>()
@ -49,7 +53,7 @@ export function useCategoryFilter(
.sort((a, b) => b.count - a.count) // Sort by popularity
})
// Optimized product filtering with early returns and efficient lookups
// Optimized product filtering with AND/OR logic
const filteredProducts = computed<Product[]>(() => {
const selectedSet = selectedCategories.value
@ -58,26 +62,34 @@ export function useCategoryFilter(
return products.value
}
// Convert selected categories to array for faster iteration in some cases
const selectedArray = Array.from(selectedSet)
return products.value.filter(product => {
// Handle empty categories
if (!product.categories?.length) {
return options.includeEmpty || false
}
// Check if product has any selected category (optimized)
for (const cat of product.categories) {
if (cat && cat.trim()) {
const normalizedCategory = options.caseSensitive ? cat : cat.toLowerCase()
if (selectedSet.has(normalizedCategory)) {
return true // Early return on first match
}
}
// Normalize product categories
const productCategories = product.categories
.filter(cat => cat && cat.trim())
.map(cat => options.caseSensitive ? cat : cat.toLowerCase())
if (productCategories.length === 0) {
return options.includeEmpty || false
}
return false
// Count matches between product categories and selected categories
const matchingCategories = productCategories.filter(cat =>
selectedSet.has(cat)
)
// Apply AND/OR logic
if (filterMode.value === 'all') {
// AND logic: Product must have ALL selected categories
return matchingCategories.length === selectedSet.size
} else {
// OR logic: Product must have ANY selected category
return matchingCategories.length > 0
}
})
})
@ -139,6 +151,14 @@ export function useCategoryFilter(
return selectedCategories.value.has(normalizedCategory)
}
const setFilterMode = (mode: 'any' | 'all') => {
filterMode.value = mode
}
const toggleFilterMode = () => {
filterMode.value = filterMode.value === 'any' ? 'all' : 'any'
}
// Category statistics
const categoryStats = computed(() => ({
totalCategories: allCategories.value.length,
@ -150,6 +170,7 @@ export function useCategoryFilter(
return {
// State (readonly to prevent external mutation)
selectedCategories: readonly(selectedCategories),
filterMode: readonly(filterMode),
allCategories,
filteredProducts,
selectedCount,
@ -163,7 +184,9 @@ export function useCategoryFilter(
removeCategory,
clearAllCategories,
selectMultipleCategories,
isSelected
isSelected,
setFilterMode,
toggleFilterMode
}
}

View file

@ -61,9 +61,42 @@
aria-labelledby="category-filters-heading"
>
<div class="flex items-center justify-between mb-3">
<h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700">
Browse by Category
</h3>
<div class="flex items-center gap-4">
<h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700">
Browse by Category
</h3>
<!-- AND/OR Filter Mode Toggle -->
<div
v-if="selectedCategoriesCount > 1"
class="flex items-center gap-2"
role="group"
aria-label="Filter mode selection"
>
<span class="text-xs text-muted-foreground">Match:</span>
<Button
@click="setFilterMode('any')"
:variant="filterMode === 'any' ? 'default' : 'outline'"
size="sm"
class="h-6 px-2 text-xs"
:aria-pressed="filterMode === 'any'"
aria-label="Show products with any selected category"
>
Any
</Button>
<Button
@click="setFilterMode('all')"
:variant="filterMode === 'all' ? 'default' : 'outline'"
size="sm"
class="h-6 px-2 text-xs"
:aria-pressed="filterMode === 'all'"
aria-label="Show products with all selected categories"
>
All
</Button>
</div>
</div>
<Button
v-if="selectedCategoriesCount > 0"
@click="clearAllCategoryFilters"
@ -145,7 +178,11 @@
<div class="flex items-center gap-2">
<Filter class="w-4 h-4 text-accent-foreground/80" aria-hidden="true" />
<span class="text-sm font-medium text-accent-foreground">
Active Filters:
Active Filters
<span v-if="selectedCategoriesCount > 1" class="text-xs opacity-75">
({{ filterMode === 'any' ? 'Any' : 'All' }} match):
</span>
<span v-else>:</span>
</span>
<div class="flex gap-1" role="list" aria-label="Selected category filters">
<Badge
@ -224,8 +261,11 @@ const {
selectedCount: selectedCategoriesCount,
selectedCategoryNames: selectedCategories,
hasActiveFilters,
filterMode,
toggleCategory,
clearAllCategories: clearAllCategoryFilters,
setFilterMode,
toggleFilterMode,
categoryStats
} = useCategoryFilter(computed(() => marketStore.products))
@ -277,17 +317,35 @@ const productsToDisplay = computed(() => {
? searchResults.value
: marketStore.products
// Apply category filtering using our composable
// Apply category filtering using our composable's proper AND/OR logic
if (!hasActiveFilters.value) {
return baseProducts
}
// Filter base products by selected categories
// Use the composable's filtering logic which supports AND/OR modes
return baseProducts.filter(product => {
if (!product.categories?.length) return false
return product.categories.some(cat =>
selectedCategories.value.includes(cat.toLowerCase())
// Normalize product categories
const productCategories = product.categories
.filter(cat => cat && cat.trim())
.map(cat => cat.toLowerCase())
if (productCategories.length === 0) return false
// Count matches between product categories and selected categories
const matchingCategories = productCategories.filter(cat =>
selectedCategories.value.includes(cat)
)
// Apply AND/OR logic based on filter mode
if (filterMode.value === 'all') {
// AND logic: Product must have ALL selected categories
return matchingCategories.length === selectedCategories.value.length
} else {
// OR logic: Product must have ANY selected category
return matchingCategories.length > 0
}
})
})