diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index ea2972e..e829ded 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -139,6 +139,21 @@ export const openWorkspaceTab = async (page: Page, fileName: string) => { await page.getByRole('button', { name: pattern }).click() } +export const reorderWorkspaceTabBefore = async ( + page: Page, + { from, to }: { from: string; to: string }, +) => { + const tabList = page.getByRole('list', { name: 'Workspace editor tabs' }) + const source = tabList.getByRole('listitem', { + name: new RegExp(`^Workspace tab ${escapeRegex(from)}$`), + }) + const target = tabList.getByRole('listitem', { + name: new RegExp(`^Workspace tab ${escapeRegex(to)}$`), + }) + + await source.dragTo(target) +} + export const setWorkspaceTabSource = async ( page: Page, { diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index 7c08bdf..a160927 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test' import { addWorkspaceTab, + reorderWorkspaceTabBefore, setWorkspaceTabSource, waitForInitialRender, } from './helpers/app-test-helpers.js' @@ -168,6 +169,65 @@ test('startup restores last active workspace tab after reload', async ({ page }) await expect(page.locator('#editor-panel-styles')).toHaveAttribute('hidden', '') }) +test('workspace tab drag reorder persists across reload', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + await reorderWorkspaceTabBefore(page, { + from: 'module-2.tsx', + to: 'App.tsx', + }) + + const orderedTabs = page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + await expect(orderedTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx') + await expect(orderedTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx') + + await page.reload() + await waitForInitialRender(page) + + const restoredTabs = page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + await expect(restoredTabs.nth(0)).toHaveAccessibleName('Workspace tab module-2.tsx') + await expect(restoredTabs.nth(1)).toHaveAccessibleName('Workspace tab App.tsx') +}) + +test('workspace tab drag onto itself keeps order unchanged', async ({ page }) => { + await waitForInitialRender(page) + + await addWorkspaceTab(page) + await addWorkspaceTab(page) + + const labelsBefore = await page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + .evaluateAll(nodes => + nodes + .map(node => node.getAttribute('aria-label')) + .filter((label): label is string => typeof label === 'string'), + ) + + await reorderWorkspaceTabBefore(page, { + from: 'App.tsx', + to: 'App.tsx', + }) + + const labelsAfter = await page + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') + .evaluateAll(nodes => + nodes + .map(node => node.getAttribute('aria-label')) + .filter((label): label is string => typeof label === 'string'), + ) + + expect(labelsAfter).toEqual(labelsBefore) +}) + test('add menu can create styles tab while component tab is active', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index f71b65f..bb0c4e0 100644 --- a/src/app.js +++ b/src/app.js @@ -191,6 +191,9 @@ let workspaceTabRenameState = { let workspaceTabAddMenuOpen = false let isRenderingWorkspaceTabs = false let hasPendingWorkspaceTabsRender = false +let draggedWorkspaceTabId = '' +let dragOverWorkspaceTabId = '' +let suppressWorkspaceTabClick = false const clipboardSupported = Boolean(navigator.clipboard?.writeText) const githubPrOpenIcon = { viewBox: '0 0 16 16', @@ -1623,6 +1626,11 @@ const setWorkspaceTabAddMenuOpen = isOpen => { } } +const clearWorkspaceTabDragState = () => { + draggedWorkspaceTabId = '' + dragOverWorkspaceTabId = '' +} + const renderWorkspaceTabs = () => { if (!(workspaceTabsStrip instanceof HTMLElement)) { return @@ -1643,11 +1651,21 @@ const renderWorkspaceTabs = () => { for (const tab of tabs) { const isActive = tab.id === activeTabId + const isRenaming = workspaceTabRenameState.tabId === tab.id const tabContainer = document.createElement('li') tabContainer.className = 'workspace-tab' tabContainer.dataset.active = isActive ? 'true' : 'false' tabContainer.dataset.tabId = tab.id + tabContainer.setAttribute('aria-label', `Workspace tab ${tab.name}`) + tabContainer.draggable = !isRenaming + tabContainer.dataset.dragOver = + dragOverWorkspaceTabId && dragOverWorkspaceTabId === tab.id ? 'true' : 'false' tabContainer.addEventListener('click', event => { + if (suppressWorkspaceTabClick) { + suppressWorkspaceTabClick = false + return + } + const clickTarget = event.target if (!(clickTarget instanceof Element)) { return @@ -1661,8 +1679,72 @@ const renderWorkspaceTabs = () => { setActiveWorkspaceTab(tab.id) }) + if (!isRenaming) { + tabContainer.addEventListener('dragstart', event => { + draggedWorkspaceTabId = tab.id + dragOverWorkspaceTabId = '' + suppressWorkspaceTabClick = true + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', tab.id) + } + }) + tabContainer.addEventListener('dragend', () => { + clearWorkspaceTabDragState() + queueMicrotask(() => { + suppressWorkspaceTabClick = false + }) + renderWorkspaceTabs() + }) + tabContainer.addEventListener('dragover', event => { + if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { + return + } + + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move' + } + + if (dragOverWorkspaceTabId !== tab.id) { + dragOverWorkspaceTabId = tab.id + tabContainer.dataset.dragOver = 'true' + } + }) + tabContainer.addEventListener('dragleave', event => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && tabContainer.contains(relatedTarget)) { + return + } + + if (dragOverWorkspaceTabId === tab.id) { + dragOverWorkspaceTabId = '' + tabContainer.dataset.dragOver = 'false' + } + }) + tabContainer.addEventListener('drop', event => { + event.preventDefault() + + if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { + clearWorkspaceTabDragState() + renderWorkspaceTabs() + return + } + + persistActiveTabEditorContent() + + const moved = workspaceTabsState.moveTabBefore(draggedWorkspaceTabId, tab.id) + clearWorkspaceTabDragState() + renderWorkspaceTabs() + + if (!moved) { + return + } + + queueWorkspaceSave() + }) + } - const isRenaming = workspaceTabRenameState.tabId === tab.id if (isRenaming) { const renameInput = document.createElement('input') renameInput.className = 'workspace-tab__name-input' diff --git a/src/modules/workspace-tabs-state.js b/src/modules/workspace-tabs-state.js index 39d9b94..ed492a7 100644 --- a/src/modules/workspace-tabs-state.js +++ b/src/modules/workspace-tabs-state.js @@ -213,6 +213,38 @@ export const createWorkspaceTabsState = ({ tabs = [], activeTabId, onChange } = return true } + const moveTabBefore = ( + sourceTabId, + targetTabId, + { emitReason = 'moveTabBefore' } = {}, + ) => { + const sourceId = toNonEmptyString(sourceTabId) + const targetId = toNonEmptyString(targetTabId) + + if ( + !sourceId || + !targetId || + sourceId === targetId || + !tabsById.has(sourceId) || + !tabsById.has(targetId) + ) { + return false + } + + const sourceIndex = orderedIds.indexOf(sourceId) + const targetIndex = orderedIds.indexOf(targetId) + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { + return false + } + + orderedIds.splice(sourceIndex, 1) + const nextTargetIndex = orderedIds.indexOf(targetId) + orderedIds.splice(nextTargetIndex, 0, sourceId) + + emit(emitReason) + return true + } + replaceTabs({ nextTabs: tabs, nextActiveTabId: activeTabId, @@ -231,5 +263,6 @@ export const createWorkspaceTabsState = ({ tabs = [], activeTabId, onChange } = upsertTab, setActiveTab, removeTab, + moveTabBefore, } } diff --git a/src/styles/panels-editor.css b/src/styles/panels-editor.css index 3d5005c..fd4bdcd 100644 --- a/src/styles/panels-editor.css +++ b/src/styles/panels-editor.css @@ -253,6 +253,17 @@ flex: 0 1 auto; min-width: 0; max-width: min(200px, 36vw); + cursor: grab; + border-top-color: var(--border-control); +} + +.workspace-tab:active { + cursor: grabbing; +} + +.workspace-tab[data-drag-over='true'] { + outline: 2px solid color-mix(in srgb, var(--accent) 72%, transparent); + outline-offset: -2px; } .workspace-tab[data-active='true'] {