Skip to content

feat: multi-row (wrapping) tabs#1385

Merged
mathuo merged 12 commits into
v8-branchfrom
feat/multi-row-tabs
Jul 1, 2026
Merged

feat: multi-row (wrapping) tabs#1385
mathuo merged 12 commits into
v8-branchfrom
feat/multi-row-tabs

Conversation

@mathuo

@mathuo mathuo commented Jun 30, 2026

Copy link
Copy Markdown
Owner

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'; omitting overflow is byte-identical to before.

This PR ships wrap end-to-end: render mode and 2-D cross-row drag reorder.

Commits

  1. PR-A — free seams + shared overflow API (core, inert/no behaviour change): overflow option (mode/maxRows + reserved search/mru/thumbnails for a future advanced-overflow module), tab-list element seam (Tabs.tabsListElementIMultiRowTabsHost.getTabsListElement), IMultiRowTabsHost/IMultiRowTabsService + slot, inert .dv-tabs-container--wrap CSS (:has() header growth).
  2. PR-B — MultiRowTabsModule (dockview-modules, in the default bundle): per-group WrapController toggles 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.
  3. refactor — TabReorderController: behaviour-preserving extraction of the ~900-line tab drag-reorder + smooth-animation subsystem out of the 2,151-line tabs.ts into a dedicated controller (narrow ITabReorderHost). Zero logic changes — validated by the existing suites (tabsAnimation 2,141 lines). Isolates the subsystem so the 2-D reorder slots in without tangling tabs.ts.
  4. Phase 3 — 2-D cross-row reorder: 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 via handlePointerDragEnd, HTML5 via the tabs-list drop listener). All wrap logic is gated on the wrap class, so the single-row path is untouched.

Free vs Pro

  • Free: the overflow option type, the tab-list seam, the inert wrap CSS, the content-sizing seam, and the TabReorderController refactor.
  • Pro (MultiRowTabsModule): the mode: 'wrap' behaviour (wrap layout + 2-D reorder).

Deferred (follow-ups)

maxRows + dropdown spillover · cross-row keyboard nav · tab-group chip wrapping · vertical-header wrap · runtime overflow.mode re-evaluation.

Tests

  • Unit (jsdom): seam (4) + module (6, incl. vertical no-op + removability via the shared spec).
  • e2e (Playwright, real geometry): tabs wrap to multiple rows + header grows; content shrinks to 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).
  • core 1107 · modules 205 · e2e 30, all green. Lint/format clean; exports snapshot regenerated.

🤖 Generated with Claude Code

mathuo and others added 12 commits June 30, 2026 21:58
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
@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@mathuo mathuo merged commit c892cbb into v8-branch Jul 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant