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);