import { useState, useRef, useCallback, useMemo, useEffect, type ComponentType, type SVGAttributes } from 'react' import type { VaultEntry } from '../types' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { getTypeIcon } from './NoteItem' import './WikilinkSuggestionMenu.css' const MIN_QUERY_LENGTH = 2 const MAX_RESULTS = 10 interface NoteAutocompleteProps { entries: VaultEntry[] typeEntryMap: Record value: string onChange: (value: string) => void onSelect: (noteTitle: string) => void onEscape?: () => void placeholder?: string autoFocus?: boolean testId?: string } interface MatchedEntry { title: string noteType?: string typeColor?: string typeLightColor?: string TypeIcon?: ComponentType> } function matchEntries(entries: VaultEntry[], typeEntryMap: Record, query: string): MatchedEntry[] { if (query.length < MIN_QUERY_LENGTH) return [] const lowerQuery = query.toLowerCase() const matches = entries.filter(e => e.title.toLowerCase().includes(lowerQuery) || e.aliases.some(a => a.toLowerCase().includes(lowerQuery)), ) return matches.slice(0, MAX_RESULTS).map(e => { const isA = e.isA const te = typeEntryMap[isA ?? ''] const noteType = isA || undefined return { title: e.title, noteType, typeColor: noteType ? getTypeColor(isA, te?.color) : undefined, typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined, TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined, } }) } export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSelect, onEscape, placeholder, autoFocus, testId }: NoteAutocompleteProps) { const [selectedIndex, setSelectedIndex] = useState(-1) const [open, setOpen] = useState(false) const inputRef = useRef(null) const menuRef = useRef(null) const matches = useMemo( () => open ? matchEntries(entries, typeEntryMap, value) : [], [entries, typeEntryMap, value, open], ) // Scroll selected item into view useEffect(() => { if (selectedIndex < 0 || !menuRef.current) return const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined el?.scrollIntoView?.({ block: 'nearest' }) }, [selectedIndex]) // Close on outside click useEffect(() => { const handler = (e: MouseEvent) => { const target = e.target as Node if (!inputRef.current?.contains(target) && !menuRef.current?.contains(target)) { setOpen(false) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, []) const handleSelect = useCallback((title: string) => { onSelect(title) setOpen(false) setSelectedIndex(-1) }, [onSelect]) const handleChange = useCallback((e: React.ChangeEvent) => { onChange(e.target.value) setOpen(true) setSelectedIndex(-1) }, [onChange]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (!open || matches.length === 0) { if (e.key === 'Enter') { onSelect(value); return } if (e.key === 'Escape') { onEscape?.(); return } return } if (e.key === 'ArrowDown') { e.preventDefault() setSelectedIndex(i => (i + 1) % matches.length) } else if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1)) } else if (e.key === 'Enter') { e.preventDefault() if (selectedIndex >= 0 && selectedIndex < matches.length) { handleSelect(matches[selectedIndex].title) } else { onSelect(value) } } else if (e.key === 'Escape') { e.preventDefault() setOpen(false) onEscape?.() } }, [open, matches, selectedIndex, value, handleSelect, onSelect, onEscape]) return (
setOpen(true)} onKeyDown={handleKeyDown} data-testid={testId} /> {open && matches.length > 0 && (
{matches.map((item, index) => (
e.preventDefault()} onClick={() => handleSelect(item.title)} onMouseEnter={() => setSelectedIndex(index)} > {item.TypeIcon && } {item.title} {item.noteType && ( {item.noteType} )}
))}
)}
) }