MitreTechniquePicker dropdown, SimulationList overflow menu, ExportEngagementButton format menu, and MitreMatrixModal dialog frame all used bg-canvas as their surface color. With the tinted canvas (#f3f5f8), these floating surfaces appeared slightly grey instead of clean white. Switched to bg-paper (#ffffff light / #1f2937 dark). MitreMatrixModal cell hover (bg-canvas) intentionally preserved — matrix cells sit on canvas, not on paper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { ChevronDown, Download } from 'lucide-react';
|
|
import { downloadEngagementExport, type ExportFormat } from '@/api/exports';
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
interface ExportEngagementButtonProps {
|
|
engagementId: number;
|
|
}
|
|
|
|
const FORMATS: { label: string; value: ExportFormat }[] = [
|
|
{ label: 'Markdown', value: 'md' },
|
|
{ label: 'CSV', value: 'csv' },
|
|
{ label: 'PDF', value: 'pdf' },
|
|
];
|
|
|
|
export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element {
|
|
const { push } = useToast();
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState<ExportFormat | null>(null);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
};
|
|
document.addEventListener('pointerdown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
const handleDownload = async (format: ExportFormat) => {
|
|
setLoading(format);
|
|
try {
|
|
await downloadEngagementExport(engagementId, format);
|
|
setOpen(false);
|
|
} catch (err) {
|
|
push(err instanceof Error ? err.message : 'Export failed', 'error');
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative" ref={ref} data-testid="export-dropdown">
|
|
<div className="inline-flex">
|
|
<button
|
|
type="button"
|
|
className="btn-outline border-r-0"
|
|
onClick={() => setOpen((v) => !v)}
|
|
data-testid="export-btn"
|
|
>
|
|
<Download size={14} aria-hidden /> Export
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Export options"
|
|
aria-expanded={open}
|
|
className="btn-outline px-sm"
|
|
onClick={() => setOpen((v) => !v)}
|
|
data-testid="export-dropdown-toggle"
|
|
>
|
|
<ChevronDown size={14} aria-hidden />
|
|
</button>
|
|
</div>
|
|
|
|
{open ? (
|
|
<div
|
|
className="absolute right-0 top-full mt-xxs bg-paper border border-hairline rounded-none z-20 min-w-[160px]"
|
|
role="menu"
|
|
>
|
|
{FORMATS.map(({ label, value }) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={loading !== null}
|
|
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog flex items-center gap-sm disabled:opacity-50"
|
|
onClick={() => handleDownload(value)}
|
|
data-testid={`export-format-${value}`}
|
|
>
|
|
{loading === value ? (
|
|
<span className="font-mono text-[11px] animate-pulse" aria-hidden>
|
|
EXPORTING…
|
|
</span>
|
|
) : null}
|
|
{loading !== value ? label : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|