diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 14:35:53 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 14:35:53 +0900 |
| commit | 0cb08678ffead285afb1f93ba50cd5a144ed5e7d (patch) | |
| tree | cf1396f1d7a065b5777ede0fd64f67ae8d6063ec /src | |
| parent | 2663fe7f7b7eb438dc295fe9dea221aa8b8b8f81 (diff) | |
| download | dispatch-web-0cb08678ffead285afb1f93ba50cd5a144ed5e7d.tar.gz dispatch-web-0cb08678ffead285afb1f93ba50cd5a144ed5e7d.zip | |
feat(tabs): extract TabBar component with horizontal scroll + sticky end '+'
Move inline tab-bar markup from the composition root into a thin
presentational TabBar in the tabs feature (feature-as-a-library: pure
reducer -> reactive store -> UI). Adds overflow-x scroll (min-w-max strip)
and a sticky right-pinned new-chat '+' that floats over scrolling tabs.
Draft-on-select / create-on-send behavior unchanged.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.svelte | 42 | ||||
| -rw-r--r-- | src/features/tabs/index.ts | 1 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 148 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabBar.svelte | 54 |
4 files changed, 211 insertions, 34 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 811dc75..cc9866e 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; import { ChatView, Composer, ModelSelector } from "../features/chat"; + import { TabBar } from "../features/tabs"; import { SurfaceView } from "../features/surface-host"; import type { AppStore } from "./store.svelte"; @@ -42,40 +43,13 @@ </div> {/if} - <div class="flex items-center gap-2 border-b border-base-300 px-4"> - <div class="tabs tabs-border flex-1"> - {#each store.tabs as tab (tab.conversationId)} - <div - class="tab" - class:tab-active={tab.conversationId === store.activeConversationId} - role="tab" - tabindex="0" - onclick={() => store.selectTab(tab.conversationId)} - onkeydown={(e) => { if (e.key === "Enter") store.selectTab(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(); - store.closeTab(tab.conversationId); - }} - > - × - </button> - </div> - {/each} - <button - class="tab" - class:tab-active={store.activeConversationId === null} - onclick={() => store.newDraft()} - aria-label="New chat" - > - + - </button> - </div> - </div> + <TabBar + tabs={store.tabs} + activeConversationId={store.activeConversationId} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + /> <div class="flex flex-1 flex-col overflow-hidden"> <div class="flex items-center gap-2 px-4 py-2"> 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); + }} + > + × + </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> |
