Files
2026-05-10 22:17:38 +05:30

192 lines
6.2 KiB
TypeScript

"use client";
import { useRef, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { motion } from "framer-motion";
import { Search, CornerDownLeft } 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=...
*
* State is controlled by the parent (PageHeader) so the header can
* hide/show adjacent elements (logo, reading settings) on mobile.
*
* Pressing Escape or clicking outside collapses the input.
* Keyboard shortcut: "/" focuses the search from anywhere on the page.
*/
/** Map the current pathname to the search `type` filter value. */
function searchTypeFromPath(pathname: string): string | null {
if (pathname.startsWith("/books")) return "books";
if (pathname.startsWith("/letters")) return "letters";
if (pathname.startsWith("/essays")) return "essays";
if (pathname.startsWith("/transcriptions")) return "transcriptions";
if (pathname.startsWith("/glossary")) return "glossary";
if (pathname.startsWith("/journals")) return "journals";
if (pathname.startsWith("/magazines")) return "magazines";
if (pathname.startsWith("/other-documents")) return "other_documents";
return null; // homepage, search page, about, etc. → "all"
}
export function HeaderSearchButton({
expanded,
onExpandedChange,
}: {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
}) {
const queryRef = useRef("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const pathname = usePathname();
// Focus input when expanded
useEffect(() => {
if (expanded && inputRef.current) {
const timer = setTimeout(() => inputRef.current?.focus(), 150);
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)
) {
onExpandedChange(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded, onExpandedChange]);
// Global "/" shortcut to open search
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
) {
return;
}
if (e.key === "/") {
e.preventDefault();
onExpandedChange(true);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onExpandedChange]);
const handleSubmit = useCallback(() => {
const trimmed = queryRef.current.trim();
const params = new URLSearchParams();
if (trimmed) params.set("q", trimmed);
// Pre-select the search filter matching the current section
const type = searchTypeFromPath(pathname);
if (type) params.set("type", type);
const qs = params.toString();
router.push(`/search${qs ? `?${qs}` : ""}`);
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}, [router, onExpandedChange, pathname]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
onExpandedChange(false);
queryRef.current = "";
if (inputRef.current) inputRef.current.value = "";
}
},
[handleSubmit, onExpandedChange],
);
return (
<div
ref={containerRef}
className={cn(
"relative flex items-center",
expanded && "max-md:flex-1 max-md:min-w-0",
)}
>
{expanded ? (
<motion.div
key="search-input"
initial={{ width: 36, opacity: 0 }}
animate={{ width: "100%", opacity: 1 }}
transition={{
width: { type: "spring", stiffness: 400, damping: 30 },
opacity: { duration: 0.15 },
}}
className={cn(
"flex items-center gap-2 h-12 md:h-10 rounded-lg border border-border bg-background px-3 overflow-hidden",
"max-md:w-full",
"md:max-w-100",
)}
>
<input
ref={inputRef}
type="text"
defaultValue=""
autoFocus
onChange={(e) => {
queryRef.current = e.target.value;
}}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 bg-transparent text-md md:text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
/>
<button
type="button"
onClick={handleSubmit}
className="shrink-0 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Search
<CornerDownLeft className="size-3 opacity-70" />
</button>
</motion.div>
) : (
<motion.button
key="search-icon"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.12 }}
type="button"
onClick={() => onExpandedChange(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-4.5" />
</motion.button>
)}
</div>
);
}