import { useEffect, useRef, useState, type KeyboardEvent, } from 'react'; import { extractApiError } from '@/api/client'; import type { MitreTechnique } from '@/api/types'; import { useMitreSearch } from '@/hooks/useMitre'; interface MitreTechniquePickerProps { techniqueId: string | null; techniqueName: string | null; onChange: (id: string | null, name: string | null) => void; disabled?: boolean; } function formatOption(t: MitreTechnique): string { const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : ''; return `${t.id} — ${t.name}${tacticList}`; } const DEBOUNCE_MS = 200; export function MitreTechniquePicker({ techniqueId, techniqueName, onChange, disabled = false, }: MitreTechniquePickerProps): JSX.Element { const [inputValue, setInputValue] = useState( techniqueId && techniqueName ? `${techniqueId} — ${techniqueName}` : '', ); const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const debounceRef = useRef | null>(null); const containerRef = useRef(null); const listRef = useRef(null); // Sync display when selection changes from outside (initial load) useEffect(() => { if (techniqueId && techniqueName) { setInputValue(`${techniqueId} — ${techniqueName}`); } else if (!techniqueId) { setInputValue(''); } }, [techniqueId, techniqueName]); const { data: results, isFetching, isError, error } = useMitreSearch(query, open); const items = results ?? []; const handleInputChange = (value: string) => { setInputValue(value); // Clear the selection when user starts typing onChange(null, null); setOpen(true); setActiveIndex(-1); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setQuery(value); }, DEBOUNCE_MS); }; const selectItem = (item: MitreTechnique) => { setInputValue(formatOption(item)); onChange(item.id, item.name); setOpen(false); setActiveIndex(-1); setQuery(''); }; const handleKeyDown = (e: KeyboardEvent) => { if (!open || items.length === 0) { if (e.key === 'Escape') setOpen(false); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, items.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); if (activeIndex >= 0 && items[activeIndex]) { selectItem(items[activeIndex]); } } else if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } }; // Scroll active item into view useEffect(() => { if (activeIndex >= 0 && listRef.current) { const el = listRef.current.children[activeIndex] as HTMLElement | undefined; el?.scrollIntoView?.({ block: 'nearest' }); } }, [activeIndex]); // Close dropdown on click outside useEffect(() => { const onPointerDown = (e: PointerEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, []); const listboxId = 'mitre-picker-listbox'; return (
= 0 ? `mitre-option-${activeIndex}` : undefined} aria-label="MITRE technique" className="text-input" value={inputValue} onChange={(e) => handleInputChange(e.target.value)} onFocus={() => { if (!techniqueId) setOpen(true); }} onKeyDown={handleKeyDown} disabled={disabled} placeholder="Search by ID or name (e.g. T1059)" autoComplete="off" /> {open && (
{isFetching && (
Searching…
)} {isError && !isFetching && (
{extractApiError(error, 'MITRE search unavailable')}
)} {!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
No results
)} {!isFetching && items.length > 0 && (
    {items.map((item, i) => (
  • { // Prevent input blur before we handle the click e.preventDefault(); selectItem(item); }} > {item.id} — {item.name} {item.tactics.length > 0 && ( ({item.tactics[0]}) )}
  • ))}
)}
)}
); }