forked from molecule-ai/molecule-core
- CreateWorkspaceDialog: replace plain div modal with @radix-ui/react-dialog (focus-trap, Escape-to-close, aria-labelledby auto-wired); tier selector uses role=radiogroup/radio + aria-checked; error uses role=alert; required fields annotate with sr-only "(required)" - SidePanel: WAI-ARIA tablist pattern — role=tablist + aria-label, role=tab + aria-selected + aria-controls + id, roving tabIndex (0/−1), ArrowRight/Left/Home/End keyboard nav with wrap, role=tabpanel + id + aria-labelledby on content area, tab icons are aria-hidden - TemplatePalette: loading and empty-state divs gain role=status + aria-live=polite - Canvas: sr-only role=status live region announces workspace count to screen readers - Tests: 7 new a11y tests for CreateWorkspaceDialog (Radix role=dialog, aria-labelledby, data-state, Cancel close, role=alert validation, role=radio tier); 12 new tab tests for SidePanel (tablist, 12 tabs, aria-selected, roving tabIndex, aria-controls, tabpanel, ArrowRight/Left/Home/End) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
import { api } from "@/lib/api";
|
|
|
|
interface WorkspaceOption {
|
|
id: string;
|
|
name: string;
|
|
tier: number;
|
|
}
|
|
|
|
export function CreateWorkspaceButton() {
|
|
const [open, setOpen] = useState(false);
|
|
const [name, setName] = useState("");
|
|
const [role, setRole] = useState("");
|
|
const [tier, setTier] = useState(1);
|
|
const [template, setTemplate] = useState("");
|
|
const [parentId, setParentId] = useState("");
|
|
const [creating, setCreating] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
|
|
|
// Reset form and load workspaces whenever dialog opens
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setName("");
|
|
setRole("");
|
|
setTier(1);
|
|
setTemplate("");
|
|
setParentId("");
|
|
setError(null);
|
|
api
|
|
.get<WorkspaceOption[]>("/workspaces")
|
|
.then((ws) => setWorkspaces(ws))
|
|
.catch(() => {});
|
|
}, [open]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!name.trim()) {
|
|
setError("Name is required");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
setError(null);
|
|
try {
|
|
await api.post("/workspaces", {
|
|
name: name.trim(),
|
|
role: role.trim() || undefined,
|
|
template: template.trim() || undefined,
|
|
tier,
|
|
parent_id: parentId || undefined,
|
|
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
|
});
|
|
setOpen(false);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to create workspace");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
<Dialog.Trigger asChild>
|
|
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 14 14"
|
|
fill="none"
|
|
className="shrink-0"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M7 1v12M1 7h12"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
New Workspace
|
|
</button>
|
|
</Dialog.Trigger>
|
|
|
|
<Dialog.Portal>
|
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
|
|
<Dialog.Content
|
|
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] p-6"
|
|
aria-describedby={undefined}
|
|
>
|
|
<Dialog.Title className="text-base font-semibold text-zinc-100 mb-1">
|
|
Create Workspace
|
|
</Dialog.Title>
|
|
<p className="text-xs text-zinc-500 mb-5">
|
|
Add a new workspace node to the canvas
|
|
</p>
|
|
|
|
<div className="space-y-3.5">
|
|
<InputField
|
|
label="Name"
|
|
required
|
|
value={name}
|
|
onChange={setName}
|
|
placeholder="e.g. SEO Agent"
|
|
/>
|
|
<InputField
|
|
label="Role"
|
|
value={role}
|
|
onChange={setRole}
|
|
placeholder="e.g. SEO Specialist"
|
|
/>
|
|
<InputField
|
|
label="Template"
|
|
value={template}
|
|
onChange={setTemplate}
|
|
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
|
|
mono
|
|
/>
|
|
|
|
<div>
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Workspace tier"
|
|
className="grid grid-cols-3 gap-1.5"
|
|
>
|
|
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
|
|
Tier
|
|
</div>
|
|
{[
|
|
{ value: 1, label: "T1", desc: "Sandboxed" },
|
|
{ value: 2, label: "T2", desc: "Standard" },
|
|
{ value: 3, label: "T3", desc: "Full Access" },
|
|
].map((t) => (
|
|
<button
|
|
key={t.value}
|
|
role="radio"
|
|
aria-checked={tier === t.value}
|
|
onClick={() => setTier(t.value)}
|
|
className={`py-2 rounded-lg text-center transition-colors ${
|
|
tier === t.value
|
|
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
|
|
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
|
|
}`}
|
|
>
|
|
<div className="text-xs font-mono font-semibold">
|
|
{t.label}
|
|
</div>
|
|
<div className="text-[10px] mt-0.5 opacity-70">
|
|
{t.desc}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[11px] text-zinc-400 block mb-1">
|
|
Parent Workspace
|
|
</label>
|
|
<select
|
|
value={parentId}
|
|
onChange={(e) => setParentId(e.target.value)}
|
|
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
|
>
|
|
<option value="">None (root level)</option>
|
|
{workspaces.map((ws) => (
|
|
<option key={ws.id} value={ws.id}>
|
|
T{ws.tier} · {ws.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400"
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2.5 mt-6">
|
|
<Dialog.Close asChild>
|
|
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
|
Cancel
|
|
</button>
|
|
</Dialog.Close>
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={creating}
|
|
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
|
>
|
|
{creating ? "Creating..." : "Create"}
|
|
</button>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
);
|
|
}
|
|
|
|
function InputField({
|
|
label,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
required,
|
|
mono,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
mono?: boolean;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="text-[11px] text-zinc-400 block mb-1">
|
|
{label}{" "}
|
|
{required && (
|
|
<>
|
|
<span aria-hidden="true" className="text-red-400">
|
|
*
|
|
</span>
|
|
<span className="sr-only"> (required)</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
<input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|