Files
tkmind_go/ui/desktop/src/components/conversation/SearchBar.tsx
T
2025-10-21 12:54:56 -07:00

236 lines
8.1 KiB
TypeScript

import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { ArrowDown, ArrowUp, Close } from '../icons';
import debounce from 'lodash/debounce';
import { Button } from '../ui/button';
/**
* Props for the SearchBar component
*/
interface SearchBarProps {
/** Callback fired when search term or case sensitivity changes */
onSearch: (term: string, caseSensitive: boolean) => void;
/** Callback fired when the search bar is closed */
onClose: () => void;
/** Optional callback for navigating between search results */
onNavigate?: (direction: 'next' | 'prev') => void;
/** Current search results state */
searchResults?: {
count: number;
currentIndex: number;
};
/** Optional ref for the search input element */
inputRef?: React.RefObject<HTMLInputElement>;
/** Initial search term */
initialSearchTerm?: string;
/** Placeholder text for the search input */
placeholder?: string;
}
/**
* SearchBar provides a search input with case-sensitive toggle and result navigation.
*/
export const SearchBar: React.FC<SearchBarProps> = ({
onSearch,
onClose,
onNavigate,
searchResults,
inputRef: externalInputRef,
initialSearchTerm = '',
placeholder = 'Search conversation...',
}: SearchBarProps) => {
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const [caseSensitive, setCaseSensitive] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const internalInputRef = React.useRef<HTMLInputElement>(null);
const inputRef = externalInputRef || internalInputRef;
const debouncedSearchRef = useRef<ReturnType<typeof debounce> | null>(null);
// Create debounced search function
useEffect(() => {
const debouncedFn = debounce((term: string, caseSensitive: boolean) => {
onSearch(term, caseSensitive);
}, 200);
debouncedSearchRef.current = debouncedFn;
return () => {
debouncedFn.cancel();
};
}, [onSearch]);
useEffect(() => {
inputRef.current?.focus();
}, [inputRef]);
// Handle changes to initialSearchTerm
useEffect(() => {
if (initialSearchTerm) {
setSearchTerm(initialSearchTerm);
if (initialSearchTerm.length >= 2) {
debouncedSearchRef.current?.(initialSearchTerm, caseSensitive);
}
}
}, [initialSearchTerm, caseSensitive, debouncedSearchRef]);
const [localSearchResults, setLocalSearchResults] = useState<typeof searchResults>(undefined);
// Sync external search results with local state
useEffect(() => {
// Only set results if we have a search term
if (!searchTerm) {
setLocalSearchResults(undefined);
} else {
setLocalSearchResults(searchResults);
}
}, [searchResults, searchTerm]);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
// Always cancel pending searches first
if (debouncedSearchRef.current) {
debouncedSearchRef.current.cancel();
}
// Update display term immediately for UI feedback
setSearchTerm(value);
// Only trigger search if we have 2 or more characters
if (value.length >= 2) {
debouncedSearchRef.current?.(value, caseSensitive);
} else {
// Clear results if less than 2 characters
onSearch('', caseSensitive);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
handleNavigate('prev', event);
} else if (event.key === 'ArrowDown' || event.key === 'Enter') {
handleNavigate('next', event);
} else if (event.key === 'Escape') {
event.preventDefault();
handleClose();
}
};
const handleNavigate = (direction: 'next' | 'prev', e?: React.MouseEvent | KeyboardEvent) => {
e?.preventDefault();
if (searchResults && searchResults.count > 0) {
inputRef.current?.focus();
onNavigate?.(direction);
}
};
const toggleCaseSensitive = () => {
const newCaseSensitive = !caseSensitive;
setCaseSensitive(newCaseSensitive);
// Immediately trigger a new search with updated case sensitivity
if (searchTerm) {
debouncedSearchRef.current?.(searchTerm, newCaseSensitive);
}
inputRef.current?.focus();
};
const handleClose = () => {
setIsExiting(true);
debouncedSearchRef.current?.cancel(); // Cancel any pending searches
setTimeout(() => {
onClose();
}, 150); // Match animation duration
};
const hasResults = searchResults && searchResults.count > 0;
return (
<div
className={`sticky top-0 bg-background-inverse text-text-inverse z-[60] mb-4 ${
isExiting ? 'search-bar-exit' : 'search-bar-enter'
}`}
>
<div className="flex w-full items-center">
<div className="relative flex flex-1 items-center h-full min-w-0">
<SearchIcon className="no-drag h-4 w-4 text-text-inverse/70 absolute left-3" />
<div className="w-full">
<input
ref={inputRef}
id="search-input"
type="text"
value={searchTerm}
onChange={handleSearch}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="no-drag w-full text-sm pl-9 pr-24 py-3 bg-background-inverse text-text-inverse
placeholder:text-text-inverse/50 focus:outline-none
active:border-border-strong"
/>
</div>
<div className="absolute right-3 flex h-full items-center justify-end">
<div className="flex items-center gap-1">
<div className="w-16 text-right text-sm text-text-inverse/80 flex items-center justify-end">
{(() => {
return localSearchResults?.count && localSearchResults.count > 0 && searchTerm
? `${localSearchResults.currentIndex}/${localSearchResults.count}`
: null;
})()}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center h-auto px-4 gap-2 flex-shrink-0">
<Button
onClick={toggleCaseSensitive}
variant="ghost"
className={`no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 ${
caseSensitive
? 'bg-white/20 shadow-[inset_0_1px_2px_rgba(0,0,0,0.2)] text-text-inverse hover:bg-white/25'
: 'text-text-inverse/70 hover:text-text-inverse hover:bg-white/10'
}`}
title="Case Sensitive"
>
<span className="text-md font-normal">Aa</span>
</Button>
<div className="flex items-center gap-2">
<Button
onClick={(e) => handleNavigate('prev', e)}
variant="ghost"
className="no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
title="Previous (↑)"
>
<ArrowUp
className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
/>
</Button>
<Button
onClick={(e) => handleNavigate('next', e)}
variant="ghost"
className="no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
title="Next (↓ or Enter)"
>
<ArrowDown
className={`h-5 w-5 transition-opacity ${!hasResults ? 'opacity-30' : ''}`}
/>
</Button>
</div>
<Button
onClick={handleClose}
variant="ghost"
className="no-drag flex items-center justify-center min-w-[32px] h-[28px] rounded transition-all duration-150 text-text-inverse/70 hover:text-text-inverse hover:bg-white/10"
title="Close (Esc)"
>
<Close className="h-5 w-5" />
</Button>
</div>
</div>
</div>
);
};
export default SearchBar;