summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/tabs/index.ts1
-rw-r--r--src/features/tabs/ui.test.ts148
-rw-r--r--src/features/tabs/ui/TabBar.svelte54
3 files changed, 203 insertions, 0 deletions
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index c01d4ac..835788a 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -12,3 +12,4 @@ export {
} from "./tabs";
export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
export { createTabsStore } from "./tabs-store.svelte";
+export { default as TabBar } from "./ui/TabBar.svelte";
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
new file mode 100644
index 0000000..53df0be
--- /dev/null
+++ b/src/features/tabs/ui.test.ts
@@ -0,0 +1,148 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { Tab } from "./tabs";
+import TabBar from "./ui/TabBar.svelte";
+
+const sampleTabs: readonly Tab[] = [
+ { conversationId: "c1", model: "openai/gpt-4", title: "First" },
+ { conversationId: "c2", model: "anthropic/claude-3", title: "Second" },
+ { conversationId: "c3", model: "google/gemini", title: "Third" },
+];
+
+describe("TabBar", () => {
+ it("renders one role=tab element per tab showing each title", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(sampleTabs.length);
+ expect(tabs[0]).toHaveTextContent("First");
+ expect(tabs[1]).toHaveTextContent("Second");
+ expect(tabs[2]).toHaveTextContent("Third");
+ });
+
+ it("applies tab-active to the active tab only", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c2",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs[0]).not.toHaveClass("tab-active");
+ expect(tabs[1]).toHaveClass("tab-active");
+ expect(tabs[2]).not.toHaveClass("tab-active");
+ });
+
+ it("applies tab-active to New chat button when activeConversationId is null", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: null,
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).toHaveClass("tab-active");
+ });
+
+ it("calls onSelect with the conversationId when a tab is clicked", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const secondTab = tabs[1];
+ if (!secondTab) throw new Error("second tab not found");
+ await user.click(secondTab);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("c2");
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("calls onClose when the close button is clicked and does not call onSelect", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
+ const firstClose = closeButtons[0];
+ if (!firstClose) throw new Error("first close button not found");
+ await user.click(firstClose);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith("c1");
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("calls onNewDraft when the New chat button is clicked", async () => {
+ const onNewDraft = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft,
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ await user.click(newChat);
+
+ expect(onNewDraft).toHaveBeenCalledTimes(1);
+ });
+
+ it("the New chat button has the sticky class", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).toHaveClass("sticky");
+ });
+});
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
new file mode 100644
index 0000000..76fab05
--- /dev/null
+++ b/src/features/tabs/ui/TabBar.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+
+ let {
+ tabs,
+ activeConversationId,
+ onSelect,
+ onClose,
+ onNewDraft,
+ }: {
+ tabs: readonly Tab[];
+ activeConversationId: string | null;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onNewDraft: () => void;
+ } = $props();
+</script>
+
+<div class="overflow-x-auto border-b border-base-300">
+ <div class="tabs tabs-border min-w-max">
+ {#each tabs as tab (tab.conversationId)}
+ <div
+ class="tab"
+ class:tab-active={tab.conversationId === activeConversationId}
+ role="tab"
+ tabindex="0"
+ onclick={() => onSelect(tab.conversationId)}
+ onkeydown={(e) => {
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ }}
+ >
+ <span class="max-w-[120px] truncate">{tab.title}</span>
+ <button
+ class="btn btn-ghost btn-xs ml-1"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {/each}
+ <button
+ class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)]"
+ class:tab-active={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ +
+ </button>
+ </div>
+</div>