Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions src/components/layout/components-layout/ComponentSearchbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"use client";

import { useComponentSearch } from "@/hooks/useComponentSearch";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { FiCommand, FiSearch } from "react-icons/fi";

const ComponentSearchbar = () => {
const { isOpen, query, setQuery, filteredResults, toggleOpen, closeModal } =
useComponentSearch();
const [selectedIndex, setSelectedIndex] = useState(0);
const [mounted, setMounted] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const router = useRouter();

useEffect(() => setMounted(true), []);

useEffect(() => setSelectedIndex(0), [filteredResults]);

useEffect(() => {
if (isOpen && inputRef.current) inputRef.current.focus();
}, [isOpen]);

useEffect(() => {
if (selectedIndex >= 0 && listRef.current) {
const activeItem = listRef.current.children[selectedIndex] as HTMLElement;
if (activeItem) {
activeItem.scrollIntoView({ block: "nearest", behavior: "auto" });
}
}
}, [selectedIndex]);

const handleNavigate = useCallback(
(href: string) => {
router.push(href);
closeModal();
},
[router, closeModal]
);

useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) =>
prev < filteredResults.length - 1 ? prev + 1 : 0
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredResults.length - 1
);
} else if (e.key === "Enter" && filteredResults[selectedIndex]) {
e.preventDefault();
handleNavigate(filteredResults[selectedIndex].href);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, filteredResults, selectedIndex, handleNavigate]);

useEffect(() => {
document.body.style.overflow = isOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);

return (
<>
<button
onClick={toggleOpen}
className="relative w-full h-10 px-3 flex items-center gap-2
bg-[var(--glass-color)] dark:bg-white/5
border border-[var(--border-color)] dark:border-white/15
hover:bg-white/10 hover:border-[var(--primary-color)] dark:hover:border-white/25
focus:border-[var(--primary-color)] dark:focus:border-white/30
rounded-xl transition-all cursor-text text-sm
text-[var(--black-color)] dark:text-white/60
focus:outline-none focus:ring-2 focus:ring-white/20"
>
<FiSearch className="w-4 h-4 text-[var(--black-color)]/50 dark:text-white/40" />
<span className="flex-1 text-left text-[var(--black-color)]/50 dark:text-white/40">
Search...
</span>
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 bg-[var(--glass-color)] dark:bg-white/10 border border-[var(--border-color)] dark:border-white/15 rounded-md text-xs font-medium text-[var(--black-color)]/60 dark:text-white/50">
<FiCommand className="w-3 h-3" />
<span>K</span>
</kbd>
</button>

{mounted &&
createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={closeModal}
className="fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm flex items-center justify-center"
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: "easeOut" }}
onClick={(e) => e.stopPropagation()}
className="w-[calc(100%-2rem)] max-w-lg bg-[var(--background-color)] border border-[var(--primary-color)] dark:border-white/10 rounded-xl shadow-2xl shadow-black/20 overflow-hidden"
>
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-color)] dark:border-white/10">
<FiSearch className="w-5 h-5 text-[var(--primary-color)] flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search components..."
className="flex-1 bg-transparent text-[var(--black-color)] dark:text-white placeholder:text-[var(--black-color)]/40 dark:placeholder:text-white/40 focus:outline-none text-sm"
/>
<kbd className="hidden sm:inline-flex px-1.5 py-0.5 bg-[var(--glass-color)] dark:bg-white/10 border border-[var(--border-color)] dark:border-white/15 rounded text-xs text-[var(--black-color)]/60 dark:text-white/50">
ESC
</kbd>
</div>

<div className="max-h-80 overflow-y-auto py-2">
{filteredResults.length === 0 ? (
<div className="px-4 py-8 text-center text-[var(--black-color)]/50 dark:text-white/50 text-sm">
No results found for &ldquo;{query}&rdquo;
</div>
) : (
<div ref={listRef} className="space-y-1 px-2">
{filteredResults.map((item, index) => (
<button
key={`${item.href}-${index}`}
onClick={() => handleNavigate(item.href)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all cursor-pointer ${
selectedIndex === index
? "bg-[var(--primary-color)]/10 dark:bg-white/10 border border-[var(--primary-color)]/30 dark:border-white/20"
: "border border-transparent hover:bg-[var(--glass-color)] dark:hover:bg-white/5"
}`}
>
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-[var(--glass-color)] dark:bg-white/10 flex-shrink-0">
<FiSearch className="w-4 h-4 text-[var(--primary-color)]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--black-color)] dark:text-white truncate capitalize">
{item.title}
</p>
<p className="text-xs text-[var(--black-color)]/50 dark:text-white/50 truncate">
{item.category}
</p>
</div>
{selectedIndex === index && (
<kbd className="hidden sm:inline-flex px-1.5 py-0.5 bg-[var(--primary-color)]/20 dark:bg-white/15 rounded text-xs text-[var(--primary-color)] dark:text-white/70">
Enter
</kbd>
)}
</button>
))}
</div>
)}
</div>

<div className="flex items-center justify-between gap-4 px-4 py-2.5 border-t border-[var(--border-color)] dark:border-white/10 bg-[var(--glass-color)] dark:bg-white/5 text-xs text-[var(--black-color)]/60 dark:text-white/50">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-[var(--glass-color)] dark:bg-white/10 border border-[var(--border-color)] dark:border-white/15 rounded text-[10px]">↑</kbd>
<kbd className="px-1.5 py-0.5 bg-[var(--glass-color)] dark:bg-white/10 border border-[var(--border-color)] dark:border-white/15 rounded text-[10px]">↓</kbd>
<span className="ml-1">Navigate</span>
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-[var(--glass-color)] dark:bg-white/10 border border-[var(--border-color)] dark:border-white/15 rounded text-[10px]">↵</kbd>
<span className="ml-1">Open</span>
</span>
</div>
<span className="text-[var(--primary-color)]">
{filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""}
</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</>
);
};

export default ComponentSearchbar;
19 changes: 13 additions & 6 deletions src/components/layout/components-layout/ComponentsNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useAppContext } from "@/context/AppContext";
import { useEffect, useState } from "react";
import { FaGithub, FaXTwitter } from "react-icons/fa6";
import { GoSidebarCollapse, GoSidebarExpand } from "react-icons/go";
import ComponentSearchbar from "./ComponentSearchbar";

const ComponentsNavbar = () => {
const { sideBar, setSideBar } = useAppContext();
Expand All @@ -22,6 +23,7 @@ const ComponentsNavbar = () => {

fetchStars();
}, []);

return (
<div
className={`fixed top-0 xl:right-0 z-50 ${
Expand All @@ -30,8 +32,9 @@ const ComponentsNavbar = () => {
: "w-full xl:w-[calc(100vw-5rem)]"
}`}
>
<nav className="w-full h-16 py-2 px-5 flex items-center justify-between border-b border-[var(--primary-color)] dark:border-[var(--primary-color-1)] border-dashed bg-[var(--background-color)] lg:bg-transparent lg:backdrop-blur-md lg:overflow-hidden">
<>
<nav className="w-full h-16 py-2 px-5 flex items-center justify-between border-b border-[var(--primary-color)] dark:border-[var(--primary-color-1)] border-dashed bg-[var(--background-color)] lg:bg-transparent lg:backdrop-blur-md">
{/* Left Group: Sidebar Toggle + VaulDrawer + Search */}
<div className="flex items-center gap-4">
{sideBar ? (
<GoSidebarExpand
className="text-2xl cursor-pointer hidden xl:block"
Expand All @@ -43,9 +46,14 @@ const ComponentsNavbar = () => {
onClick={() => setSideBar(true)}
/>
)}
</>
<VaulDrawer />
<div className="flex items-center md:gap-3.5">
<VaulDrawer />
<div className="hidden md:block w-64">
<ComponentSearchbar />
</div>
</div>

{/* Right Group: Social Icons + Theme Switcher */}
<div className="flex items-center gap-3.5">
<RoundedButton
href="https://x.com/md_afsar_mahmud"
icon={
Expand All @@ -59,7 +67,6 @@ const ComponentsNavbar = () => {
}
iconInfo={stars || 0}
/>

<ThemeSwitcher />
</div>
</nav>
Expand Down
73 changes: 73 additions & 0 deletions src/hooks/useComponentSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { navigation } from "@/registry/component-navigation";
import {
flattenNavigation,
FlattenedItem,
NavigationItem,
} from "@/utils/search-utils";
import { useCallback, useEffect, useMemo, useState } from "react";

interface UseComponentSearchReturn {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
query: string;
setQuery: React.Dispatch<React.SetStateAction<string>>;
filteredResults: FlattenedItem[];
toggleOpen: () => void;
closeModal: () => void;
}

export function useComponentSearch(): UseComponentSearchReturn {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");

const flattenedItems = useMemo(
() => flattenNavigation(navigation as NavigationItem[]),
[]
);

const filteredResults = useMemo(() => {
if (!query.trim()) return flattenedItems;

const lowerQuery = query.toLowerCase();
return flattenedItems.filter(
(item) =>
item.title.toLowerCase().includes(lowerQuery) ||
item.category.toLowerCase().includes(lowerQuery)
);
}, [query, flattenedItems]);

const toggleOpen = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);

const closeModal = useCallback(() => {
setIsOpen(false);
setQuery("");
}, []);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
toggleOpen();
}

if (e.key === "Escape") {
closeModal();
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggleOpen, closeModal]);

return {
isOpen,
setIsOpen,
query,
setQuery,
filteredResults,
toggleOpen,
closeModal,
};
}
37 changes: 37 additions & 0 deletions src/utils/search-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface FlattenedItem {
title: string;
href: string;
category: string;
}

export interface NavigationItem {
title: string;
href?: string;
submenu?: { name: string; href: string }[];
}

export function flattenNavigation(nav: NavigationItem[]): FlattenedItem[] {
const result: FlattenedItem[] = [];

for (const item of nav) {
if (item.href && !item.submenu) {
result.push({
title: item.title,
href: item.href,
category: "General",
});
}

if (item.submenu) {
for (const sub of item.submenu) {
result.push({
title: sub.name,
href: sub.href,
category: item.title,
});
}
}
}

return result;
}