Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions apps/web/src/components/global-search.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resultRefs = useRef<Array<HTMLButtonElement | null>>([]);

const { data: tasks = [] } = useListTasks();
const { data: journalEntries = [] } = useListJournalEntries();
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

// Keyboard shortcut Ctrl+K to open search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
Expand All @@ -116,6 +151,7 @@ export function GlobalSearch() {
if (e.key === "Escape") {
setIsOpen(false);
setQuery("");
setSelectedIndex(0);
}
};
window.addEventListener("keydown", handler);
Expand All @@ -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 (
<div ref={containerRef} className="relative">
{isOpen ? (
<div className="relative">
<div className="relative" role="combobox" aria-expanded={shouldRenderListbox} aria-haspopup="listbox" aria-owns={shouldRenderListbox ? LISTBOX_ID : undefined}>
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyDown={handleInputKeyDown}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 && (
<button
type="button"
onClick={() => setQuery("")}
onClick={() => handleQueryChange("")}
aria-label="Effacer la recherche"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
Expand Down Expand Up @@ -186,15 +230,26 @@ export function GlobalSearch() {
Aucun résultat pour "{query}"
</div>
) : (
<div className="p-1">
{results.map((result) => {
<div id={LISTBOX_ID} role="listbox" className="p-1">
{results.map((result, index) => {
const config = TYPE_CONFIG[result.type];
const Icon = config.icon;
const isActive = index === activeIndex;
return (
<button
key={result.id}
id={getSuggestionId(index)}
role="option"
aria-selected={isActive}
ref={(el) => {
resultRefs.current[index] = el;
}}
onClick={() => handleSelect(result)}
className="w-full flex items-start gap-3 rounded-xl p-3 text-left hover:bg-secondary/50 transition-colors"
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"w-full flex items-start gap-3 rounded-xl p-3 text-left transition-colors",
isActive ? "bg-secondary/70" : "hover:bg-secondary/50"
)}
>
<Icon className={cn("w-4 h-4 mt-0.5 flex-shrink-0", config.color)} />
<div className="flex-1 min-w-0">
Expand Down
Loading