web-app/src/modules/market/components/MarketFuzzySearch.vue
padreug f2a432b6df Enhance MarketFuzzySearch component with improved suggestion display and input focus handling
- 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.
2025-09-26 17:02:00 +02:00

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>