diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html
index c1535be22..f600bbda5 100644
--- a/e2e/fixtures/index.html
+++ b/e2e/fixtures/index.html
@@ -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=` 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 =
diff --git a/e2e/tests/multi-row-tabs.spec.ts b/e2e/tests/multi-row-tabs.spec.ts
index 60d871b8c..66c965d4f 100644
--- a/e2e/tests/multi-row-tabs.spec.ts
+++ b/e2e/tests/multi-row-tabs.spec.ts
@@ -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();
+ document
+ .querySelectorAll('.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);
diff --git a/packages/dockview-core/src/theme/_space-mixin.scss b/packages/dockview-core/src/theme/_space-mixin.scss
index 1883aa2b2..4f509ee7f 100644
--- a/packages/dockview-core/src/theme/_space-mixin.scss
+++ b/packages/dockview-core/src/theme/_space-mixin.scss
@@ -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;
@@ -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);