Search Box
Autocomplete search input powered by the Garagem SDK. Shows matching listings as the user types with keyboard navigation support.
Instalar
npx shadcn@latest add https://sdk.garagem.site/r/search-box.jsonDependências npm
@garagem-ai/site-sdklucide-react
Componentes shadcn (auto-instalados)
buttoninput
Arquivos instalados
- components/garagem/search-box.tsx
Código fonte
search-box/search-box.tsx
"use client";
import {
useState,
useCallback,
useRef,
useEffect,
type KeyboardEvent,
} from "react";
import { Search, X } from "lucide-react";
import { searchListings } from "@garagem-ai/site-sdk";
import type { Listing } from "@garagem-ai/site-sdk";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface SearchBoxProps {
/** Garagem site ID for search queries. */
siteId: string;
/** API base URL. Defaults to `https://app.garagem.ai`. */
baseUrl?: string;
/** Placeholder text. Defaults to "Buscar imóveis...". */
placeholder?: string;
/** Debounce delay in ms. Defaults to 300. */
debounceMs?: number;
/** Max suggestions shown. Defaults to 5. */
maxSuggestions?: number;
/** Called when user selects a suggestion. */
onSelect?: (listing: Listing) => void;
/** Called on form submit (enter key). Receives the query string. */
onSearch?: (query: string) => void;
/** Pass-through className for the outer wrapper. */
className?: string;
}
/**
* Autocomplete search input powered by the Garagem SDK's `searchListings`.
* Shows a popover with matching listings as the user types.
*/
export function SearchBox({
siteId,
baseUrl,
placeholder = "Buscar imóveis...",
debounceMs = 300,
maxSuggestions = 5,
onSelect,
onSearch,
className,
}: SearchBoxProps) {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Listing[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fetchSuggestions = useCallback(
async (q: string) => {
if (q.trim().length < 2) {
setSuggestions([]);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const result = await searchListings({
siteId,
baseUrl,
params: {
q,
query_by: "title,description,neighborhood,city",
per_page: maxSuggestions,
page: 1,
},
});
const items = result.hits.map((h) => h.document as unknown as Listing);
setSuggestions(items);
setIsOpen(items.length > 0);
} catch {
setSuggestions([]);
setIsOpen(false);
} finally {
setIsLoading(false);
}
},
[siteId, baseUrl, maxSuggestions]
);
const handleChange = (value: string) => {
setQuery(value);
setActiveIndex(-1);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fetchSuggestions(value), debounceMs);
};
const handleSelect = (listing: Listing) => {
setQuery(listing.title);
setIsOpen(false);
onSelect?.(listing);
};
const handleSubmit = () => {
setIsOpen(false);
onSearch?.(query);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) {
if (e.key === "Enter") handleSubmit();
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, -1));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && suggestions[activeIndex]) {
handleSelect(suggestions[activeIndex]);
} else {
handleSubmit();
}
break;
case "Escape":
setIsOpen(false);
setActiveIndex(-1);
break;
}
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={containerRef} className={cn("relative w-full", className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={placeholder}
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => suggestions.length > 0 && setIsOpen(true)}
className="pl-10 pr-10"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls="search-suggestions"
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
/>
{query && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => {
setQuery("");
setSuggestions([]);
setIsOpen(false);
}}
aria-label="Limpar busca"
>
<X className="size-3.5" />
</Button>
)}
</div>
{isOpen && (
<ul
id="search-suggestions"
role="listbox"
className="absolute z-50 mt-1 w-full rounded-md border bg-popover p-1 shadow-md"
>
{suggestions.map((listing, i) => (
<li
key={listing.id}
id={`suggestion-${i}`}
role="option"
aria-selected={i === activeIndex}
className={cn(
"cursor-pointer rounded-sm px-3 py-2 text-sm transition-colors",
i === activeIndex
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50"
)}
onClick={() => handleSelect(listing)}
onMouseEnter={() => setActiveIndex(i)}
>
<p className="font-medium line-clamp-1">{listing.title}</p>
<p className="text-xs text-muted-foreground line-clamp-1">
{[listing.location?.neighborhood, listing.location?.city]
.filter(Boolean)
.join(", ")}
</p>
</li>
))}
{isLoading && (
<li className="px-3 py-2 text-sm text-muted-foreground">
Buscando...
</li>
)}
</ul>
)}
</div>
);
}