feat: add search
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* HeaderSearchButton — a search icon in the page header that expands
|
||||
* into a full search input on click. Typing a query and pressing Enter
|
||||
* (or clicking the search icon again) navigates to /search?q=...
|
||||
*
|
||||
* The expansion/collapse is animated with framer-motion.
|
||||
* Pressing Escape or clicking outside collapses the input.
|
||||
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
|
||||
*/
|
||||
export function HeaderSearchButton() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Focus input when expanded
|
||||
useEffect(() => {
|
||||
if (expanded && inputRef.current) {
|
||||
// Small delay to let the animation start before focusing
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [expanded]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [expanded]);
|
||||
|
||||
// Global "/" shortcut to open search
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Don't trigger if user is typing in an input/textarea
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "/") {
|
||||
e.preventDefault();
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) {
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
setExpanded(false);
|
||||
setQuery("");
|
||||
} else {
|
||||
router.push("/search");
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [query, router]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setExpanded(false);
|
||||
setQuery("");
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{expanded ? (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
initial={{ width: 36, opacity: 0.8 }}
|
||||
animate={{ width: 280, opacity: 1 }}
|
||||
exit={{ width: 36, opacity: 0.8 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 350 }}
|
||||
className="flex items-center gap-2 h-9 rounded-lg border border-border bg-background px-3 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
aria-label="Search"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpanded(false);
|
||||
setQuery("");
|
||||
}}
|
||||
aria-label="Close search"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="search-icon"
|
||||
initial={{ opacity: 0.8, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0.8, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-label="Search (press /)"
|
||||
title="Search (press /)"
|
||||
className={cn(
|
||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
"transition-colors outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<Search className="size-[18px]" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user