130 lines
3.4 KiB
TypeScript
130 lines
3.4 KiB
TypeScript
import { Search, X } from "lucide-react";
|
|
import {
|
|
type KeyboardEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
|
import { Input } from "./ui/input.js";
|
|
|
|
interface BestiarySearchProps {
|
|
onSelectCreature: (result: SearchResult) => void;
|
|
onClose: () => void;
|
|
searchFn: (query: string) => SearchResult[];
|
|
}
|
|
|
|
export function BestiarySearch({
|
|
onSelectCreature,
|
|
onClose,
|
|
searchFn,
|
|
}: BestiarySearchProps) {
|
|
const [query, setQuery] = useState("");
|
|
const [highlightIndex, setHighlightIndex] = useState(-1);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const results = query.length >= 2 ? searchFn(query) : [];
|
|
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setHighlightIndex(-1);
|
|
}, [query]);
|
|
|
|
useEffect(() => {
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(e.target as Node)
|
|
) {
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [onClose]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
onClose();
|
|
return;
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
|
|
return;
|
|
}
|
|
if (e.key === "Enter" && highlightIndex >= 0) {
|
|
e.preventDefault();
|
|
onSelectCreature(results[highlightIndex]);
|
|
}
|
|
},
|
|
[results, highlightIndex, onClose, onSelectCreature],
|
|
);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full max-w-sm">
|
|
<div className="flex items-center gap-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Search bestiary..."
|
|
className="flex-1"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-muted-foreground hover:text-hover-neutral"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{query.length >= 2 && (
|
|
<div className="absolute bottom-full z-50 mb-1 w-full rounded-md border border-border bg-card shadow-lg">
|
|
{results.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
|
No creatures found
|
|
</div>
|
|
) : (
|
|
<ul className="max-h-60 overflow-y-auto py-1">
|
|
{results.map((result, i) => (
|
|
<li key={`${result.source}:${result.name}`}>
|
|
<button
|
|
type="button"
|
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
|
i === highlightIndex
|
|
? "bg-accent/20 text-foreground"
|
|
: "text-foreground hover:bg-hover-neutral-bg"
|
|
}`}
|
|
onClick={() => onSelectCreature(result)}
|
|
onMouseEnter={() => setHighlightIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{result.sourceDisplayName}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|