From 03ca7525afdda7080ca7fd54aa6d6bd1a591e2da Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 25 Sep 2025 23:51:32 +0200 Subject: [PATCH] Add useCategoryFilter composable and enhance MarketPage for category filtering - Introduced a new `useCategoryFilter` composable to manage category filtering functionality, providing reactive state management and optimized performance for product filtering based on selected categories. - Updated `MarketPage` to integrate the new composable, enhancing the UI with improved accessibility features, including ARIA roles and labels for category filters and active filters summary. - Refactored category filtering logic to streamline product display based on selected categories, improving user experience in navigating and filtering products. These changes enhance the overall functionality and usability of category filtering within the market module. --- .../market/composables/useCategoryFilter.ts | 170 ++++++++++++++++++ src/modules/market/views/MarketPage.vue | 118 +++++++----- 2 files changed, 244 insertions(+), 44 deletions(-) create mode 100644 src/modules/market/composables/useCategoryFilter.ts diff --git a/src/modules/market/composables/useCategoryFilter.ts b/src/modules/market/composables/useCategoryFilter.ts new file mode 100644 index 0000000..68998e2 --- /dev/null +++ b/src/modules/market/composables/useCategoryFilter.ts @@ -0,0 +1,170 @@ +import { ref, computed, readonly, type Ref } from 'vue' +import type { Product } from '../types/market' + +export interface CategoryItem { + category: string + count: number + selected: boolean +} + +export interface CategoryFilterOptions { + caseSensitive?: boolean + includeEmpty?: boolean + minCount?: number +} + +/** + * Composable for category filtering functionality + * Provides reactive category management with optimized performance + */ +export function useCategoryFilter( + products: Ref, + options: CategoryFilterOptions = {} +) { + // Use Set for O(1) lookups instead of array + const selectedCategories = ref>(new Set()) + + // Computed property for all available categories with counts + const allCategories = computed(() => { + const categoryMap = new Map() + + // Count categories across all products + products.value.forEach(product => { + product.categories?.forEach(cat => { + if (cat && cat.trim()) { + const category = options.caseSensitive ? cat : cat.toLowerCase() + categoryMap.set(category, (categoryMap.get(category) || 0) + 1) + } + }) + }) + + // Convert to CategoryItem array with selection state + return Array.from(categoryMap.entries()) + .filter(([_, count]) => count >= (options.minCount || 1)) + .map(([category, count]) => ({ + category, + count, + selected: selectedCategories.value.has(category) + })) + .sort((a, b) => b.count - a.count) // Sort by popularity + }) + + // Optimized product filtering with early returns and efficient lookups + const filteredProducts = computed(() => { + const selectedSet = selectedCategories.value + + // Early return if no filters + if (selectedSet.size === 0) { + 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 + } + } + } + + return false + }) + }) + + // Computed properties for UI state + const selectedCount = computed(() => selectedCategories.value.size) + + const selectedCategoryNames = computed(() => + Array.from(selectedCategories.value) + ) + + const hasActiveFilters = computed(() => selectedCategories.value.size > 0) + + // Actions with optimized reactivity + const toggleCategory = (category: string) => { + const normalizedCategory = options.caseSensitive ? category : category.toLowerCase() + const currentSet = selectedCategories.value + + // Create new Set to maintain reactivity (more efficient than copying) + if (currentSet.has(normalizedCategory)) { + const newSet = new Set(currentSet) + newSet.delete(normalizedCategory) + selectedCategories.value = newSet + } else { + const newSet = new Set(currentSet) + newSet.add(normalizedCategory) + selectedCategories.value = newSet + } + } + + const addCategory = (category: string) => { + const normalizedCategory = options.caseSensitive ? category : category.toLowerCase() + const newSet = new Set(selectedCategories.value) + newSet.add(normalizedCategory) + selectedCategories.value = newSet + } + + const removeCategory = (category: string) => { + const normalizedCategory = options.caseSensitive ? category : category.toLowerCase() + const newSet = new Set(selectedCategories.value) + newSet.delete(normalizedCategory) + selectedCategories.value = newSet + } + + const clearAllCategories = () => { + selectedCategories.value = new Set() // Create new empty Set + } + + const selectMultipleCategories = (categories: string[]) => { + const newSet = new Set(selectedCategories.value) + categories.forEach(cat => { + const normalizedCategory = options.caseSensitive ? cat : cat.toLowerCase() + newSet.add(normalizedCategory) + }) + selectedCategories.value = newSet + } + + const isSelected = (category: string): boolean => { + const normalizedCategory = options.caseSensitive ? category : category.toLowerCase() + return selectedCategories.value.has(normalizedCategory) + } + + // Category statistics + const categoryStats = computed(() => ({ + totalCategories: allCategories.value.length, + selectedCategories: selectedCategories.value.size, + filteredProductCount: filteredProducts.value.length, + totalProductCount: products.value.length + })) + + return { + // State (readonly to prevent external mutation) + selectedCategories: readonly(selectedCategories), + allCategories, + filteredProducts, + selectedCount, + selectedCategoryNames, + hasActiveFilters, + categoryStats, + + // Actions + toggleCategory, + addCategory, + removeCategory, + clearAllCategories, + selectMultipleCategories, + isSelected + } +} + +export default useCategoryFilter \ No newline at end of file diff --git a/src/modules/market/views/MarketPage.vue b/src/modules/market/views/MarketPage.vue index 85acaaf..febdf9f 100644 --- a/src/modules/market/views/MarketPage.vue +++ b/src/modules/market/views/MarketPage.vue @@ -53,28 +53,47 @@ + -
-
-

Browse by Category

+
+
+

+ Browse by Category +

-
+
{{ category.category }} @@ -98,6 +118,11 @@
+ + + {{ category.selected ? `${category.category} filter is active` : `${category.category} filter is inactive` }} + +
-
+
- +
-
+
@@ -169,6 +201,7 @@ import { useRouter } from 'vue-router' import { useMarketStore } from '@/modules/market/stores/market' import { useMarket } from '../composables/useMarket' import { useMarketPreloader } from '../composables/useMarketPreloader' +import { useCategoryFilter } from '../composables/useCategoryFilter' import { config } from '@/lib/config' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -184,6 +217,18 @@ const marketStore = useMarketStore() const market = useMarket() const marketPreloader = useMarketPreloader() +// Category filtering with optimized composable +const { + allCategories, + filteredProducts: categoryFilteredProducts, + selectedCount: selectedCategoriesCount, + selectedCategoryNames: selectedCategories, + hasActiveFilters, + toggleCategory, + clearAllCategories: clearAllCategoryFilters, + categoryStats +} = useCategoryFilter(computed(() => marketStore.products)) + let unsubscribe: (() => void) | null = null // Fuzzy search state @@ -225,26 +270,25 @@ const isMarketReady = computed(() => { return ready }) -// Products to display (either search results or filtered products) +// Products to display (combines search results with category filtering) const productsToDisplay = computed(() => { - // If we have search results (meaning user is searching), use those - if (searchResults.value.length > 0 || searchResults.value.length === 0) { - // Still need to apply category filters to search results - let products = searchResults.value + // Start with either search results or all products + const baseProducts = searchResults.value.length > 0 + ? searchResults.value + : marketStore.products - // Apply category filters if any are selected - const selectedCategories = marketStore.filterData.categories - if (selectedCategories.length > 0) { - products = products.filter(product => - product.categories?.some(cat => selectedCategories.includes(cat)) - ) - } - - return products + // Apply category filtering using our composable + if (!hasActiveFilters.value) { + return baseProducts } - // Otherwise, use the store's filtered products - return marketStore.filteredProducts + // Filter base products by selected categories + return baseProducts.filter(product => { + if (!product.categories?.length) return false + return product.categories.some(cat => + selectedCategories.value.includes(cat.toLowerCase()) + ) + }) }) const loadMarket = async () => { @@ -295,21 +339,7 @@ const handleSearchResults = (results: Product[]) => { // Handle category filtering from fuzzy search const handleCategoryFilter = (category: string) => { - marketStore.toggleCategoryFilter(category) -} - -// Computed properties for enhanced category filtering -const selectedCategoriesCount = computed(() => { - return marketStore.filterData.categories.length -}) - -const selectedCategories = computed(() => { - return marketStore.filterData.categories -}) - -// Clear all category filters -const clearAllCategoryFilters = () => { - marketStore.clearCategoryFilters() + toggleCategory(category) } onMounted(() => {