Skip to content

Commit 0af82cb

Browse files
authored
Merge pull request #140 from afsar-dev/dev
component search bar added
2 parents 07ac6c3 + d09a1aa commit 0af82cb

File tree

4 files changed

+320
-6
lines changed

4 files changed

+320
-6
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"use client";
2+
3+
import { useComponentSearch } from "@/hooks/useComponentSearch";
4+
import { AnimatePresence, motion } from "motion/react";
5+
import { useRouter } from "next/navigation";
6+
import { useCallback, useEffect, useRef, useState } from "react";
7+
import { createPortal } from "react-dom";
8+
import { FiCommand, FiSearch } from "react-icons/fi";
9+
10+
const ComponentSearchbar = () => {
11+
const { isOpen, query, setQuery, filteredResults, toggleOpen, closeModal } =
12+
useComponentSearch();
13+
const [selectedIndex, setSelectedIndex] = useState(0);
14+
const [mounted, setMounted] = useState(false);
15+
const inputRef = useRef<HTMLInputElement>(null);
16+
const listRef = useRef<HTMLDivElement>(null);
17+
const router = useRouter();
18+
19+
useEffect(() => setMounted(true), []);
20+
21+
useEffect(() => setSelectedIndex(0), [filteredResults]);
22+
23+
useEffect(() => {
24+
if (isOpen && inputRef.current) inputRef.current.focus();
25+
}, [isOpen]);
26+
27+
useEffect(() => {
28+
if (selectedIndex >= 0 && listRef.current) {
29+
const activeItem = listRef.current.children[selectedIndex] as HTMLElement;
30+
if (activeItem) {
31+
activeItem.scrollIntoView({ block: "nearest", behavior: "auto" });
32+
}
33+
}
34+
}, [selectedIndex]);
35+
36+
const handleNavigate = useCallback(
37+
(href: string) => {
38+
router.push(href);
39+
closeModal();
40+
},
41+
[router, closeModal]
42+
);
43+
44+
useEffect(() => {
45+
if (!isOpen) return;
46+
const handleKeyDown = (e: KeyboardEvent) => {
47+
if (e.key === "ArrowDown") {
48+
e.preventDefault();
49+
setSelectedIndex((prev) =>
50+
prev < filteredResults.length - 1 ? prev + 1 : 0
51+
);
52+
} else if (e.key === "ArrowUp") {
53+
e.preventDefault();
54+
setSelectedIndex((prev) =>
55+
prev > 0 ? prev - 1 : filteredResults.length - 1
56+
);
57+
} else if (e.key === "Enter" && filteredResults[selectedIndex]) {
58+
e.preventDefault();
59+
handleNavigate(filteredResults[selectedIndex].href);
60+
}
61+
};
62+
document.addEventListener("keydown", handleKeyDown);
63+
return () => document.removeEventListener("keydown", handleKeyDown);
64+
}, [isOpen, filteredResults, selectedIndex, handleNavigate]);
65+
66+
useEffect(() => {
67+
document.body.style.overflow = isOpen ? "hidden" : "";
68+
return () => {
69+
document.body.style.overflow = "";
70+
};
71+
}, [isOpen]);
72+
73+
return (
74+
<>
75+
<button
76+
onClick={toggleOpen}
77+
className="relative w-full h-10 px-3 flex items-center gap-2
78+
bg-[var(--glass-color)] dark:bg-white/5
79+
border border-[var(--border-color)] dark:border-white/15
80+
hover:bg-white/10 hover:border-[var(--primary-color)] dark:hover:border-white/25
81+
focus:border-[var(--primary-color)] dark:focus:border-white/30
82+
rounded-xl transition-all cursor-text text-sm
83+
text-[var(--black-color)] dark:text-white/60
84+
focus:outline-none focus:ring-2 focus:ring-white/20"
85+
>
86+
<FiSearch className="w-4 h-4 text-[var(--black-color)]/50 dark:text-white/40" />
87+
<span className="flex-1 text-left text-[var(--black-color)]/50 dark:text-white/40">
88+
Search...
89+
</span>
90+
<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">
91+
<FiCommand className="w-3 h-3" />
92+
<span>K</span>
93+
</kbd>
94+
</button>
95+
96+
{mounted &&
97+
createPortal(
98+
<AnimatePresence>
99+
{isOpen && (
100+
<motion.div
101+
initial={{ opacity: 0 }}
102+
animate={{ opacity: 1 }}
103+
exit={{ opacity: 0 }}
104+
transition={{ duration: 0.15 }}
105+
onClick={closeModal}
106+
className="fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm flex items-center justify-center"
107+
>
108+
<motion.div
109+
initial={{ opacity: 0, scale: 0.95 }}
110+
animate={{ opacity: 1, scale: 1 }}
111+
exit={{ opacity: 0, scale: 0.95 }}
112+
transition={{ duration: 0.15, ease: "easeOut" }}
113+
onClick={(e) => e.stopPropagation()}
114+
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"
115+
>
116+
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-color)] dark:border-white/10">
117+
<FiSearch className="w-5 h-5 text-[var(--primary-color)] flex-shrink-0" />
118+
<input
119+
ref={inputRef}
120+
type="text"
121+
value={query}
122+
onChange={(e) => setQuery(e.target.value)}
123+
placeholder="Search components..."
124+
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"
125+
/>
126+
<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">
127+
ESC
128+
</kbd>
129+
</div>
130+
131+
<div className="max-h-80 overflow-y-auto py-2">
132+
{filteredResults.length === 0 ? (
133+
<div className="px-4 py-8 text-center text-[var(--black-color)]/50 dark:text-white/50 text-sm">
134+
No results found for &ldquo;{query}&rdquo;
135+
</div>
136+
) : (
137+
<div ref={listRef} className="space-y-1 px-2">
138+
{filteredResults.map((item, index) => (
139+
<button
140+
key={`${item.href}-${index}`}
141+
onClick={() => handleNavigate(item.href)}
142+
onMouseEnter={() => setSelectedIndex(index)}
143+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all cursor-pointer ${
144+
selectedIndex === index
145+
? "bg-[var(--primary-color)]/10 dark:bg-white/10 border border-[var(--primary-color)]/30 dark:border-white/20"
146+
: "border border-transparent hover:bg-[var(--glass-color)] dark:hover:bg-white/5"
147+
}`}
148+
>
149+
<div className="w-8 h-8 flex items-center justify-center rounded-lg bg-[var(--glass-color)] dark:bg-white/10 flex-shrink-0">
150+
<FiSearch className="w-4 h-4 text-[var(--primary-color)]" />
151+
</div>
152+
<div className="flex-1 min-w-0">
153+
<p className="text-sm font-medium text-[var(--black-color)] dark:text-white truncate capitalize">
154+
{item.title}
155+
</p>
156+
<p className="text-xs text-[var(--black-color)]/50 dark:text-white/50 truncate">
157+
{item.category}
158+
</p>
159+
</div>
160+
{selectedIndex === index && (
161+
<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">
162+
Enter
163+
</kbd>
164+
)}
165+
</button>
166+
))}
167+
</div>
168+
)}
169+
</div>
170+
171+
<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">
172+
<div className="flex items-center gap-3">
173+
<span className="flex items-center gap-1">
174+
<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>
175+
<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>
176+
<span className="ml-1">Navigate</span>
177+
</span>
178+
<span className="flex items-center gap-1">
179+
<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>
180+
<span className="ml-1">Open</span>
181+
</span>
182+
</div>
183+
<span className="text-[var(--primary-color)]">
184+
{filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""}
185+
</span>
186+
</div>
187+
</motion.div>
188+
</motion.div>
189+
)}
190+
</AnimatePresence>,
191+
document.body
192+
)}
193+
</>
194+
);
195+
};
196+
197+
export default ComponentSearchbar;

src/components/layout/components-layout/ComponentsNavbar.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useAppContext } from "@/context/AppContext";
66
import { useEffect, useState } from "react";
77
import { FaGithub, FaXTwitter } from "react-icons/fa6";
88
import { GoSidebarCollapse, GoSidebarExpand } from "react-icons/go";
9+
import ComponentSearchbar from "./ComponentSearchbar";
910

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

2324
fetchStars();
2425
}, []);
26+
2527
return (
2628
<div
2729
className={`fixed top-0 xl:right-0 z-50 ${
@@ -30,8 +32,9 @@ const ComponentsNavbar = () => {
3032
: "w-full xl:w-[calc(100vw-5rem)]"
3133
}`}
3234
>
33-
<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">
34-
<>
35+
<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">
36+
{/* Left Group: Sidebar Toggle + VaulDrawer + Search */}
37+
<div className="flex items-center gap-4">
3538
{sideBar ? (
3639
<GoSidebarExpand
3740
className="text-2xl cursor-pointer hidden xl:block"
@@ -43,9 +46,14 @@ const ComponentsNavbar = () => {
4346
onClick={() => setSideBar(true)}
4447
/>
4548
)}
46-
</>
47-
<VaulDrawer />
48-
<div className="flex items-center md:gap-3.5">
49+
<VaulDrawer />
50+
<div className="hidden md:block w-64">
51+
<ComponentSearchbar />
52+
</div>
53+
</div>
54+
55+
{/* Right Group: Social Icons + Theme Switcher */}
56+
<div className="flex items-center gap-3.5">
4957
<RoundedButton
5058
href="https://x.com/md_afsar_mahmud"
5159
icon={
@@ -59,7 +67,6 @@ const ComponentsNavbar = () => {
5967
}
6068
iconInfo={stars || 0}
6169
/>
62-
6370
<ThemeSwitcher />
6471
</div>
6572
</nav>

src/hooks/useComponentSearch.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { navigation } from "@/registry/component-navigation";
2+
import {
3+
flattenNavigation,
4+
FlattenedItem,
5+
NavigationItem,
6+
} from "@/utils/search-utils";
7+
import { useCallback, useEffect, useMemo, useState } from "react";
8+
9+
interface UseComponentSearchReturn {
10+
isOpen: boolean;
11+
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
12+
query: string;
13+
setQuery: React.Dispatch<React.SetStateAction<string>>;
14+
filteredResults: FlattenedItem[];
15+
toggleOpen: () => void;
16+
closeModal: () => void;
17+
}
18+
19+
export function useComponentSearch(): UseComponentSearchReturn {
20+
const [isOpen, setIsOpen] = useState(false);
21+
const [query, setQuery] = useState("");
22+
23+
const flattenedItems = useMemo(
24+
() => flattenNavigation(navigation as NavigationItem[]),
25+
[]
26+
);
27+
28+
const filteredResults = useMemo(() => {
29+
if (!query.trim()) return flattenedItems;
30+
31+
const lowerQuery = query.toLowerCase();
32+
return flattenedItems.filter(
33+
(item) =>
34+
item.title.toLowerCase().includes(lowerQuery) ||
35+
item.category.toLowerCase().includes(lowerQuery)
36+
);
37+
}, [query, flattenedItems]);
38+
39+
const toggleOpen = useCallback(() => {
40+
setIsOpen((prev) => !prev);
41+
}, []);
42+
43+
const closeModal = useCallback(() => {
44+
setIsOpen(false);
45+
setQuery("");
46+
}, []);
47+
48+
useEffect(() => {
49+
const handleKeyDown = (e: KeyboardEvent) => {
50+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
51+
e.preventDefault();
52+
toggleOpen();
53+
}
54+
55+
if (e.key === "Escape") {
56+
closeModal();
57+
}
58+
};
59+
60+
document.addEventListener("keydown", handleKeyDown);
61+
return () => document.removeEventListener("keydown", handleKeyDown);
62+
}, [toggleOpen, closeModal]);
63+
64+
return {
65+
isOpen,
66+
setIsOpen,
67+
query,
68+
setQuery,
69+
filteredResults,
70+
toggleOpen,
71+
closeModal,
72+
};
73+
}

src/utils/search-utils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export interface FlattenedItem {
2+
title: string;
3+
href: string;
4+
category: string;
5+
}
6+
7+
export interface NavigationItem {
8+
title: string;
9+
href?: string;
10+
submenu?: { name: string; href: string }[];
11+
}
12+
13+
export function flattenNavigation(nav: NavigationItem[]): FlattenedItem[] {
14+
const result: FlattenedItem[] = [];
15+
16+
for (const item of nav) {
17+
if (item.href && !item.submenu) {
18+
result.push({
19+
title: item.title,
20+
href: item.href,
21+
category: "General",
22+
});
23+
}
24+
25+
if (item.submenu) {
26+
for (const sub of item.submenu) {
27+
result.push({
28+
title: sub.name,
29+
href: sub.href,
30+
category: item.title,
31+
});
32+
}
33+
}
34+
}
35+
36+
return result;
37+
}

0 commit comments

Comments
 (0)