Files
IPFS-portal/src/components/SearchBar.tsx
T

235 lines
8.8 KiB
TypeScript
Raw Normal View History

'use client';
/* ════════════════════════════ SearchBar ════════════════════════════
*
* Reusable search bar met debounce, resultaten dropdown, en toetsenbordnavigatie.
* Gebruikt de client-side search index (search-index.ts) of een custom onSearch callback.
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Search, X, FileIcon, FolderIcon, Loader2 } from 'lucide-react';
import { searchIndex, type SearchResult } from '@/lib/search-index';
/* ════════════════════════════ Types ════════════════════════════ */
export interface SearchBarProps {
/** External search handler (optioneel — gebruikt search-index.ts anders) */
onSearch?: (query: string) => Promise<SearchResult[]> | SearchResult[];
/** Called when user selects a result */
onSelect: (result: SearchResult) => void;
/** Placeholder text */
placeholder?: string;
/** Auto-focus on mount */
autoFocus?: boolean;
/** Extra CSS classes */
className?: string;
}
/* ════════════════════════════ Component ════════════════════════════ */
export default function SearchBar({
onSearch,
onSelect,
placeholder = 'Search files, CIDs, and content…',
autoFocus = false,
className = '',
}: SearchBarProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [selectedIdx, setSelectedIdx] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
/* ── Search logic ── */
const doSearch = useCallback(async (q: string) => {
if (!q || q.trim().length < 2) {
setResults([]);
setShowResults(false);
return;
}
setLoading(true);
try {
const hits = onSearch
? await onSearch(q)
: searchIndex(q);
setResults(hits);
setShowResults(true);
setSelectedIdx(-1);
} finally {
setLoading(false);
}
}, [onSearch]);
/* ── Debounced input ── */
function handleChange(value: string) {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(value), 250);
}
/* ── Keyboard navigation ── */
function handleKeyDown(e: React.KeyboardEvent) {
if (!showResults || results.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIdx((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < results.length) {
handleSelect(results[selectedIdx]);
}
break;
case 'Escape':
setShowResults(false);
inputRef.current?.blur();
break;
}
}
/* ── Select ── */
function handleSelect(result: SearchResult) {
setShowResults(false);
setQuery('');
onSelect(result);
}
/* ── Click outside ── */
useEffect(() => {
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setShowResults(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
/* ── Cleanup ── */
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ════════════════════════════ Render ════════════════════════════ */
return (
<div ref={panelRef} className={`relative ${className}`}>
{/* Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
<input
ref={inputRef}
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (results.length > 0) setShowResults(true); }}
placeholder={placeholder}
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
autoFocus={autoFocus}
role="combobox"
aria-expanded={showResults}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls="search-results-listbox"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
)}
{!loading && query && (
<button
onClick={() => { setQuery(''); setResults([]); setShowResults(false); inputRef.current?.focus(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
>
<X className="w-4 h-4 text-surface-500" />
</button>
)}
</div>
{/* Live region for screen readers */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{showResults ? `${results.length} resultaten` : ''}
</div>
{/* Results dropdown */}
{showResults && (
<div
id="search-results-listbox"
role="listbox"
className="absolute top-full left-0 right-0 mt-2 glass rounded-xl overflow-hidden shadow-xl z-50 max-h-72 overflow-y-auto animate-fade-in">
{results.length === 0 && !loading ? (
<div className="p-4 text-sm text-surface-500 text-center">No results found</div>
) : (
results.map((result, idx) => (
<button
key={result.entry.cid + result.matchField}
id={`search-result-${idx}`}
role="option"
aria-selected={idx === selectedIdx}
onClick={() => handleSelect(result)}
onMouseEnter={() => setSelectedIdx(idx)}
className={`w-full flex items-start gap-3 px-4 py-3 text-left text-sm transition-colors ${
idx === selectedIdx ? 'bg-surface-700/50' : 'hover:bg-surface-800/50'
}`}
>
{result.entry.type === 'dir'
? <FolderIcon className="w-4 h-4 text-accent-amber shrink-0 mt-0.5" />
: <FileIcon className="w-4 h-4 text-accent-blue shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-surface-200 font-medium truncate">
{result.entry.name || result.entry.cid.slice(0, 20) + '…'}
</span>
{result.matchField === 'name' && (
<span className="text-[10px] text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full shrink-0">Name</span>
)}
{result.matchField === 'cid' && (
<span className="text-[10px] text-accent-cyan bg-accent-cyan/10 px-1.5 py-0.5 rounded-full shrink-0">CID</span>
)}
</div>
{result.entry.text && (
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">
{result.entry.text.slice(0, 120)}
</p>
)}
<p className="text-[11px] text-surface-600 font-mono mt-0.5">
{result.entry.cid.slice(0, 16)}
</p>
</div>
<span className="text-[11px] text-surface-500 shrink-0">
{result.entry.size > 0
? result.entry.size < 1024
? `${result.entry.size} B`
: `${(result.entry.size / 1024).toFixed(1)} KB`
: ''}
</span>
</button>
))
)}
</div>
)}
</div>
);
}