mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
feat(renderer): TabBar wired to file store (dirty dot, close, active highlight)
- Renders tabs from useFileStore.openTabs with title + dirty dot + close button - Uses Zustand selectors for granular re-renders - Active tab highlighted with aria-current and accent background - Close button stops propagation; does not trigger tab activation - Horizontal scroll on overflow, h-9 fixed bar with border - Empty state preserved when no tabs open Tests: 6 cases (empty, render, highlight, click-switch, click-close, dirty indicator) - 75 total tests (was 70, gained 5 new) - All pass - Build: succeeds Amit Haridas
This commit is contained in:
@@ -1,7 +1,58 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TabBar() {
|
||||
const openTabs = useFileStore((s) => s.openTabs);
|
||||
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||
const setActiveTab = useFileStore((s) => s.setActiveTab);
|
||||
const closeTab = useFileStore((s) => s.closeTab);
|
||||
|
||||
if (openTabs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||
<span>No files open</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||
<span>No files open</span>
|
||||
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
|
||||
<div className="flex h-full items-center px-1">
|
||||
{openTabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'group flex h-full cursor-pointer items-center gap-1 border-r border-border px-3 text-xs transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.dirty && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-label="Unsaved changes" />
|
||||
)}
|
||||
<span className="truncate max-w-[120px]">{tab.title}</span>
|
||||
<button
|
||||
aria-label={`Close ${tab.title}`}
|
||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { TabBar } from '@/components/layout/TabBar';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
|
||||
describe('TabBar', () => {
|
||||
it('renders an empty state when no tabs are open', () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({ openTabs: [], activeTabId: null });
|
||||
});
|
||||
|
||||
it('renders "No files open" when openTabs is empty', () => {
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a tab for each OpenTab', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText('foo.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('bar.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights the active tab with aria-current', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
const fooTab = screen.getByText('foo.md').closest('[role="tab"]');
|
||||
expect(fooTab).toHaveAttribute('aria-current', 'page');
|
||||
const barTab = screen.getByText('bar.md').closest('[role="tab"]');
|
||||
expect(barTab).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('clicking a tab calls setActiveTab', async () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
await act(async () => {
|
||||
screen.getByText('bar.md').closest('[role="tab"]')!.click();
|
||||
});
|
||||
expect(useFileStore.getState().activeTabId).toBe('/tmp/bar.md');
|
||||
});
|
||||
|
||||
it('clicking the close button calls closeTab and does not call setActiveTab separately', async () => {
|
||||
// Use a non-active tab to test that closeTab doesn't change activeTabId
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||
],
|
||||
activeTabId: '/tmp/bar.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
|
||||
const fooTab = screen.getByText('foo.md');
|
||||
const closeBtn = fooTab.closest('[role="tab"]')!.querySelector('button')!;
|
||||
await act(async () => {
|
||||
closeBtn.click();
|
||||
});
|
||||
|
||||
const state = useFileStore.getState();
|
||||
expect(state.openTabs.find((t) => t.id === '/tmp/foo.md')).toBeUndefined();
|
||||
// activeTabId stays on bar since we closed a non-active tab
|
||||
expect(state.activeTabId).toBe('/tmp/bar.md');
|
||||
});
|
||||
|
||||
it('renders a dirty indicator when dirty === true', () => {
|
||||
useFileStore.setState({
|
||||
openTabs: [
|
||||
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true },
|
||||
],
|
||||
activeTabId: '/tmp/foo.md',
|
||||
});
|
||||
render(<TabBar />);
|
||||
const dirtyDot = screen.getByLabelText('Unsaved changes');
|
||||
expect(dirtyDot).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user