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
14 changes: 13 additions & 1 deletion e2e/fixtures/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,22 @@
: undefined;
// `?smooth=1` turns on the smooth tab reorder animation (needed
// to exercise the 2-D cross-row wrap reorder path).
// `?theme=<name>` selects an exported theme by its `lib.theme*`
// export name (e.g. `abyssSpaced` -> `lib.themeAbyssSpaced`) so
// the spaced-theme wrap layout can be exercised; inert (core
// default) when absent so other specs are unaffected.
const themeParam = params.get('theme');
const themeExport =
themeParam &&
lib[
'theme' +
themeParam.charAt(0).toUpperCase() +
themeParam.slice(1)
];
const theme =
params.get('smooth') === '1'
? { ...lib.themeAbyss, tabAnimation: 'smooth' }
: undefined;
: themeExport || undefined;
// `?pinned=separate-row` exercises the two-row pinned layout;
// `?compact=1` opts into icon-only pinned tabs.
const pinnedMode =
Expand Down
103 changes: 103 additions & 0 deletions e2e/tests/multi-row-tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,109 @@ test.describe('multi-row tabs (wrap mode)', () => {
}).toPass();
});

// --- Spaced themes: the wrapped tab must keep its pill inset ---
// Spaced themes give tabs a `--dv-tab-margin`. Core pins each wrapped tab to
// the full row-height/column-width; without the theme's compensating
// subtraction the tab fills that whole size and its margin then pushes the
// row band past one header unit (horizontal) / overflows the strip
// (vertical) — the pill inset it has in single-row mode is lost. The
// invariant is the ROW BAND (tab border-box + its cross-axis margins), not
// the tab box alone: it must equal exactly one header unit. The default
// theme has a zero tab margin, so only the spaced theme exercises this.
test('spaced theme: each wrapped row band equals one header height (pill inset kept)', async ({
page,
}) => {
await page.goto(
'/e2e/fixtures/index.html?overflow=wrap&theme=abyssSpaced'
);
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => (window as any).__dv.setupWrapTabs(12));

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 rowH = parseFloat(
getComputedStyle(header).getPropertyValue(
'--dv-tabs-and-actions-container-height'
)
);
const cs = getComputedStyle(tab);
const band =
tab.offsetHeight +
parseFloat(cs.marginTop) +
parseFloat(cs.marginBottom);
const rows = new Set<number>();
document
.querySelectorAll<HTMLElement>('.dv-tabs-container .dv-tab')
.forEach((t) => rows.add(t.offsetTop));
return {
rowH,
tabH: tab.offsetHeight,
marginY:
parseFloat(cs.marginTop) + parseFloat(cs.marginBottom),
band,
headerH: header.offsetHeight,
rowCount: rows.size,
};
});

// The theme actually has a vertical tab margin (this is what the bug
// hinged on — a zero-margin theme can't regress here).
expect(m.marginY).toBeGreaterThan(0);
// The tab keeps its pill inset: it is SHORTER than the full row unit ...
expect(m.tabH).toBeLessThan(m.rowH);
// ... and the row band (tab + margins) is exactly one row unit, so
// wrapped rows don't overshoot (the pre-fix band was rowH + marginY).
expect(m.band).toBeCloseTo(m.rowH, 0);
// Tabs really wrapped, and the header is a whole number of row units.
expect(m.rowCount).toBeGreaterThan(1);
expect(m.headerH).toBeCloseTo(m.rowCount * m.rowH, 0);
});

test('spaced theme (vertical header): each wrapped column band equals one header width (pill inset kept)', async ({
page,
}) => {
await setupVertical(page, 'overflow=wrap&theme=abyssSpaced', 12);
await expect.poll(() => columnCount(page)).toBeGreaterThan(1);

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 colW = parseFloat(
getComputedStyle(header).getPropertyValue(
'--dv-tabs-and-actions-container-height'
)
);
const cs = getComputedStyle(tab);
const band =
tab.offsetWidth +
parseFloat(cs.marginLeft) +
parseFloat(cs.marginRight);
return {
colW,
tabW: tab.offsetWidth,
marginX:
parseFloat(cs.marginLeft) + parseFloat(cs.marginRight),
band,
};
});

expect(m.marginX).toBeGreaterThan(0);
// The cross-axis mirror: the vertical tab keeps its pill inset (narrower
// than the strip) and its column band fits exactly one header width, so
// the strip doesn't overflow (the pre-fix band was colW + marginX).
expect(m.tabW).toBeLessThan(m.colW);
expect(m.band).toBeCloseTo(m.colW, 0);
});

test('Arrow Up/Down move focus between wrapped rows', async ({ page }) => {
await setup(page);

Expand Down
54 changes: 49 additions & 5 deletions packages/dockview-core/src/theme/_space-mixin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
--dv-spacing-padding: 10px;
--dv-tab-font-size: 12px;
--dv-border-radius: 12px;
--dv-tab-margin: 0.5rem 0.25rem;
// Split into block (top/bottom) and inline (left/right) parts so the
// multi-row wrap rule below can subtract the exact vertical margin from
// the wrapped-tab height (calc can't read a component out of the
// `--dv-tab-margin` shorthand).
--dv-tab-margin-block: 0.5rem;
--dv-tab-margin-inline: 0.25rem;
--dv-tab-margin: var(--dv-tab-margin-block) var(--dv-tab-margin-inline);
--dv-tabs-and-actions-container-height: 44px;
--dv-tab-border-radius: 8px;
--dv-sash-border-radius: 4px;
Expand Down Expand Up @@ -105,17 +111,55 @@
}
}

// Multi-row (wrap) tabs. Core pins each wrapped tab to the full
// `--dv-tabs-and-actions-container-height` (tabs.scss) so every row matches
// the single-row header height. That assumes a zero tab margin, which holds
// for the default theme but not the spaced themes: here `--dv-tab-margin`
// adds a vertical inset that flex-wrap counts toward each row's height, so a
// full-height tab plus its margins makes every wrapped row taller than the
// header — and the tab fills that whole height instead of keeping the pill
// inset it has in single-row mode (where flex `align-items: stretch` sizes
// the tab to the header height minus its margins). Subtract the vertical
// margin so each wrapped row is exactly one header height and the tab keeps
// its pill proportions. Horizontal headers only — the vertical wrap case
// sizes tabs from `--dv-wrap-vertical-tab-height` (measured live).
.dv-tabs-container--wrap:not(.dv-tabs-container-vertical) .dv-tab {
height: calc(
var(--dv-tabs-and-actions-container-height) -
2 * var(--dv-tab-margin-block)
);
}

// In writing-mode: vertical-rl (used by vertical tab bars), physical
// margin-left/right are the cross-axis margins that control the tab
// pill's visual "height" (physical width). Match the 0.5rem cross-axis
// margin used by horizontal tabs so the pill proportions are consistent
// across all four edge-group positions.
// pill's visual "height" (physical width). Swap the block/inline parts so
// the 0.5rem cross-axis margin matches the one horizontal tabs use, keeping
// the pill proportions consistent across all four edge-group positions.
// (`--dv-tab-margin-block` is thus the cross-axis margin in BOTH
// orientations — top/bottom horizontally, left/right vertically.)
.dv-tabs-container-vertical {
.dv-tab {
margin: 0.25rem 0.5rem;
margin: var(--dv-tab-margin-inline) var(--dv-tab-margin-block);
}
}

// Multi-column (wrap) tabs on a vertical edge-group header — the cross-axis
// mirror of the horizontal wrap rule above. Core pins each wrapped vertical
// tab to the full `--dv-tabs-and-actions-container-height` as its WIDTH
// (tabs.scss), which (as with the horizontal case) ignores the spaced
// theme's cross-axis tab margin: the full-width tab plus its left/right
// margins overflows the edge-group strip and the pill loses the inset it
// has in single-column mode (flex `align-items: stretch` sizing it to the
// strip width minus its margins). Subtract the cross-axis margin so the
// pill fits the strip exactly. Only the width (cross axis) is corrected —
// the height is core's live-measured `--dv-wrap-vertical-tab-height`.
.dv-tabs-container--wrap.dv-tabs-container-vertical .dv-tab {
width: calc(
var(--dv-tabs-and-actions-container-height) -
2 * var(--dv-tab-margin-block)
);
}

.dv-groupview {
border-radius: var(--dv-border-radius);

Expand Down
Loading