Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions __generated__/dockview-core-exports.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 30 additions & 3 deletions e2e/fixtures/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,35 @@
]);

const el = document.getElementById('app');
const params = new URLSearchParams(location.search);
// Opt-in multi-row (wrapping) tabs via `?overflow=wrap` so the
// wrap behaviour is exercised without affecting other specs.
const overflow =
params.get('overflow') === 'wrap'
? { mode: 'wrap' }
: undefined;
// `?smooth=1` turns on the smooth tab reorder animation (needed
// to exercise the 2-D cross-row wrap reorder path).
const theme =
params.get('smooth') === '1'
? { ...core.themeAbyss, tabAnimation: 'smooth' }
: undefined;
// `?pinned=separate-row` exercises the two-row pinned layout;
// `?compact=1` opts into icon-only pinned tabs.
const pinnedParams = new URLSearchParams(location.search);
const pinnedMode =
pinnedParams.get('pinned') === 'separate-row'
params.get('pinned') === 'separate-row'
? 'separate-row'
: 'inline';
const dockview = new core.DockviewComponent(el, {
keyboardNavigation: true,
overflow,
theme,
// Pinned tabs — inert until a panel is pinned. `separate-row`
// mounts the pinned second row (real-browser geometry).
pinnedTabs: {
enabled: true,
mode: pinnedMode,
compact: pinnedParams.get('compact') === '1',
compact: params.get('compact') === '1',
},
// Record layout history so the harness can exercise undo/redo
// (incl. async popout re-open).
Expand Down Expand Up @@ -138,6 +152,19 @@
// The content-area dimensions a panel was last laid out
// with — the seam reports the group box minus the header.
panelDimensions: (id) => panelDimensions[id],
// Add `count` long-titled tabs to a single group so the
// strip overflows its width — under `?overflow=wrap` they
// wrap onto multiple rows.
setupWrapTabs: (count) => {
for (let i = 0; i < count; i++) {
const id = `wrap-tab-${i}`;
panels[id] = dockview.addPanel({
id,
component: 'default',
title: `wrap-tab-long-title-${i}`,
});
}
},
closePanel: (id) => panels[id] && panels[id].api.close(),
// Create a new panel and move it to the right of the
// popped-out group — the move lands in the popout's own
Expand Down
188 changes: 188 additions & 0 deletions e2e/tests/multi-row-tabs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { test, expect } from '@playwright/test';

/**
* Multi-row (wrapping) tabs — Phase 2 wrap render mode. Real-browser only: it
* depends on the tab strip actually wrapping and the header growing, which jsdom
* (no layout) can't produce. The fixture opts into `overflow.mode: 'wrap'` via
* `?overflow=wrap`.
*/
test.describe('multi-row tabs (wrap mode)', () => {
const setup = async (page) => {
await page.goto('/e2e/fixtures/index.html?overflow=wrap');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => (window as any).__dv.setupWrapTabs(20));
};

test('tabs wrap onto multiple rows and the header grows', async ({
page,
}) => {
await setup(page);

const tabsList = page.locator('.dv-tabs-container').first();
await expect(tabsList).toHaveClass(/dv-tabs-container--wrap/);

// the tabs occupy more than one row (more than one distinct offsetTop)
const rowCount = await page.evaluate(() => {
const list = document.querySelector('.dv-tabs-container')!;
const tops = new Set<number>();
list.querySelectorAll<HTMLElement>('.dv-tab').forEach((t) =>
tops.add(t.offsetTop)
);
return tops.size;
});
expect(rowCount).toBeGreaterThan(1);

// the header grew past a single row (default row height is 44px)
const header = (await page
.locator('.dv-tabs-and-actions-container')
.first()
.boundingBox())!;
expect(header.height).toBeGreaterThan(44);
});

test('wrapped rows fill the row height (no gap) and header chrome stays on the first row', async ({
page,
}) => {
await setup(page);

const m = await page.evaluate(() => {
const header = document.querySelector(
'.dv-tabs-and-actions-container'
) as HTMLElement;
const tab = document.querySelector(
'.dv-tabs-container .dv-tab'
) as HTMLElement;
const right = document.querySelector(
'.dv-right-actions-container'
) as HTMLElement;
const rowH = parseFloat(
getComputedStyle(header).getPropertyValue(
'--dv-tabs-and-actions-container-height'
)
);
return {
rowH,
headerH: header.offsetHeight,
tabH: tab.offsetHeight,
rightH: right ? right.offsetHeight : 0,
};
});

expect(m.rowH).toBeGreaterThan(0); // the row-height var resolves
// header actually wrapped to more than one row
expect(m.headerH).toBeGreaterThan(m.rowH);
// each wrapped tab fills a full row — no gap below the tab
expect(m.tabH).toBe(m.rowH);
// header actions sit on the first row, not stretched across all rows
expect(m.rightH).toBe(m.rowH);
expect(m.rightH).toBeLessThan(m.headerH);
});

test('content shrinks to the area below the multi-row header', async ({
page,
}) => {
await setup(page);

const groupBox = (await page
.locator('.dv-groupview')
.first()
.boundingBox())!;
const headerBox = (await page
.locator('.dv-tabs-and-actions-container')
.first()
.boundingBox())!;
const contentBox = (await page
.locator('.dv-content-container')
.first()
.boundingBox())!;

// multi-row header
expect(headerBox.height).toBeGreaterThan(44);

// the active panel was laid out with the shrunk content area, not the
// header-inclusive group box (the content-sizing seam + relayout)
const reported = await page.evaluate(() =>
(window as any).__dv.panelDimensions('wrap-tab-19')
);
expect(reported).toBeTruthy();
expect(reported.height).toBeCloseTo(contentBox.height, 0);
expect(reported.height).toBeCloseTo(
groupBox.height - headerBox.height,
0
);
});

test('no wrap class without the opt-in', async ({ page }) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => (window as any).__dv.setupWrapTabs(20));

await expect(
page.locator('.dv-tabs-container').first()
).not.toHaveClass(/dv-tabs-container--wrap/);
});

// Phase 3: 2-D cross-row drag reorder (smooth mode). A tab dragged from a
// lower row to a slot on an upper row reorders across the row boundary.
// Covers both animation modes: default (per-tab drop targets — the common
// case) and smooth (the tabs-list / pointer-end commit path). `tabAnimation`
// defaults to 'default', so both must work.
const reorderAcrossRows = async (page, query: string) => {
await page.goto(`/e2e/fixtures/index.html?${query}`);
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => (window as any).__dv.setupWrapTabs(20));

const tabOrder = () =>
page.evaluate(() =>
Array.from(
document.querySelectorAll('.dv-tabs-container .dv-tab')
).map((t) => (t as HTMLElement).innerText.trim())
);

const before = await tabOrder();
expect(before.length).toBe(20);
// sanity: tabs really wrapped to more than one row
const rows = await page.evaluate(() => {
const tops = new Set<number>();
document
.querySelectorAll<HTMLElement>('.dv-tabs-container .dv-tab')
.forEach((t) => tops.add(t.offsetTop));
return tops.size;
});
expect(rows).toBeGreaterThan(1);

// drag the last tab (a lower row) to the front (first tab, upper row)
const s = (await page
.locator('.dv-tab', { hasText: 'wrap-tab-long-title-19' })
.boundingBox())!;
const t = (await page
.locator('.dv-tab', { hasText: 'wrap-tab-long-title-0' })
.boundingBox())!;

await page.mouse.move(s.x + s.width / 2, s.y + s.height / 2);
await page.mouse.down();
// nudge to start the drag
await page.mouse.move(s.x + s.width / 2 + 6, s.y + s.height / 2);
// move onto the left half of the first tab (insert before it)
await page.mouse.move(t.x + 3, t.y + t.height / 2, { steps: 20 });
await page.mouse.up();

const after = await tabOrder();
// the dragged tab moved to the front and the set is unchanged
expect(after[0]).toContain('wrap-tab-long-title-19');
expect([...after].sort()).toEqual([...before].sort());
expect(after).not.toEqual(before);
};

test('a tab drags across rows to reorder (default mode)', async ({
page,
}) => {
await reorderAcrossRows(page, 'overflow=wrap');
});

test('a tab drags across rows to reorder (smooth mode)', async ({
page,
}) => {
await reorderAcrossRows(page, 'overflow=wrap&smooth=1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,58 @@ describe('WrapTabGroupIndicator', () => {
indicator.dispose();
expect(indicator.underlines.size).toBe(0);
});

test('wrapped strip draws one underline segment per row a group spans', () => {
const tabsList = document.createElement('div');
tabsList.classList.add('dv-tabs-container--wrap');

const makeTab = (top: number) => {
const el = document.createElement('div');
el.getBoundingClientRect = () =>
({
top,
bottom: top + 26,
left: 0,
right: 50,
width: 50,
height: 26,
}) as DOMRect;
return { value: { element: el } };
};
// two tabs of the group on different rows (top 0 and top 30)
const tabMap = new Map<string, any>([
['a', makeTab(0)],
['b', makeTab(30)],
]);

const tg = new TabGroup('tg-1', { label: 'Test', color: 'blue' });
tg.addPanel('a');
tg.addPanel('b');

const ctx = createContext({
tabsList,
getTabGroups: () => [tg],
getTabMap: () => tabMap as any,
getActivePanelId: () => 'a',
getHeaderPosition: () => 'top',
});
const indicator = new WrapTabGroupIndicator(ctx);
indicator.syncUnderlineElements(new Set(['tg-1']));
// simulate a leftover background from a prior non-wrap `none` render
indicator.getUnderline('tg-1')!.style.backgroundColor = 'red';
(indicator as any)._positionUnderlinesSync();

const underline = indicator.getUnderline('tg-1')!;
const path = underline.querySelector('path')!;
const d = path.getAttribute('d') ?? '';
// one straight segment per row → two `M` (move) commands
const segments = (d.match(/M /g) ?? []).length;
expect(segments).toBe(2);
// stale non-wrap background is cleared (no colored block behind the SVG)
expect(underline.style.backgroundColor).toBe('');

indicator.dispose();
});
});

describe('indicator type identity', () => {
Expand Down
Loading
Loading