- Introduced an internal query state to improve UI responsiveness during search input. - Implemented a debounced search function to optimize performance and reduce unnecessary emissions. - Updated conditions for displaying keyboard hints and clear button based on the new internal query. - Ensured both internal and actual search queries are cleared appropriately. These changes enhance the user experience by providing immediate feedback while typing and optimizing search operations.
402 lines
No EOL
12 KiB
Vue
402 lines
No EOL
12 KiB
Vue
<template>
|
|
<div class="market-search-bar" :class="props.class">
|
|
<!-- Enhanced Search Input with Features -->
|
|
<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="internalQuery || searchQuery"
|
|
@update:model-value="handleSearchChange"
|
|
@keydown="handleKeydown"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
:placeholder="computedPlaceholder"
|
|
:disabled="disabled"
|
|
:class="['pl-10', showEnhancements ? 'pr-20' : 'pr-10']"
|
|
/>
|
|
|
|
<!-- Enhanced Features (keyboard hints, clear button) -->
|
|
<div class="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
|
|
<!-- Keyboard Shortcuts Hint (only for enhanced mode on desktop) -->
|
|
<div v-if="showEnhancements && showKeyboardHints && !internalQuery && !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="internalQuery || 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>
|
|
|
|
<!-- Enhanced Features Section -->
|
|
<template v-if="showEnhancements">
|
|
<!-- Result Count with Quick Filters -->
|
|
<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 Category 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"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Basic Result Count (for simple mode) -->
|
|
<div v-else-if="showResultCount && isSearching" class="mt-2 text-sm text-muted-foreground">
|
|
{{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found
|
|
</div>
|
|
</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 { useLocalStorage, useBreakpoints, breakpointsTailwind, useDebounceFn } from '@vueuse/core'
|
|
import type { Product } from '../types/market'
|
|
|
|
// Conditional imports for enhanced mode
|
|
const SearchSuggestions = ref()
|
|
const useSearchKeyboardShortcuts = ref()
|
|
|
|
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 enhanced features (suggestions, keyboard shortcuts, etc.)
|
|
*/
|
|
showEnhancements?: boolean
|
|
/**
|
|
* Whether to show keyboard hints (only works with enhancements)
|
|
*/
|
|
showKeyboardHints?: boolean
|
|
/**
|
|
* Whether to show the result count
|
|
*/
|
|
showResultCount?: boolean
|
|
/**
|
|
* Whether to show search suggestions (only works with enhancements)
|
|
*/
|
|
showSuggestions?: boolean
|
|
/**
|
|
* Whether to show recent searches (only works with enhancements)
|
|
*/
|
|
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...',
|
|
showEnhancements: false,
|
|
showKeyboardHints: true,
|
|
showResultCount: true,
|
|
showSuggestions: true,
|
|
showRecentSearches: true,
|
|
disabled: false
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
// Dynamically import enhanced features only when needed
|
|
const initializeEnhancements = async () => {
|
|
if (props.showEnhancements) {
|
|
const [suggestionModule, shortcutModule] = await Promise.all([
|
|
import('./SearchSuggestions.vue'),
|
|
import('../composables/useSearchKeyboardShortcuts')
|
|
])
|
|
SearchSuggestions.value = suggestionModule.default
|
|
useSearchKeyboardShortcuts.value = shortcutModule.useSearchKeyboardShortcuts
|
|
}
|
|
}
|
|
|
|
// Initialize enhancements
|
|
initializeEnhancements()
|
|
|
|
// 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)
|
|
|
|
// Enhanced features state (only initialized if showEnhancements is true)
|
|
const recentSearches = computed(() =>
|
|
props.showEnhancements ? useLocalStorage<string[]>('market-recent-searches', []) : ref([])
|
|
)
|
|
|
|
// Responsive breakpoints (only for enhanced mode)
|
|
const breakpoints = computed(() =>
|
|
props.showEnhancements ? useBreakpoints(breakpointsTailwind) : null
|
|
)
|
|
const isDesktop = computed(() =>
|
|
props.showEnhancements && breakpoints.value ? breakpoints.value.greaterOrEqual('lg') : ref(false)
|
|
)
|
|
|
|
// Computed placeholder with keyboard shortcut hint
|
|
const computedPlaceholder = computed(() => {
|
|
if (props.showEnhancements && props.showKeyboardHints && isDesktop.value?.value) {
|
|
return `${props.placeholder} (⌘K to focus)`
|
|
}
|
|
return props.placeholder
|
|
})
|
|
|
|
// Extract categories from search results for quick filters (enhanced mode only)
|
|
const topCategories = computed(() => {
|
|
if (!props.showEnhancements) return []
|
|
|
|
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 (enhanced mode only)
|
|
const searchSuggestions = computed(() => {
|
|
if (!props.showEnhancements) return []
|
|
|
|
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
|
|
// Internal search query for immediate UI updates
|
|
const internalQuery = ref('')
|
|
|
|
// Debounced search function for performance optimization
|
|
const debouncedSearch = useDebounceFn((value: string) => {
|
|
setSearchQuery(value)
|
|
emit('search', value)
|
|
emit('results', filteredItems.value)
|
|
|
|
// Add to recent searches when user finishes typing (enhanced mode only)
|
|
if (props.showEnhancements && value.trim() && value.length >= 3) {
|
|
addToRecentSearches(value.trim())
|
|
}
|
|
}, 300)
|
|
|
|
const handleSearchChange = (value: string | number) => {
|
|
const stringValue = String(value)
|
|
|
|
// Update internal query immediately for responsive UI
|
|
internalQuery.value = stringValue
|
|
emit('update:modelValue', stringValue)
|
|
|
|
// Debounce the actual search computation
|
|
debouncedSearch(stringValue)
|
|
}
|
|
|
|
const handleClear = () => {
|
|
// Clear both internal and actual search queries
|
|
internalQuery.value = ''
|
|
clearSearch()
|
|
emit('update:modelValue', '')
|
|
emit('search', '')
|
|
emit('results', filteredItems.value)
|
|
emit('clear')
|
|
|
|
// Focus the input after clearing (enhanced mode only)
|
|
if (props.showEnhancements) {
|
|
focusSearchInput()
|
|
}
|
|
}
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
if (!props.showEnhancements) return
|
|
|
|
// Handle basic Escape key for simple mode
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
if (internalQuery.value || searchQuery.value) {
|
|
handleClear()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Use enhanced keyboard shortcuts if available
|
|
if (useSearchKeyboardShortcuts.value && handleSearchKeydown) {
|
|
const shouldClear = handleSearchKeydown(event)
|
|
if (shouldClear) {
|
|
if (internalQuery.value || searchQuery.value) {
|
|
handleClear()
|
|
} else {
|
|
blurSearchInput()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const filterByCategory = (category: string) => {
|
|
emit('filter-category', category)
|
|
internalQuery.value = category
|
|
setSearchQuery(category)
|
|
}
|
|
|
|
const applySuggestion = (suggestion: string) => {
|
|
internalQuery.value = suggestion
|
|
setSearchQuery(suggestion)
|
|
addToRecentSearches(suggestion)
|
|
}
|
|
|
|
const applyRecentSearch = (recent: string) => {
|
|
internalQuery.value = recent
|
|
setSearchQuery(recent)
|
|
}
|
|
|
|
const addToRecentSearches = (query: string) => {
|
|
if (!props.showEnhancements) return
|
|
|
|
const searches = recentSearches.value.value.filter(s => s !== query)
|
|
searches.unshift(query)
|
|
recentSearches.value.value = searches.slice(0, 10) // Keep only 10 recent searches
|
|
}
|
|
|
|
const clearRecentSearches = () => {
|
|
if (props.showEnhancements) {
|
|
recentSearches.value.value = []
|
|
}
|
|
}
|
|
|
|
// Focus handling
|
|
const handleFocus = () => {
|
|
isFocused.value = true
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
// Delay hiding to allow clicking on suggestions
|
|
setTimeout(() => {
|
|
isFocused.value = false
|
|
}, 200)
|
|
}
|
|
|
|
// Enhanced mode methods (initialized conditionally)
|
|
let focusSearchInput = () => {}
|
|
let blurSearchInput = () => {}
|
|
let handleSearchKeydown = (_event: KeyboardEvent) => false
|
|
|
|
// Initialize keyboard shortcuts for enhanced mode
|
|
watch(() => props.showEnhancements, async (showEnhancements) => {
|
|
if (showEnhancements && useSearchKeyboardShortcuts.value) {
|
|
const shortcuts = useSearchKeyboardShortcuts.value(searchInputRef)
|
|
focusSearchInput = shortcuts.focusSearchInput
|
|
blurSearchInput = shortcuts.blurSearchInput
|
|
handleSearchKeydown = shortcuts.handleSearchKeydown
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Watch for changes in filtered items and emit results
|
|
watch(filteredItems, (items) => {
|
|
emit('results', items)
|
|
}, { immediate: true })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.market-search-bar {
|
|
@apply w-full relative;
|
|
}
|
|
</style> |