Give the terminal and Add Project buttons a reason a screen reader can hear
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 1s

Both were the defect the new hook exists for. The sidebar's Claude terminal
button is disabled whenever the container is not running and never said so —
its `title` names the action, so the precondition appeared nowhere in the
accessibility tree at all. Add Project's submit button is disabled while an
add is in flight, and its only signal is the label swapping to "Adding…" on
an element a screen reader can no longer reach.

The submit button needs a second guard the hook cannot supply: Enter inside
a text field submits a form without touching the submit button, so
`handleSubmit` now returns early while loading. Without it, swapping
`disabled` for `aria-disabled` would have turned an accessibility fix into a
double-submit bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
This commit is contained in:
2026-09-02 09:08:57 -07:00
co-authored by Claude Opus 5
parent aa0a574091
commit 1eb91a35eb
4 changed files with 185 additions and 13 deletions
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import AddProjectDialog from "./AddProjectDialog";
const add = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({ add }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => null),
}));
/** A promise whose resolution this test controls, so `loading` can be held open. */
function deferred() {
let resolve!: (v: unknown) => void;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
function fillValidForm() {
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "my-project" },
});
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
target: { value: "/home/user/my-project" },
});
}
function submitButton() {
return screen.getByRole("button", { name: /Add Project|Adding/ });
}
describe("AddProjectDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds the project with the name and folder entered", async () => {
add.mockResolvedValue({ id: "p1" });
const onClose = vi.fn();
render(<AddProjectDialog onClose={onClose} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(add).toHaveBeenCalledWith("my-project", [
{ host_path: "/home/user/my-project", mount_name: "my-project" },
]),
);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("keeps the submit button announced, and explains why, while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
// Native `disabled` would remove the button from the accessibility tree
// exactly when it has something to say.
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
expect(submitButton()).not.toBeDisabled();
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
await act(async () => resolve({ id: "p1" }));
});
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
fireEvent.click(submitButton());
fireEvent.keyDown(submitButton(), { key: "Enter" });
fireEvent.keyDown(submitButton(), { key: " " });
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("ignores a form submit raised from elsewhere while adding", async () => {
const { promise, resolve } = deferred();
add.mockReturnValue(promise);
render(<AddProjectDialog onClose={vi.fn()} />);
fillValidForm();
fireEvent.click(submitButton());
await waitFor(() =>
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
);
// Enter in a text field submits a form regardless of the submit button's
// state, so the handler has to guard itself too.
// Modal portals to document.body, so the form is not under `container`.
const form = document.querySelector("form");
expect(form).not.toBeNull();
fireEvent.submit(form!);
expect(add).toHaveBeenCalledTimes(1);
await act(async () => resolve({ id: "p1" }));
});
it("leaves the submit button plainly available when idle", () => {
render(<AddProjectDialog onClose={vi.fn()} />);
expect(submitButton()).not.toHaveAttribute("aria-disabled");
expect(submitButton()).toHaveAccessibleDescription("");
});
});
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
// The submit button is `aria-disabled` rather than `disabled` while an add
// is in flight, and Enter inside a text field submits the form without
// touching the button at all. Both routes end here, so the guard does too.
if (loading) return;
if (!name.trim()) { if (!name.trim()) {
setError("Project name is required"); setError("Project name is required");
return; return;
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
<Button size="md" variant="ghost" onClick={onClose}> <Button size="md" variant="ghost" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}> <Button
size="md"
variant="primary"
type="submit"
form={formId}
unavailable={loading}
unavailableReason="The project is being added. Wait for it to finish."
title={
loading
? "The project is being added. Wait for it to finish."
: undefined
}
>
{loading ? "Adding…" : "Add Project"} {loading ? "Adding…" : "Add Project"}
</Button> </Button>
</> </>
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
}); });
it("only allows opening a terminal while the container runs", () => { it("only allows opening a terminal while the container runs", () => {
const { unmount } = render(<ProjectRow project={baseProject} />);
expect(
screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
}),
).toBeDisabled();
unmount();
render(<ProjectRow project={{ ...baseProject, status: "running" }} />); render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
fireEvent.click( fireEvent.click(
screen.getByRole("button", { screen.getByRole("button", {
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
expect(mockOpenClaudeTerminal).toHaveBeenCalled(); expect(mockOpenClaudeTerminal).toHaveBeenCalled();
}); });
it("keeps the terminal button announced, and explains why, while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
// Native `disabled` would drop the button out of the accessibility tree
// and out of the tab order, taking the reason with it.
expect(button).not.toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAccessibleDescription(/is not running/i);
});
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
render(<ProjectRow project={baseProject} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
fireEvent.click(button);
fireEvent.keyDown(button, { key: "Enter" });
fireEvent.keyDown(button, { key: " " });
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
});
it("drops aria-disabled once the container is running", () => {
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
const button = screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
});
expect(button).not.toHaveAttribute("aria-disabled");
expect(button).not.toHaveAccessibleDescription(/is not running/i);
});
it("shows container progress inline rather than in a blocking modal", () => { it("shows container progress inline rather than in a blocking modal", () => {
setStore({ containerProgress: { "test-1": "Pulling image…" } }); setStore({ containerProgress: { "test-1": "Pulling image…" } });
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />); render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
+18 -4
View File
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
import { useAppState, homeTabKey } from "../../store/appState"; import { useAppState, homeTabKey } from "../../store/appState";
import { useProjectActions } from "../../hooks/useProjectActions"; import { useProjectActions } from "../../hooks/useProjectActions";
import { ProjectStatusIndicator } from "../ui/StatusIndicator"; import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import { useUnavailable } from "../ui/unavailable";
interface Props { interface Props {
project: Project; project: Project;
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
const isTransitioning = const isTransitioning =
project.status === "starting" || project.status === "stopping"; project.status === "starting" || project.status === "stopping";
// A terminal needs a running container. Saying so out loud beats a `disabled`
// attribute that hides the button — and the reason — from anyone not using a
// mouse and eyes.
const terminal = useUnavailable({
unavailable: !isRunning,
reason: `${project.name} is not running. Start it to open a terminal.`,
onClick: () => openClaudeTerminal(),
});
return ( return (
<div <div
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${ className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
</button> </button>
<button <button
type="button" type="button"
disabled={!isRunning} {...terminal.controlProps}
onClick={() => openClaudeTerminal()} title={
title={`Open a Claude terminal for ${project.name}`} isRunning
? `Open a Claude terminal for ${project.name}`
: `${project.name} is not running. Start it to open a terminal.`
}
aria-label={`Open a Claude terminal for ${project.name}`} aria-label={`Open a Claude terminal for ${project.name}`}
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors" className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
> >
<svg <svg
className="w-3.5 h-3.5" className="w-3.5 h-3.5"
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
<line x1="13" y1="15" x2="17" y2="15" /> <line x1="13" y1="15" x2="17" y2="15" />
</svg> </svg>
</button> </button>
{terminal.reasonNode}
</div> </div>
</div> </div>
); );