Todos os blocos

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.json
Abrir no v0.dev

Dependê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>
  );
}