Files
mimic/frontend/src/components/ExportEngagementButton.tsx
Knacky 8b5b5d94d8 fix(frontend): floating dropdowns and matrix modal frame use bg-paper
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>
2026-06-11 11:12:07 +02:00

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>
);
}