Files
mimic/frontend/src/components/MitreTechniquePicker.tsx

196 lines
6.3 KiB
TypeScript
Raw Normal View History

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<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// True once we've synced the first real techniqueId from props (parent/API load).
// After that we stop reacting to null, so keystrokes that emit onChange(null,null)
// don't propagate back and wipe the input mid-stroke.
const hasHydratedFromProps = useRef(false);
useEffect(() => {
if (techniqueId && techniqueName) {
setInputValue(`${techniqueId}${techniqueName}`);
hasHydratedFromProps.current = true;
} else if (!techniqueId && !hasHydratedFromProps.current) {
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<HTMLInputElement>) => {
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 (
<div ref={containerRef} className="relative">
<input
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 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 && (
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-md shadow-floating overflow-hidden">
{isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div>
)}
{isError && !isFetching && (
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
{extractApiError(error, 'MITRE search unavailable')}
</div>
)}
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
)}
{!isFetching && items.length > 0 && (
<ul
id={listboxId}
ref={listRef}
role="listbox"
aria-label="MITRE techniques"
className="max-h-64 overflow-y-auto"
>
{items.map((item, i) => (
<li
key={item.id}
id={`mitre-option-${i}`}
role="option"
aria-selected={i === activeIndex}
className={`px-md py-sm text-[14px] cursor-pointer select-none ${
i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud'
}`}
onPointerDown={(e) => {
// Prevent input blur before we handle the click
e.preventDefault();
selectItem(item);
}}
>
<span className="font-medium">{item.id}</span>
<span className="text-charcoal"> {item.name}</span>
{item.tactics.length > 0 && (
<span className="text-graphite"> ({item.tactics[0]})</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}