feat: multi-row (wrapping) tabs#1385
Merged
Merged
Conversation
Free-core groundwork for the MultiRowTabsModule (the wrap mode itself ships in dockview-modules). No behaviour change — all additions are inert until a module toggles them. - `overflow` option (`DockviewOverflowOptions`): `mode: 'dropdown' | 'wrap'` (default dropdown), `maxRows`, plus `search`/`mru`/`thumbnails` reserved for the AdvancedOverflowModule. Registered in PROPERTY_KEYS_DOCKVIEW. - Tab-list element seam: `Tabs.tabsListElement` → `ITabsContainer` → `DockviewGroupPanelModel.tabsListElement` → `IMultiRowTabsHost.getTabsListElement`, so the wrap controller can measure rows + toggle the wrap class. Returns undefined for a hidden header. - `IMultiRowTabsHost` / `IMultiRowTabsService` contracts + `multiRowTabsService` ServiceCollection slot; `relayoutGroup` host hook wraps the free `DockviewGroupPanel.relayout()` (#1384). - Inert wrap CSS: `.dv-tabs-container--wrap` (flex-wrap, horizontal headers only) + header `:has()` growth rule. Applied only by the module. Exports regenerated (+DockviewOverflowOptions, OverflowThumbnailRenderer, IMultiRowTabsHost, IMultiRowTabsService). Seam unit test added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `overflow.mode: 'wrap'`: tabs wrap onto multiple rows and the header grows, instead of clipping into the chevron dropdown. Phase 2 (wrap render mode); true cross-row DnD reorder is a later phase. - `MultiRowTabsModule` / `MultiRowTabsService` in dockview-modules, added to the default `Modules` bundle. Per-group `WrapController` toggles the inert `.dv-tabs-container--wrap` class (PR-A) on the group's tab list and, on integer row-count change (ResizeObserver + panel add/remove), calls the host `relayoutGroup` so the taller header shrinks the content area via the free content-sizing seam. v1 wraps horizontal headers only (hidden/vertical are a no-op). - core: `tabs.ts` skips the x-only smooth in-strip reorder for a wrapped strip (it breaks across rows); drag-to-detach + `default`-mode per-tab drop targets are unaffected. 2-D reorder ships later. - Tests: 6 jsdom unit (class application, gating, vertical no-op, relayout, removability via the shared spec) + 3 Playwright (real wrap geometry: rows, header growth, content shrink). Fixture opts in via `?overflow=wrap`. core 1107 · modules 205 · e2e 29, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ontroller Behaviour-preserving extraction of the ~900-line tab drag-reorder / smooth-animation subsystem out of the 2,151-line tabs.ts into a dedicated TabReorderController (operating on Tabs via a narrow ITabReorderHost). Zero logic changes — validated by the existing suites (tabsAnimation 2,141 lines, tabs, tabsContainer): 1107 tests / 65 suites green, unchanged. tabs.ts keeps the DOM event wiring (constructor listeners, per-tab drag/drop handlers, void/chip handlers) and delegates the reorder logic + `_animState` to the controller. Isolates the subsystem so the multi-row 2-D cross-row reorder can be added without tangling it into tabs.ts. No public API change; the `dv-tabs-container--wrap` reorder guard is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drag-reorder tabs across wrapped rows. Built on the extracted TabReorderController; the 1-D smooth path is untouched (all wrap logic is gated on the `dv-tabs-container--wrap` class). - `handleDragOver` gains a wrap branch: `computeWrappedInsertionIndex` maps the pointer to (row-by-y, slot-by-x-midpoint) → a flat insertion index, and a discrete drop indicator (`dv-tab--reorder-before/after`) replaces the gap animation. `clientY` is threaded through processDragOver / the HTML5 + pointer dragover entry points. - The 1-D gap (`applyDragOverTransforms`) and FLIP (`runFlipAnimation`) both early-return in wrap — they animate single-axis deltas that fight the flow layout. - Commit: in smooth wrap the per-tab pointer drop target delegates to the content override and doesn't reorder, so `handlePointerDragEnd` commits the reorder from the computed 2-D index when the drag ends over the strip. HTML5 wrap drops commit via the existing tabs-list `drop` listener (`currentInsertionIndex`, now 2-D). Tests: e2e drags a tab from a lower row to the front (smooth mode) and asserts the reorder. core 1107 / e2e 30 green; the single-row drag suites (tabsAnimation 2,141 lines) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior comment claimed the per-tab pointer drop target "delegates to the content override and doesn't reorder" — that's wrong: the override still fires onDrop. The accurate reason the per-tab path doesn't commit in smooth wrap is that its drop target never latches a drop state for the intra-group drag, so onDrop isn't fired at all. Reword the comment to say that, and to make the no-double-commit guarantee explicit (tab.onDrop nulls _animState before handlePointerDragEnd runs). Comment-only; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two findings from PR review:
- Guard group-chip drags out of the 2-D wrap hit-test. A chip drag carries
group-move semantics ("can't drop inside another group") the single-tab
hit-test doesn't model; it now stays on the 1-D path (whose gap animation
no-ops in wrap anyway) instead of having `targetTabGroupId` forced to null.
- Cover default-mode wrap reorder with e2e. `tabAnimation` defaults to
'default' (per-tab drop targets), a different commit path from smooth; only
smooth was tested. Refactor the reorder e2e into a shared helper and run it
for both modes.
core 1107 · multi-row e2e 5, all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From an independent review of the PR: - Group-chip drags: my earlier handleDragOver guard wasn't enough — the pointer wrap-commit only checked `sourceIndex !== -1`, but a chip drag's sourceIndex is its firstIdx (>= 0). So a chip pointer-drag in wrap could fire commitWrapReorder (a bogus single-tab drop with sourceTabId '') AND the chip drop target (which doesn't null _animState) → double-commit. Also gate the commit on `!sourceTabGroupId`. - computeWrappedInsertionIndex: a pointer in the gap *between* two rows was clamped to the last row; pick the nearest row by vertical distance instead. - Runtime `overflow.mode` change is now honored — MultiRowTabsService subscribes to onDidOptionsChange and re-applies wrap to every group (was construction-time only, while `enabled` already read live options). New host member `onDidOptionsChange`; unit test added. - `maxRows` is documented as NOT YET IMPLEMENTED (it was inert but read like a working option). - Share the wrap CSS class as `OVERFLOW_WRAP_TABS_CLASS` (core + module) so the cross-package seam can't drift on a rename. core 1107 · modules 206 · e2e 31, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The remaining non-blocking review nits: - Skip the source-tab collapse in wrap smooth drag. Collapsing it (width→0) reflowed the wrapped rows mid-drag under the 2-D hit-test; the source now stays in place (the 1-D gap it fed no-ops in wrap anyway). - Defer the ResizeObserver-driven relayout to requestAnimationFrame, avoiding the browser's "ResizeObserver loop" console warning when a row is added/ removed. Pending frame is cancelled on teardown; uses the list's own document (popout-safe). - Don't draw the drop indicator on the dragged source tab (the slot at its own position is a no-op drop). - Document that a runtime header-direction flip isn't re-evaluated (no core signal; the CSS guard still prevents a vertical header from wrapping). core 1107 · modules 206 · e2e 31, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visual fixes found while testing wrap in the demo: - Tab-group underline: the single horizontal-bar model (pinned `bottom:0`, first-left→last-right span) is wrong across wrapped rows. Add a wrap-aware path in `_positionUnderlinesSync` that draws one straight segment under each row's run of a group's tabs (horizontal + wrapped only; single-row path unchanged). Unit-tested with stubbed multi-row rects. - Wrapped row height: switching the container to `height:auto` for wrapping collapsed each flex line to the tab's content height, leaving a gap below the tabs. Give wrapped tabs the base row height so every row matches the single-row header height. - Header chrome: on a multi-row header the prefix/left/right actions + drag void stretched and centered across the full height. Top-align them and size them to the first row so they stay put; only the tab list grows downward. e2e asserts the no-gap row height + first-row action alignment. core 1108 · modules 206 · e2e 32, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tial value Sonar flags Array.reduce() without an initial value. Pass rows[0] as the seed — behaviour-identical (reduce already used the first element as the initial accumulator), no-throw on the guaranteed-non-empty rows array. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From an independent review of the rendering diff: - Bug: `_positionWrappedUnderline` didn't clear `background-color`. With `theme.tabGroupIndicator: 'none'` (which paints the underline element itself) + a runtime wrap toggle, the stale background showed as a colored block behind the per-row SVG segments. Reset it in the wrap path. Regression test added. - Extract the duplicated SVG-ensure block (wrap path + applyShape) into a shared `ensureSvgPath` helper (DRY + clears the Sonar duplication). - Clear `bottom` on the vertical non-wrap branch too, for symmetry with the horizontal branch's wrap-override reset. core 1108 · multi-row e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # e2e/fixtures/index.html # packages/dockview-core/src/dockview/moduleContracts.ts # packages/dockview-core/src/dockview/modules.ts # packages/dockview-modules/src/index.ts
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What
Adds
overflow.mode: 'wrap'— when a group has more tabs than fit on one row, the strip wraps onto multiple rows and the header grows (JetBrains / classic-VS behaviour) instead of clipping into the chevron dropdown. Default stays'dropdown'; omittingoverflowis byte-identical to before.This PR ships wrap end-to-end: render mode and 2-D cross-row drag reorder.
Commits
overflowAPI (core, inert/no behaviour change):overflowoption (mode/maxRows+ reservedsearch/mru/thumbnailsfor a future advanced-overflow module), tab-list element seam (Tabs.tabsListElement→IMultiRowTabsHost.getTabsListElement),IMultiRowTabsHost/IMultiRowTabsService+ slot, inert.dv-tabs-container--wrapCSS (:has()header growth).MultiRowTabsModule(dockview-modules, in the default bundle): per-groupWrapControllertoggles the wrap class and relayouts on integer row-count change (via the feat(core): header-aware content sizing seam #1384 content-sizing seam), horizontal headers only in v1.TabReorderController: behaviour-preserving extraction of the ~900-line tab drag-reorder + smooth-animation subsystem out of the 2,151-linetabs.tsinto a dedicated controller (narrowITabReorderHost). Zero logic changes — validated by the existing suites (tabsAnimation2,141 lines). Isolates the subsystem so the 2-D reorder slots in without tanglingtabs.ts.computeWrappedInsertionIndex(row-by-y, slot-by-x-midpoint → flat index) + a discrete drop indicator replace the 1-D gap; the gap/FLIP animations early-return in wrap; the reorder commits from the 2-D index (pointer viahandlePointerDragEnd, HTML5 via the tabs-listdroplistener). All wrap logic is gated on the wrap class, so the single-row path is untouched.Free vs Pro
overflowoption type, the tab-list seam, the inert wrap CSS, the content-sizing seam, and theTabReorderControllerrefactor.MultiRowTabsModule): themode: 'wrap'behaviour (wrap layout + 2-D reorder).Deferred (follow-ups)
maxRows+ dropdown spillover · cross-row keyboard nav · tab-group chip wrapping · vertical-header wrap · runtimeoverflow.modere-evaluation.Tests
group − header; a tab drags across rows to reorder (smooth mode); no wrap without the opt-in. Fixture opts in via?overflow=wrap(+?smooth=1).🤖 Generated with Claude Code