Skip to content

Commit 3ef90be

Browse files
authored
feat: add tab reorder (#25)
* feat: add tab reorder * test: fix tests
1 parent e6844c0 commit 3ef90be

File tree

5 files changed

+233
-16
lines changed

5 files changed

+233
-16
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
]
2929
},
3030
"dependencies": {
31+
"@dnd-kit/core": "^6.3.1",
32+
"@dnd-kit/modifiers": "^9.0.0",
33+
"@dnd-kit/sortable": "^10.0.0",
34+
"@dnd-kit/utilities": "^3.2.2",
3135
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
3236
"@monaco-editor/react": "^4.6.0",
3337
"@radix-ui/react-alert-dialog": "^1.0.5",

pnpm-lock.yaml

Lines changed: 72 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/databrowser/components/databrowser-tabs.tsx

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,157 @@
1+
import { useEffect, useRef, useState } from "react"
12
import type { TabId } from "@/store"
23
import { useDatabrowserStore } from "@/store"
34
import { TabIdProvider } from "@/tab-provider"
5+
import {
6+
closestCenter,
7+
DndContext,
8+
MeasuringStrategy,
9+
PointerSensor,
10+
useSensor,
11+
useSensors,
12+
type DragEndEvent,
13+
} from "@dnd-kit/core"
14+
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"
15+
import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"
16+
import { CSS } from "@dnd-kit/utilities"
417
import { IconPlus } from "@tabler/icons-react"
518

619
import { Button } from "@/components/ui/button"
720

821
import { Tab } from "./tab"
922

23+
const SortableTab = ({ id }: { id: TabId }) => {
24+
const [originalWidth, setOriginalWidth] = useState<number | null>(null)
25+
const textRef = useRef<HTMLElement | null>(null)
26+
27+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
28+
id,
29+
resizeObserverConfig: {
30+
disabled: true,
31+
},
32+
})
33+
34+
const measureRef = (element: HTMLDivElement | null) => {
35+
if (element && !originalWidth) {
36+
const width = element.getBoundingClientRect().width
37+
setOriginalWidth(width)
38+
39+
if (element) {
40+
const textSpan = element.querySelector("span")
41+
if (textSpan) {
42+
textRef.current = textSpan as HTMLElement
43+
}
44+
}
45+
}
46+
setNodeRef(element)
47+
}
48+
useEffect(() => {
49+
if (textRef.current && isDragging) {
50+
const originalMaxWidth = textRef.current.style.maxWidth
51+
const originalWhiteSpace = textRef.current.style.whiteSpace
52+
const originalOverflow = textRef.current.style.overflow
53+
const originalTextOverflow = textRef.current.style.textOverflow
54+
55+
textRef.current.style.maxWidth = "none"
56+
textRef.current.style.whiteSpace = "nowrap"
57+
textRef.current.style.overflow = "visible"
58+
textRef.current.style.textOverflow = "clip"
59+
60+
return () => {
61+
if (textRef.current) {
62+
textRef.current.style.maxWidth = originalMaxWidth
63+
textRef.current.style.whiteSpace = originalWhiteSpace
64+
textRef.current.style.overflow = originalOverflow
65+
textRef.current.style.textOverflow = originalTextOverflow
66+
}
67+
}
68+
}
69+
}, [isDragging])
70+
useEffect(() => {
71+
const resizeObserver = new ResizeObserver((entries) => {
72+
if (entries[0]) {
73+
setOriginalWidth(entries[0].contentRect.width)
74+
}
75+
})
76+
77+
return () => resizeObserver.disconnect()
78+
}, [])
79+
80+
const style = {
81+
transform: transform
82+
? CSS.Transform.toString({
83+
...transform,
84+
y: 0,
85+
scaleX: 1,
86+
scaleY: 1,
87+
})
88+
: "",
89+
transition,
90+
...(isDragging
91+
? {
92+
zIndex: 50,
93+
minWidth: originalWidth ? `${originalWidth}px` : undefined,
94+
}
95+
: {}),
96+
}
97+
98+
return (
99+
<div
100+
ref={measureRef}
101+
style={style}
102+
className={isDragging ? "cursor-grabbing" : "cursor-grab"}
103+
{...attributes}
104+
{...listeners}
105+
>
106+
<TabIdProvider value={id as TabId}>
107+
<Tab id={id} />
108+
</TabIdProvider>
109+
</div>
110+
)
111+
}
112+
10113
export const DatabrowserTabs = () => {
11-
const { tabs, addTab, selectedTab } = useDatabrowserStore()
114+
const { tabs, addTab, reorderTabs, selectedTab } = useDatabrowserStore()
115+
116+
const sensors = useSensors(
117+
useSensor(PointerSensor, {
118+
activationConstraint: {
119+
distance: 5,
120+
},
121+
})
122+
)
123+
124+
const handleDragEnd = (event: DragEndEvent) => {
125+
const { active, over } = event
126+
127+
if (over && active.id !== over.id) {
128+
const oldIndex = tabs.findIndex(([id]) => id === active.id)
129+
const newIndex = tabs.findIndex(([id]) => id === over.id)
130+
131+
reorderTabs(oldIndex, newIndex)
132+
}
133+
}
12134

13135
return (
14136
<div className="relative mb-2 shrink-0">
15137
<div className="absolute bottom-0 left-0 right-0 -z-10 h-[1px] w-full bg-zinc-200" />
16138

17139
<div className="scrollbar-hide flex translate-y-[1px] items-center gap-1 overflow-x-scroll pb-[1px] [&::-webkit-scrollbar]:hidden">
18-
{selectedTab &&
19-
tabs.map(([id]) => (
20-
<TabIdProvider key={id} value={id as TabId}>
21-
<Tab id={id} />
22-
</TabIdProvider>
23-
))}
140+
<DndContext
141+
sensors={sensors}
142+
collisionDetection={closestCenter}
143+
onDragEnd={handleDragEnd}
144+
modifiers={[restrictToHorizontalAxis]}
145+
measuring={{
146+
droppable: {
147+
strategy: MeasuringStrategy.Always,
148+
},
149+
}}
150+
>
151+
<SortableContext items={tabs.map(([id]) => id)} strategy={horizontalListSortingStrategy}>
152+
{selectedTab && tabs.map(([id]) => <SortableTab key={id} id={id} />)}
153+
</SortableContext>
154+
</DndContext>
24155
<Button
25156
variant="secondary"
26157
size="icon-sm"

src/store.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ type DatabrowserStore = {
9898
addTab: () => TabId
9999
removeTab: (id: TabId) => void
100100
selectTab: (id: TabId) => void
101+
reorderTabs: (oldIndex: number, newIndex: number) => void
101102

102103
// Tab actions
103104
getSelectedKey: (tabId: TabId) => string | undefined
@@ -133,6 +134,15 @@ const storeCreator: StateCreator<DatabrowserStore> = (set, get) => ({
133134
return id
134135
},
135136

137+
reorderTabs: (oldIndex, newIndex) => {
138+
set((old) => {
139+
const newTabs = [...old.tabs]
140+
const [movedTab] = newTabs.splice(oldIndex, 1)
141+
newTabs.splice(newIndex, 0, movedTab)
142+
return { ...old, tabs: newTabs }
143+
})
144+
},
145+
136146
removeTab: (id) => {
137147
set((old) => {
138148
const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id)

tests/test.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,21 @@ describe("keys", () => {
5050
await page.getByRole("textbox", { name: "Search" }).fill("mykey-13")
5151
await page.getByRole("textbox", { name: "Search" }).press("Enter")
5252

53-
await page.getByRole("button", { name: "mykey-13" }).click()
53+
await page.getByRole("button", { name: "mykey-13", exact: true }).click()
5454

5555
await page.getByRole("button", { name: "Key actions" }).click()
5656
await page.getByRole("menuitem", { name: "Delete key" }).click()
5757
await page.getByRole("button", { name: "Cancel" }).press("Escape")
5858

59-
await page.getByRole("button", { name: "mykey-13" }).click()
59+
await page.getByRole("button", { name: "mykey-13", exact: true }).click()
6060
})
6161

6262
test("can delete a key", async ({ page }) => {
6363
await page.getByRole("textbox", { name: "Search" }).click()
6464
await page.getByRole("textbox", { name: "Search" }).fill("mykey-13")
6565
await page.getByRole("textbox", { name: "Search" }).press("Enter")
6666

67-
await page.getByRole("button", { name: "mykey-13" }).click()
67+
await page.getByRole("button", { name: "mykey-13", exact: true }).click()
6868

6969
await page.getByRole("button", { name: "Key actions" }).click()
7070
await page.getByRole("menuitem", { name: "Delete key" }).click()
@@ -73,7 +73,7 @@ describe("keys", () => {
7373

7474
await markDatabaseAsModified()
7575

76-
await expect(page.getByRole("button", { name: "mykey-13" })).not.toBeVisible()
76+
await expect(page.getByRole("button", { name: "mykey-13", exact: true })).not.toBeVisible()
7777
})
7878

7979
test("can add a string key", async ({ page }) => {
@@ -127,7 +127,7 @@ describe("hash", () => {
127127
await page.getByRole("textbox", { name: "Search" }).click()
128128
await page.getByRole("textbox", { name: "Search" }).fill("myhash")
129129
await page.getByRole("textbox", { name: "Search" }).press("Enter")
130-
await page.getByRole("button", { name: "myhash" }).click()
130+
await page.getByRole("button", { name: "myhash", exact: true }).click()
131131

132132
await page.getByRole("cell", { name: "field-10" }).click()
133133

@@ -146,7 +146,7 @@ describe("hash", () => {
146146
await page.getByRole("textbox", { name: "Search" }).click()
147147
await page.getByRole("textbox", { name: "Search" }).fill("myhash")
148148
await page.getByRole("textbox", { name: "Search" }).press("Enter")
149-
await page.getByRole("button", { name: "myhash" }).click()
149+
await page.getByRole("button", { name: "myhash", exact: true }).click()
150150

151151
await page.getByRole("cell", { name: "field-10" }).click()
152152

@@ -179,7 +179,7 @@ describe("hash", () => {
179179
await page.getByRole("textbox", { name: "Search" }).click()
180180
await page.getByRole("textbox", { name: "Search" }).fill("myhash")
181181
await page.getByRole("textbox", { name: "Search" }).press("Enter")
182-
await page.getByRole("button", { name: "myhash" }).click()
182+
await page.getByRole("button", { name: "myhash", exact: true }).click()
183183

184184
await page.getByRole("row", { name: "field-10 value-10" }).getByRole("button").click()
185185
await page.getByRole("button", { name: "Yes, Delete" }).click()
@@ -195,14 +195,14 @@ describe("tabs", () => {
195195
await page.getByRole("textbox", { name: "Search" }).click()
196196
await page.getByRole("textbox", { name: "Search" }).fill("mykey-42")
197197
await page.getByRole("textbox", { name: "Search" }).press("Enter")
198-
await page.getByRole("button", { name: "mykey-42" }).click()
198+
await page.getByRole("button", { name: "mykey-42", exact: true }).click()
199199

200200
await page.getByRole("button", { name: "Add new tab" }).click()
201201

202202
await page.getByRole("textbox", { name: "Search" }).click()
203203
await page.getByRole("textbox", { name: "Search" }).fill("mykey-13")
204204
await page.getByRole("textbox", { name: "Search" }).press("Enter")
205-
await page.getByRole("button", { name: "mykey-13" }).click()
205+
await page.getByRole("button", { name: "mykey-13", exact: true }).click()
206206

207207
// Changes to the first tab
208208
await page.getByText("mykey-42*").click()

0 commit comments

Comments
 (0)