vn6295337's picture
Initial commit: Instant SWOT Agent
0c591a7
import { useState, useEffect, useRef, useCallback } from "react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Search, X, Check, Loader2, ChevronLeft, ChevronRight } from "lucide-react"
import { searchStocks, StockResult } from "@/lib/api"
interface StockSearchProps {
onSelect: (stock: StockResult) => void
disabled?: boolean
selectedStock?: StockResult | null
onClear?: () => void
onSearchChange?: (isSearching: boolean) => void
}
export function StockSearch({
onSelect,
disabled = false,
selectedStock,
onClear,
onSearchChange,
}: StockSearchProps) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<StockResult[]>([])
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout>()
const nameScrollRef = useRef<HTMLDivElement>(null)
const scrollName = (direction: 'left' | 'right') => {
if (nameScrollRef.current) {
const scrollAmount = direction === 'left' ? -80 : 80
nameScrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
}
}
// Debounced search
const performSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < 1) {
setResults([])
setIsOpen(false)
return
}
setIsLoading(true)
try {
const response = await searchStocks(searchQuery)
setResults(response.results)
setIsOpen(response.results.length > 0)
setHighlightedIndex(0)
} catch (error) {
console.error("Stock search error:", error)
setResults([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
performSearch(query)
}, 150) // 150ms debounce
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
}
}, [query, performSearch])
// Notify parent when search state changes
useEffect(() => {
onSearchChange?.(query.length > 0)
}, [query, onSearchChange])
// Keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setHighlightedIndex((prev) =>
prev < results.length - 1 ? prev + 1 : prev
)
break
case "ArrowUp":
e.preventDefault()
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0))
break
case "Enter":
e.preventDefault()
if (results[highlightedIndex]) {
handleSelect(results[highlightedIndex])
}
break
case "Escape":
e.preventDefault()
setIsOpen(false)
inputRef.current?.blur()
break
}
}
const handleSelect = (stock: StockResult) => {
onSelect(stock)
setQuery("")
setIsOpen(false)
setResults([])
}
const handleClear = () => {
setQuery("")
setResults([])
setIsOpen(false)
onClear?.()
inputRef.current?.focus()
}
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
listRef.current &&
!listRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
// Show selected stock
if (selectedStock) {
return (
<div className="relative">
<div className="flex items-center gap-1 px-2 py-2 bg-card border border-border rounded-lg">
<Check className="w-4 h-4 text-emerald-500 shrink-0" />
<button
onClick={() => scrollName('left')}
className="shrink-0 p-0.5 hover:bg-muted rounded transition-colors"
>
<ChevronLeft className="w-3 h-3 text-muted-foreground" />
</button>
<div
ref={nameScrollRef}
className="flex-1 overflow-x-auto whitespace-nowrap scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<span className="font-medium text-foreground text-sm">
{selectedStock.name}
</span>
<span className="text-muted-foreground text-sm ml-1">
({selectedStock.symbol})
</span>
</div>
<button
onClick={() => scrollName('right')}
className="shrink-0 p-0.5 hover:bg-muted rounded transition-colors"
>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
<span className="text-xs text-muted-foreground px-1.5 py-0.5 bg-muted rounded shrink-0">
{selectedStock.exchange}
</span>
{!disabled && (
<button
onClick={handleClear}
className="p-0.5 hover:bg-muted rounded transition-colors shrink-0"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
</div>
)
}
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => query.length > 0 && results.length > 0 && setIsOpen(true)}
placeholder="Search U.S. listed companies..."
disabled={disabled}
className="pl-10 pr-10 bg-background border-input text-foreground focus:border-primary"
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
)}
{!isLoading && query && (
<button
onClick={() => {
setQuery("")
setResults([])
setIsOpen(false)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 hover:bg-muted rounded"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Dropdown */}
{isOpen && results.length > 0 && (
<div
ref={listRef}
className="absolute z-50 w-full mt-1 bg-card border border-border rounded-lg shadow-xl overflow-hidden"
>
<div className="max-h-64 overflow-y-auto">
{results.map((stock, index) => (
<button
key={stock.symbol}
onClick={() => handleSelect(stock)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 text-left transition-colors",
index === highlightedIndex
? "bg-muted"
: "hover:bg-muted/50"
)}
>
<span className="font-mono font-medium text-foreground min-w-[60px]">
{stock.symbol}
</span>
<span className="flex-1 text-sm text-muted-foreground truncate">
{stock.name}
</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 bg-muted rounded">
{stock.exchange}
</span>
</button>
))}
</div>
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground">
↑↓ navigate · Enter select · Esc close
</div>
</div>
)}
</div>
)
}
export default StockSearch