Merge pull request #74 from xxxinsxxx/feat/search-functionality

feat: Add Search Functionality
This commit is contained in:
David
2025-08-19 12:45:50 +03:00
committed by GitHub
3 changed files with 415 additions and 5 deletions

View File

@@ -0,0 +1,371 @@
<template>
<div
v-if="isOpen"
class="search-dialog fixed inset-0 bg-black/90 z-[9999] flex items-start justify-center pt-[64px] animate-fade-in"
@click="closeDialog"
>
<div
class="bg-[#0b0b0b] border border-[#2e4e3e] p-4 mx-4 pb-2 rounded-xl w-full max-w-[720px] overflow-hidden animate-slide-in"
@click.stop
>
<div class="relative flex items-center pb-2">
<i class="pi pi-search text-[#9eefb0] py-2.5 px-3"></i>
<input
ref="searchInputRef"
v-model="inputValue"
type="text"
placeholder="Search the docs"
class="flex-1 bg-transparent border-none outline-none text-white text-lg placeholder:text-[#182916] focus:outline-none"
@keydown="handleKeyDown"
/>
</div>
<AnimatePresence>
<Motion
v-if="searchValue"
key="results"
:initial="{ height: 0, opacity: 0 }"
:animate="{ height: 'auto', opacity: 1 }"
:exit="{ height: 0, opacity: 0 }"
:transition="{ duration: 0.3 }"
class="overflow-hidden"
>
<div
v-if="searchResults.length > 0"
class="relative max-h-[400px] overflow-hidden border-t border-[#2e4e3e] pb-4"
>
<div
class="max-h-[400px] overflow-y-auto"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: '#1e3725 #0b0b0b'
}"
ref="listRef"
@scroll="handleScroll"
>
<Motion
v-for="(result, index) in searchResults"
:key="`${result.categoryName}-${result.componentName}-${index}`"
:data-index="index"
class="cursor-pointer mr-2"
:initial="{ scale: 0.7, opacity: 0 }"
:animate="getItemInView(index) ? { scale: 1, opacity: 1 } : { scale: 0.7, opacity: 0 }"
:transition="{ duration: 0.2, delay: 0.05 }"
@mouseenter="() => setSelectedIndex(index)"
@click="
() => {
handleSelect(searchResults[index]);
}
"
>
<div
class="flex items-center p-[14px] rounded-xl transition-all duration-200"
:class="[
selectedIndex === index ? 'bg-[#2e4e31]' : 'hover:bg-[#2e4e31] bg-[#222]',
index === 0 ? 'mt-4' : 'mt-2'
]"
>
<i
class="text-[#9eefb0] mr-4"
:class="
categoryIconMapping[result.categoryName as keyof typeof categoryIconMapping] || 'pi pi-search'
"
></i>
<div class="flex flex-col flex-1">
<div class="text-white text-base font-bold">{{ result.componentName }}</div>
<div class="text-[#9eefb0] text-sm">in {{ result.categoryName }}</div>
</div>
<i class="pi pi-reply rotate-180 text-[#9eefb0]"></i>
</div>
</Motion>
</div>
<div
class="absolute top-0 left-0 right-0 h-[50px] bg-gradient-to-b from-[#0b0b0b] to-transparent pointer-events-none transition-opacity duration-300 ease"
:style="{ opacity: topGradientOpacity }"
></div>
<div
class="absolute bottom-0 left-0 right-0 h-[100px] bg-gradient-to-t from-[#0b0b0b] to-transparent pointer-events-none transition-opacity duration-300 ease"
:style="{ opacity: bottomGradientOpacity }"
></div>
</div>
<div
v-else-if="searchValue && searchResults.length === 0"
class="p-4 pb-2 text-center border-t border-[#2e4e3e]"
>
<div class="text-[#9eefac] text-sm">
No results found for "
<span class="font-black">{{ searchValue }}</span>
"
</div>
</div>
</Motion>
</AnimatePresence>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onUnmounted, useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
import { CATEGORIES } from '../../constants/Categories';
import { Motion, AnimatePresence } from 'motion-v';
const props = defineProps({
isOpen: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'open']);
const router = useRouter();
const searchInputRef = ref<HTMLInputElement | null>(null);
const inputValue = ref('');
const selectedIndex = ref(0);
const listRef = useTemplateRef<HTMLDivElement>('listRef');
const itemsInView = ref<boolean[]>([]);
const keyboardNav = ref(false);
const topGradientOpacity = ref(0);
const bottomGradientOpacity = ref(1);
const searchValue = ref('');
let debounceTimer: any = null;
interface SearchResult {
categoryName: string;
componentName: string;
}
const categoryIconMapping = {
'Get Started': 'pi pi-file',
'Text Animations': 'pi pi-hashtag',
Animations: 'pi pi-circle',
Components: 'pi pi-box',
Backgrounds: 'pi pi-image'
};
const searchResults = computed(() => {
return searchComponents(searchValue.value);
});
watch(inputValue, newValue => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
searchValue.value = newValue;
selectedIndex.value = -1;
}, 500);
});
watch([selectedIndex, keyboardNav], () => {
if (!keyboardNav.value || selectedIndex.value < 0 || !listRef.value) return;
const container = listRef.value;
const selectedItem = container.querySelector(`[data-index="${selectedIndex.value}"]`) as HTMLElement | null;
if (selectedItem) {
const extraMargin = 50;
const containerScrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const itemTop = selectedItem.offsetTop;
const itemBottom = itemTop + selectedItem.offsetHeight;
if (itemTop < containerScrollTop + extraMargin) {
container.scrollTo({ top: itemTop - extraMargin, behavior: 'smooth' });
} else if (itemBottom > containerScrollTop + containerHeight - extraMargin) {
container.scrollTo({
top: itemBottom - containerHeight + extraMargin,
behavior: 'smooth'
});
}
}
keyboardNav.value = false;
});
const levenshtein = (a: string, b: string): number => {
const m = a.length,
n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] =
a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : Math.min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1, dp[i - 1][j] + 1);
}
}
return dp[m][n];
};
const fuzzyMatch = (candidate: string, query: string): boolean => {
const lowerCandidate = candidate.toLowerCase();
const lowerQuery = query.toLowerCase();
if (lowerCandidate.includes(lowerQuery)) return true;
const candidateWords = lowerCandidate.split(/\s+/);
const queryWords = lowerQuery.split(/\s+/);
return queryWords.every((qw: string) =>
candidateWords.some((cw: string) => {
const distance = levenshtein(cw, qw);
const threshold = Math.max(1, Math.floor(qw.length / 3));
return distance <= threshold;
})
);
};
const searchComponents = (query: string): SearchResult[] => {
if (!query || query.trim() === '') return [];
const results: SearchResult[] = [];
CATEGORIES.forEach(category => {
const { name: categoryName, subcategories } = category;
if (fuzzyMatch(categoryName, query)) {
subcategories.forEach(component => results.push({ categoryName, componentName: component }));
} else {
subcategories.forEach(component => {
if (fuzzyMatch(component, query)) results.push({ categoryName, componentName: component });
});
}
});
return results;
};
watch(searchResults, () => {
nextTick(() => {
updateItemsInView();
if (!listRef.value) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.value;
const bottomDistance = scrollHeight - (scrollTop + clientHeight);
bottomGradientOpacity.value = scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1);
});
});
const handleKeyDown = (event: KeyboardEvent) => {
if (!searchValue.value) return;
if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) {
event.preventDefault();
keyboardNav.value = true;
selectedIndex.value = Math.min(selectedIndex.value + 1, searchResults.value.length - 1);
} else if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) {
event.preventDefault();
keyboardNav.value = true;
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
} else if (event.key === 'Enter' && selectedIndex.value >= 0) {
event.preventDefault();
handleSelect(searchResults.value[selectedIndex.value]);
}
};
const handleScroll = (e: Event) => {
const target = e.target as HTMLDivElement;
const { scrollTop, scrollHeight, clientHeight } = target;
topGradientOpacity.value = Math.min(scrollTop / 50, 1);
const bottomDistance = scrollHeight - (scrollTop + clientHeight);
bottomGradientOpacity.value = scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1);
updateItemsInView();
};
const handleSelect = (result: SearchResult) => {
const slug = (result: string) => result.replace(/\s+/g, '-').toLowerCase();
router.push(`/${slug(result.categoryName)}/${slug(result.componentName)}`);
closeDialog();
};
const setSelectedIndex = (index: number) => {
selectedIndex.value = index;
};
const getItemInView = (index: number) => {
return itemsInView.value[index] ?? false;
};
const updateItemsInView = () => {
if (!listRef.value) return;
const container = listRef.value;
const containerRect = container.getBoundingClientRect();
itemsInView.value = searchResults.value.map((_, index) => {
const item = container.querySelector(`[data-index="${index}"]`) as HTMLElement;
if (!item) return false;
const itemRect = item.getBoundingClientRect();
const viewHeight = containerRect.height;
const itemTop = itemRect.top - containerRect.top;
const itemBottom = itemTop + itemRect.height;
return itemTop < viewHeight && itemBottom > 0;
});
};
const closeDialog = () => {
document.body.classList.remove('overflow-hidden');
emit('close');
inputValue.value = '';
selectedIndex.value = 0;
};
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === '/' && !props.isOpen) {
event.preventDefault();
emit('open');
}
};
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
document.body.classList.add('overflow-hidden');
nextTick(() => {
searchInputRef.value?.focus();
});
}
}
);
onUnmounted(() => {
document.removeEventListener('keydown', handleGlobalKeyDown);
if (debounceTimer) clearTimeout(debounceTimer);
});
</script>
<style scoped>
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-slide-in {
animation: none;
}
.transition-all {
transition: none;
}
}
</style>

View File

@@ -5,12 +5,27 @@
<img :src="Logo" alt="Logo" /> <img :src="Logo" alt="Logo" />
</router-link> </router-link>
<button class="mobile-menu-button" aria-label="Open Menu" @click="toggleDrawer"> <div class="flex gap-2">
<i class="pi pi-bars"></i> <button class="mobile-menu-button" aria-label="Open Search" @click="openSearch">
</button> <i class="pi pi-search"></i>
</button>
<button class="mobile-menu-button" aria-label="Open Menu" @click="toggleDrawer">
<i class="pi pi-bars"></i>
</button>
</div>
<div class="desktop-nav"> <div class="desktop-nav">
<!-- TODO: Search Component --> <FadeContent blur>
<button
class="search-button"
@click="openSearch"
>
<i class="pi pi-search search-icon"></i>
<span class="search-text">Search Docs</span>
<kbd class="search-kbd">/</kbd>
</button>
</FadeContent>
<FadeContent blur> <FadeContent blur>
<button class="cta-button-docs" @click="openGitHub"> <button class="cta-button-docs" @click="openGitHub">
@@ -77,6 +92,11 @@
</div> </div>
</div> </div>
</div> </div>
<SearchDialog
:is-open="isSearchOpen"
@close="closeSearch"
@open="openSearch"
/>
</div> </div>
</template> </template>
@@ -88,9 +108,11 @@ import { CATEGORIES, NEW, UPDATED } from '../../constants/Categories';
import FadeContent from '../../content/Animations/FadeContent/FadeContent.vue'; import FadeContent from '../../content/Animations/FadeContent/FadeContent.vue';
import Logo from '../../assets/logos/vue-bits-logo.svg'; import Logo from '../../assets/logos/vue-bits-logo.svg';
import Star from '../../assets/common/star.svg'; import Star from '../../assets/common/star.svg';
import SearchDialog from '../common/SearchDialog.vue'
const isDrawerOpen = ref(false); const isDrawerOpen = ref(false);
const isTransitioning = ref(false); const isTransitioning = ref(false);
const isSearchOpen = ref(false)
const stars = useStars(); const stars = useStars();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -132,6 +154,13 @@ const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isDrawerOpen.value) { if (e.key === 'Escape' && isDrawerOpen.value) {
closeDrawer(); closeDrawer();
} }
if (e.key === '/' && !isSearchOpen.value) {
e.preventDefault()
openSearch()
}
if (e.key === 'Escape' && isSearchOpen.value) {
closeSearch()
}
}; };
const Category = defineComponent({ const Category = defineComponent({
@@ -224,6 +253,14 @@ const Category = defineComponent({
} }
}); });
const openSearch = () => {
isSearchOpen.value = true
}
const closeSearch = () => {
isSearchOpen.value = false
}
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
}); });

View File

@@ -380,11 +380,13 @@
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
font-size: 10px; font-size: 10px;
padding: 0 0.5rem;
border-radius: 50px; border-radius: 50px;
background: #1a1a1a; background: #1a1a1a;
color: #a6a6a6; color: #a6a6a6;
border: 1px solid #333; border: 1px solid #333;
width: 20px;
height: 20px;
justify-content: center;
} }
.star-count { .star-count {