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:
commit
9d2f9edff3
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -28,6 +28,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}`}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -22,6 +22,7 @@ function makeNodeData(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNode
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ function makeNode(
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
||||
@ -31,6 +31,7 @@ function makeNode(
|
||||
currentTask: "",
|
||||
needsRestart: false,
|
||||
runtime: "",
|
||||
budgetLimit: null,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
|
||||
@ -24,6 +24,7 @@ function makeWS(overrides: Partial<WorkspaceData> & { id: string }): WorkspaceDa
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user