mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
feat(renderer): drag-to-reorder tabs (dnd-kit)
This commit is contained in:
@@ -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">
|
||||
<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>
|
||||
<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) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
onSelect={setActiveTab}
|
||||
onClose={closeTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user