diff --git a/frontend/src/features/projects/ProjectTabs.test.tsx b/frontend/src/features/projects/ProjectTabs.test.tsx new file mode 100644 index 0000000..45ac3e5 --- /dev/null +++ b/frontend/src/features/projects/ProjectTabs.test.tsx @@ -0,0 +1,76 @@ +/** + * #46 — the project tab bar's "+" add-project button. It must be present, + * enabled and visibly a button in EVERY state (notably with zero open projects, + * where it is the only way to add a project as a tab), and clicking it opens the + * Projects panel. The visibility regression it guards against: the button used + * to render with the `ghost` variant (no border/background, muted text) so it + * read as faint text rather than a control. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import type { TabItem } from "@/shared"; +import { ProjectTabs } from "./ProjectTabs"; + +const TABS: TabItem[] = [ + { id: "a", label: "alpha" }, + { id: "b", label: "beta" }, +]; + +function renderTabs(props: Partial> = {}) { + const onOpenProjectsPanel = vi.fn(); + render( + , + ); + return { onOpenProjectsPanel }; +} + +describe("ProjectTabs add-project button (#46)", () => { + it("renders the + button when projects are open", () => { + renderTabs({ items: TABS }); + const btn = screen.getByRole("button", { name: "open projects panel" }); + expect(btn).toBeTruthy(); + expect(btn.textContent).toContain("+"); + expect((btn as HTMLButtonElement).disabled).toBe(false); + }); + + it("still renders the + button with ZERO open projects (only way to add one)", () => { + renderTabs({ items: [], activeTabId: null }); + // The empty placeholder is shown… + expect(screen.getByText("No open tabs.")).toBeTruthy(); + // …and the add-project button is still present and enabled. + const btn = screen.getByRole("button", { name: "open projects panel" }); + expect(btn).toBeTruthy(); + expect((btn as HTMLButtonElement).disabled).toBe(false); + }); + + it("is a visible bordered control, not the near-invisible ghost glyph", () => { + // Regression guard for #46: the secondary variant carries a border; the old + // ghost variant did not. Asserting the border class keeps the button from + // silently reverting to the invisible style. + renderTabs({ items: [] }); + const btn = screen.getByRole("button", { name: "open projects panel" }); + expect(btn.className).toContain("border"); + expect(btn.className).not.toContain("text-muted"); + }); + + it("reflects the panel-open state via aria-pressed", () => { + const { onOpenProjectsPanel } = renderTabs({ projectsPanelOpen: true }); + const btn = screen.getByRole("button", { name: "open projects panel" }); + expect(btn.getAttribute("aria-pressed")).toBe("true"); + expect(onOpenProjectsPanel).not.toHaveBeenCalled(); + }); + + it("opens the Projects panel on click, independent of any active project", () => { + const { onOpenProjectsPanel } = renderTabs({ items: [], activeTabId: null }); + fireEvent.click(screen.getByRole("button", { name: "open projects panel" })); + expect(onOpenProjectsPanel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/features/projects/ProjectTabs.tsx b/frontend/src/features/projects/ProjectTabs.tsx index 2bec108..ecc6eb3 100644 --- a/frontend/src/features/projects/ProjectTabs.tsx +++ b/frontend/src/features/projects/ProjectTabs.tsx @@ -52,12 +52,21 @@ export function ProjectTabs({ className="flex-1" /> )} + {/* Add-project affordance (#46): always a bordered `secondary` button so + it reads as a clickable control in every state — including zero open + projects — instead of the near-invisible `ghost` glyph it used to be. + When the Projects panel is open it also shows the pressed ring. */}