"use client"; import { LoaderCircleIcon, SearchIcon, X } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useId, useRef, useState } from "react"; import { Badge } from "@/components/shadcn/badge/badge"; import { Input } from "@/components/shadcn/input/input"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; import { useUrlFilters } from "@/hooks/use-url-filters"; import { cn } from "@/lib/utils"; const SEARCH_DEBOUNCE_MS = 500; interface DataTableSearchProps { /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */ paramPrefix?: string; /* * Controlled mode: Use these props to manage search via React state * instead of URL params. Useful for tables in drawers/modals to avoid * triggering page re-renders when searching. */ controlledValue?: string; onSearchChange?: (value: string) => void; /** * Called when the user commits a search (pressing Enter). * When provided, the search is only "committed" on Enter, while * onSearchChange still fires on every keystroke for responsive display. * Use this to avoid remounting child components on every keystroke. */ onSearchCommit?: (value: string) => void; placeholder?: string; /** Badge shown inside the search input (e.g., active drill-down group title) */ badge?: { label: string; onDismiss: () => void }; } export const DataTableSearch = ({ paramPrefix = "", controlledValue, onSearchChange, onSearchCommit, placeholder = "Search... (press Enter)", badge, }: DataTableSearchProps) => { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); const { updateFilter } = useUrlFilters(); const [internalValue, setInternalValue] = useState(""); // In controlled mode, track display value separately for immediate feedback const [displayValue, setDisplayValue] = useState(controlledValue ?? ""); const [isLoading, setIsLoading] = useState(false); const [isFocused, setIsFocused] = useState(false); const id = useId(); const debounceTimeoutRef = useRef(null); const inputRef = useRef(null); // Use controlled value if provided, otherwise internal state const isControlled = controlledValue !== undefined && onSearchChange; // For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise const value = isControlled ? displayValue : internalValue; // Force expanded when badge is present const hasBadge = !!badge; // Sync displayValue when controlledValue changes externally (e.g., clear filters) useEffect(() => { if (isControlled) { setDisplayValue(controlledValue); } }, [controlledValue, isControlled]); // Determine param names based on prefix const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]"; const pageParam = paramPrefix ? `${paramPrefix}Page` : "page"; // Sync with URL on mount (only for uncontrolled mode) useEffect(() => { if (isControlled) return; const searchFromUrl = searchParams.get(searchParam) || ""; setInternalValue(searchFromUrl); }, [searchParams, searchParam, isControlled]); // Handle input change with debounce const handleChange = (newValue: string) => { if (isControlled) { setDisplayValue(newValue); if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } if (onSearchCommit) { // Enter-to-commit mode: sync parent immediately (no debounce, no loading). // The actual search commit happens on Enter via onSearchCommit. onSearchChange(newValue); return; } // Standard controlled mode: debounce the callback setIsLoading(true); debounceTimeoutRef.current = setTimeout(() => { onSearchChange(newValue); setIsLoading(false); }, SEARCH_DEBOUNCE_MS); return; } // Uncontrolled mode: only update display value on keystroke. // The actual URL update happens on Enter (see onKeyDown handler). setInternalValue(newValue); }; // Cleanup timeout on unmount useEffect(() => { return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } }; }, []); const handleFocus = () => { setIsFocused(true); }; const handleBlur = () => { setIsFocused(false); }; return (
{hasBadge && ( {badge.label} {badge.label} )} handleChange(e.target.value)} onKeyDown={(e) => { if (e.key !== "Enter") return; // Cancel any pending debounce — Enter commits immediately if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); debounceTimeoutRef.current = null; } setIsLoading(false); // Controlled mode with explicit commit callback if (isControlled && onSearchCommit) { onSearchCommit(value); return; } // Uncontrolled mode: immediate URL update (shortcut for debounce) if (!isControlled) { if (paramPrefix) { const params = new URLSearchParams(searchParams.toString()); if (value) { params.set(searchParam, value); } else { params.delete(searchParam); } params.set(pageParam, "1"); router.push(`${pathname}?${params.toString()}`, { scroll: false, }); } else { updateFilter("search", value || null); } } }} onFocus={handleFocus} onBlur={handleBlur} className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none" /> {isLoading && (
)}
); };