WCAG 4.1.2 / bug #1669 follow-up — final batch completing the campaign. Added type="button" to all buttons missing it across 14 canvas components. Files changed (14, all additions): - Toolbar.tsx: Stop All, Restart All, A2A toggle, Audit shortcut, Quick help, Search shortcut, Help close (7) - MemoryInspectorPanel.tsx: scope tabs, refresh, search clear ×2, expand, delete (6) - TemplatePalette.tsx: org refresh, toggle, Import Agent, org import, deploy template, palette refresh (6) - ProvisioningTimeout.tsx: Retry, Cancel Request, View Logs, Keep, Remove Workspace (5) - ConsoleModal.tsx: close, Copy output, Close (3) - OnboardingWizard.tsx: Skip guide, action, Next (3) - ConversationTraceModal.tsx: close ×2 (2) - WorkspaceNode.tsx: Restart banner, Extract from team (2) - CommunicationOverlay.tsx: toggle, close panel (2) - Toaster.tsx: dismiss ×2 (2) - SearchDialog.tsx: search result button (1) - TermsGate.tsx: accept (1) - ErrorBoundary.tsx: Reload (1) - BundleDropZone.tsx: import trigger (1) Total campaign (batches 1-3): 27 + 42 = 69 buttons fixed across 24 components. All 477 canvas vitest tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useRef } from "react";
|
|
import { api } from "@/lib/api";
|
|
|
|
export function BundleDropZone() {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [result, setResult] = useState<{ status: string; name?: string } | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
/**
|
|
* Core file processor — shared between drag-drop and keyboard file-picker
|
|
* so both code paths have identical import behaviour (WCAG 2.1.1).
|
|
*/
|
|
const processFile = useCallback(async (file: File) => {
|
|
if (!file.name.endsWith(".bundle.json")) {
|
|
setResult({ status: "error", name: "Only .bundle.json files are accepted" });
|
|
setTimeout(() => setResult(null), 3000);
|
|
return;
|
|
}
|
|
setImporting(true);
|
|
try {
|
|
const text = await file.text();
|
|
const bundle = JSON.parse(text);
|
|
const res = await api.post<{ workspace_id: string; name: string; status: string }>(
|
|
"/bundles/import",
|
|
bundle
|
|
);
|
|
setResult({ status: "success", name: res.name || bundle.name });
|
|
setTimeout(() => setResult(null), 4000);
|
|
} catch (e) {
|
|
setResult({
|
|
status: "error",
|
|
name: e instanceof Error ? e.message : "Import failed",
|
|
});
|
|
setTimeout(() => setResult(null), 4000);
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.dataTransfer.types.includes("Files")) {
|
|
setIsDragging(true);
|
|
}
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
const file = Array.from(e.dataTransfer.files).find(
|
|
(f) => f.name.endsWith(".bundle.json")
|
|
);
|
|
if (!file) {
|
|
setResult({ status: "error", name: "Only .bundle.json files are accepted" });
|
|
setTimeout(() => setResult(null), 3000);
|
|
return;
|
|
}
|
|
await processFile(file);
|
|
}, [processFile]);
|
|
|
|
const handleFileInput = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = ""; // reset so the same file can be re-selected
|
|
await processFile(file);
|
|
}, [processFile]);
|
|
|
|
return (
|
|
<>
|
|
{/* Hidden file input — keyboard / assistive-tech alternative to drag-drop (WCAG 2.1.1) */}
|
|
<input
|
|
ref={fileInputRef}
|
|
id="bundle-file-input"
|
|
type="file"
|
|
accept=".bundle.json"
|
|
className="sr-only"
|
|
onChange={handleFileInput}
|
|
aria-label="Import bundle file"
|
|
/>
|
|
|
|
{/* Invisible drop zone covering the canvas */}
|
|
<div
|
|
className="fixed inset-0 z-10 pointer-events-none"
|
|
style={{ pointerEvents: isDragging ? "auto" : "none" }}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
/>
|
|
|
|
{/* Global drag listener to detect file entering the window */}
|
|
<div
|
|
className="fixed inset-0 z-[5]"
|
|
onDragOver={handleDragOver}
|
|
style={{ pointerEvents: "none" }}
|
|
/>
|
|
|
|
{/* Keyboard-accessible import button — visible on focus or hover so
|
|
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
aria-label="Import bundle file"
|
|
aria-controls="bundle-file-input"
|
|
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors"
|
|
>
|
|
📦 Import bundle
|
|
</button>
|
|
|
|
{/* Visual overlay when dragging */}
|
|
{isDragging && (
|
|
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
|
|
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
|
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
|
|
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
|
|
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Importing spinner */}
|
|
{importing && (
|
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-zinc-900/95 border border-zinc-700/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3">
|
|
<div className="w-4 h-4 border-2 border-sky-400 border-t-transparent rounded-full animate-spin" />
|
|
<span className="text-sm text-zinc-200">Importing bundle...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result toast — role="status" announces import outcome to screen readers */}
|
|
{result && (
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl px-5 py-3 shadow-2xl text-sm ${
|
|
result.status === "success"
|
|
? "bg-emerald-950/90 border border-emerald-700/50 text-emerald-200"
|
|
: "bg-red-950/90 border border-red-700/50 text-red-200"
|
|
}`}
|
|
>
|
|
{result.status === "success"
|
|
? `Imported "${result.name}" successfully`
|
|
: result.name}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|