- Added focus and blur event handlers to manage input focus state. - Updated suggestion display to show both search suggestions and recent searches in a unified dropdown. - Improved styling for suggestion buttons to enhance user interaction. - Ensured input field is focused after clearing search, improving usability. These changes enhance the search experience by providing clearer suggestions and better input management.
374 lines
No EOL
11 KiB
Vue
374 lines
No EOL
11 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" class="text-xs text-muted-foreground hidden sm: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>
|
|
|
|
<!-- Floating Search Suggestions & Recent Searches -->
|
|
<div
|
|
v-if="(showSuggestions || showRecentSearches) && !searchQuery && isFocused"
|
|
class="absolute top-full left-0 right-0 z-50 mt-1 max-h-80 overflow-y-auto bg-popover border border-border rounded-md shadow-lg"
|
|
>
|
|
<!-- Search Suggestions -->
|
|
<div v-if="showSuggestions && searchSuggestions.length > 0" class="p-3 border-b border-border">
|
|
<div class="text-sm font-medium mb-2 text-foreground">Try searching for:</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<Button
|
|
v-for="suggestion in searchSuggestions"
|
|
:key="suggestion"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="applySuggestion(suggestion)"
|
|
class="h-6 px-2 text-xs hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
{{ suggestion }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Searches -->
|
|
<div v-if="showRecentSearches && recentSearches.length > 0" class="p-3">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="text-sm font-medium text-foreground">Recent searches:</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="clearRecentSearches"
|
|
class="h-5 px-1 text-xs hover:bg-destructive hover:text-destructive-foreground"
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<Button
|
|
v-for="recent in recentSearches.slice(0, 5)"
|
|
:key="recent"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="applyRecentSearch(recent)"
|
|
class="h-6 px-2 text-xs hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
<History class="w-3 h-3 mr-1" />
|
|
{{ recent }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted, onUnmounted } 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, History } from 'lucide-vue-next'
|
|
import { useLocalStorage } from '@vueuse/core'
|
|
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<HTMLInputElement>()
|
|
const isFocused = ref(false)
|
|
|
|
// Persistent recent searches (stored in localStorage)
|
|
const recentSearches = useLocalStorage<string[]>('market-recent-searches', [])
|
|
|
|
// Enhanced placeholder with keyboard shortcut
|
|
const enhancedPlaceholder = computed(() => {
|
|
if (props.showKeyboardHints) {
|
|
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
|
|
if (searchInputRef.value) {
|
|
const inputElement = searchInputRef.value.$el?.querySelector('input') || searchInputRef.value.$el
|
|
if (inputElement && typeof inputElement.focus === 'function') {
|
|
inputElement.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
// Escape key clears search
|
|
if (event.key === 'Escape') {
|
|
if (searchQuery.value) {
|
|
handleClear()
|
|
} else if (searchInputRef.value) {
|
|
const inputElement = searchInputRef.value.$el?.querySelector('input') || searchInputRef.value.$el
|
|
if (inputElement && typeof inputElement.blur === 'function') {
|
|
inputElement.blur()
|
|
}
|
|
}
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
const handleGlobalKeydown = (event: KeyboardEvent) => {
|
|
// ⌘K or Ctrl+K to focus search
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
event.preventDefault()
|
|
console.log('⌘K/Ctrl+K pressed, focusing search input', !!searchInputRef.value)
|
|
if (searchInputRef.value) {
|
|
// Access the underlying HTML input element from the Shadcn Input component
|
|
const inputElement = searchInputRef.value.$el?.querySelector('input') || searchInputRef.value.$el
|
|
if (inputElement && typeof inputElement.focus === 'function') {
|
|
inputElement.focus()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Watch for changes in filtered items and emit results
|
|
watch(filteredItems, (items) => {
|
|
emit('results', items)
|
|
}, { immediate: true })
|
|
|
|
// Setup and cleanup
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleGlobalKeydown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleGlobalKeydown)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.market-fuzzy-search {
|
|
@apply w-full relative;
|
|
}
|
|
</style> |