Skip to content

Commit fd9a617

Browse files
feat: tab reordering via drag-n-drop. (#70)
1 parent 7b742b5 commit fd9a617

5 files changed

Lines changed: 202 additions & 1 deletion

File tree

playwright/helpers/app-test-helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ 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 tabList = page.getByRole('list', { name: 'Workspace editor tabs' })
147+
const source = tabList.getByRole('listitem', {
148+
name: new RegExp(`^Workspace tab ${escapeRegex(from)}$`),
149+
})
150+
const target = tabList.getByRole('listitem', {
151+
name: new RegExp(`^Workspace tab ${escapeRegex(to)}$`),
152+
})
153+
154+
await source.dragTo(target)
155+
}
156+
142157
export const setWorkspaceTabSource = async (
143158
page: Page,
144159
{

playwright/workspace-tabs.spec.ts

Lines changed: 60 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,65 @@ 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
184+
.getByRole('list', { name: 'Workspace editor tabs' })
185+
.getByRole('listitem')
186+
await expect(orderedTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx')
187+
await expect(orderedTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx')
188+
189+
await page.reload()
190+
await waitForInitialRender(page)
191+
192+
const restoredTabs = page
193+
.getByRole('list', { name: 'Workspace editor tabs' })
194+
.getByRole('listitem')
195+
await expect(restoredTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx')
196+
await expect(restoredTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx')
197+
})
198+
199+
test('workspace tab drag onto itself keeps order unchanged', async ({ page }) => {
200+
await waitForInitialRender(page)
201+
202+
await addWorkspaceTab(page)
203+
await addWorkspaceTab(page)
204+
205+
const labelsBefore = await page
206+
.getByRole('list', { name: 'Workspace editor tabs' })
207+
.getByRole('listitem')
208+
.evaluateAll(nodes =>
209+
nodes
210+
.map(node => node.getAttribute('aria-label'))
211+
.filter((label): label is string => typeof label === 'string'),
212+
)
213+
214+
await reorderWorkspaceTabBefore(page, {
215+
from: 'App.tsx',
216+
to: 'App.tsx',
217+
})
218+
219+
const labelsAfter = await page
220+
.getByRole('list', { name: 'Workspace editor tabs' })
221+
.getByRole('listitem')
222+
.evaluateAll(nodes =>
223+
nodes
224+
.map(node => node.getAttribute('aria-label'))
225+
.filter((label): label is string => typeof label === 'string'),
226+
)
227+
228+
expect(labelsAfter).toEqual(labelsBefore)
229+
})
230+
171231
test('add menu can create styles tab while component tab is active', async ({ page }) => {
172232
await waitForInitialRender(page)
173233

src/app.js

Lines changed: 83 additions & 1 deletion
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
@@ -1643,11 +1651,21 @@ const renderWorkspaceTabs = () => {
16431651

16441652
for (const tab of tabs) {
16451653
const isActive = tab.id === activeTabId
1654+
const isRenaming = workspaceTabRenameState.tabId === tab.id
16461655
const tabContainer = document.createElement('li')
16471656
tabContainer.className = 'workspace-tab'
16481657
tabContainer.dataset.active = isActive ? 'true' : 'false'
16491658
tabContainer.dataset.tabId = tab.id
1659+
tabContainer.setAttribute('aria-label', `Workspace tab ${tab.name}`)
1660+
tabContainer.draggable = !isRenaming
1661+
tabContainer.dataset.dragOver =
1662+
dragOverWorkspaceTabId && dragOverWorkspaceTabId === tab.id ? 'true' : 'false'
16501663
tabContainer.addEventListener('click', event => {
1664+
if (suppressWorkspaceTabClick) {
1665+
suppressWorkspaceTabClick = false
1666+
return
1667+
}
1668+
16511669
const clickTarget = event.target
16521670
if (!(clickTarget instanceof Element)) {
16531671
return
@@ -1661,8 +1679,72 @@ const renderWorkspaceTabs = () => {
16611679

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

1665-
const isRenaming = workspaceTabRenameState.tabId === tab.id
16661748
if (isRenaming) {
16671749
const renameInput = document.createElement('input')
16681750
renameInput.className = 'workspace-tab__name-input'

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)