From 8c44faded7fdeb509aa0a2aa23e11d05e91445e6 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 15:47:47 -0500 Subject: [PATCH 1/3] feat: tab reordering via drag-n-drop. --- playwright/helpers/app-test-helpers.ts | 14 +++++ playwright/workspace-tabs.spec.ts | 54 ++++++++++++++++++ src/app.js | 78 ++++++++++++++++++++++++++ src/modules/workspace-tabs-state.js | 33 +++++++++++ src/styles/panels-editor.css | 11 ++++ 5 files changed, 190 insertions(+) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index ea2972e..33c9ab5 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -139,6 +139,20 @@ 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 source = page + .getByRole('button', { name: new RegExp(`^Open tab ${escapeRegex(from)}$`) }) + .locator('..') + const target = page + .getByRole('button', { name: new RegExp(`^Open tab ${escapeRegex(to)}$`) }) + .locator('..') + + 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..aed3bfa 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,59 @@ 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.locator('#workspace-tabs-strip .workspace-tab__select') + await expect(orderedTabs.nth(0)).toHaveAttribute('aria-label', 'Open tab module-2.tsx') + await expect(orderedTabs.nth(1)).toHaveAttribute('aria-label', 'Open tab App.tsx') + + await page.reload() + await waitForInitialRender(page) + + const restoredTabs = page.locator('#workspace-tabs-strip .workspace-tab__select') + await expect(restoredTabs.nth(0)).toHaveAttribute('aria-label', 'Open tab module-2.tsx') + await expect(restoredTabs.nth(1)).toHaveAttribute('aria-label', 'Open 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 + .locator('#workspace-tabs-strip .workspace-tab__select') + .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 + .locator('#workspace-tabs-strip .workspace-tab__select') + .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..bb2b848 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 @@ -1647,7 +1655,15 @@ const renderWorkspaceTabs = () => { tabContainer.className = 'workspace-tab' tabContainer.dataset.active = isActive ? 'true' : 'false' tabContainer.dataset.tabId = tab.id + tabContainer.draggable = true + 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,6 +1677,68 @@ const renderWorkspaceTabs = () => { setActiveWorkspaceTab(tab.id) }) + 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 => { + if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { + clearWorkspaceTabDragState() + renderWorkspaceTabs() + return + } + + event.preventDefault() + persistActiveTabEditorContent() + + const moved = workspaceTabsState.moveTabBefore(draggedWorkspaceTabId, tab.id) + clearWorkspaceTabDragState() + renderWorkspaceTabs() + + if (!moved) { + return + } + + queueWorkspaceSave() + }) const isRenaming = workspaceTabRenameState.tabId === tab.id if (isRenaming) { 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'] { From 1929ed4796a4ffd838466326f8c3da24b042bbab Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 16:02:53 -0500 Subject: [PATCH 2/3] test: more accessibility. --- playwright/helpers/app-test-helpers.ts | 13 +++++++------ playwright/workspace-tabs.spec.ts | 22 ++++++++++++++-------- src/app.js | 1 + 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index 33c9ab5..e829ded 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -143,12 +143,13 @@ export const reorderWorkspaceTabBefore = async ( page: Page, { from, to }: { from: string; to: string }, ) => { - const source = page - .getByRole('button', { name: new RegExp(`^Open tab ${escapeRegex(from)}$`) }) - .locator('..') - const target = page - .getByRole('button', { name: new RegExp(`^Open tab ${escapeRegex(to)}$`) }) - .locator('..') + 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) } diff --git a/playwright/workspace-tabs.spec.ts b/playwright/workspace-tabs.spec.ts index aed3bfa..a160927 100644 --- a/playwright/workspace-tabs.spec.ts +++ b/playwright/workspace-tabs.spec.ts @@ -180,16 +180,20 @@ test('workspace tab drag reorder persists across reload', async ({ page }) => { to: 'App.tsx', }) - const orderedTabs = page.locator('#workspace-tabs-strip .workspace-tab__select') - await expect(orderedTabs.nth(0)).toHaveAttribute('aria-label', 'Open tab module-2.tsx') - await expect(orderedTabs.nth(1)).toHaveAttribute('aria-label', 'Open tab 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.locator('#workspace-tabs-strip .workspace-tab__select') - await expect(restoredTabs.nth(0)).toHaveAttribute('aria-label', 'Open tab module-2.tsx') - await expect(restoredTabs.nth(1)).toHaveAttribute('aria-label', 'Open tab App.tsx') + 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 }) => { @@ -199,7 +203,8 @@ test('workspace tab drag onto itself keeps order unchanged', async ({ page }) => await addWorkspaceTab(page) const labelsBefore = await page - .locator('#workspace-tabs-strip .workspace-tab__select') + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') .evaluateAll(nodes => nodes .map(node => node.getAttribute('aria-label')) @@ -212,7 +217,8 @@ test('workspace tab drag onto itself keeps order unchanged', async ({ page }) => }) const labelsAfter = await page - .locator('#workspace-tabs-strip .workspace-tab__select') + .getByRole('list', { name: 'Workspace editor tabs' }) + .getByRole('listitem') .evaluateAll(nodes => nodes .map(node => node.getAttribute('aria-label')) diff --git a/src/app.js b/src/app.js index bb2b848..a0c7e60 100644 --- a/src/app.js +++ b/src/app.js @@ -1655,6 +1655,7 @@ const renderWorkspaceTabs = () => { 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 = true tabContainer.dataset.dragOver = dragOverWorkspaceTabId && dragOverWorkspaceTabId === tab.id ? 'true' : 'false' From f01aa499c913a1c5513eb245e8dcbfe5fa9d135b Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 11 Apr 2026 16:05:52 -0500 Subject: [PATCH 3/3] refactor: address pr comments. --- src/app.js | 115 +++++++++++++++++++++++++++-------------------------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/src/app.js b/src/app.js index a0c7e60..bb0c4e0 100644 --- a/src/app.js +++ b/src/app.js @@ -1651,12 +1651,13 @@ 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 = true + tabContainer.draggable = !isRenaming tabContainer.dataset.dragOver = dragOverWorkspaceTabId && dragOverWorkspaceTabId === tab.id ? 'true' : 'false' tabContainer.addEventListener('click', event => { @@ -1678,70 +1679,72 @@ const renderWorkspaceTabs = () => { setActiveWorkspaceTab(tab.id) }) - 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 + 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) + } }) - renderWorkspaceTabs() - }) - tabContainer.addEventListener('dragover', event => { - if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { - return - } + 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' - } + 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 = 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 => { - if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { - clearWorkspaceTabDragState() - renderWorkspaceTabs() - return - } + if (dragOverWorkspaceTabId === tab.id) { + dragOverWorkspaceTabId = '' + tabContainer.dataset.dragOver = 'false' + } + }) + tabContainer.addEventListener('drop', event => { + event.preventDefault() + + if (!draggedWorkspaceTabId || draggedWorkspaceTabId === tab.id) { + clearWorkspaceTabDragState() + renderWorkspaceTabs() + return + } - event.preventDefault() - persistActiveTabEditorContent() + persistActiveTabEditorContent() - const moved = workspaceTabsState.moveTabBefore(draggedWorkspaceTabId, tab.id) - clearWorkspaceTabDragState() - renderWorkspaceTabs() + const moved = workspaceTabsState.moveTabBefore(draggedWorkspaceTabId, tab.id) + clearWorkspaceTabDragState() + renderWorkspaceTabs() - if (!moved) { - return - } + if (!moved) { + return + } - queueWorkspaceSave() - }) + queueWorkspaceSave() + }) + } - const isRenaming = workspaceTabRenameState.tabId === tab.id if (isRenaming) { const renameInput = document.createElement('input') renameInput.className = 'workspace-tab__name-input'