Files
TaskTracker/TaskTracker.Web/src/components/SearchBar.tsx
2026-02-26 22:27:55 -05:00

180 lines
5.7 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react'
import { Search } from 'lucide-react'
import { useTasks } from '../api/tasks.ts'
import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
import type { WorkTask } from '../types/index.ts'
interface SearchBarProps {
onSelect: (taskId: number) => void
}
export default function SearchBar({ onSelect }: SearchBarProps) {
const { data: tasks } = useTasks()
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounce the query by 200ms
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
setDebouncedQuery(query)
}, 200)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [query])
// Filter tasks based on debounced query
const results: WorkTask[] = (() => {
if (!debouncedQuery.trim() || !tasks) return []
const q = debouncedQuery.toLowerCase()
return tasks
.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q))
)
.slice(0, 8)
})()
// Open/close dropdown based on results
useEffect(() => {
setIsOpen(results.length > 0)
setSelectedIndex(0)
}, [results.length])
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = useCallback(
(taskId: number) => {
onSelect(taskId)
setQuery('')
setDebouncedQuery('')
setIsOpen(false)
inputRef.current?.blur()
},
[onSelect]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (results[selectedIndex]) {
handleSelect(results[selectedIndex].id)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
inputRef.current?.blur()
break
}
},
[isOpen, results, selectedIndex, handleSelect]
)
const getStatusLabel = (status: number) => {
const col = COLUMN_CONFIG.find((c) => c.status === status)
return col ? col.label : 'Unknown'
}
const getStatusColor = (status: number) => {
const col = COLUMN_CONFIG.find((c) => c.status === status)
return col ? col.color : '#64748b'
}
return (
<div ref={containerRef} className="relative w-[300px]">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[#94a3b8] pointer-events-none"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (results.length > 0) setIsOpen(true)
}}
placeholder="Search tasks..."
className="w-full h-8 pl-9 pr-3 rounded-full bg-[#1a1d27] text-white text-sm placeholder-[#94a3b8] border border-white/5 focus:border-indigo-500/50 focus:outline-none transition-colors"
/>
</div>
{isOpen && results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-2 rounded-lg bg-[#1a1d27] border border-white/10 shadow-xl shadow-black/40 z-50 overflow-hidden">
{results.map((task, index) => {
const categoryColor =
CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
const statusColor = getStatusColor(task.status)
const statusLabel = getStatusLabel(task.status)
return (
<button
key={task.id}
onClick={() => handleSelect(task.id)}
onMouseEnter={() => setSelectedIndex(index)}
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${
index === selectedIndex ? 'bg-[#2a2d37]' : ''
}`}
>
{/* Status dot */}
<span
className="shrink-0 w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
title={statusLabel}
/>
{/* Title */}
<span className="flex-1 text-sm text-white truncate">{task.title}</span>
{/* Category badge */}
{task.category && (
<span
className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: categoryColor + '20',
color: categoryColor,
}}
>
{task.category}
</span>
)}
</button>
)
})}
</div>
)}
</div>
)
}