import { useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import type { Skill } from '@/api/types'; interface Props { query: string; skills: Skill[]; anchorRect: { top: number; left: number }; onSelect: (skillName: string) => void; onClose: () => void; } // Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern — // fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn // `Command` (cmdk) isn't installed in this project; per the addendum we use // a plain div + Tailwind instead of pulling a new primitive autonomously. // Case-insensitive prefix match on `name` only. Description is display-only // in v1 (substring search across description is deferred to a polish batch). function filterByPrefix(skills: Skill[], query: string): Skill[] { const q = query.toLowerCase(); const filtered = q ? skills.filter((s) => s.name.toLowerCase().startsWith(q)) : skills; // Stable alphabetical ordering matches the server's cache order (skills.ts // sorts on name asc) but we re-sort here so a stale client cache doesn't // surprise the user. return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); } export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) { const [highlightIndex, setHighlightIndex] = useState(0); const popoverRef = useRef(null); const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]); useEffect(() => { setHighlightIndex(0); }, [query]); // Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the // textarea reach the popover even though focus stays in the textarea. useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); } else if (e.key === 'Enter' || e.key === 'Tab') { if (filtered.length === 0) return; e.preventDefault(); const target = filtered[highlightIndex] ?? filtered[0]; if (target) onSelect(target.name); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [filtered, highlightIndex, onSelect, onClose]); useEffect(() => { function handleMouseDown(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } } document.addEventListener('mousedown', handleMouseDown); return () => document.removeEventListener('mousedown', handleMouseDown); }, [onClose]); useEffect(() => { const el = popoverRef.current?.querySelector('[data-highlighted="true"]'); if (el) el.scrollIntoView({ block: 'nearest' }); }, [highlightIndex]); // Anchor sits above the input — translate(-100%) on Y so the dropdown // expands upward from the anchor point rather than over the textarea. const style = { top: anchorRect.top, left: anchorRect.left, transform: 'translateY(-100%)', } as const; if (filtered.length === 0) { return (
{query ? `No skill starts with "/${query}"` : 'No skills available'}
); } return (
{filtered.map((skill, i) => ( ))}
); }