From 320ef51c749ff2a9ea671bd165a163f4fc0db5d6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:02:48 -0500 Subject: [PATCH] feat(ui): add Command-K search modal, remove inline search bar Co-Authored-By: Claude Opus 4.6 --- TaskTracker.Web/src/components/SearchBar.tsx | 179 ---------------- .../src/components/SearchModal.tsx | 192 ++++++++++++++++++ 2 files changed, 192 insertions(+), 179 deletions(-) delete mode 100644 TaskTracker.Web/src/components/SearchBar.tsx create mode 100644 TaskTracker.Web/src/components/SearchModal.tsx diff --git a/TaskTracker.Web/src/components/SearchBar.tsx b/TaskTracker.Web/src/components/SearchBar.tsx deleted file mode 100644 index f167f40..0000000 --- a/TaskTracker.Web/src/components/SearchBar.tsx +++ /dev/null @@ -1,179 +0,0 @@ -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(null) - const inputRef = useRef(null) - const timerRef = useRef | 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: string) => { - const col = COLUMN_CONFIG.find((c) => c.status === status) - return col ? col.label : 'Unknown' - } - - const getStatusColor = (status: string) => { - const col = COLUMN_CONFIG.find((c) => c.status === status) - return col ? col.color : '#64748b' - } - - return ( -
-
- - 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" - /> -
- - {isOpen && results.length > 0 && ( -
- {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 ( - - ) - })} -
- )} -
- ) -} diff --git a/TaskTracker.Web/src/components/SearchModal.tsx b/TaskTracker.Web/src/components/SearchModal.tsx new file mode 100644 index 0000000..06b0295 --- /dev/null +++ b/TaskTracker.Web/src/components/SearchModal.tsx @@ -0,0 +1,192 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { Search, ArrowRight } from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' +import { useTasks } from '../api/tasks.ts' +import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts' +import type { WorkTask } from '../types/index.ts' + +interface SearchModalProps { + onSelect: (taskId: number) => void + onClose: () => void +} + +export default function SearchModal({ onSelect, onClose }: SearchModalProps) { + const { data: tasks } = useTasks() + const [query, setQuery] = useState('') + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + // Filter tasks + const results: WorkTask[] = (() => { + if (!tasks) return [] + if (!query.trim()) { + // Show recent/active tasks when no query + return tasks + .filter((t) => t.status === 'Active' || t.status === 'Paused' || t.status === 'Pending') + .slice(0, 8) + } + const q = query.toLowerCase() + return tasks + .filter( + (t) => + t.title.toLowerCase().includes(q) || + (t.description && t.description.toLowerCase().includes(q)) || + (t.category && t.category.toLowerCase().includes(q)) + ) + .slice(0, 8) + })() + + useEffect(() => { + setSelectedIndex(0) + }, [query]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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]) { + onSelect(results[selectedIndex].id) + } + break + case 'Escape': + e.preventDefault() + onClose() + break + } + }, + [results, selectedIndex, onSelect, onClose] + ) + + const getStatusColor = (status: string) => { + const col = COLUMN_CONFIG.find((c) => c.status === status) + return col ? col.color : '#64748b' + } + + return ( + + + {/* Backdrop */} +
+ + {/* Modal */} + + {/* Search input */} +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search tasks..." + className="flex-1 bg-transparent text-[var(--color-text-primary)] text-sm placeholder-[var(--color-text-tertiary)] outline-none" + /> + + ESC + +
+ + {/* Results */} + {results.length > 0 ? ( +
+ {!query.trim() && ( +
+ Recent tasks +
+ )} + {results.map((task, index) => { + const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown'] + const statusColor = getStatusColor(task.status) + + return ( + + ) + })} +
+ ) : query.trim() ? ( +
+ No tasks found +
+ ) : null} + + {/* Footer */} +
+ + ↑↓ + Navigate + + + + Open + +
+
+ + + ) +}