diff --git a/apps/web/src/components/global-search.tsx b/apps/web/src/components/global-search.tsx index 5cc11d4..42e7654 100644 --- a/apps/web/src/components/global-search.tsx +++ b/apps/web/src/components/global-search.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback, useEffect, useRef } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useLocation } from "wouter"; import { Search, X, BookOpen, Flag, CheckSquare, FileCheck } from "lucide-react"; import { format, parseISO } from "date-fns"; @@ -22,12 +22,17 @@ const TYPE_CONFIG = { deliverable: { icon: FileCheck, color: "text-amber-500", label: "Livrable" }, } as const; +const getSuggestionId = (index: number) => `global-search-suggestion-${index}`; +const LISTBOX_ID = "global-search-listbox"; + export function GlobalSearch() { const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); const [, navigate] = useLocation(); const inputRef = useRef(null); const containerRef = useRef(null); + const resultRefs = useRef>([]); const { data: tasks = [] } = useListTasks(); const { data: journalEntries = [] } = useListJournalEntries(); @@ -100,11 +105,41 @@ export function GlobalSearch() { (result: SearchResult) => { setQuery(""); setIsOpen(false); + setSelectedIndex(0); navigate(result.href); }, [navigate] ); + const activeIndex = results.length > 0 ? Math.min(selectedIndex, results.length - 1) : 0; + + useEffect(() => { + resultRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + const handleQueryChange = useCallback((value: string) => { + setQuery(value); + setSelectedIndex(0); + }, []); + + const handleInputKeyDown = useCallback( + (e: ReactKeyboardEvent) => { + if (results.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex(() => Math.min(activeIndex + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex(() => Math.max(activeIndex - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const target = results[activeIndex]; + if (target) handleSelect(target); + } + }, + [results, activeIndex, handleSelect] + ); + // Keyboard shortcut Ctrl+K to open search useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -116,6 +151,7 @@ export function GlobalSearch() { if (e.key === "Escape") { setIsOpen(false); setQuery(""); + setSelectedIndex(0); } }; window.addEventListener("keydown", handler); @@ -134,22 +170,30 @@ export function GlobalSearch() { return () => document.removeEventListener("mousedown", handler); }, [isOpen]); + const hasResults = results.length > 0; + const shouldRenderListbox = isOpen && query.length >= 2 && hasResults; + const activeDescendantId = shouldRenderListbox ? getSuggestionId(activeIndex) : undefined; + return (
{isOpen ? ( -
+
setQuery(e.target.value)} + onChange={(e) => handleQueryChange(e.target.value)} + onKeyDown={handleInputKeyDown} placeholder="Rechercher dans les tâches, journal, jalons..." className="pl-9 pr-9 h-10 rounded-xl border-primary" + aria-autocomplete="list" + aria-controls={shouldRenderListbox ? LISTBOX_ID : undefined} + aria-activedescendant={activeDescendantId} /> {query && (
) : ( -
- {results.map((result) => { +
+ {results.map((result, index) => { const config = TYPE_CONFIG[result.type]; const Icon = config.icon; + const isActive = index === activeIndex; return (