"use client"; import { Search, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Spinner } from "./ui/spinner"; type Token = | { type: "text"; value: string; start: number; end: number } | { type: "filter"; key: string; value?: string; start: number; end: number }; interface InputContext { mode: "text" | "filterKey" | "filterValue"; activeFilter?: string; token?: Token; } export interface ParsedQuery { search: string; filters: Record; } /** Split text into tokens and track their ranges */ function tokenize(input: string): Token[] { const tokens: Token[] = []; let cursor = 0; const parts = input.split(/(\s+)/); for (const part of parts) { const start = cursor; const end = start + part.length; cursor = end; if (!part.trim()) continue; const colonIndex = part.indexOf(":"); if (colonIndex !== -1) { const key = part.slice(0, colonIndex); const val = part.slice(colonIndex + 1); tokens.push({ type: "filter", key, value: val || undefined, start, end, }); } else { tokens.push({ type: "text", value: part, start, end }); } } return tokens; } /** Parse query into search text and filters */ function parseQuery(input: string): ParsedQuery { const tokens = tokenize(input); const searchParts: string[] = []; const filters: Record = {}; for (const token of tokens) { if (token.type === "filter" && token.value) { filters[token.key] = token.value; } else if (token.type === "text") { searchParts.push(token.value); } } return { search: searchParts.join(" ").trim(), filters, }; } /** Determine which token and mode the caret is inside */ function detectContext(input: string, caret: number): InputContext { const tokens = tokenize(input); // Find token at caret position const token = tokens.find((t) => caret >= t.start && caret <= t.end); if (!token) { // Check if we're right after a token const prevToken = tokens.find((t) => t.end === caret); if (prevToken?.type === "filter" && !prevToken.value) { return { mode: "filterValue", activeFilter: prevToken.key, token: prevToken, }; } return { mode: "text" }; } if (token.type === "filter") { const colonPos = input.indexOf(":", token.start); if (colonPos === -1 || caret <= colonPos) { return { mode: "filterKey", token }; } else { return { mode: "filterValue", activeFilter: token.key, token }; } } // Check if text token looks like it's starting a filter if (token.type === "text" && token.value.endsWith(":")) { const key = token.value.slice(0, -1); return { mode: "filterValue", activeFilter: key, token }; } return { mode: "filterKey", token }; } export type Filter = () => Promise | string[]; export type Filters = Record; /** Filter colors for highlighting */ const filterColors: string[] = [ "bg-blue-700 text-blue-100", "bg-purple-700 text-purple-100", "bg-green-700 text-green-100", "bg-orange-700 text-orange-100", "bg-yellow-700 text-yellow-100", "bg-red-700 text-red-100", ]; /** Get suggestions based on caret context */ async function getSuggestions(input: string, caret: number, filters: Filters) { const ctx = detectContext(input, caret); if (ctx.mode === "filterKey") { const partial = ctx.token?.type === "text" ? ctx.token.value.toLowerCase() : ""; return Object.keys(filters) .filter((f) => f.startsWith(partial)) .map((f) => `${f}:`); } if (ctx.mode === "filterValue" && ctx.activeFilter) { const source = filters[ctx.activeFilter]; if (source) { const values = await source(); const partial = ctx.token?.type === "filter" && ctx.token.value ? ctx.token.value.toLowerCase() : ""; return values.filter((v) => v.toLowerCase().includes(partial)); } } return []; } export default function SearchBox({ onChange = () => {}, // Prevent react screaming about undefined dependencies onQueryChange = () => {}, filters, value = "", className, }: { onChange?: (value: string) => void; onQueryChange?: (value: ParsedQuery) => void; className?: string; filters: Filters; value?: string; }) { const [query, setQuery] = useState(value); useEffect(() => onChange?.(query), [onChange, query]); const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestions, setSuggestions] = useState([]); const [highlight, setHighlight] = useState(0); const [isOpen, setIsOpen] = useState(false); const [parsedQuery, setParsedQuery] = useState({ search: "", filters: {}, }); useEffect(() => onQueryChange?.(parsedQuery), [onQueryChange, parsedQuery]); const inputRef = useRef(null); async function updateSuggestions(newValue: string, caretPos?: number) { const caret = caretPos ?? inputRef.current?.selectionStart ?? newValue.length; setSuggestionLoading(true); const items = await getSuggestions(newValue, caret, filters).finally(() => setSuggestionLoading(false), ); setSuggestions(items); setHighlight(0); setIsOpen(items.length > 0); setParsedQuery(parseQuery(newValue)); } function applySuggestion(s: string) { const caret = inputRef.current?.selectionStart ?? query.length; const ctx = detectContext(query, caret); if (!ctx.token) { setQuery(`${query}${s} `); setIsOpen(false); inputRef.current?.focus(); return; } const before = query.slice(0, ctx.token.start); const after = query.slice(ctx.token.end); let replacement = ""; if (ctx.mode === "filterKey") { replacement = s; } else if (ctx.mode === "filterValue") { replacement = `${ctx.activeFilter}:${s}`; } const newQuery = `${before}${replacement}${after}`.trim(); setQuery(`${newQuery} `); setIsOpen(false); setParsedQuery(parseQuery(`${newQuery} `)); // Focus back and move caret to end setTimeout(() => { inputRef.current?.focus(); const newPos = before.length + replacement.length + 1; inputRef.current?.setSelectionRange(newPos, newPos); }, 0); } useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (inputRef.current && !inputRef.current.contains(e.target as Node)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Render highlighted input function renderHighlightedInput() { const tokens = tokenize(query); const parts: React.ReactNode[] = []; let lastEnd = 0; tokens.forEach((token, i) => { const idx = i; // Add any text before this token if (token.start > lastEnd) { parts.push( {query.slice(lastEnd, token.start)}, ); } // Add the token if (token.type === "filter" && token.value) { const filterId = Object.keys(filters).indexOf(token.key); const colorClass = filterId < 0 ? "text-gray-500" : filterColors[filterId % filterColors.length]; parts.push( {query.slice(token.start, token.end)} , ); } else { parts.push( {query.slice(token.start, token.end)} , ); } lastEnd = token.end; }); // Add any remaining text if (lastEnd < query.length) { parts.push({query.slice(lastEnd)}); } return parts; } return (
{/* Highlighted overlay */}
{renderHighlightedInput()}
{/* Actual input (transparent text) */} { setQuery(e.target.value); updateSuggestions(e.target.value); }} onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }} onKeyDown={(e) => { if (!isOpen) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); updateSuggestions(query); } return; } if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((i) => Math.min(i + 1, suggestions.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter" && suggestions[highlight]) { e.preventDefault(); applySuggestion(suggestions[highlight]); } else if (e.key === "Escape") { setIsOpen(false); } else if (e.key === "Tab" && suggestions[highlight]) { e.preventDefault(); applySuggestion(suggestions[highlight]); } }} className={cn( "relative h-10 w-full rounded-md border bg-transparent pr-9 pl-9 text-sm text-transparent caret-black ring-offset-gray-400 placeholder:text-slate-500 focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", className, )} placeholder={`Search...${Object.keys(filters).length ? " Try: " : ""}${Object.keys( filters, ) .slice(0, 4) .map((f) => `${f}:`) .join(" ")}`} /> {query && ( )}
{isOpen && (suggestions.length > 0 || suggestionLoading) && (
    {suggestionLoading ? (
  • Loading...
  • ) : ( suggestions.map((s, i) => (
  • applySuggestion(s)} onMouseEnter={() => setHighlight(i)} className={` relative mx-1 flex cursor-pointer items-center rounded-sm px-3 py-2 text-sm outline-none select-none ${i === highlight ? "bg-muted" : "hover:brightness-125"} `} > {s}
  • )) )}
)}
); }