import React from 'react'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; interface SkillInfo { name: string; scope: string; description?: string; } export interface SkillAutocompleteHandle { handleKeyDown: (key: string) => void; } interface SkillAutocompleteProps { searchQuery: string; onSkillSelect: (skillName: string) => void; onClose: () => void; style?: React.CSSProperties; } export const SkillAutocomplete = React.forwardRef(({ searchQuery, onSkillSelect, onClose, style, }, ref) => { const containerRef = React.useRef(null); const [selectedIndex, setSelectedIndex] = React.useState(0); const [filteredSkills, setFilteredSkills] = React.useState([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const { skills, loadSkills } = useSkillsStore(); React.useEffect(() => { // Always trigger loadSkills when autocomplete opens to ensure project context is fresh void loadSkills(); }, [loadSkills]); React.useEffect(() => { const normalizedQuery = searchQuery.trim(); const matches = normalizedQuery.length ? skills.filter((skill) => fuzzyMatch(skill.name, normalizedQuery)) : skills; const sorted = [...matches].sort((a, b) => { // Sort by project scope first, then name if (a.scope === 'project' && b.scope !== 'project') return -1; if (a.scope !== 'project' && b.scope === 'project') return 1; return a.name.localeCompare(b.name); }); setFilteredSkills(sorted); setSelectedIndex(0); }, [skills, searchQuery]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', }); }, [selectedIndex]); React.useEffect(() => { const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; if (!target || !containerRef.current) { return; } if (!containerRef.current.contains(target)) { onClose(); } }; document.addEventListener('pointerdown', handlePointerDown, true); return () => { document.removeEventListener('pointerdown', handlePointerDown, true); }; }, [onClose]); React.useImperativeHandle(ref, () => ({ handleKeyDown: (key: string) => { if (key === 'Escape') { onClose(); return; } if (!filteredSkills.length) { return; } if (key === 'ArrowDown') { setSelectedIndex((prev) => (prev + 1) % filteredSkills.length); return; } if (key === 'ArrowUp') { setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length); return; } if (key === 'Enter' || key === 'Tab') { const skill = filteredSkills[selectedIndex]; if (skill) { onSkillSelect(skill.name); } } }, }), [filteredSkills, onSkillSelect, onClose, selectedIndex]); const renderSkill = (skill: SkillInfo, index: number) => { const isProject = skill.scope === 'project'; return (
{ itemRefs.current[index] = el; }} className={cn( 'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label', index === selectedIndex && 'bg-interactive-selection' )} onClick={() => onSkillSelect(skill.name)} onMouseEnter={() => setSelectedIndex(index)} >
{skill.name} {skill.scope}
{skill.description && (
{skill.description}
)}
); }; return (
{filteredSkills.length ? (
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
) : (
No skills found
)}
↑↓ navigate • Enter select • Esc close
); }); SkillAutocomplete.displayName = 'SkillAutocomplete';