- Added CategoryFilterBar.vue to manage category filtering with AND/OR toggle options and clear all functionality. - Implemented ProductGrid.vue to display products with loading and empty states, improving user experience. - Refactored MarketPage.vue to utilize the new components, streamlining the layout and enhancing responsiveness. - Updated StallView.vue to incorporate ProductGrid for consistent product display across views. These changes enhance the overall usability and visual appeal of the market components, providing users with a more intuitive filtering and browsing experience.
313 lines
No EOL
8.5 KiB
Vue
313 lines
No EOL
8.5 KiB
Vue
<template>
|
|
<div class="market-fuzzy-search" :class="props.class">
|
|
<!-- Enhanced Search Input with Keyboard Shortcuts -->
|
|
<div class="relative">
|
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Search class="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
|
|
<Input
|
|
ref="searchInputRef"
|
|
:model-value="searchQuery"
|
|
@update:model-value="handleSearchChange"
|
|
@keydown="handleKeydown"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
:placeholder="enhancedPlaceholder"
|
|
:disabled="disabled"
|
|
class="pl-10 pr-20"
|
|
/>
|
|
|
|
<!-- Keyboard Shortcuts Hint -->
|
|
<div class="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
|
|
<div v-if="showKeyboardHints && !searchQuery && isDesktop" class="text-xs text-muted-foreground flex items-center gap-1">
|
|
<Badge variant="outline" class="px-1 py-0 text-xs">⌘ K</Badge>
|
|
</div>
|
|
|
|
<!-- Clear Button -->
|
|
<Button
|
|
v-if="searchQuery"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="handleClear"
|
|
class="h-6 w-6 p-0 hover:bg-muted"
|
|
>
|
|
<X class="h-3 w-3" />
|
|
<span class="sr-only">Clear search</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Search Results with Categories and Previews -->
|
|
<div v-if="isSearching && showResultCount" class="mt-2 flex items-center justify-between text-sm text-muted-foreground">
|
|
<span>{{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found</span>
|
|
|
|
<!-- Quick Filters -->
|
|
<div v-if="searchQuery && topCategories.length > 0" class="flex items-center gap-1">
|
|
<span class="text-xs">In:</span>
|
|
<Button
|
|
v-for="category in topCategories.slice(0, 3)"
|
|
:key="category"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="filterByCategory(category)"
|
|
class="h-5 px-1 text-xs"
|
|
>
|
|
{{ category }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Suggestions Dropdown -->
|
|
<SearchSuggestions
|
|
:show-suggestions="showSuggestions"
|
|
:show-recent-searches="showRecentSearches"
|
|
:search-query="searchQuery"
|
|
:is-focused="isFocused"
|
|
:suggestions="searchSuggestions"
|
|
:recent-searches="recentSearches"
|
|
@apply-suggestion="applySuggestion"
|
|
@apply-recent="applyRecentSearch"
|
|
@clear-recent="clearRecentSearches"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Search, X } from 'lucide-vue-next'
|
|
import SearchSuggestions from './SearchSuggestions.vue'
|
|
import { useLocalStorage, useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
|
import { useSearchKeyboardShortcuts } from '../composables/useSearchKeyboardShortcuts'
|
|
import type { Product } from '../types/market'
|
|
|
|
interface Props {
|
|
/**
|
|
* The data to search through
|
|
*/
|
|
data: Product[]
|
|
/**
|
|
* Configuration options for the fuzzy search
|
|
*/
|
|
options?: FuzzySearchOptions<Product>
|
|
/**
|
|
* Placeholder text for the search input
|
|
*/
|
|
placeholder?: string
|
|
/**
|
|
* Whether to show keyboard hints
|
|
*/
|
|
showKeyboardHints?: boolean
|
|
/**
|
|
* Whether to show the result count
|
|
*/
|
|
showResultCount?: boolean
|
|
/**
|
|
* Whether to show search suggestions
|
|
*/
|
|
showSuggestions?: boolean
|
|
/**
|
|
* Whether to show recent searches
|
|
*/
|
|
showRecentSearches?: boolean
|
|
/**
|
|
* Custom class for the search container
|
|
*/
|
|
class?: string | string[]
|
|
/**
|
|
* Whether the search input should be disabled
|
|
*/
|
|
disabled?: boolean
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'update:modelValue', value: string): void
|
|
(e: 'search', query: string): void
|
|
(e: 'results', results: Product[]): void
|
|
(e: 'clear'): void
|
|
(e: 'filter-category', category: string): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
placeholder: 'Search products, stalls, categories...',
|
|
showKeyboardHints: true,
|
|
showResultCount: true,
|
|
showSuggestions: true,
|
|
showRecentSearches: true,
|
|
disabled: false
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// Create reactive data ref for the composable
|
|
const dataRef = computed(() => props.data)
|
|
|
|
// Use the fuzzy search composable
|
|
const {
|
|
searchQuery,
|
|
filteredItems,
|
|
isSearching,
|
|
resultCount,
|
|
clearSearch,
|
|
setSearchQuery
|
|
} = useFuzzySearch(dataRef, props.options)
|
|
|
|
// Local state
|
|
const searchInputRef = ref()
|
|
const isFocused = ref(false)
|
|
|
|
// Persistent recent searches (stored in localStorage)
|
|
const recentSearches = useLocalStorage<string[]>('market-recent-searches', [])
|
|
|
|
// Responsive breakpoints
|
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
const isDesktop = breakpoints.greaterOrEqual('lg')
|
|
|
|
// Enhanced placeholder with keyboard shortcut (only on desktop)
|
|
const enhancedPlaceholder = computed(() => {
|
|
if (props.showKeyboardHints && isDesktop.value) {
|
|
return `${props.placeholder} (⌘K to focus)`
|
|
}
|
|
return props.placeholder
|
|
})
|
|
|
|
// Extract categories from search results for quick filters
|
|
const topCategories = computed(() => {
|
|
const categoryCount = new Map<string, number>()
|
|
|
|
filteredItems.value.forEach(product => {
|
|
product.categories?.forEach(category => {
|
|
categoryCount.set(category, (categoryCount.get(category) || 0) + 1)
|
|
})
|
|
})
|
|
|
|
return Array.from(categoryCount.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([category]) => category)
|
|
})
|
|
|
|
// Generate search suggestions based on popular categories and products
|
|
const searchSuggestions = computed(() => {
|
|
const suggestions = new Set<string>()
|
|
|
|
// Add popular categories
|
|
const allCategories = new Map<string, number>()
|
|
props.data.forEach(product => {
|
|
product.categories?.forEach(category => {
|
|
allCategories.set(category, (allCategories.get(category) || 0) + 1)
|
|
})
|
|
})
|
|
|
|
Array.from(allCategories.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 4)
|
|
.forEach(([category]) => suggestions.add(category))
|
|
|
|
// Add popular stall names
|
|
const stallNames = new Map<string, number>()
|
|
props.data.forEach(product => {
|
|
if (product.stallName && product.stallName !== 'Unknown Stall') {
|
|
stallNames.set(product.stallName, (stallNames.get(product.stallName) || 0) + 1)
|
|
}
|
|
})
|
|
|
|
Array.from(stallNames.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 2)
|
|
.forEach(([stall]) => suggestions.add(stall))
|
|
|
|
return Array.from(suggestions)
|
|
})
|
|
|
|
// Methods
|
|
const handleSearchChange = (value: string | number) => {
|
|
const stringValue = String(value)
|
|
setSearchQuery(stringValue)
|
|
emit('update:modelValue', stringValue)
|
|
emit('search', stringValue)
|
|
emit('results', filteredItems.value)
|
|
|
|
// Add to recent searches when user finishes typing
|
|
if (stringValue.trim() && stringValue.length >= 3) {
|
|
addToRecentSearches(stringValue.trim())
|
|
}
|
|
}
|
|
|
|
const handleClear = () => {
|
|
clearSearch()
|
|
emit('update:modelValue', '')
|
|
emit('search', '')
|
|
emit('results', filteredItems.value)
|
|
emit('clear')
|
|
|
|
// Focus the input after clearing
|
|
focusSearchInput()
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
const shouldClear = handleSearchKeydown(event)
|
|
if (shouldClear) {
|
|
if (searchQuery.value) {
|
|
handleClear()
|
|
} else {
|
|
blurSearchInput()
|
|
}
|
|
}
|
|
}
|
|
|
|
const filterByCategory = (category: string) => {
|
|
emit('filter-category', category)
|
|
setSearchQuery(category)
|
|
}
|
|
|
|
const applySuggestion = (suggestion: string) => {
|
|
setSearchQuery(suggestion)
|
|
addToRecentSearches(suggestion)
|
|
}
|
|
|
|
const applyRecentSearch = (recent: string) => {
|
|
setSearchQuery(recent)
|
|
}
|
|
|
|
const addToRecentSearches = (query: string) => {
|
|
const searches = recentSearches.value.filter(s => s !== query)
|
|
searches.unshift(query)
|
|
recentSearches.value = searches.slice(0, 10) // Keep only 10 recent searches
|
|
}
|
|
|
|
const clearRecentSearches = () => {
|
|
recentSearches.value = []
|
|
}
|
|
|
|
// Focus handling
|
|
const handleFocus = () => {
|
|
isFocused.value = true
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
// Delay hiding to allow clicking on suggestions
|
|
setTimeout(() => {
|
|
isFocused.value = false
|
|
}, 200)
|
|
}
|
|
|
|
// Use keyboard shortcuts composable
|
|
const { focusSearchInput, blurSearchInput, handleSearchKeydown } = useSearchKeyboardShortcuts(searchInputRef)
|
|
|
|
// Watch for changes in filtered items and emit results
|
|
watch(filteredItems, (items) => {
|
|
emit('results', items)
|
|
}, { immediate: true })
|
|
|
|
// The keyboard shortcuts composable handles setup and cleanup
|
|
</script>
|
|
|
|
<style scoped>
|
|
.market-fuzzy-search {
|
|
@apply w-full relative;
|
|
}
|
|
</style> |