234 lines
7.1 KiB
TypeScript
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 "{query}".
|
|
</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>
|
|
);
|
|
}
|