Files
spsays-training/components/header-search.tsx
T
2026-05-10 20:41:58 +05:30

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>
);
}