import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { Input } from "~/components/primitives/Input"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { useSearchParams } from "~/hooks/useSearchParam"; import { cn } from "~/utils/cn"; export type SearchInputProps = { placeholder?: string; /** The URL search param name to read/write. Defaults to "search". */ paramName?: string; /** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */ resetParams?: string[]; autoFocus?: boolean; /** * Controlled value. When provided alongside `onValueChange`, the input * skips URL params entirely and acts as a controlled component — useful * for client-side filters (e.g. tree filtering) that don't round-trip * to the server. */ value?: string; /** * Called on every keystroke and on clear when in controlled mode. The * presence of this prop is what switches the input into controlled * mode; `paramName`/`resetParams` are ignored when it's set. */ onValueChange?: (value: string) => void; }; export function SearchInput({ placeholder = "Search logs…", paramName = "search", resetParams = ["cursor", "direction"], autoFocus, value: controlledValue, onValueChange, }: SearchInputProps) { const inputRef = useRef(null); const { value, replace, del } = useSearchParams(); const isControlled = onValueChange !== undefined; const initialSearch = isControlled ? (controlledValue ?? "") : (value(paramName) ?? ""); const [text, setText] = useState(initialSearch); const [isFocused, setIsFocused] = useState(false); // Compare against a ref, not `text`, so the effect stays off the keystroke path. // Trade-off: controlled mode assumes the parent accepts onValueChange; it won't // re-sync `text` if the parent rejects a change and holds `value` unchanged. const lastSyncedRef = useRef(initialSearch); useEffect(() => { if (isControlled) { if (controlledValue !== undefined && controlledValue !== lastSyncedRef.current) { lastSyncedRef.current = controlledValue; setText(controlledValue); } return; } const urlSearch = value(paramName) ?? ""; if (urlSearch === lastSyncedRef.current) return; // Only mark synced once we actually apply it, so a URL change during focus still syncs on blur. if (!isFocused) { lastSyncedRef.current = urlSearch; setText(urlSearch); } }, [isControlled, controlledValue, value, isFocused, paramName]); const updateText = (next: string) => { setText(next); if (isControlled) { onValueChange?.(next); } }; const handleSubmit = () => { if (isControlled) { // Live updates already fired through onValueChange; submit is a no-op. return; } const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined])); if (text.trim()) { replace({ [paramName]: text.trim(), ...resetValues }); } else { del([paramName, ...resetParams]); } }; const handleClear = () => { setText(""); if (isControlled) { onValueChange?.(""); } else { del([paramName, ...resetParams]); } }; return (
0 ? "24rem" : "auto" }} transition={{ type: "spring", stiffness: 300, damping: 30, }} className="relative h-6 min-w-52" > updateText(e.target.value)} fullWidth autoFocus={autoFocus} className={cn("", isFocused && "placeholder:text-text-dimmed/70")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSubmit(); } if (e.key === "Escape") { if (text.length > 0) { e.stopPropagation(); handleClear(); } else { e.currentTarget.blur(); } } }} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} icon={} accessory={ text.length > 0 ? (
{!isControlled && ( )} e.preventDefault()} onClick={() => handleClear()} className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-surface-control hover:text-text-bright" > } content={
Clear field
} className="px-2 py-1.5 text-xs" disableHoverableContent />
) : undefined } />
); }