Merge pull request #906 from Molecule-AI/fix/a11y-audit-902-905

fix(a11y): resolve accessibility issues #902–#905 (aria-pressed, aria-expanded, alertdialog, ID sanitisation)
This commit is contained in:
molecule-ai[bot] 2026-04-18 01:34:47 +00:00 committed by GitHub
commit 9d2f9edff3
11 changed files with 64 additions and 24 deletions

View File

@ -33,6 +33,19 @@ interface Props {
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Sanitise a memory key for use in an HTML id attribute.
* HTML IDs must not contain whitespace; many non-alphanumeric characters also
* cause selector or ARIA failures. Replace every non-alphanumeric character
* with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones.
*/
function sanitizeId(key: string): string {
return key
.replace(/[^a-zA-Z0-9]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`;
@ -414,7 +427,7 @@ function MemoryEntryRow({
onCancelEdit,
onDelete,
}: MemoryEntryRowProps) {
const bodyId = `memory-body-${entry.key.replace(/\s+/g, "-")}`;
const bodyId = `mem-body-${sanitizeId(entry.key)}`;
return (
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
{/* Header row — click to expand/collapse */}

View File

@ -19,12 +19,14 @@ beforeEach(() => {
});
vi.mock("@/lib/auth", () => ({
fetchSession: (...args: unknown[]) => mockFetchSession(...args),
redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args),
// Cast required: vi.fn() returns Mock<Procedure | Constructable> which TypeScript
// won't call directly inside a factory closure (TS2348). Cast to Function resolves it.
fetchSession: (...args: unknown[]) => (mockFetchSession as unknown as (...a: unknown[]) => unknown)(...args),
redirectToLogin: (...args: unknown[]) => (mockRedirectToLogin as unknown as (...a: unknown[]) => unknown)(...args),
}));
vi.mock("@/lib/tenant", () => ({
getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args),
getTenantSlug: (...args: unknown[]) => (mockGetTenantSlug as unknown as (...a: unknown[]) => unknown)(...args),
}));
// Import after mocks are set up

View File

@ -28,6 +28,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
y: 0,
collapsed: false,
runtime: "",
budget_limit: null,
...overrides,
};
}

View File

@ -80,6 +80,7 @@ export function ActivityTab({ workspaceId }: Props) {
<button
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
filter === f.id
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
@ -92,6 +93,7 @@ export function ActivityTab({ workspaceId }: Props) {
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
className={`text-[11px] px-1.5 py-0.5 rounded ${
autoRefresh ? "text-emerald-400 bg-emerald-950/30" : "text-zinc-500"
}`}

View File

@ -257,24 +257,34 @@ export function DetailsTab({ workspaceId, data }: Props) {
</div>
)}
{confirmDelete ? (
<div role="alert" className="flex gap-2">
<button
onClick={handleDelete}
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
>
Confirm Delete
</button>
<button
onClick={() => {
setConfirmDelete(false);
setDeleteError(null);
// Return focus to the trigger so keyboard users aren't stranded
deleteButtonRef.current?.focus();
}}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
>
Cancel
</button>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-confirm-title"
className="space-y-2"
>
<h3 id="delete-confirm-title" className="text-xs font-medium text-red-400">
Confirm deletion
</h3>
<div className="flex gap-2">
<button
onClick={handleDelete}
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
>
Confirm Delete
</button>
<button
onClick={() => {
setConfirmDelete(false);
setDeleteError(null);
// Return focus to the trigger so keyboard users aren't stranded
deleteButtonRef.current?.focus();
}}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300"
>
Cancel
</button>
</div>
</div>
) : (
<button

View File

@ -120,7 +120,7 @@ export function MemoryTab({ workspaceId }: Props) {
return (
<div className="p-4 space-y-4">
{error && !showAdd && (
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
<div role="alert" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
{error}
</div>
)}
@ -233,6 +233,7 @@ export function MemoryTab({ workspaceId }: Props) {
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="Key"
aria-label="Memory key"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
/>
<textarea
@ -240,15 +241,17 @@ export function MemoryTab({ workspaceId }: Props) {
onChange={(e) => setNewValue(e.target.value)}
placeholder='Value (JSON or plain text)'
rows={3}
aria-label="Memory value (JSON or plain text)"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs font-mono text-zinc-100 focus:outline-none focus:border-blue-500 resize-none"
/>
<input
value={newTTL}
onChange={(e) => setNewTTL(e.target.value)}
placeholder="TTL in seconds (optional)"
aria-label="TTL in seconds (optional)"
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:border-blue-500"
/>
{error && <div className="text-xs text-red-400">{error}</div>}
{error && <div role="alert" className="text-xs text-red-400">{error}</div>}
<div className="flex gap-2">
<button
onClick={handleAdd}
@ -279,6 +282,7 @@ export function MemoryTab({ workspaceId }: Props) {
<button
onClick={() => setExpanded(expanded === entry.key ? null : entry.key)}
className="w-full flex items-center justify-between px-3 py-2 text-left"
aria-expanded={expanded === entry.key}
>
<span className="text-xs font-mono text-blue-400">{entry.key}</span>
<div className="flex items-center gap-2">

View File

@ -22,6 +22,7 @@ function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNode
currentTask: "",
needsRestart: false,
runtime: "",
budgetLimit: null,
...overrides,
};
}

View File

@ -34,6 +34,7 @@ function makeNode(
currentTask: "",
needsRestart: false,
runtime: "",
budgetLimit: null,
...overrides,
},
};

View File

@ -31,6 +31,7 @@ function makeNode(
currentTask: "",
needsRestart: false,
runtime: "",
budgetLimit: null,
...overrides,
},
};

View File

@ -24,6 +24,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
y: 0,
collapsed: false,
runtime: "",
budget_limit: null,
...overrides,
};
}

View File

@ -27,6 +27,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
y: 0,
collapsed: false,
runtime: "",
budget_limit: null,
...overrides,
};
}
@ -172,6 +173,7 @@ describe("summarizeWorkspaceCapabilities", () => {
currentTask: "Reviewing docs",
needsRestart: false,
runtime: "claude-code",
budgetLimit: null,
});
expect(summary.runtime).toBe("claude-code");
@ -197,6 +199,7 @@ describe("summarizeWorkspaceCapabilities", () => {
currentTask: " ",
needsRestart: false,
runtime: "",
budgetLimit: null,
});
expect(summary.runtime).toBeNull();
@ -554,6 +557,7 @@ describe("context menu", () => {
currentTask: "",
needsRestart: false,
runtime: "",
budgetLimit: null,
},
};