192 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|