chore: code refactor
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user