Files
vyndr/web/src/components/PlayerSearch.tsx
T

234 lines
7.1 KiB
TypeScript

'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
export type PlayerResult = {
id: string;
full_name: string;
team?: string;
position?: string;
headshot_url?: string;
};
export type Sport = 'NBA' | 'MLB' | 'WNBA';
type Props = {
sport: Sport;
gameId?: string;
placeholder?: string;
initialValue?: string;
onSelect: (player: PlayerResult) => void;
autoFocus?: boolean;
};
const SPORT_TINT: Record<Sport, string> = {
NBA: 'var(--nba)',
MLB: 'var(--mlb)',
WNBA: 'var(--wnba)',
};
export default function PlayerSearch({
sport,
gameId,
placeholder = 'Search players…',
initialValue = '',
onSelect,
autoFocus = false,
}: Props) {
const [query, setQuery] = useState(initialValue);
const [results, setResults] = useState<PlayerResult[] | null>(null);
const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(0);
const [open, setOpen] = useState(false);
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const containerRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Debounced fetch
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults(null);
setLoading(false);
return;
}
setLoading(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const t = setTimeout(async () => {
try {
const params = new URLSearchParams({ sport, q });
if (gameId) params.set('game_id', gameId);
const res = await fetch(`/api/players/search?${params.toString()}`, { signal: ctrl.signal });
const data = await res.json().catch(() => ({}));
if (!ctrl.signal.aborted) {
setResults(Array.isArray(data?.players) ? data.players.slice(0, 5) : []);
setHighlight(0);
}
} catch {
if (!ctrl.signal.aborted) setResults([]);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
}, 220);
return () => {
clearTimeout(t);
ctrl.abort();
};
}, [query, sport, gameId]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
const noResults = useMemo(
() => !loading && results !== null && results.length === 0 && query.trim().length >= 2,
[loading, results, query],
);
const choose = (p: PlayerResult) => {
onSelect(p);
setQuery(p.full_name);
setOpen(false);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!results || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setHighlight((h) => Math.min(results.length - 1, h + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(0, h - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
const sel = results[highlight];
if (sel) choose(sel);
} else if (e.key === 'Escape') {
setOpen(false);
}
};
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<input
id={inputId}
role="combobox"
aria-controls={listboxId}
aria-expanded={open && (loading || !!results?.length || noResults)}
aria-autocomplete="list"
aria-activedescendant={results && results[highlight] ? `${inputId}-opt-${highlight}` : undefined}
autoComplete="off"
autoFocus={autoFocus}
placeholder={placeholder}
className="input-field"
value={query}
onFocus={() => setOpen(true)}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onKeyDown={onKeyDown}
style={{ width: '100%' }}
/>
{open && (loading || results !== null) && (
<ul
id={listboxId}
role="listbox"
className="surface-elevated"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 30,
margin: 0,
padding: 4,
listStyle: 'none',
maxHeight: 280,
overflowY: 'auto',
}}
>
{loading && (
<li style={{ padding: 12 }}>
<span className="lbl" style={{ color: 'var(--text-1)' }}>SEARCHING</span>
</li>
)}
{!loading && noResults && (
<li style={{ padding: 12 }}>
<p style={{ fontSize: 14, color: 'var(--text-0)', margin: 0 }}>
No players found for &quot;{query}&quot;.
</p>
<p style={{ fontSize: 12, color: 'var(--text-1)', margin: '4px 0 0' }}>Check spelling.</p>
</li>
)}
{!loading && results && results.map((p, i) => {
const active = i === highlight;
return (
<li
key={p.id}
id={`${inputId}-opt-${i}`}
role="option"
aria-selected={active}
onMouseDown={(e) => { e.preventDefault(); choose(p); }}
onMouseEnter={() => setHighlight(i)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
borderRadius: 8,
background: active ? 'var(--bg-2)' : 'transparent',
cursor: 'pointer',
}}
>
<span
aria-hidden
style={{
width: 24, height: 24, borderRadius: 999,
background: 'var(--bg-3)',
border: `1px solid ${SPORT_TINT[sport]}`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
fontFamily: 'var(--font-mono, "IBM Plex Mono")',
fontWeight: 700,
color: 'var(--text-1)',
}}
>
{p.full_name.split(' ').map((n) => n[0]).slice(0, 2).join('')}
</span>
<span style={{ flex: 1, color: 'var(--text-0)', fontSize: 14, fontWeight: 600 }}>
{p.full_name}
</span>
{p.team ? (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{p.team}</span>
) : null}
<span
className="pill"
style={{
color: SPORT_TINT[sport],
background: 'transparent',
border: `1px solid ${SPORT_TINT[sport]}`,
}}
>
{sport}
</span>
</li>
);
})}
</ul>
)}
</div>
);
}