feat(renderer): drag-to-reorder tabs (dnd-kit)

This commit is contained in:
2026-06-05 15:34:25 +05:30
parent 6da98c3a54
commit ce2bf62d42
2 changed files with 128 additions and 36 deletions
+102 -33
View File
@@ -1,12 +1,92 @@
import { X } from 'lucide-react';
import { useFileStore } from '@/stores/file-store';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useFileStore, type OpenTab } from '@/stores/file-store';
import { cn } from '@/lib/utils';
function SortableTab({
tab,
isActive,
onSelect,
onClose,
}: {
tab: OpenTab;
isActive: boolean;
onSelect: (id: string) => void;
onClose: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
role="tab"
aria-selected={isActive}
aria-current={isActive ? 'page' : undefined}
data-testid={`tab-${tab.id}`}
className={cn(
'group flex h-full cursor-pointer select-none 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={() => onSelect(tab.id)}
>
{tab.dirty && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
aria-label="Unsaved changes"
/>
)}
<span className="max-w-[120px] truncate">{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"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
}
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);
const reorderTabs = useFileStore((s) => s.reorderTabs);
// Require a small drag distance before activating — so a click on the tab
// body still triggers `onClick` (dnd-kit's PointerSensor default is 0).
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
if (openTabs.length === 0) {
return (
@@ -16,43 +96,32 @@ export function TabBar() {
);
}
function handleDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const fromIndex = openTabs.findIndex((t) => t.id === active.id);
const toIndex = openTabs.findIndex((t) => t.id === over.id);
if (fromIndex === -1 || toIndex === -1) return;
reorderTabs(fromIndex, toIndex);
}
return (
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={openTabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
<div className="flex h-full items-center px-1">
{openTabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
{openTabs.map((tab) => (
<SortableTab
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>
);
})}
tab={tab}
isActive={tab.id === activeTabId}
onSelect={setActiveTab}
onClose={closeTab}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}
+23
View File
@@ -90,4 +90,27 @@ describe('TabBar', () => {
const dirtyDot = screen.getByLabelText('Unsaved changes');
expect(dirtyDot).toBeInTheDocument();
});
it('reorders tabs when reorderTabs is called (drag end handler integration)', () => {
useFileStore.setState({
openTabs: [
{ id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false },
{ id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false },
{ id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false },
],
activeTabId: '/tmp/a.md',
});
render(<TabBar />);
// Initial DOM order
const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']);
// Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2
act(() => {
useFileStore.getState().reorderTabs(0, 2);
});
const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']);
});
});