Replace inline search with dropdown overlay for mobile
New ActivitySearchOverlay component: compact dropdown anchored to the search input showing thumbnail, title, date, and location for each result. Limited to 8 results, scrollable. Stays visible above the keyboard on mobile. Tap a result to navigate, tap outside or clear to dismiss. Uses the same Fuse.js fuzzy search under the hood. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a75885ca76
commit
f782cd1a7a
2 changed files with 171 additions and 42 deletions
164
src/modules/activities/components/ActivitySearchOverlay.vue
Normal file
164
src/modules/activities/components/ActivitySearchOverlay.vue
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { format } from 'date-fns'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
|
||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
import type { Activity } from '../types/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
activities: Activity[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [activity: Activity]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const searchOptions: FuzzySearchOptions<Activity> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'summary', weight: 0.2 },
|
||||
{ name: 'description', weight: 0.15 },
|
||||
{ name: 'location', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.05 },
|
||||
],
|
||||
threshold: 0.35,
|
||||
ignoreLocation: true,
|
||||
},
|
||||
matchAllWhenSearchEmpty: false,
|
||||
minSearchLength: 2,
|
||||
resultLimit: 8,
|
||||
}
|
||||
|
||||
const activitiesRef = computed(() => props.activities)
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
filteredItems,
|
||||
isSearching,
|
||||
clearSearch,
|
||||
setSearchQuery,
|
||||
} = useFuzzySearch(activitiesRef, searchOptions)
|
||||
|
||||
const showResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length > 0)
|
||||
const showNoResults = computed(() => isOpen.value && isSearching.value && filteredItems.value.length === 0)
|
||||
|
||||
function formatDate(activity: Activity): string {
|
||||
if (!activity.startDate || isNaN(activity.startDate.getTime())) return ''
|
||||
try {
|
||||
if (activity.type === 'date') return format(activity.startDate, 'MMM d')
|
||||
return format(activity.startDate, 'MMM d · HH:mm')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(activity: Activity) {
|
||||
clearSearch()
|
||||
isOpen.value = false
|
||||
emit('select', activity)
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
clearSearch()
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function handleInput(value: string | number) {
|
||||
setSearchQuery(String(value))
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
// Close on click outside
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.search-overlay-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
} else {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-overlay-container relative">
|
||||
<!-- Search input -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref="inputRef"
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="handleInput"
|
||||
@focus="handleFocus"
|
||||
placeholder="Search activities..."
|
||||
class="pl-9 pr-9"
|
||||
/>
|
||||
<Button
|
||||
v-if="searchQuery"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||
@click="handleClear"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Results dropdown overlay -->
|
||||
<div
|
||||
v-if="showResults || showNoResults"
|
||||
class="absolute left-0 right-0 top-full mt-1 z-50 bg-background border rounded-lg shadow-lg max-h-[50vh] overflow-y-auto"
|
||||
>
|
||||
<!-- No results -->
|
||||
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
|
||||
No activities found
|
||||
</div>
|
||||
|
||||
<!-- Result items -->
|
||||
<button
|
||||
v-for="activity in filteredItems"
|
||||
:key="activity.nostrEventId"
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-muted transition-colors text-left border-b last:border-b-0"
|
||||
@click="handleSelect(activity)"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<img
|
||||
v-if="activity.image"
|
||||
:src="activity.image"
|
||||
:alt="activity.title"
|
||||
class="w-10 h-10 rounded object-cover shrink-0"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded bg-muted flex items-center justify-center shrink-0">
|
||||
<Calendar class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ activity.title }}</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span v-if="formatDate(activity)" class="truncate">{{ formatDate(activity) }}</span>
|
||||
<span v-if="activity.location" class="flex items-center gap-0.5 truncate">
|
||||
<MapPin class="w-2.5 h-2.5 shrink-0" />
|
||||
{{ activity.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -8,12 +8,11 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { FuzzySearch } from '@/components/ui/fuzzy-search'
|
||||
import { RefreshCw, SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
import { useActivities } from '../composables/useActivities'
|
||||
import CreateActivityDialog from '../components/CreateActivityDialog.vue'
|
||||
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
|
||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
||||
|
|
@ -44,36 +43,6 @@ const {
|
|||
} = useActivities()
|
||||
|
||||
const filtersOpen = ref(false)
|
||||
const fuzzyResults = ref<Activity[]>([])
|
||||
const isFuzzySearching = ref(false)
|
||||
|
||||
const searchOptions: FuzzySearchOptions<Activity> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'summary', weight: 0.2 },
|
||||
{ name: 'description', weight: 0.15 },
|
||||
{ name: 'location', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.05 },
|
||||
],
|
||||
threshold: 0.35,
|
||||
ignoreLocation: true,
|
||||
},
|
||||
matchAllWhenSearchEmpty: true,
|
||||
minSearchLength: 2,
|
||||
}
|
||||
|
||||
const displayedActivities = computed(() => {
|
||||
return isFuzzySearching.value ? fuzzyResults.value : activities.value
|
||||
})
|
||||
|
||||
function handleSearchResults(results: Activity[]) {
|
||||
fuzzyResults.value = results
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
isFuzzySearching.value = query.trim().length >= 2
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
|
|
@ -110,15 +79,11 @@ function handleRefresh() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuzzy search -->
|
||||
<!-- Search with dropdown overlay -->
|
||||
<div class="mb-4">
|
||||
<FuzzySearch
|
||||
:data="activities"
|
||||
:options="searchOptions"
|
||||
placeholder="Search activities..."
|
||||
:show-result-count="false"
|
||||
@search="handleSearch"
|
||||
@results="handleSearchResults"
|
||||
<ActivitySearchOverlay
|
||||
:activities="activities"
|
||||
@select="handleSelectActivity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -168,7 +133,7 @@ function handleRefresh() {
|
|||
|
||||
<!-- Activity feed -->
|
||||
<ActivityList
|
||||
:activities="displayedActivities"
|
||||
:activities="activities"
|
||||
:is-loading="isLoading"
|
||||
@select="handleSelectActivity"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue