veza/apps/web/src/components/search/Search.tsx
senke 64265f5438 fix: Corriger problèmes de superposition (layering) des composants
- Ajouter margin-left au contenu principal pour compenser la sidebar fixe
- Margin dynamique basé sur l'état ouvert/fermé de la sidebar
- Augmenter z-index du dropdown de recherche à z-[110] pour être au-dessus du header (z-100)
- Ajouter z-index au wrapper de recherche dans Header
- Le contenu principal ne se superpose plus avec la sidebar
- Le dropdown de recherche s'affiche correctement au-dessus de tous les éléments
2026-01-18 13:32:17 +01:00

494 lines
16 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { cn } from '@/lib/utils';
import {
Search as SearchIcon,
X,
Clock,
Music,
User,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { logger } from '@/utils/logger';
export interface SearchResult {
id: string;
type: 'track' | 'user' | 'playlist';
title: string;
subtitle?: string;
image?: string;
}
export interface SearchProps {
onSearch: (query: string) => void;
onResultSelect?: (result: SearchResult) => void;
placeholder?: string;
showSuggestions?: boolean;
showHistory?: boolean;
maxHistoryItems?: number;
fetchSuggestions?: (
query: string,
) => Promise<SearchResult[]> | SearchResult[];
className?: string;
debounceDelay?: number;
}
const HISTORY_KEY = 'veza_search_history';
const MAX_HISTORY_ITEMS = 10;
/**
* Composant Search avec autocomplete, suggestions, et historique.
*/
export function Search({
onSearch,
onResultSelect,
placeholder = 'Rechercher...',
showSuggestions = true,
showHistory = true,
maxHistoryItems = MAX_HISTORY_ITEMS,
fetchSuggestions,
className,
debounceDelay = 300,
}: SearchProps) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [history, setHistory, removeHistory] = useLocalStorage<string[]>(
HISTORY_KEY,
[],
);
const searchRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Debounce de la query pour la recherche
const debouncedQuery = useDebounce(query, debounceDelay);
// Log query changes
useEffect(() => {
console.log('[🔍 Search] Query changed', {
rawQuery: query,
debouncedQuery,
debounceDelay,
});
}, [query, debouncedQuery, debounceDelay]);
// Fetch suggestions lorsque la query change
useEffect(() => {
console.log('[🔍 Search] useEffect triggered for suggestions', {
debouncedQuery,
hasFetchSuggestions: !!fetchSuggestions,
showSuggestions,
});
if (!debouncedQuery.trim() || !fetchSuggestions || !showSuggestions) {
console.log('[🔍 Search] Skipping suggestions fetch', {
reason: !debouncedQuery.trim()
? 'empty query'
: !fetchSuggestions
? 'no fetchSuggestions'
: 'showSuggestions disabled',
});
setSuggestions([]);
setIsLoading(false);
return;
}
console.log('[🔍 Search] Starting suggestions fetch', { debouncedQuery });
setIsLoading(true);
const loadSuggestions = async () => {
const startTime = performance.now();
try {
const results = await Promise.resolve(fetchSuggestions(debouncedQuery));
const duration = performance.now() - startTime;
console.log('[🔍 Search] Suggestions fetched', {
count: results.length,
duration: `${duration.toFixed(2)}ms`,
results: results.map((r) => ({ type: r.type, title: r.title })),
});
setSuggestions(results);
logger.debug('Suggestions loaded', {
query: debouncedQuery,
count: results.length,
component: 'Search',
});
} catch (error) {
const duration = performance.now() - startTime;
console.error('[🔍 Search] Error fetching suggestions', {
error,
query: debouncedQuery,
duration: `${duration.toFixed(2)}ms`,
errorMessage: error instanceof Error ? error.message : String(error),
});
logger.error('Error fetching suggestions', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
query: debouncedQuery,
component: 'Search',
});
setSuggestions([]);
} finally {
setIsLoading(false);
console.log('[🔍 Search] Suggestions fetch completed', { isLoading: false });
}
};
loadSuggestions();
}, [debouncedQuery, fetchSuggestions, showSuggestions]);
// FIX: Ne pas appeler onSearch automatiquement à chaque changement de query
// onSearch sera appelé seulement quand l'utilisateur soumet la recherche (Enter ou clic)
// Cela évite de naviguer vers /search à chaque frappe
// L'effet précédent qui appelait onSearch à chaque debouncedQuery a été supprimé
// Gérer le clic en dehors pour fermer le dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
searchRef.current &&
!searchRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
setActiveIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
console.log('[🔍 Search] Input changed', { value, length: value.length });
setQuery(value);
setShowDropdown(true);
setActiveIndex(-1);
};
const handleInputFocus = () => {
console.log('[🔍 Search] Input focused', {
query,
hasHistory: showHistory && history.length > 0,
historyCount: history.length,
});
if (query.trim() || (showHistory && history.length > 0)) {
setShowDropdown(true);
}
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const items = [
...(showHistory && query.trim() === '' ? history.slice(0, 5) : []),
...suggestions,
];
console.log('[🔍 Search] Key pressed', {
key: e.key,
activeIndex,
itemsCount: items.length,
query,
});
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const newIndexDown = activeIndex < items.length - 1 ? activeIndex + 1 : activeIndex;
console.log('[🔍 Search] ArrowDown', { previousIndex: activeIndex, newIndex: newIndexDown });
setActiveIndex(newIndexDown);
break;
case 'ArrowUp':
e.preventDefault();
const newIndexUp = activeIndex > 0 ? activeIndex - 1 : -1;
console.log('[🔍 Search] ArrowUp', { previousIndex: activeIndex, newIndex: newIndexUp });
setActiveIndex(newIndexUp);
break;
case 'Enter':
e.preventDefault();
console.log('[🔍 Search] Enter pressed', {
activeIndex,
itemsCount: items.length,
query,
});
if (activeIndex >= 0 && activeIndex < items.length) {
if (
activeIndex <
(showHistory && query.trim() === '' ? history.length : 0)
) {
// C'est un item de l'historique
const historyItem = history[activeIndex];
console.log('[🔍 Search] Selecting history item', { historyItem, index: activeIndex });
setQuery(historyItem);
handleSearch(historyItem);
} else {
// C'est une suggestion
const suggestionIndex =
activeIndex -
(showHistory && query.trim() === ''
? Math.min(history.length, 5)
: 0);
const result = suggestions[suggestionIndex];
console.log('[🔍 Search] Selecting suggestion', {
suggestionIndex,
result: result ? { type: result.type, title: result.title } : null,
});
handleResultSelect(result);
}
} else if (query.trim()) {
console.log('[🔍 Search] Submitting query directly', { query });
handleSearch(query);
}
break;
case 'Escape':
console.log('[🔍 Search] Escape pressed, closing dropdown');
setShowDropdown(false);
setActiveIndex(-1);
inputRef.current?.blur();
break;
}
};
const handleSearch = useCallback(
(searchQuery: string) => {
console.log('[🔍 Search] handleSearch called', { searchQuery });
if (searchQuery.trim()) {
// Ajouter à l'historique
if (showHistory) {
setHistory((prev) => {
const filtered = prev.filter((item) => item !== searchQuery);
const updated = [searchQuery, ...filtered].slice(
0,
maxHistoryItems,
);
console.log('[🔍 Search] History updated', {
previousCount: prev.length,
newCount: updated.length,
added: searchQuery,
});
return updated;
});
}
setShowDropdown(false);
console.log('[🔍 Search] Calling onSearch callback', { searchQuery });
onSearch(searchQuery);
logger.debug('Search submitted from Search component', {
query: searchQuery,
component: 'Search',
});
} else {
console.log('[🔍 Search] Empty search query, skipping');
}
},
[onSearch, showHistory, setHistory, maxHistoryItems],
);
const handleResultSelect = useCallback(
(result: SearchResult) => {
console.log('[🔍 Search] handleResultSelect called', {
type: result.type,
id: result.id,
title: result.title,
});
setQuery(result.title);
setShowDropdown(false);
setActiveIndex(-1);
onResultSelect?.(result);
logger.debug('Search result selected from Search component', {
type: result.type,
id: result.id,
component: 'Search',
});
},
[onResultSelect],
);
const handleHistoryItemClick = (item: string) => {
setQuery(item);
handleSearch(item);
};
const handleClearHistory = () => {
removeHistory();
};
const handleClear = () => {
setQuery('');
setSuggestions([]);
setShowDropdown(false);
inputRef.current?.focus();
};
const getResultIcon = (type: SearchResult['type']) => {
switch (type) {
case 'track':
return <Music className="h-4 w-4" />;
case 'user':
return <User className="h-4 w-4" />;
case 'playlist':
return <List className="h-4 w-4" />;
}
};
const displayItems = useMemo(() => {
const items: Array<{
type: 'history' | 'suggestion';
data: string | SearchResult;
}> = [];
if (showHistory && query.trim() === '' && history.length > 0) {
items.push(
...history
.slice(0, 5)
.map((item) => ({ type: 'history' as const, data: item })),
);
}
if (showSuggestions && suggestions.length > 0) {
items.push(
...suggestions.map((result) => ({
type: 'suggestion' as const,
data: result,
})),
);
}
return items;
}, [showHistory, showSuggestions, query, history, suggestions]);
const shouldShowDropdown =
showDropdown &&
(displayItems.length > 0 || isLoading) &&
(showSuggestions || (showHistory && query.trim() === ''));
return (
<div ref={searchRef} className={cn('relative w-full', className)}>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none z-10" />
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
onFocus={handleInputFocus}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
className={cn(
'w-full rounded-md border border-input bg-background pl-10 pr-10 py-2 text-sm',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
// Support pour className personnalisé (utilisé dans Header avec bg-transparent)
className && className.includes('bg-transparent') ? 'bg-transparent border-none' : '',
)}
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{shouldShowDropdown && (
<div className="absolute z-[110] mt-2 w-full rounded-md border bg-popover shadow-lg">
{isLoading && (
<div className="p-4 text-center text-sm text-muted-foreground">
Recherche en cours...
</div>
)}
{!isLoading && displayItems.length === 0 && query.trim() && (
<div className="p-4 text-center text-sm text-muted-foreground">
Aucun résultat trouvé
</div>
)}
{!isLoading && displayItems.length > 0 && (
<div className="max-h-96 overflow-y-auto">
{showHistory && query.trim() === '' && history.length > 0 && (
<div className="border-b p-2">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">
RÉCENTS
</span>
<Button
variant="ghost"
size="sm"
onClick={handleClearHistory}
className="h-6 text-xs"
>
Effacer
</Button>
</div>
{history.slice(0, 5).map((item, index) => (
<button
key={index}
type="button"
onClick={() => handleHistoryItemClick(item)}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-accent',
'flex items-center gap-2',
activeIndex === index && 'bg-accent',
)}
>
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">{item}</span>
</button>
))}
</div>
)}
{showSuggestions && suggestions.length > 0 && (
<div className="p-2">
{query.trim() && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
SUGGESTIONS
</div>
)}
{suggestions.map((result, index) => {
const itemIndex =
(showHistory && query.trim() === ''
? Math.min(history.length, 5)
: 0) + index;
return (
<button
key={result.id}
type="button"
onClick={() => handleResultSelect(result)}
className={cn(
'w-full px-4 py-2 text-left text-sm hover:bg-accent',
'flex items-center gap-4',
activeIndex === itemIndex && 'bg-accent',
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded bg-muted">
{getResultIcon(result.type)}
</div>
<div className="flex-1">
<div className="font-medium">{result.title}</div>
{result.subtitle && (
<div className="text-xs text-muted-foreground">
{result.subtitle}
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}