feat: extract and consolidate common UI patterns across market module
## Component Extraction
- Create MarketSearchBar component with dual-mode support (enhanced/simple)
- Enhanced mode: suggestions, keyboard shortcuts, category filters
- Simple mode: basic search functionality
- Dynamic imports for performance optimization
- Extract LoadingErrorState component for consistent loading/error handling
- Configurable compact/full modes with custom messages
- Built-in retry functionality
- Standardized spinner and error displays
- Consolidate CartButton component (already extracted in previous commit)
## UI Standardization
- Replace inline category badges in StallView with CategoryFilterBar component
- Add missing state management for category filtering (filterMode, setFilterMode)
- Ensure consistent filtering UI between MarketPage and StallView
- Standardize loading states across MarketPage, ProductGrid, and MerchantStore
## Code Organization
- MarketPage: Uses enhanced MarketSearchBar with full feature set
- StallView: Uses simple MarketSearchBar for cleaner stall-specific search
- Both views now share CategoryFilterBar, CartButton, and ProductGrid
- LoadingErrorState provides unified loading/error UX patterns
## Technical Improvements
- Eliminate code duplication following DRY principles
- Improve maintainability with single source of truth for UI patterns
- Optimize performance with conditional feature loading
- Enhance accessibility with consistent keyboard shortcuts and ARIA labels
- Ensure mobile-responsive designs with unified behavior
BREAKING CHANGE: MarketFuzzySearch component replaced by MarketSearchBar
This commit is contained in:
parent
8821f604be
commit
c8860dc937
6 changed files with 586 additions and 94 deletions
372
src/modules/market/components/MarketSearchBar.vue
Normal file
372
src/modules/market/components/MarketSearchBar.vue
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<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="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 && !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>
|
||||
|
||||
<!-- 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 } 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
|
||||
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 (enhanced mode only)
|
||||
if (props.showEnhancements && 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 (enhanced mode only)
|
||||
if (props.showEnhancements) {
|
||||
focusSearchInput()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!props.showEnhancements || !useSearchKeyboardShortcuts.value) return
|
||||
|
||||
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) => {
|
||||
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 = () => 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue