167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
"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>
|
|
);
|
|
}
|