Skip to content

Commit 96a1f57

Browse files
committed
fix: show a close button on every tab, including the last one
The tab close (✕) was hidden when only one tab remained (`tabs.length > 1`), so the final tab couldn't be closed with the mouse even though the app already falls back to a Zed-style empty pane (matching Cmd+W). Always render the close button so no tab is unclosable, and add an aria-label/title for accessibility. Adds TabBar regression tests covering: a close button on every tab, on the last remaining tab specifically, and that close-click stops propagation (doesn't also select the tab).
1 parent e3a5a68 commit 96a1f57

2 files changed

Lines changed: 78 additions & 22 deletions

File tree

src/components/TabBar.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -84,28 +84,30 @@ export function TabBar({
8484
/>
8585
)}
8686
<span className="truncate max-w-[150px] font-medium">{tab.label}</span>
87-
{tabs.length > 1 && (
88-
<button
89-
className="ml-1 p-0.5 rounded transition-colors"
90-
style={{ color: "var(--nh-text-tertiary)" }}
91-
onMouseEnter={(e) => {
92-
e.currentTarget.style.background = "var(--nh-border)";
93-
e.currentTarget.style.color = "var(--nh-text)";
94-
}}
95-
onMouseLeave={(e) => {
96-
e.currentTarget.style.background = "transparent";
97-
e.currentTarget.style.color = "var(--nh-text-tertiary)";
98-
}}
99-
onClick={(e) => {
100-
e.stopPropagation();
101-
onCloseTab(tab.id);
102-
}}
103-
>
104-
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
105-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
106-
</svg>
107-
</button>
108-
)}
87+
{/* Every tab gets a close button — including the last one, which falls back to the
88+
empty pane (matching Cmd+W). No tab is unclosable from the mouse. */}
89+
<button
90+
className="ml-1 p-0.5 rounded transition-colors"
91+
aria-label={`Close ${tab.label}`}
92+
title="Close"
93+
style={{ color: "var(--nh-text-tertiary)" }}
94+
onMouseEnter={(e) => {
95+
e.currentTarget.style.background = "var(--nh-border)";
96+
e.currentTarget.style.color = "var(--nh-text)";
97+
}}
98+
onMouseLeave={(e) => {
99+
e.currentTarget.style.background = "transparent";
100+
e.currentTarget.style.color = "var(--nh-text-tertiary)";
101+
}}
102+
onClick={(e) => {
103+
e.stopPropagation();
104+
onCloseTab(tab.id);
105+
}}
106+
>
107+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
108+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
109+
</svg>
110+
</button>
109111
</div>
110112
);
111113
})}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { render, screen, fireEvent } from "@testing-library/react";
3+
4+
import { TabBar } from "../TabBar";
5+
import type { TabInfo } from "../../lib/types";
6+
7+
type Props = Parameters<typeof TabBar>[0];
8+
9+
function tab(id: string, label: string, filePath: string | null = `/ws/${label}`): TabInfo {
10+
return { id, label, filePath, kind: "markdown" } as TabInfo;
11+
}
12+
13+
function setup(overrides: Partial<Props> = {}) {
14+
const props: Props = {
15+
tabs: [tab("t1", "a.md"), tab("t2", "b.md")],
16+
activeTabId: "t1",
17+
onSelectTab: vi.fn(),
18+
onCloseTab: vi.fn(),
19+
onAddTab: vi.fn(),
20+
onDetachTab: vi.fn(),
21+
...overrides,
22+
};
23+
render(<TabBar {...props} />);
24+
return props;
25+
}
26+
27+
describe("TabBar", () => {
28+
it("renders a close button on every tab", () => {
29+
setup({ tabs: [tab("t1", "a.md"), tab("t2", "b.md"), tab("t3", "c.md")] });
30+
expect(screen.getAllByRole("button", { name: /^Close / })).toHaveLength(3);
31+
});
32+
33+
it("renders a close button on the LAST remaining tab (closing all tabs is allowed)", () => {
34+
// The regression: the close ✕ used to be hidden when only one tab was left
35+
// (`tabs.length > 1`), so the final tab was unclosable with the mouse even though
36+
// the app now falls back to an empty pane. Every tab must stay closable.
37+
const onCloseTab = vi.fn();
38+
setup({ tabs: [tab("only", "solo.md")], activeTabId: "only", onCloseTab });
39+
40+
const closeBtn = screen.getByRole("button", { name: "Close solo.md" });
41+
fireEvent.click(closeBtn);
42+
expect(onCloseTab).toHaveBeenCalledWith("only");
43+
});
44+
45+
it("close click doesn't also select the tab (stops propagation)", () => {
46+
const onSelectTab = vi.fn();
47+
const onCloseTab = vi.fn();
48+
setup({ onSelectTab, onCloseTab });
49+
50+
fireEvent.click(screen.getByRole("button", { name: "Close b.md" }));
51+
expect(onCloseTab).toHaveBeenCalledWith("t2");
52+
expect(onSelectTab).not.toHaveBeenCalled();
53+
});
54+
});

0 commit comments

Comments
 (0)