Skip to content

Commit 8c44fad

Browse files
feat: tab reordering via drag-n-drop.
1 parent 7b742b5 commit 8c44fad

5 files changed

Lines changed: 190 additions & 0 deletions

File tree

playwright/helpers/app-test-helpers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,20 @@ export const openWorkspaceTab = async (page: Page, fileName: string) => {
139139
await page.getByRole('button', { name: pattern }).click()
140140
}
141141

142+
export const reorderWorkspaceTabBefore = async (
143+
page: Page,
144+
{ from, to }: { from: string; to: string },
145+
) => {
146+
const source = page
147+
.getByRole('button', { name: new RegExp(`^Open tab ${escapeRegex(from)}$`) })
148+
.locator('..')
149+
const target = page
150+
.getByRole('button', { name: new RegExp(`^Open tab ${escapeRegex(to)}$`) })
151+
.locator('..')
152+
153+
await source.dragTo(target)
154+
}
155+
142156
export const setWorkspaceTabSource = async (
143157
page: Page,
144158
{

playwright/workspace-tabs.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test } from '@playwright/test'
22
import {
33
addWorkspaceTab,
4+
reorderWorkspaceTabBefore,
45
setWorkspaceTabSource,
56
waitForInitialRender,
67
} from './helpers/app-test-helpers.js'
@@ -168,6 +169,59 @@ test('startup restores last active workspace tab after reload', async ({ page })
168169
await expect(page.locator('#editor-panel-styles')).toHaveAttribute('hidden', '')
169170
})
170171

172+
test('workspace tab drag reorder persists across reload', async ({ page }) => {
173+
await waitForInitialRender(page)
174+
175+
await addWorkspaceTab(page)
176+
await addWorkspaceTab(page)
177+
178+
await reorderWorkspaceTabBefore(page, {
179+
from: 'module-2.tsx',
180+
to: 'App.tsx',
181+
})
182+
183+
const orderedTabs = page.locator('#workspace-tabs-strip .workspace-tab__select')
184+
await expect(orderedTabs.nth(0)).toHaveAttribute('aria-label', 'Open tab module-2.tsx')
185+
await expect(orderedTabs.nth(1)).toHaveAttribute('aria-label', 'Open tab App.tsx')
186+
187+
await page.reload()
188+
await waitForInitialRender(page)
189+
190+
const restoredTabs = page.locator('#workspace-tabs-strip .workspace-tab__select')
191+
await expect(restoredTabs.nth(0)).toHaveAttribute('aria-label', 'Open tab module-2.tsx')
192+
await expect(restoredTabs.nth(1)).toHaveAttribute('aria-label', 'Open tab App.tsx')
193+
})
194+
195+
test('workspace tab drag onto itself keeps order unchanged', async ({ page }) => {
196+
await waitForInitialRender(page)
197+
198+
await addWorkspaceTab(page)
199+
await addWorkspaceTab(page)
200+
201+
const labelsBefore = await page
202+
.locator('#workspace-tabs-strip .workspace-tab__select')
203+
.evaluateAll(nodes =>
204+
nodes
205+
.map(node => node.getAttribute('aria-label'))
206+
.filter((label): label is string => typeof label === 'string'),
207+
)
208+
209+
await reorderWorkspaceTabBefore(page, {
210+
from: 'App.tsx',
211+
to: 'App.tsx',
212+
})
213+
214+
const labelsAfter = await page
215+
.locator('#workspace-tabs-strip .workspace-tab__select')
216+
.evaluateAll(nodes =>
217+
nodes
218+
.map(node => node.getAttribute('aria-label'))
219+
.filter((label): label is string => typeof label === 'string'),
220+
)
221+
222+
expect(labelsAfter).toEqual(labelsBefore)
223+
})
224+
171225
test('add menu can create styles tab while component tab is active', async ({ page }) => {
172226
await waitForInitialRender(page)
173227

src/app.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ let workspaceTabRenameState = {
191191
let workspaceTabAddMenuOpen = false
192192
let isRenderingWorkspaceTabs = false
193193
let hasPendingWorkspaceTabsRender = false
194+
let draggedWorkspaceTabId = ''
195+
let dragOverWorkspaceTabId = ''
196+
let suppressWorkspaceTabClick = false
194197
const clipboardSupported = Boolean(navigator.clipboard?.writeText)
195198
const githubPrOpenIcon = {
196199
viewBox: '0 0 16 16',
@@ -1623,6 +1626,11 @@ const setWorkspaceTabAddMenuOpen = isOpen => {
16231626
}
16241627
}
16251628

1629+
const clearWorkspaceTabDragState = () => {
1630+
draggedWorkspaceTabId = ''
1631+
dragOverWorkspaceTabId = ''
1632+
}
1633+
16261634
const renderWorkspaceTabs = () => {
16271635
if (!(workspaceTabsStrip instanceof HTMLElement)) {
16281636
return
@@ -1647,7 +1655,15 @@ const renderWorkspaceTabs = () => {
16471655
tabContainer.className = 'workspace-tab'
16481656
tabContainer.dataset.active = isActive ? 'true' : 'false'
16491657
tabContainer.dataset.tabId = tab.id
1658+
tabContainer.draggable = true
1659+
tabContainer.dataset.dragOver =
1660+
dragOverWorkspaceTabId && dragOverWorkspaceTabId === tab.id ? 'true' : 'false'
16501661
tabContainer.addEventListener('click', event => {
1662+
if (suppressWorkspaceTabClick) {
1663+
suppressWorkspaceTabClick = false
1664+
return
1665+
}
1666+
16511667
const clickTarget = event.target
16521668
if (!(clickTarget instanceof Element)) {
16531669
return
@@ -1661,6 +1677,68 @@ const renderWorkspaceTabs = () => {
16611677

16621678
setActiveWorkspaceTab(tab.id)
16631679
})
1680+
tabContainer.addEventListener('dragstart', event => {
1681+
draggedWorkspaceTabId = tab.id
1682+
dragOverWorkspaceTabId = ''
1683+
suppressWorkspaceTabClick = true
1684+
if (event.dataTransfer) {
1685+
event.dataTransfer.effectAllowed = 'move'
1686+
event.dataTransfer.setData('text/plain', tab.id)
1687+
}
1688+
})
1689+
tabContainer.addEventListener('dragend', () => {
1690+
clearWorkspaceTabDragState()
1691+
queueMicrotask(() => {
1692+
suppressWorkspaceTabClick = false
1693+
})
1694+
renderWorkspaceTabs()
1695+
})
1696+
tabContainer.addEventListener('dragover', event => {
1697+
if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) {
1698+
return
1699+
}
1700+
1701+
event.preventDefault()
1702+
if (event.dataTransfer) {
1703+
event.dataTransfer.dropEffect = 'move'
1704+
}
1705+
1706+
if (dragOverWorkspaceTabId !== tab.id) {
1707+
dragOverWorkspaceTabId = tab.id
1708+
tabContainer.dataset.dragOver = 'true'
1709+
}
1710+
})
1711+
tabContainer.addEventListener('dragleave', event => {
1712+
const relatedTarget = event.relatedTarget
1713+
if (relatedTarget instanceof Node && tabContainer.contains(relatedTarget)) {
1714+
return
1715+
}
1716+
1717+
if (dragOverWorkspaceTabId === tab.id) {
1718+
dragOverWorkspaceTabId = ''
1719+
tabContainer.dataset.dragOver = 'false'
1720+
}
1721+
})
1722+
tabContainer.addEventListener('drop', event => {
1723+
if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) {
1724+
clearWorkspaceTabDragState()
1725+
renderWorkspaceTabs()
1726+
return
1727+
}
1728+
1729+
event.preventDefault()
1730+
persistActiveTabEditorContent()
1731+
1732+
const moved = workspaceTabsState.moveTabBefore(draggedWorkspaceTabId, tab.id)
1733+
clearWorkspaceTabDragState()
1734+
renderWorkspaceTabs()
1735+
1736+
if (!moved) {
1737+
return
1738+
}
1739+
1740+
queueWorkspaceSave()
1741+
})
16641742

16651743
const isRenaming = workspaceTabRenameState.tabId === tab.id
16661744
if (isRenaming) {

src/modules/workspace-tabs-state.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,38 @@ export const createWorkspaceTabsState = ({ tabs = [], activeTabId, onChange } =
213213
return true
214214
}
215215

216+
const moveTabBefore = (
217+
sourceTabId,
218+
targetTabId,
219+
{ emitReason = 'moveTabBefore' } = {},
220+
) => {
221+
const sourceId = toNonEmptyString(sourceTabId)
222+
const targetId = toNonEmptyString(targetTabId)
223+
224+
if (
225+
!sourceId ||
226+
!targetId ||
227+
sourceId === targetId ||
228+
!tabsById.has(sourceId) ||
229+
!tabsById.has(targetId)
230+
) {
231+
return false
232+
}
233+
234+
const sourceIndex = orderedIds.indexOf(sourceId)
235+
const targetIndex = orderedIds.indexOf(targetId)
236+
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
237+
return false
238+
}
239+
240+
orderedIds.splice(sourceIndex, 1)
241+
const nextTargetIndex = orderedIds.indexOf(targetId)
242+
orderedIds.splice(nextTargetIndex, 0, sourceId)
243+
244+
emit(emitReason)
245+
return true
246+
}
247+
216248
replaceTabs({
217249
nextTabs: tabs,
218250
nextActiveTabId: activeTabId,
@@ -231,5 +263,6 @@ export const createWorkspaceTabsState = ({ tabs = [], activeTabId, onChange } =
231263
upsertTab,
232264
setActiveTab,
233265
removeTab,
266+
moveTabBefore,
234267
}
235268
}

src/styles/panels-editor.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,17 @@
253253
flex: 0 1 auto;
254254
min-width: 0;
255255
max-width: min(200px, 36vw);
256+
cursor: grab;
257+
border-top-color: var(--border-control);
258+
}
259+
260+
.workspace-tab:active {
261+
cursor: grabbing;
262+
}
263+
264+
.workspace-tab[data-drag-over='true'] {
265+
outline: 2px solid color-mix(in srgb, var(--accent) 72%, transparent);
266+
outline-offset: -2px;
256267
}
257268

258269
.workspace-tab[data-active='true'] {

0 commit comments

Comments
 (0)