dockview-core 5.2.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +3 -1
  2. package/dist/cjs/api/component.api.d.ts +93 -1
  3. package/dist/cjs/api/component.api.js +146 -0
  4. package/dist/cjs/api/dockviewGroupPanelApi.d.ts +26 -0
  5. package/dist/cjs/api/dockviewGroupPanelApi.js +21 -1
  6. package/dist/cjs/api/entryPoints.js +4 -5
  7. package/dist/cjs/array.js +7 -8
  8. package/dist/cjs/dnd/dataTransfer.d.ts +2 -1
  9. package/dist/cjs/dnd/dataTransfer.js +5 -4
  10. package/dist/cjs/dnd/droptarget.d.ts +12 -0
  11. package/dist/cjs/dnd/droptarget.js +38 -10
  12. package/dist/cjs/dnd/ghost.js +1 -2
  13. package/dist/cjs/dockview/components/panel/content.js +5 -1
  14. package/dist/cjs/dockview/components/popupService.d.ts +9 -2
  15. package/dist/cjs/dockview/components/popupService.js +24 -9
  16. package/dist/cjs/dockview/components/tab/tab.d.ts +6 -1
  17. package/dist/cjs/dockview/components/tab/tab.js +81 -9
  18. package/dist/cjs/dockview/components/titlebar/tabGroupChip.d.ts +30 -0
  19. package/dist/cjs/dockview/components/titlebar/tabGroupChip.js +95 -0
  20. package/dist/cjs/dockview/components/titlebar/tabGroupIndicator.d.ts +71 -0
  21. package/dist/cjs/dockview/components/titlebar/tabGroupIndicator.js +471 -0
  22. package/dist/cjs/dockview/components/titlebar/tabGroups.d.ts +57 -0
  23. package/dist/cjs/dockview/components/titlebar/tabGroups.js +612 -0
  24. package/dist/cjs/dockview/components/titlebar/tabOverflowControl.js +1 -2
  25. package/dist/cjs/dockview/components/titlebar/tabs.d.ts +59 -0
  26. package/dist/cjs/dockview/components/titlebar/tabs.js +1227 -144
  27. package/dist/cjs/dockview/components/titlebar/tabsContainer.d.ts +6 -0
  28. package/dist/cjs/dockview/components/titlebar/tabsContainer.js +132 -14
  29. package/dist/cjs/dockview/contextMenu.d.ts +10 -0
  30. package/dist/cjs/dockview/contextMenu.js +298 -0
  31. package/dist/cjs/dockview/dockviewComponent.d.ts +60 -3
  32. package/dist/cjs/dockview/dockviewComponent.js +712 -126
  33. package/dist/cjs/dockview/dockviewGroupPanelModel.d.ts +83 -0
  34. package/dist/cjs/dockview/dockviewGroupPanelModel.js +619 -27
  35. package/dist/cjs/dockview/dockviewShell.d.ts +128 -0
  36. package/dist/cjs/dockview/dockviewShell.js +681 -0
  37. package/dist/cjs/dockview/events.d.ts +9 -0
  38. package/dist/cjs/dockview/framework.d.ts +14 -0
  39. package/dist/cjs/dockview/options.d.ts +92 -10
  40. package/dist/cjs/dockview/options.js +10 -7
  41. package/dist/cjs/dockview/tabGroup.d.ts +99 -0
  42. package/dist/cjs/dockview/tabGroup.js +219 -0
  43. package/dist/cjs/dockview/tabGroupAccent.d.ts +65 -0
  44. package/dist/cjs/dockview/tabGroupAccent.js +128 -0
  45. package/dist/cjs/dockview/theme.d.ts +56 -1
  46. package/dist/cjs/dockview/theme.js +103 -6
  47. package/dist/cjs/dockview/types.d.ts +2 -0
  48. package/dist/cjs/dom.js +19 -19
  49. package/dist/cjs/events.js +2 -2
  50. package/dist/cjs/gridview/baseComponentGridview.d.ts +1 -0
  51. package/dist/cjs/gridview/baseComponentGridview.js +6 -3
  52. package/dist/cjs/gridview/gridview.js +7 -7
  53. package/dist/cjs/index.d.ts +8 -5
  54. package/dist/cjs/index.js +6 -1
  55. package/dist/cjs/popoutWindow.js +3 -3
  56. package/dist/cjs/splitview/splitviewPanel.d.ts +1 -1
  57. package/dist/dockview-core.js +6942 -2777
  58. package/dist/dockview-core.min.js +2 -2
  59. package/dist/dockview-core.min.js.map +1 -1
  60. package/dist/dockview-core.min.noStyle.js +2 -2
  61. package/dist/dockview-core.min.noStyle.js.map +1 -1
  62. package/dist/dockview-core.noStyle.js +6940 -2775
  63. package/dist/esm/api/component.api.d.ts +93 -1
  64. package/dist/esm/api/component.api.js +118 -0
  65. package/dist/esm/api/dockviewGroupPanelApi.d.ts +26 -0
  66. package/dist/esm/api/dockviewGroupPanelApi.js +21 -1
  67. package/dist/esm/dnd/dataTransfer.d.ts +2 -1
  68. package/dist/esm/dnd/dataTransfer.js +2 -1
  69. package/dist/esm/dnd/droptarget.d.ts +12 -0
  70. package/dist/esm/dnd/droptarget.js +33 -5
  71. package/dist/esm/dockview/components/panel/content.js +5 -1
  72. package/dist/esm/dockview/components/popupService.d.ts +9 -2
  73. package/dist/esm/dockview/components/popupService.js +23 -9
  74. package/dist/esm/dockview/components/tab/tab.d.ts +6 -1
  75. package/dist/esm/dockview/components/tab/tab.js +83 -9
  76. package/dist/esm/dockview/components/titlebar/tabGroupChip.d.ts +30 -0
  77. package/dist/esm/dockview/components/titlebar/tabGroupChip.js +68 -0
  78. package/dist/esm/dockview/components/titlebar/tabGroupIndicator.d.ts +71 -0
  79. package/dist/esm/dockview/components/titlebar/tabGroupIndicator.js +354 -0
  80. package/dist/esm/dockview/components/titlebar/tabGroups.d.ts +57 -0
  81. package/dist/esm/dockview/components/titlebar/tabGroups.js +406 -0
  82. package/dist/esm/dockview/components/titlebar/tabs.d.ts +59 -0
  83. package/dist/esm/dockview/components/titlebar/tabs.js +1011 -99
  84. package/dist/esm/dockview/components/titlebar/tabsContainer.d.ts +6 -0
  85. package/dist/esm/dockview/components/titlebar/tabsContainer.js +105 -7
  86. package/dist/esm/dockview/contextMenu.d.ts +10 -0
  87. package/dist/esm/dockview/contextMenu.js +213 -0
  88. package/dist/esm/dockview/dockviewComponent.d.ts +60 -3
  89. package/dist/esm/dockview/dockviewComponent.js +460 -35
  90. package/dist/esm/dockview/dockviewGroupPanelModel.d.ts +83 -0
  91. package/dist/esm/dockview/dockviewGroupPanelModel.js +460 -4
  92. package/dist/esm/dockview/dockviewShell.d.ts +128 -0
  93. package/dist/esm/dockview/dockviewShell.js +621 -0
  94. package/dist/esm/dockview/events.d.ts +9 -0
  95. package/dist/esm/dockview/framework.d.ts +14 -0
  96. package/dist/esm/dockview/options.d.ts +92 -10
  97. package/dist/esm/dockview/options.js +5 -2
  98. package/dist/esm/dockview/tabGroup.d.ts +99 -0
  99. package/dist/esm/dockview/tabGroup.js +144 -0
  100. package/dist/esm/dockview/tabGroupAccent.d.ts +65 -0
  101. package/dist/esm/dockview/tabGroupAccent.js +116 -0
  102. package/dist/esm/dockview/theme.d.ts +56 -1
  103. package/dist/esm/dockview/theme.js +102 -5
  104. package/dist/esm/dockview/types.d.ts +2 -0
  105. package/dist/esm/dom.js +1 -1
  106. package/dist/esm/gridview/baseComponentGridview.d.ts +1 -0
  107. package/dist/esm/gridview/baseComponentGridview.js +4 -1
  108. package/dist/esm/index.d.ts +8 -5
  109. package/dist/esm/index.js +2 -1
  110. package/dist/esm/popoutWindow.js +1 -1
  111. package/dist/esm/splitview/splitviewPanel.d.ts +1 -1
  112. package/dist/package/main.cjs.js +6936 -2801
  113. package/dist/package/main.cjs.min.js +2 -2
  114. package/dist/package/main.esm.min.mjs +2 -2
  115. package/dist/package/main.esm.mjs +6922 -2800
  116. package/dist/styles/dockview.css +1945 -196
  117. package/package.json +5 -1
@@ -1,10 +1,11 @@
1
- import { getPanelData } from '../../../dnd/dataTransfer';
2
- import { addClasses, isChildEntirelyVisibleWithinParent, OverflowObserver, removeClasses, toggleClass, } from '../../../dom';
1
+ import { getPanelData, LocalSelectionTransfer, PanelTransfer, } from '../../../dnd/dataTransfer';
2
+ import { addClasses, disableIframePointEvents, isChildEntirelyVisibleWithinParent, OverflowObserver, removeClasses, toggleClass, } from '../../../dom';
3
3
  import { addDisposableListener, Emitter } from '../../../events';
4
4
  import { CompositeDisposable, Disposable, MutableDisposable, } from '../../../lifecycle';
5
5
  import { Scrollbar } from '../../../scrollbar';
6
6
  import { DockviewWillShowOverlayLocationEvent } from '../../events';
7
7
  import { Tab } from '../tab/tab';
8
+ import { TabGroupManager } from './tabGroups';
8
9
  export class Tabs extends CompositeDisposable {
9
10
  get showTabsOverflowControl() {
10
11
  return this._showTabsOverflowControl;
@@ -19,14 +20,57 @@ export class Tabs extends CompositeDisposable {
19
20
  this._observerDisposable.value = new CompositeDisposable(observer, observer.onDidChange((event) => {
20
21
  const hasOverflow = event.hasScrollX || event.hasScrollY;
21
22
  this.toggleDropdown({ reset: !hasOverflow });
23
+ if (this._tabGroupManager.groupUnderlines.size > 0) {
24
+ this._tabGroupManager.positionUnderlines();
25
+ }
22
26
  }), addDisposableListener(this._tabsList, 'scroll', () => {
23
27
  this.toggleDropdown({ reset: false });
28
+ if (this._tabGroupManager.groupUnderlines.size > 0) {
29
+ this._tabGroupManager.positionUnderlines();
30
+ }
24
31
  }));
25
32
  }
26
33
  }
27
34
  get element() {
28
35
  return this._element;
29
36
  }
37
+ set voidContainer(el) {
38
+ var _a;
39
+ (_a = this._voidContainerListeners) === null || _a === void 0 ? void 0 : _a.dispose();
40
+ this._voidContainerListeners = null;
41
+ this._voidContainer = el;
42
+ if (el) {
43
+ this._voidContainerListeners = new CompositeDisposable(addDisposableListener(el, 'dragover', (event) => {
44
+ if (this._animState) {
45
+ event.preventDefault();
46
+ }
47
+ }), addDisposableListener(el, 'drop', (event) => {
48
+ var _a;
49
+ if (((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabGroupId) &&
50
+ this._animState.currentInsertionIndex !== null) {
51
+ event.preventDefault();
52
+ event.stopPropagation();
53
+ this.handleVoidDrop();
54
+ }
55
+ }));
56
+ }
57
+ }
58
+ /**
59
+ * Handle a drop that occurred on the void container (empty header
60
+ * space to the right of the tabs). Returns `true` if the drop was
61
+ * consumed by an active group drag, `false` otherwise.
62
+ */
63
+ handleVoidDrop() {
64
+ var _a, _b;
65
+ if (!((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabGroupId)) {
66
+ return false;
67
+ }
68
+ const sourceTabGroupId = this._animState.sourceTabGroupId;
69
+ const insertionIndex = (_b = this._animState.currentInsertionIndex) !== null && _b !== void 0 ? _b : this._tabs.length;
70
+ this._animState = null;
71
+ this._commitGroupMove(sourceTabGroupId, insertionIndex);
72
+ return true;
73
+ }
30
74
  get panels() {
31
75
  return this._tabs.map((_) => _.value.panel.id);
32
76
  }
@@ -55,6 +99,9 @@ export class Tabs extends CompositeDisposable {
55
99
  removeClasses(this._tabsList, 'dv-tabs-container-vertical');
56
100
  addClasses(this._tabsList, 'dv-horizontal');
57
101
  }
102
+ for (const tab of this._tabs) {
103
+ tab.value.setDirection(value);
104
+ }
58
105
  }
59
106
  constructor(group, accessor, options) {
60
107
  super();
@@ -63,10 +110,18 @@ export class Tabs extends CompositeDisposable {
63
110
  this._observerDisposable = new MutableDisposable();
64
111
  this._scrollbar = null;
65
112
  this._tabs = [];
113
+ this._tabMap = new Map();
66
114
  this.selectedIndex = -1;
67
115
  this._showTabsOverflowControl = false;
68
116
  this._direction = 'horizontal';
69
117
  this._animState = null;
118
+ this._pendingMarginCleanups = new Map();
119
+ this._pendingCollapse = false;
120
+ this._flipTransitionCleanup = null;
121
+ this._voidContainer = null;
122
+ this._voidContainerListeners = null;
123
+ this._extendedDropZone = null;
124
+ this._chipDragCleanup = null;
70
125
  this._onTabDragStart = new Emitter();
71
126
  this.onTabDragStart = this._onTabDragStart.event;
72
127
  this._onDrop = new Emitter();
@@ -87,7 +142,27 @@ export class Tabs extends CompositeDisposable {
87
142
  this._element = this._scrollbar.element;
88
143
  this.addDisposables(this._scrollbar);
89
144
  }
90
- this.addDisposables(this._onOverflowTabsChange, this._observerDisposable, this._onWillShowOverlay, this._onDrop, this._onTabDragStart, addDisposableListener(this.element, 'pointerdown', (event) => {
145
+ this._tabGroupManager = new TabGroupManager({
146
+ group: this.group,
147
+ accessor: this.accessor,
148
+ tabsList: this._tabsList,
149
+ getTabs: () => this._tabs,
150
+ getTabMap: () => this._tabMap,
151
+ getDirection: () => this._direction,
152
+ }, {
153
+ onChipContextMenu: (tabGroup, event) => {
154
+ this.accessor.contextMenuController.showForChip(tabGroup, this.group, event);
155
+ },
156
+ onChipDragStart: (tabGroup, chip, event) => {
157
+ this._handleChipDragStart(tabGroup, chip, event);
158
+ },
159
+ });
160
+ this.addDisposables(this._onOverflowTabsChange, this._observerDisposable, this._onWillShowOverlay, this._onDrop, this._onTabDragStart, {
161
+ dispose: () => {
162
+ var _a;
163
+ (_a = this._flipTransitionCleanup) === null || _a === void 0 ? void 0 : _a.call(this);
164
+ },
165
+ }, addDisposableListener(this.element, 'pointerdown', (event) => {
91
166
  if (event.defaultPrevented) {
92
167
  return;
93
168
  }
@@ -96,42 +171,128 @@ export class Tabs extends CompositeDisposable {
96
171
  this.accessor.doSetGroupActive(this.group);
97
172
  }
98
173
  }), addDisposableListener(this._tabsList, 'dragover', (event) => {
174
+ var _a, _b, _c, _d;
175
+ if (this.accessor.options.disableDnd) {
176
+ return;
177
+ }
178
+ // If _animState exists but belongs to a different
179
+ // drag (stale from a previous operation), replace it
180
+ // so the current drag is handled correctly.
181
+ if (this._animState) {
182
+ const data = getPanelData();
183
+ if ((data === null || data === void 0 ? void 0 : data.tabGroupId) &&
184
+ data.groupId !== this.group.id &&
185
+ this._animState.sourceTabGroupId !== data.tabGroupId) {
186
+ this._animState = null;
187
+ }
188
+ }
99
189
  if (!this._animState) {
100
- // Check for external drag from another group
101
- if (this.accessor.options.tabAnimation !== 'smooth' ||
102
- this.accessor.options.disableDnd) {
190
+ const data = getPanelData();
191
+ // In default animation mode, individual tab drops
192
+ // are handled by per-tab Droptargets. But tab group
193
+ // chip drags still need tab-list-level handling so
194
+ // that drops on gaps / void space work.
195
+ if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) ===
196
+ 'default' &&
197
+ !(data === null || data === void 0 ? void 0 : data.tabGroupId)) {
103
198
  return;
104
199
  }
105
- const data = getPanelData();
106
200
  if (data &&
107
- data.panelId &&
201
+ (data.panelId || data.tabGroupId) &&
108
202
  data.groupId !== this.group.id) {
109
- this._animState = {
110
- sourceTabId: data.panelId,
111
- sourceIndex: -1,
112
- tabPositions: this.snapshotTabPositions(),
113
- currentInsertionIndex: null,
114
- };
203
+ const avgWidth = this.getAverageTabWidth();
204
+ if (data.tabGroupId) {
205
+ // External group drag — look up the
206
+ // source tab group to size the gap
207
+ const sourceGroup = this.accessor.getPanel(data.groupId);
208
+ const sourceTg = sourceGroup === null || sourceGroup === void 0 ? void 0 : sourceGroup.model.getTabGroups().find((tg) => tg.id === data.tabGroupId);
209
+ const panelCount = (_b = sourceTg === null || sourceTg === void 0 ? void 0 : sourceTg.panelIds.length) !== null && _b !== void 0 ? _b : 1;
210
+ const groupGapWidth = avgWidth * panelCount + avgWidth;
211
+ this._animState = {
212
+ sourceTabId: '',
213
+ sourceIndex: -1,
214
+ tabPositions: this.snapshotTabPositions(),
215
+ chipPositions: this._tabGroupManager.snapshotChipWidths(),
216
+ currentInsertionIndex: null,
217
+ targetTabGroupId: null,
218
+ sourceTabGroupId: data.tabGroupId,
219
+ sourceGroupPanelIds: sourceTg
220
+ ? new Set(sourceTg.panelIds)
221
+ : new Set(),
222
+ sourceChipWidth: avgWidth,
223
+ cursorOffsetFromDragLeft: groupGapWidth / 2,
224
+ sourceGapWidth: groupGapWidth,
225
+ containerLeft: this._tabsList.getBoundingClientRect()
226
+ .left,
227
+ };
228
+ }
229
+ else {
230
+ this._animState = {
231
+ sourceTabId: data.panelId,
232
+ sourceIndex: -1,
233
+ tabPositions: this.snapshotTabPositions(),
234
+ chipPositions: this._tabGroupManager.snapshotChipWidths(),
235
+ currentInsertionIndex: null,
236
+ targetTabGroupId: null,
237
+ sourceTabGroupId: null,
238
+ sourceGroupPanelIds: null,
239
+ sourceChipWidth: 0,
240
+ cursorOffsetFromDragLeft: avgWidth / 2,
241
+ sourceGapWidth: avgWidth,
242
+ containerLeft: this._tabsList.getBoundingClientRect()
243
+ .left,
244
+ };
245
+ }
115
246
  }
116
247
  else {
117
248
  return;
118
249
  }
119
250
  }
251
+ event.preventDefault(); // allow drop to fire on the container
252
+ // For intra-group drag (sourceIndex >= 0) the gap
253
+ // animation is the sole visual indicator — clear any
254
+ // stale anchor overlay that may have been set while the
255
+ // cursor was over the panel content area or another zone.
256
+ // External drags (sourceIndex === -1) leave the overlay
257
+ // to the individual tab Droptargets so cross-group
258
+ // animation is not disrupted.
259
+ if (this._animState.sourceIndex !== -1) {
260
+ (_d = (_c = this.group.model.dropTargetContainer) === null || _c === void 0 ? void 0 : _c.model) === null || _d === void 0 ? void 0 : _d.clear();
261
+ }
120
262
  this.handleDragOver(event);
121
263
  }, true), addDisposableListener(this._tabsList, 'dragleave', (event) => {
264
+ var _a, _b, _c;
122
265
  if (!this._animState) {
123
266
  return;
124
267
  }
125
- // Only handle if leaving the container itself, not moving between children
126
- if (event.relatedTarget &&
127
- this._tabsList.contains(event.relatedTarget)) {
268
+ const related = event.relatedTarget;
269
+ // Ignore moves between children of the tabs list
270
+ if (related && this._tabsList.contains(related)) {
271
+ return;
272
+ }
273
+ // If moving into the broader drop zone (e.g. void container,
274
+ // left actions), keep _animState alive so the external
275
+ // dragover listeners can continue the gap animation.
276
+ if (related && ((_a = this._extendedDropZone) === null || _a === void 0 ? void 0 : _a.contains(related))) {
277
+ this.resetTabTransforms();
278
+ this._animState.currentInsertionIndex = null;
279
+ return;
280
+ }
281
+ // When leaving toward the void container (empty header space
282
+ // to the right), keep the animation state so the drop can
283
+ // still land at the end position.
284
+ const rt = event.relatedTarget;
285
+ const isVoid = this._voidContainer &&
286
+ rt &&
287
+ (rt === this._voidContainer ||
288
+ this._voidContainer.contains(rt));
289
+ if (isVoid) {
128
290
  return;
129
291
  }
130
292
  this.resetTabTransforms();
131
293
  if (this._animState) {
132
294
  if (this._animState.sourceIndex === -1) {
133
- // External drag left clear state entirely
134
- // (no dragend will fire on this tab list)
295
+ (_c = (_b = this.group.model.dropTargetContainer) === null || _b === void 0 ? void 0 : _b.model) === null || _c === void 0 ? void 0 : _c.clear();
135
296
  this._animState = null;
136
297
  }
137
298
  else {
@@ -139,49 +300,72 @@ export class Tabs extends CompositeDisposable {
139
300
  }
140
301
  }
141
302
  }, true), addDisposableListener(this._tabsList, 'dragend', () => {
142
- // Only fires for cancel (not after successful drop, since
143
- // source tab is removed from DOM and doesn't bubble)
144
303
  this.resetDragAnimation();
145
304
  }), addDisposableListener(this._tabsList, 'drop', (event) => {
146
- if (this.accessor.options.tabAnimation !== 'smooth' ||
147
- !this._animState ||
305
+ var _a, _b, _c;
306
+ if (!this._animState ||
148
307
  this._animState.currentInsertionIndex === null) {
149
308
  return;
150
309
  }
310
+ // In non-smooth mode only handle group drags here;
311
+ // individual tab drops are handled by tab Droptargets.
312
+ if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) !==
313
+ 'smooth' &&
314
+ !this._animState.sourceTabGroupId) {
315
+ return;
316
+ }
151
317
  event.stopPropagation();
152
318
  event.preventDefault();
319
+ // The capturing stopPropagation above prevents the
320
+ // individual tab's Droptarget.onDrop from firing, so
321
+ // the anchor overlay won't be cleared by that path.
322
+ // Clear it explicitly here before processing the drop.
323
+ (_c = (_b = this.group.model.dropTargetContainer) === null || _b === void 0 ? void 0 : _b.model) === null || _c === void 0 ? void 0 : _c.clear();
153
324
  const animState = this._animState;
154
325
  this._animState = null;
326
+ this._pendingCollapse = false;
327
+ // Handle group drag (entire group repositioned)
328
+ if (animState.sourceTabGroupId) {
329
+ this._commitGroupMove(animState.sourceTabGroupId, animState.currentInsertionIndex);
330
+ return;
331
+ }
155
332
  const insertionIndex = animState.currentInsertionIndex;
156
333
  const sourceIndex = animState.sourceIndex;
157
- // After the source tab is removed, indices after it shift
158
- // down by one, so adjust the target index accordingly.
159
334
  const adjustedIndex = insertionIndex -
160
335
  (sourceIndex !== -1 && sourceIndex < insertionIndex
161
336
  ? 1
162
337
  : 0);
163
- // No-op: drop at the same position, nothing to animate
164
- if (adjustedIndex === sourceIndex) {
338
+ const sourceCurrentGroup = this.group.model.getTabGroupForPanel(animState.sourceTabId);
339
+ if (adjustedIndex === sourceIndex &&
340
+ !animState.targetTabGroupId &&
341
+ !sourceCurrentGroup) {
342
+ this._uncollapsSourceTab(animState.sourceTabId);
165
343
  this.resetTabTransforms();
166
344
  return;
167
345
  }
168
- // Snapshot current visual positions (with margins still applied)
169
- // before resetting transforms, so FLIP starts from what the
170
- // user currently sees — not from a teleported state.
346
+ this._uncollapsSourceTab(animState.sourceTabId);
171
347
  const firstPositions = this.snapshotTabPositions();
172
348
  this.resetTabTransforms();
173
- this._onDrop.fire({ event, index: adjustedIndex });
349
+ this._onDrop.fire({
350
+ event,
351
+ index: adjustedIndex,
352
+ targetTabGroupId: animState.targetTabGroupId,
353
+ });
174
354
  this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, {
175
355
  from: Math.min(sourceIndex, adjustedIndex),
176
356
  to: Math.max(sourceIndex, adjustedIndex),
177
357
  });
178
358
  }, true), Disposable.from(() => {
359
+ var _a;
360
+ (_a = this._voidContainerListeners) === null || _a === void 0 ? void 0 : _a.dispose();
179
361
  this.resetDragAnimation();
362
+ this._tabGroupManager.disposeAll();
180
363
  for (const { value, disposable } of this._tabs) {
181
364
  disposable.dispose();
182
365
  value.dispose();
183
366
  }
184
367
  this._tabs = [];
368
+ this._tabMap.clear();
185
369
  }));
186
370
  }
187
371
  indexOf(id) {
@@ -192,37 +376,113 @@ export class Tabs extends CompositeDisposable {
192
376
  this._tabs[this.selectedIndex].value === tab);
193
377
  }
194
378
  setActivePanel(panel) {
195
- let runningWidth = 0;
379
+ const isVertical = this._direction === 'vertical';
380
+ let running = 0;
196
381
  for (const tab of this._tabs) {
197
382
  const isActivePanel = panel.id === tab.value.panel.id;
198
383
  tab.value.setActive(isActivePanel);
199
384
  if (isActivePanel) {
200
385
  const element = tab.value.element;
201
386
  const parentElement = element.parentElement;
202
- if (runningWidth < parentElement.scrollLeft ||
203
- runningWidth + element.clientWidth >
204
- parentElement.scrollLeft + parentElement.clientWidth) {
205
- parentElement.scrollLeft = runningWidth;
387
+ if (isVertical) {
388
+ if (running < parentElement.scrollTop ||
389
+ running + element.clientHeight >
390
+ parentElement.scrollTop + parentElement.clientHeight) {
391
+ parentElement.scrollTop = running;
392
+ }
393
+ }
394
+ else {
395
+ if (running < parentElement.scrollLeft ||
396
+ running + element.clientWidth >
397
+ parentElement.scrollLeft + parentElement.clientWidth) {
398
+ parentElement.scrollLeft = running;
399
+ }
206
400
  }
207
401
  }
208
- runningWidth += tab.value.element.clientWidth;
402
+ running += isVertical
403
+ ? tab.value.element.clientHeight
404
+ : tab.value.element.clientWidth;
405
+ }
406
+ // Reposition underlines so the wrap-around follows the new active tab
407
+ if (this._tabGroupManager.groupUnderlines.size > 0) {
408
+ this._tabGroupManager.positionUnderlines();
209
409
  }
210
410
  }
211
411
  openPanel(panel, index = this._tabs.length) {
212
- if (this._tabs.find((tab) => tab.value.panel.id === panel.id)) {
412
+ if (this._tabMap.has(panel.id)) {
213
413
  return;
214
414
  }
215
415
  const tab = new Tab(panel, this.accessor, this.group);
216
416
  tab.setContent(panel.view.tab);
417
+ if (this._direction !== 'horizontal') {
418
+ tab.setDirection(this._direction);
419
+ }
217
420
  const disposable = new CompositeDisposable(tab.onDragStart((event) => {
421
+ var _a;
218
422
  this._onTabDragStart.fire({ nativeEvent: event, panel });
219
- if (this.accessor.options.tabAnimation === 'smooth') {
423
+ if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) === 'smooth') {
424
+ const tabWidth = tab.element.getBoundingClientRect().width;
425
+ const sourceIndex = this._tabs.findIndex((x) => x.value === tab);
220
426
  this._animState = {
221
427
  sourceTabId: panel.id,
222
- sourceIndex: this._tabs.findIndex((x) => x.value === tab),
428
+ sourceIndex,
223
429
  tabPositions: this.snapshotTabPositions(),
430
+ chipPositions: this._tabGroupManager.snapshotChipWidths(),
224
431
  currentInsertionIndex: null,
432
+ targetTabGroupId: null,
433
+ sourceTabGroupId: null,
434
+ sourceGroupPanelIds: null,
435
+ sourceChipWidth: 0,
436
+ cursorOffsetFromDragLeft: tabWidth / 2,
437
+ sourceGapWidth: tabWidth,
438
+ containerLeft: this._tabsList.getBoundingClientRect().left,
225
439
  };
440
+ // Collapse the source tab after the browser captures the
441
+ // drag image, then open the gap at the source position in
442
+ // the same paint frame — no visual jump.
443
+ // Both collapse and gap must be instant (no transition).
444
+ this._pendingCollapse = true;
445
+ requestAnimationFrame(() => {
446
+ var _a;
447
+ var _b;
448
+ this._pendingCollapse = false;
449
+ if (!this._animState) {
450
+ return;
451
+ }
452
+ // Collapse source tab instantly (no transition)
453
+ tab.element.style.transition = 'none';
454
+ toggleClass(tab.element, 'dv-tab--dragging', true);
455
+ void tab.element.offsetHeight; // force reflow
456
+ (_a = (_b = this._animState).currentInsertionIndex) !== null && _a !== void 0 ? _a : (_b.currentInsertionIndex = sourceIndex);
457
+ // Apply gap with transitions disabled on the target
458
+ this.applyDragOverTransforms(true);
459
+ // Re-enable transitions for subsequent moves
460
+ tab.element.style.removeProperty('transition');
461
+ });
462
+ }
463
+ }), tab.onTabClick((event) => {
464
+ if (event.defaultPrevented) {
465
+ return;
466
+ }
467
+ if (this.group.api.location.type !== 'edge') {
468
+ return;
469
+ }
470
+ if (this.group.activePanel === panel) {
471
+ // Clicking the active tab toggles expansion
472
+ if (this.group.api.isCollapsed()) {
473
+ this.group.api.expand();
474
+ }
475
+ else {
476
+ this.group.api.collapse();
477
+ }
478
+ }
479
+ else {
480
+ // Clicking a non-active tab switches the active tab.
481
+ // If the group is collapsed, also expand it.
482
+ this.group.model.openPanel(panel);
483
+ if (this.group.api.isCollapsed()) {
484
+ this.group.api.expand();
485
+ }
226
486
  }
227
487
  }), tab.onPointerDown((event) => {
228
488
  if (event.defaultPrevented) {
@@ -246,34 +506,69 @@ export class Tabs extends CompositeDisposable {
246
506
  return;
247
507
  }
248
508
  switch (event.button) {
249
- case 0: // left click or touch
250
- if (this.group.activePanel !== panel) {
251
- this.group.model.openPanel(panel);
509
+ case 0:
510
+ if (this.group.api.location.type === 'edge') {
511
+ // All tab interaction for edge groups is handled by
512
+ // onTabClick to avoid race conditions with active panel state
513
+ }
514
+ else {
515
+ if (this.group.activePanel !== panel) {
516
+ this.group.model.openPanel(panel);
517
+ }
252
518
  }
253
519
  break;
254
520
  }
255
521
  }), tab.onDrop((event) => {
522
+ var _a, _b, _c, _d;
256
523
  const animState = this._animState;
257
524
  this._animState = null;
258
- const dropIndex = this._tabs.findIndex((x) => x.value === tab);
525
+ this._pendingCollapse = false;
526
+ const tabIndex = this._tabs.findIndex((x) => x.value === tab);
259
527
  if (animState) {
528
+ const dropIndex = event.position === 'right' ? tabIndex + 1 : tabIndex;
529
+ if (animState.sourceTabGroupId) {
530
+ this._commitGroupMove(animState.sourceTabGroupId, (_a = animState.currentInsertionIndex) !== null && _a !== void 0 ? _a : dropIndex);
531
+ return;
532
+ }
533
+ this._uncollapsSourceTab(animState.sourceTabId);
260
534
  const firstPositions = this.snapshotTabPositions();
261
535
  this.resetTabTransforms();
262
536
  this._onDrop.fire({
263
537
  event: event.nativeEvent,
264
538
  index: dropIndex,
539
+ targetTabGroupId: animState.targetTabGroupId,
265
540
  });
266
- this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, animState.sourceIndex !== -1
267
- ? {
268
- from: Math.min(animState.sourceIndex, dropIndex),
269
- to: Math.max(animState.sourceIndex, dropIndex),
270
- }
271
- : undefined);
541
+ if (((_b = this.accessor.options.theme) === null || _b === void 0 ? void 0 : _b.tabAnimation) === 'smooth') {
542
+ this.runFlipAnimation(firstPositions, animState.sourceTabId, animState.sourceIndex === -1, animState.sourceIndex !== -1
543
+ ? {
544
+ from: Math.min(animState.sourceIndex, dropIndex),
545
+ to: Math.max(animState.sourceIndex, dropIndex),
546
+ }
547
+ : undefined);
548
+ }
272
549
  }
273
550
  else {
551
+ // Compute insertion index based on which half of the tab
552
+ // the pointer is over, then adjust for same-group removal:
553
+ // when the source tab sits before the insertion point,
554
+ // removing it shifts all subsequent indices down by one.
555
+ const afterPosition = this._direction === 'vertical' ? 'bottom' : 'right';
556
+ const insertionIndex = event.position === afterPosition
557
+ ? tabIndex + 1
558
+ : tabIndex;
559
+ const data = getPanelData();
560
+ const sourceIndex = data
561
+ ? this._tabs.findIndex((x) => x.value.panel.id === data.panelId)
562
+ : -1;
563
+ const adjustedIndex = insertionIndex -
564
+ (sourceIndex !== -1 && sourceIndex < insertionIndex
565
+ ? 1
566
+ : 0);
567
+ const targetTabGroupId = (_d = (_c = this.group.model.getTabGroupForPanel(tab.panel.id)) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null;
274
568
  this._onDrop.fire({
275
569
  event: event.nativeEvent,
276
- index: dropIndex,
570
+ index: adjustedIndex,
571
+ targetTabGroupId,
277
572
  });
278
573
  }
279
574
  }), tab.onWillShowOverlay((event) => {
@@ -287,9 +582,14 @@ export class Tabs extends CompositeDisposable {
287
582
  }));
288
583
  const value = { value: tab, disposable };
289
584
  this.addTab(value, index);
585
+ // A new tab may have been inserted between a chip and its
586
+ // group's first tab — reposition all chips to stay correct.
587
+ this._tabGroupManager.positionAllChips();
290
588
  // If a tab was added during active drag, refresh positions
291
589
  if (this._animState) {
292
590
  this._animState.tabPositions = this.snapshotTabPositions();
591
+ this._animState.chipPositions =
592
+ this._tabGroupManager.snapshotChipWidths();
293
593
  this.applyDragOverTransforms();
294
594
  }
295
595
  }
@@ -299,8 +599,11 @@ export class Tabs extends CompositeDisposable {
299
599
  this.resetTabTransforms();
300
600
  this._animState = null;
301
601
  }
602
+ // Force-clean any pending transitionend listener
603
+ this._tabGroupManager.cleanupTransition(id);
302
604
  const index = this.indexOf(id);
303
605
  const tabToRemove = this._tabs.splice(index, 1)[0];
606
+ this._tabMap.delete(id);
304
607
  const { value, disposable } = tabToRemove;
305
608
  disposable.dispose();
306
609
  value.dispose();
@@ -308,6 +611,8 @@ export class Tabs extends CompositeDisposable {
308
611
  // If a non-source tab was removed during active drag, refresh positions
309
612
  if (this._animState) {
310
613
  this._animState.tabPositions = this.snapshotTabPositions();
614
+ this._animState.chipPositions =
615
+ this._tabGroupManager.snapshotChipWidths();
311
616
  this.applyDragOverTransforms();
312
617
  }
313
618
  }
@@ -315,29 +620,217 @@ export class Tabs extends CompositeDisposable {
315
620
  if (index < 0 || index > this._tabs.length) {
316
621
  throw new Error('invalid location');
317
622
  }
318
- this._tabsList.insertBefore(tab.value.element, this._tabsList.children[index]);
623
+ // Use the tab element at `index` as the reference node rather than
624
+ // `children[index]`, because `_tabsList` may contain non-tab children
625
+ // (e.g. group chips, underlines) that shift the DOM indices.
626
+ const refNode = index < this._tabs.length ? this._tabs[index].value.element : null;
627
+ this._tabsList.insertBefore(tab.value.element, refNode);
319
628
  this._tabs = [
320
629
  ...this._tabs.slice(0, index),
321
630
  tab,
322
631
  ...this._tabs.slice(index),
323
632
  ];
633
+ this._tabMap.set(tab.value.panel.id, tab);
324
634
  if (this.selectedIndex < 0) {
325
635
  this.selectedIndex = index;
326
636
  }
327
637
  }
328
638
  toggleDropdown(options) {
329
- const tabs = options.reset
330
- ? []
331
- : this._tabs
332
- .filter((tab) => !isChildEntirelyVisibleWithinParent(tab.value.element, this._tabsList))
333
- .map((x) => x.value.panel.id);
334
- this._onOverflowTabsChange.fire({ tabs, reset: options.reset });
639
+ if (options.reset) {
640
+ this._onOverflowTabsChange.fire({
641
+ tabs: [],
642
+ tabGroups: [],
643
+ reset: true,
644
+ });
645
+ return;
646
+ }
647
+ const tabs = this._tabs
648
+ .filter((tab) => !isChildEntirelyVisibleWithinParent(tab.value.element, this._tabsList))
649
+ .map((x) => x.value.panel.id);
650
+ // Detect tab groups whose chip is clipped or whose tabs are all
651
+ // in the overflow set (e.g. collapsed groups scrolled out of view).
652
+ const overflowTabSet = new Set(tabs);
653
+ const tabGroups = [];
654
+ for (const tg of this.group.model.getTabGroups()) {
655
+ const chipEntry = this._tabGroupManager.chipRenderers.get(tg.id);
656
+ const chipClipped = chipEntry &&
657
+ !isChildEntirelyVisibleWithinParent(chipEntry.chip.element, this._tabsList);
658
+ // A group is in overflow if its chip is clipped OR all its
659
+ // visible tabs are in the overflow set.
660
+ const allTabsOverflow = tg.panelIds.length > 0 &&
661
+ tg.panelIds.every((pid) => overflowTabSet.has(pid));
662
+ if (chipClipped || allTabsOverflow) {
663
+ tabGroups.push(tg.id);
664
+ // For collapsed groups whose chip is clipped, ensure all
665
+ // member tabs are included in the overflow list so they
666
+ // appear in the dropdown.
667
+ if (tg.collapsed) {
668
+ for (const pid of tg.panelIds) {
669
+ if (!overflowTabSet.has(pid)) {
670
+ overflowTabSet.add(pid);
671
+ tabs.push(pid);
672
+ }
673
+ }
674
+ }
675
+ }
676
+ }
677
+ this._onOverflowTabsChange.fire({ tabs, tabGroups, reset: false });
335
678
  }
336
679
  updateDragAndDropState() {
337
680
  for (const tab of this._tabs) {
338
681
  tab.value.updateDragAndDropState();
339
682
  }
340
683
  }
684
+ /**
685
+ * Synchronize chip elements and CSS classes for all tab groups
686
+ * in the parent group model. Call after any tab group mutation.
687
+ */
688
+ updateTabGroups() {
689
+ this._tabGroupManager.update();
690
+ }
691
+ refreshTabGroupAccent() {
692
+ this._tabGroupManager.refreshAccents();
693
+ }
694
+ _handleChipDragStart(tabGroup, chip, event) {
695
+ var _a;
696
+ const firstPanelId = tabGroup.panelIds[0];
697
+ const firstIdx = firstPanelId
698
+ ? this._tabs.findIndex((t) => t.value.panel.id === firstPanelId)
699
+ : -1;
700
+ const chipRect = chip.element.getBoundingClientRect();
701
+ // Compute total group width (chip + all tabs)
702
+ let groupGapWidth = chipRect.width;
703
+ for (const pid of tabGroup.panelIds) {
704
+ const tabEntry = this._tabMap.get(pid);
705
+ if (tabEntry) {
706
+ groupGapWidth +=
707
+ tabEntry.value.element.getBoundingClientRect().width;
708
+ }
709
+ }
710
+ this._animState = {
711
+ sourceTabId: '',
712
+ sourceIndex: firstIdx,
713
+ tabPositions: this.snapshotTabPositions(),
714
+ chipPositions: this._tabGroupManager.snapshotChipWidths(),
715
+ currentInsertionIndex: null,
716
+ targetTabGroupId: null,
717
+ sourceTabGroupId: tabGroup.id,
718
+ sourceGroupPanelIds: new Set(tabGroup.panelIds),
719
+ sourceChipWidth: chipRect.width,
720
+ cursorOffsetFromDragLeft: event.clientX - chipRect.left,
721
+ sourceGapWidth: groupGapWidth,
722
+ containerLeft: this._tabsList.getBoundingClientRect().left,
723
+ };
724
+ // Set LocalSelectionTransfer so drop targets recognise this as
725
+ // an internal dockview drag. panelId is null (group-level),
726
+ // tabGroupId identifies which tab group is being dragged.
727
+ const panelTransfer = LocalSelectionTransfer.getInstance();
728
+ panelTransfer.setData([
729
+ new PanelTransfer(this.accessor.id, this.group.id, null, tabGroup.id),
730
+ ], PanelTransfer.prototype);
731
+ const iframes = disableIframePointEvents();
732
+ this._chipDragCleanup = {
733
+ dispose: () => {
734
+ panelTransfer.clearData(PanelTransfer.prototype);
735
+ iframes.release();
736
+ },
737
+ };
738
+ if (event.dataTransfer) {
739
+ event.dataTransfer.effectAllowed = 'move';
740
+ if (event.dataTransfer.items.length === 0) {
741
+ event.dataTransfer.setData('text/plain', '');
742
+ }
743
+ }
744
+ if (((_a = this.accessor.options.theme) === null || _a === void 0 ? void 0 : _a.tabAnimation) === 'smooth') {
745
+ // Collapse group tabs + chip after the browser
746
+ // captures the drag image, then open the gap at the
747
+ // source position — all instant (no transitions).
748
+ const groupPanelIds = new Set(tabGroup.panelIds);
749
+ this._pendingCollapse = true;
750
+ requestAnimationFrame(() => {
751
+ var _a;
752
+ var _b;
753
+ this._pendingCollapse = false;
754
+ if (!this._animState) {
755
+ return;
756
+ }
757
+ // Collapse all group tabs instantly
758
+ for (const t of this._tabs) {
759
+ if (groupPanelIds.has(t.value.panel.id)) {
760
+ t.value.element.style.transition = 'none';
761
+ toggleClass(t.value.element, 'dv-tab--dragging', true);
762
+ }
763
+ }
764
+ // Collapse the group chip instantly
765
+ const chipEntry = this._tabGroupManager.chipRenderers.get(tabGroup.id);
766
+ if (chipEntry) {
767
+ chipEntry.chip.element.style.transition = 'none';
768
+ toggleClass(chipEntry.chip.element, 'dv-tab-group-chip--dragging', true);
769
+ }
770
+ // Single reflow for the entire batch
771
+ void this._tabsList.offsetHeight;
772
+ const underline = this._tabGroupManager.groupUnderlines.get(tabGroup.id);
773
+ if (underline) {
774
+ underline.style.display = 'none';
775
+ }
776
+ (_a = (_b = this._animState).currentInsertionIndex) !== null && _a !== void 0 ? _a : (_b.currentInsertionIndex = firstIdx);
777
+ // Apply gap with transitions disabled
778
+ this.applyDragOverTransforms(true);
779
+ // Re-enable transitions for subsequent moves
780
+ for (const t of this._tabs) {
781
+ if (groupPanelIds.has(t.value.panel.id)) {
782
+ t.value.element.style.removeProperty('transition');
783
+ }
784
+ }
785
+ if (chipEntry) {
786
+ chipEntry.chip.element.style.removeProperty('transition');
787
+ }
788
+ });
789
+ }
790
+ // Build a composite drag image showing chip + group tabs
791
+ this._tabGroupManager.setGroupDragImage(event, tabGroup, chip.element);
792
+ }
793
+ /**
794
+ * Sets the broader container that is part of the same logical drop surface
795
+ * as this tab list (e.g. the full header element). When a dragleave from
796
+ * the tabs list lands inside this container, `_animState` is preserved so
797
+ * that external dragover listeners can continue the animation.
798
+ */
799
+ setExtendedDropZone(el) {
800
+ this._extendedDropZone = el;
801
+ }
802
+ /**
803
+ * Allows external elements (e.g. void container, left actions) to push an
804
+ * insertion index into the animation while the cursor is outside the tabs
805
+ * list itself. Pass `null` to clear the indicator.
806
+ */
807
+ setExternalInsertionIndex(index) {
808
+ if (!this._animState) {
809
+ return;
810
+ }
811
+ if (index === this._animState.currentInsertionIndex) {
812
+ return;
813
+ }
814
+ this._animState.currentInsertionIndex = index;
815
+ this.applyDragOverTransforms();
816
+ }
817
+ /**
818
+ * Called when the drag cursor leaves the entire header area (not just the
819
+ * tabs list). Clears animation state for cross-group drags, which never
820
+ * receive a `dragend` event on this tab list.
821
+ */
822
+ clearExternalAnimState() {
823
+ if (!this._animState) {
824
+ return;
825
+ }
826
+ this.resetTabTransforms();
827
+ if (this._animState.sourceIndex === -1) {
828
+ this._animState = null;
829
+ }
830
+ else {
831
+ this._animState.currentInsertionIndex = null;
832
+ }
833
+ }
341
834
  snapshotTabPositions() {
342
835
  const positions = new Map();
343
836
  for (const tab of this._tabs) {
@@ -349,103 +842,505 @@ export class Tabs extends CompositeDisposable {
349
842
  if (this._tabs.length === 0) {
350
843
  return 0;
351
844
  }
352
- let totalWidth = 0;
845
+ const isVertical = this._direction === 'vertical';
846
+ let total = 0;
353
847
  for (const tab of this._tabs) {
354
- totalWidth += tab.value.element.getBoundingClientRect().width;
848
+ const rect = tab.value.element.getBoundingClientRect();
849
+ total += isVertical ? rect.height : rect.width;
355
850
  }
356
- return totalWidth / this._tabs.length;
851
+ return total / this._tabs.length;
357
852
  }
358
853
  handleDragOver(event) {
854
+ var _a, _b, _c, _d, _e;
359
855
  if (!this._animState) {
360
856
  return;
361
857
  }
362
858
  const mouseX = event.clientX;
363
859
  let insertionIndex = null;
860
+ let targetTabGroupId = null;
861
+ const sourceGroupPanelIds = this._animState.sourceGroupPanelIds;
862
+ // Accumulation approach: compute where the drag image's left edge
863
+ // would be, then walk tabs left-to-right using their original widths.
864
+ // A tab fits to the left of the gap if the cumulative width of all
865
+ // preceding non-source tabs <= available space.
866
+ const dragLeftEdge = mouseX - this._animState.cursorOffsetFromDragLeft;
867
+ const availableSpace = dragLeftEdge - this._animState.containerLeft;
868
+ let accWidth = 0;
869
+ // Build lookup: first panel ID of each non-source group → group ID
870
+ // so we can add chip widths when we encounter a group's first tab.
871
+ const firstPanelToGroup = new Map();
872
+ if (this._tabGroupManager.chipRenderers.size > 0) {
873
+ const tabGroups = this.group.model.getTabGroups();
874
+ for (const tg of tabGroups) {
875
+ if (tg.id === this._animState.sourceTabGroupId) {
876
+ continue;
877
+ }
878
+ if (tg.panelIds.length > 0) {
879
+ firstPanelToGroup.set(tg.panelIds[0], tg.id);
880
+ }
881
+ }
882
+ }
364
883
  for (let i = 0; i < this._tabs.length; i++) {
365
884
  const tab = this._tabs[i].value;
366
885
  if (tab.panel.id === this._animState.sourceTabId) {
367
886
  continue;
368
887
  }
369
- const rect = tab.element.getBoundingClientRect();
370
- const midpoint = rect.left + rect.width / 2;
371
- if (mouseX < midpoint) {
372
- insertionIndex = i;
888
+ if (sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(tab.panel.id)) {
889
+ continue;
890
+ }
891
+ // If this tab is the first of a non-source group, include
892
+ // the chip width (which sits before it in the DOM).
893
+ const groupId = firstPanelToGroup.get(tab.panel.id);
894
+ if (groupId) {
895
+ const chipWidth = (_a = this._animState.chipPositions.get(groupId)) !== null && _a !== void 0 ? _a : 0;
896
+ if (accWidth + chipWidth > availableSpace) {
897
+ // Chip alone overflows — gap goes before this group
898
+ insertionIndex !== null && insertionIndex !== void 0 ? insertionIndex : (insertionIndex = i);
899
+ break;
900
+ }
901
+ accWidth += chipWidth;
902
+ }
903
+ // Use original width (before collapse/transforms)
904
+ const origRect = this._animState.tabPositions.get(tab.panel.id);
905
+ const tabWidth = origRect
906
+ ? origRect.width
907
+ : tab.element.getBoundingClientRect().width;
908
+ // Shift at the midpoint: a tab moves left once the drag image
909
+ // covers half of it (like Chrome's tab drag behavior).
910
+ if (accWidth + tabWidth / 2 <= availableSpace) {
911
+ accWidth += tabWidth;
912
+ insertionIndex = i + 1;
913
+ }
914
+ else {
915
+ insertionIndex !== null && insertionIndex !== void 0 ? insertionIndex : (insertionIndex = i);
373
916
  break;
374
917
  }
375
- insertionIndex = i + 1;
376
918
  }
377
- if (insertionIndex === this._animState.currentInsertionIndex) {
919
+ // Determine which tab group (if any) the insertion index falls within.
920
+ //
921
+ // We use snapshot-based positions (accWidth from the accumulation loop
922
+ // above) to compute original chip boundaries. This avoids reading
923
+ // getBoundingClientRect() on chips whose live position is shifted by
924
+ // the drag gap margin, which caused oscillation / visual jumps.
925
+ if (insertionIndex !== null &&
926
+ this._tabGroupManager.chipRenderers.size > 0) {
927
+ const isGroupDrag = !!this._animState.sourceTabGroupId;
928
+ const tabGroups = this.group.model.getTabGroups();
929
+ // Rebuild the accumulated width up to insertionIndex so we know
930
+ // the original right edge of the chip (if any) that precedes it.
931
+ // We walk exactly the same way as the accumulation loop above.
932
+ let accUpTo = 0;
933
+ for (let i = 0; i < this._tabs.length; i++) {
934
+ const tab = this._tabs[i].value;
935
+ if (tab.panel.id === this._animState.sourceTabId) {
936
+ continue;
937
+ }
938
+ if (sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(tab.panel.id)) {
939
+ continue;
940
+ }
941
+ if (i >= insertionIndex) {
942
+ break;
943
+ }
944
+ const gid = firstPanelToGroup.get(tab.panel.id);
945
+ if (gid) {
946
+ accUpTo += (_b = this._animState.chipPositions.get(gid)) !== null && _b !== void 0 ? _b : 0;
947
+ }
948
+ const origRect = this._animState.tabPositions.get(tab.panel.id);
949
+ accUpTo += origRect
950
+ ? origRect.width
951
+ : tab.element.getBoundingClientRect().width;
952
+ }
953
+ for (const tg of tabGroups) {
954
+ // Build effective panel list: exclude the source tab
955
+ // so that dragging a tab out of its own group doesn't
956
+ // inflate the group's index range.
957
+ const effectivePanelIds = tg.panelIds.filter((pid) => pid !== this._animState.sourceTabId &&
958
+ !(sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(pid)));
959
+ if (effectivePanelIds.length === 0) {
960
+ continue;
961
+ }
962
+ const firstIdx = this._tabs.findIndex((t) => t.value.panel.id === effectivePanelIds[0]);
963
+ const lastIdx = this._tabs.findIndex((t) => t.value.panel.id ===
964
+ effectivePanelIds[effectivePanelIds.length - 1]);
965
+ if (firstIdx === -1 || lastIdx === -1) {
966
+ continue;
967
+ }
968
+ const isInsideRange = insertionIndex >= firstIdx && insertionIndex <= lastIdx;
969
+ const isJustBeforeGroup = !isInsideRange && insertionIndex === firstIdx - 1;
970
+ if (!isInsideRange && !isJustBeforeGroup) {
971
+ continue;
972
+ }
973
+ if (isGroupDrag) {
974
+ // A group cannot be dropped inside another group.
975
+ // Snap the insertion index to just before or just
976
+ // after this group based on cursor position relative
977
+ // to the group's midpoint.
978
+ const groupMid = (firstIdx + lastIdx + 1) / 2;
979
+ if (insertionIndex < groupMid) {
980
+ insertionIndex = firstIdx;
981
+ }
982
+ else {
983
+ insertionIndex = lastIdx + 1;
984
+ }
985
+ // targetTabGroupId stays null
986
+ break;
987
+ }
988
+ if (isJustBeforeGroup) {
989
+ // Check whether only the source tab (or source group
990
+ // tabs) sits between insertionIndex and firstIdx.
991
+ // If so, the source is being dragged away from that
992
+ // slot, so we ARE effectively "just before" the group
993
+ // and should still allow dropping into position 0.
994
+ let allInBetweenAreSource = true;
995
+ for (let j = insertionIndex; j < firstIdx; j++) {
996
+ const pid = this._tabs[j].value.panel.id;
997
+ if (pid !== this._animState.sourceTabId &&
998
+ !(sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(pid))) {
999
+ allInBetweenAreSource = false;
1000
+ break;
1001
+ }
1002
+ }
1003
+ if (!allInBetweenAreSource) {
1004
+ continue;
1005
+ }
1006
+ const chipWidth = (_c = this._animState.chipPositions.get(tg.id)) !== null && _c !== void 0 ? _c : 0;
1007
+ const threshold = tg.collapsed
1008
+ ? this._animState.containerLeft +
1009
+ accUpTo +
1010
+ chipWidth / 2
1011
+ : this._animState.containerLeft + accUpTo + chipWidth;
1012
+ if (mouseX >= threshold) {
1013
+ insertionIndex = firstIdx;
1014
+ targetTabGroupId = tg.id;
1015
+ }
1016
+ break;
1017
+ }
1018
+ if (isInsideRange) {
1019
+ const chipWidth = (_d = this._animState.chipPositions.get(tg.id)) !== null && _d !== void 0 ? _d : 0;
1020
+ const chipOriginalRight = this._animState.containerLeft + accUpTo + chipWidth;
1021
+ if (insertionIndex === firstIdx) {
1022
+ if (mouseX >= chipOriginalRight) {
1023
+ targetTabGroupId = tg.id;
1024
+ }
1025
+ }
1026
+ else {
1027
+ targetTabGroupId = tg.id;
1028
+ }
1029
+ break;
1030
+ }
1031
+ }
1032
+ }
1033
+ if (insertionIndex === this._animState.currentInsertionIndex &&
1034
+ targetTabGroupId === this._animState.targetTabGroupId) {
378
1035
  return;
379
1036
  }
380
1037
  this._animState.currentInsertionIndex = insertionIndex;
381
- this.applyDragOverTransforms();
1038
+ this._animState.targetTabGroupId = targetTabGroupId;
1039
+ if (((_e = this.accessor.options.theme) === null || _e === void 0 ? void 0 : _e.tabAnimation) === 'smooth') {
1040
+ this.applyDragOverTransforms();
1041
+ }
1042
+ }
1043
+ /**
1044
+ * Batch-remove a CSS class from multiple elements instantly,
1045
+ * forcing only a single reflow for the entire batch.
1046
+ */
1047
+ _removeClassInstantlyBatch(elements, cls) {
1048
+ const affected = [];
1049
+ for (const el of elements) {
1050
+ if (el.classList.contains(cls)) {
1051
+ el.style.transition = 'none';
1052
+ toggleClass(el, cls, false);
1053
+ affected.push(el);
1054
+ }
1055
+ }
1056
+ if (affected.length > 0) {
1057
+ void affected[0].offsetHeight; // single reflow for entire batch
1058
+ for (const el of affected) {
1059
+ el.style.removeProperty('transition');
1060
+ }
1061
+ }
1062
+ }
1063
+ /**
1064
+ * Remove `dv-tab--dragging` from the source tab instantly so it
1065
+ * regains its real width before FLIP snapshots.
1066
+ */
1067
+ _uncollapsSourceTab(sourceTabId) {
1068
+ const entry = this._tabMap.get(sourceTabId);
1069
+ if (entry) {
1070
+ this._removeClassInstantlyBatch([entry.value.element], 'dv-tab--dragging');
1071
+ }
382
1072
  }
383
- applyDragOverTransforms() {
1073
+ applyDragOverTransforms(skipTransition = false) {
384
1074
  if (!this._animState ||
385
1075
  this._animState.currentInsertionIndex === null) {
386
1076
  this.resetTabTransforms();
387
1077
  return;
388
1078
  }
1079
+ // Don't apply transforms until the source tab has been collapsed
1080
+ // in the rAF callback — otherwise the gap + visible source = jump.
1081
+ if (this._pendingCollapse) {
1082
+ return;
1083
+ }
389
1084
  const insertionIndex = this._animState.currentInsertionIndex;
390
- const sourceRect = this._animState.tabPositions.get(this._animState.sourceTabId);
391
- const gapWidth = sourceRect
392
- ? sourceRect.width
393
- : this.getAverageTabWidth();
394
- // Find the first non-source tab at insertionIndex to receive the gap margin
1085
+ // For group drags, gap = sum of all group member widths
1086
+ let gapWidth;
1087
+ const sourceGroupPanelIds = this._animState.sourceGroupPanelIds;
1088
+ if (this._animState.sourceTabGroupId && sourceGroupPanelIds) {
1089
+ gapWidth = this._animState.sourceGapWidth;
1090
+ }
1091
+ else {
1092
+ const sourceRect = this._animState.tabPositions.get(this._animState.sourceTabId);
1093
+ gapWidth = sourceRect
1094
+ ? sourceRect.width
1095
+ : this.getAverageTabWidth();
1096
+ }
1097
+ // When the insertion lands at or before a group's first tab, shift
1098
+ // the chip so the gap appears before the entire group.
1099
+ //
1100
+ // Two cases:
1101
+ // 1. targetTabGroupId is null (standalone drop) — always shift chip.
1102
+ // 2. targetTabGroupId is set AND the group is collapsed — shift chip
1103
+ // because the collapsed tabs are invisible, so putting the gap on
1104
+ // them has no visual effect.
1105
+ let chipToShift = null;
1106
+ if (this._tabGroupManager.chipRenderers.size > 0) {
1107
+ const tabGroups = this.group.model.getTabGroups();
1108
+ for (const tg of tabGroups) {
1109
+ if (tg.id === this._animState.sourceTabGroupId)
1110
+ continue;
1111
+ // Skip the group that the dragged tab belongs to — the
1112
+ // gap should appear after the chip (where the tab was),
1113
+ // not before it.
1114
+ if (tg.panelIds.includes(this._animState.sourceTabId))
1115
+ continue;
1116
+ const effectivePids = tg.panelIds.filter((pid) => pid !== this._animState.sourceTabId &&
1117
+ !(sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(pid)));
1118
+ if (effectivePids.length === 0)
1119
+ continue;
1120
+ const firstIdx = this._tabs.findIndex((t) => t.value.panel.id === effectivePids[0]);
1121
+ // Only consider chip-shifting when dropping outside the
1122
+ // group, or when dropping inside a collapsed group (whose
1123
+ // tabs are invisible).
1124
+ const shouldShiftChip = !this._animState.targetTabGroupId ||
1125
+ (this._animState.targetTabGroupId === tg.id &&
1126
+ tg.collapsed);
1127
+ if (!shouldShiftChip)
1128
+ continue;
1129
+ if (firstIdx >= insertionIndex) {
1130
+ let hasTabs = false;
1131
+ for (let j = insertionIndex; j < firstIdx; j++) {
1132
+ const pid = this._tabs[j].value.panel.id;
1133
+ if (pid === this._animState.sourceTabId)
1134
+ continue;
1135
+ if (sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(pid))
1136
+ continue;
1137
+ hasTabs = true;
1138
+ break;
1139
+ }
1140
+ if (!hasTabs) {
1141
+ const chipEntry = this._tabGroupManager.chipRenderers.get(tg.id);
1142
+ if (chipEntry) {
1143
+ chipToShift = chipEntry.chip.element;
1144
+ }
1145
+ }
1146
+ break;
1147
+ }
1148
+ }
1149
+ }
1150
+ // Helper: pick the correct shifting class for tabs vs chips.
1151
+ const shiftingClass = (el) => el.classList.contains('dv-tab-group-chip')
1152
+ ? 'dv-tab-group-chip--shifting'
1153
+ : 'dv-tab--shifting';
1154
+ // Helper: apply a margin-left value to an element, optionally
1155
+ // bypassing CSS transitions for instant positioning.
1156
+ const setMargin = (el, value) => {
1157
+ if (skipTransition) {
1158
+ el.style.transition = 'none';
1159
+ el.style.marginLeft = value;
1160
+ void el.offsetHeight;
1161
+ el.style.removeProperty('transition');
1162
+ }
1163
+ else {
1164
+ el.style.marginLeft = value;
1165
+ }
1166
+ toggleClass(el, shiftingClass(el), true);
1167
+ };
1168
+ const clearMargin = (el) => {
1169
+ const cls = shiftingClass(el);
1170
+ // Remove any previous pending listener for this element
1171
+ const prev = this._pendingMarginCleanups.get(el);
1172
+ if (prev) {
1173
+ prev();
1174
+ }
1175
+ if (skipTransition || !el.style.marginLeft) {
1176
+ el.style.removeProperty('margin-left');
1177
+ toggleClass(el, cls, false);
1178
+ }
1179
+ else {
1180
+ el.style.marginLeft = '0px';
1181
+ toggleClass(el, cls, true);
1182
+ const onEnd = () => {
1183
+ el.style.removeProperty('margin-left');
1184
+ toggleClass(el, cls, false);
1185
+ el.removeEventListener('transitionend', onEnd);
1186
+ clearTimeout(fallbackTimer);
1187
+ this._pendingMarginCleanups.delete(el);
1188
+ };
1189
+ // Fallback in case transitionend never fires
1190
+ // (e.g. element removed from DOM mid-transition)
1191
+ const fallbackTimer = setTimeout(onEnd, 300);
1192
+ this._pendingMarginCleanups.set(el, onEnd);
1193
+ el.addEventListener('transitionend', onEnd);
1194
+ }
1195
+ };
395
1196
  let gapApplied = false;
1197
+ // Reset all non-source chip margins first
1198
+ for (const [groupId, entry] of this._tabGroupManager.chipRenderers) {
1199
+ if (groupId === this._animState.sourceTabGroupId)
1200
+ continue;
1201
+ clearMargin(entry.chip.element);
1202
+ }
1203
+ // Apply gap to chip if insertion is before a group
1204
+ if (chipToShift) {
1205
+ setMargin(chipToShift, `${gapWidth}px`);
1206
+ gapApplied = true;
1207
+ }
396
1208
  for (let i = 0; i < this._tabs.length; i++) {
397
1209
  const tab = this._tabs[i].value;
398
1210
  if (tab.panel.id === this._animState.sourceTabId) {
399
1211
  continue;
400
1212
  }
1213
+ if (sourceGroupPanelIds === null || sourceGroupPanelIds === void 0 ? void 0 : sourceGroupPanelIds.has(tab.panel.id)) {
1214
+ continue;
1215
+ }
401
1216
  if (!gapApplied && i >= insertionIndex) {
402
- tab.element.style.marginLeft = `${gapWidth}px`;
403
- toggleClass(tab.element, 'dv-tab--shifting', true);
1217
+ setMargin(tab.element, `${gapWidth}px`);
404
1218
  gapApplied = true;
405
1219
  }
406
1220
  else {
407
- // Keep shifting class while margin animates back to 0,
408
- // then remove both once the transition ends
409
- if (tab.element.style.marginLeft) {
410
- tab.element.style.marginLeft = '0px';
411
- toggleClass(tab.element, 'dv-tab--shifting', true);
412
- const onEnd = () => {
413
- tab.element.style.removeProperty('margin-left');
414
- toggleClass(tab.element, 'dv-tab--shifting', false);
415
- tab.element.removeEventListener('transitionend', onEnd);
416
- };
417
- tab.element.addEventListener('transitionend', onEnd);
418
- }
419
- else {
420
- toggleClass(tab.element, 'dv-tab--shifting', false);
421
- }
1221
+ clearMargin(tab.element);
422
1222
  }
423
1223
  }
1224
+ // Reposition underlines to follow shifted chips/tabs
1225
+ this._tabGroupManager.trackUnderlines();
424
1226
  }
425
1227
  resetTabTransforms() {
1228
+ // Cancel any pending margin transitionend listeners
1229
+ for (const [, cleanup] of this._pendingMarginCleanups) {
1230
+ cleanup();
1231
+ }
1232
+ this._pendingMarginCleanups.clear();
426
1233
  for (const tab of this._tabs) {
427
1234
  tab.value.element.style.removeProperty('margin-left');
1235
+ tab.value.element.style.removeProperty('margin-right');
1236
+ tab.value.element.style.removeProperty('margin-top');
1237
+ tab.value.element.style.removeProperty('margin-bottom');
428
1238
  tab.value.element.style.removeProperty('transform');
429
1239
  toggleClass(tab.value.element, 'dv-tab--shifting', false);
430
1240
  }
1241
+ for (const [, entry] of this._tabGroupManager.chipRenderers) {
1242
+ entry.chip.element.style.removeProperty('margin-left');
1243
+ toggleClass(entry.chip.element, 'dv-tab-group-chip--shifting', false);
1244
+ }
1245
+ this._tabGroupManager.positionUnderlines();
1246
+ }
1247
+ /**
1248
+ * Commit a group-drag drop: clear drag classes, move the group
1249
+ * in the model, and run a FLIP animation.
1250
+ */
1251
+ _commitGroupMove(sourceTabGroupId, insertionIndex) {
1252
+ var _a, _b, _c;
1253
+ // Read transfer data BEFORE disposing cleanup — disposing
1254
+ // _chipDragCleanup clears the global LocalSelectionTransfer
1255
+ // singleton which getPanelData() reads from.
1256
+ const data = getPanelData();
1257
+ (_a = this._chipDragCleanup) === null || _a === void 0 ? void 0 : _a.dispose();
1258
+ this._chipDragCleanup = null;
1259
+ // Check if the tab group exists in this group (within-group reorder)
1260
+ // or in another group (cross-group move).
1261
+ const isLocal = this.group.model
1262
+ .getTabGroups()
1263
+ .some((tg) => tg.id === sourceTabGroupId);
1264
+ if (isLocal) {
1265
+ if (((_b = this.accessor.options.theme) === null || _b === void 0 ? void 0 : _b.tabAnimation) === 'smooth') {
1266
+ this._clearGroupDragClasses(sourceTabGroupId);
1267
+ const firstPositions = this.snapshotTabPositions();
1268
+ this.resetTabTransforms();
1269
+ this.group.model.moveTabGroup(sourceTabGroupId, insertionIndex);
1270
+ this.runFlipAnimation(firstPositions, '', false);
1271
+ }
1272
+ else {
1273
+ this._tabGroupManager.skipNextCollapseAnimation = true;
1274
+ this.group.model.moveTabGroup(sourceTabGroupId, insertionIndex);
1275
+ }
1276
+ }
1277
+ else if (data) {
1278
+ // Cross-group: delegate to the component-level move which
1279
+ // handles panel transfer and tab group recreation.
1280
+ // Use the REAL tab group ID from transfer data, not the
1281
+ // potentially stale one from _animState.
1282
+ this.accessor.moveGroupOrPanel({
1283
+ from: {
1284
+ groupId: data.groupId,
1285
+ tabGroupId: (_c = data.tabGroupId) !== null && _c !== void 0 ? _c : sourceTabGroupId,
1286
+ },
1287
+ to: {
1288
+ group: this.group,
1289
+ position: 'center',
1290
+ index: insertionIndex,
1291
+ },
1292
+ });
1293
+ }
1294
+ }
1295
+ _clearGroupDragClasses(sourceTabGroupId) {
1296
+ const chipEntry = this._tabGroupManager.chipRenderers.get(sourceTabGroupId);
1297
+ if (chipEntry) {
1298
+ this._removeClassInstantlyBatch([chipEntry.chip.element], 'dv-tab-group-chip--dragging');
1299
+ }
1300
+ this._removeClassInstantlyBatch(this._tabs.map((t) => t.value.element), 'dv-tab--dragging');
1301
+ // Restore underline
1302
+ const underline = this._tabGroupManager.groupUnderlines.get(sourceTabGroupId);
1303
+ if (underline) {
1304
+ underline.style.removeProperty('display');
1305
+ }
1306
+ // The subsequent moveTabGroup will re-create tabs and call
1307
+ // updateTabGroups → _updateTabGroupClasses. For collapsed groups
1308
+ // the new tabs don't have dv-tab--group-collapsed yet, which
1309
+ // would trigger the collapse animation. Skip it.
1310
+ this._tabGroupManager.skipNextCollapseAnimation = true;
431
1311
  }
432
1312
  resetDragAnimation() {
1313
+ var _a, _b;
1314
+ this._pendingCollapse = false;
433
1315
  this.resetTabTransforms();
1316
+ // Clear drag-collapse classes instantly (no transition)
1317
+ if ((_a = this._animState) === null || _a === void 0 ? void 0 : _a.sourceTabGroupId) {
1318
+ this._clearGroupDragClasses(this._animState.sourceTabGroupId);
1319
+ }
1320
+ else {
1321
+ this._removeClassInstantlyBatch(this._tabs.map((t) => t.value.element), 'dv-tab--dragging');
1322
+ }
434
1323
  this._animState = null;
435
- for (const tab of this._tabs) {
436
- toggleClass(tab.value.element, 'dv-tab--dragging', false);
1324
+ (_b = this._chipDragCleanup) === null || _b === void 0 ? void 0 : _b.dispose();
1325
+ this._chipDragCleanup = null;
1326
+ // Restore any hidden underlines from group drags
1327
+ for (const [, el] of this._tabGroupManager.groupUnderlines) {
1328
+ el.style.removeProperty('display');
437
1329
  }
438
1330
  }
439
1331
  runFlipAnimation(firstPositions, sourceTabId, isCrossGroup = false, animRange) {
1332
+ const isVertical = this._direction === 'vertical';
440
1333
  let hasAnimation = false;
441
1334
  for (let i = 0; i < this._tabs.length; i++) {
442
1335
  const tab = this._tabs[i];
443
1336
  const panelId = tab.value.panel.id;
444
1337
  if (panelId === sourceTabId) {
445
1338
  if (isCrossGroup) {
446
- // Newly inserted tab: slide in from the right
1339
+ // Newly inserted tab: slide in from the end
447
1340
  const rect = tab.value.element.getBoundingClientRect();
448
- tab.value.element.style.transform = `translateX(${rect.width}px)`;
1341
+ tab.value.element.style.transform = isVertical
1342
+ ? `translateY(${rect.height}px)`
1343
+ : `translateX(${rect.width}px)`;
449
1344
  toggleClass(tab.value.element, 'dv-tab--shifting', true);
450
1345
  hasAnimation = true;
451
1346
  }
@@ -461,11 +1356,15 @@ export class Tabs extends CompositeDisposable {
461
1356
  continue;
462
1357
  }
463
1358
  const lastRect = tab.value.element.getBoundingClientRect();
464
- const deltaX = firstRect.left - lastRect.left;
465
- if (Math.abs(deltaX) < 1) {
1359
+ const delta = isVertical
1360
+ ? firstRect.top - lastRect.top
1361
+ : firstRect.left - lastRect.left;
1362
+ if (Math.abs(delta) < 1) {
466
1363
  continue;
467
1364
  }
468
- tab.value.element.style.transform = `translateX(${deltaX}px)`;
1365
+ tab.value.element.style.transform = isVertical
1366
+ ? `translateY(${delta}px)`
1367
+ : `translateX(${delta}px)`;
469
1368
  toggleClass(tab.value.element, 'dv-tab--shifting', true);
470
1369
  hasAnimation = true;
471
1370
  }
@@ -473,19 +1372,32 @@ export class Tabs extends CompositeDisposable {
473
1372
  return;
474
1373
  }
475
1374
  requestAnimationFrame(() => {
1375
+ var _a;
476
1376
  for (const tab of this._tabs) {
477
1377
  if (tab.value.element.style.transform) {
478
1378
  tab.value.element.style.transform = '';
479
1379
  }
480
1380
  }
1381
+ // Track underlines during the FLIP transition so they
1382
+ // follow tabs as they slide to their final positions.
1383
+ this._tabGroupManager.trackUnderlines();
1384
+ // Clean up any previous flip transition listener
1385
+ (_a = this._flipTransitionCleanup) === null || _a === void 0 ? void 0 : _a.call(this);
481
1386
  const onTransitionEnd = (event) => {
482
1387
  if (event.propertyName === 'transform') {
483
- this._tabsList.removeEventListener('transitionend', onTransitionEnd);
1388
+ cleanup();
484
1389
  for (const tab of this._tabs) {
485
1390
  toggleClass(tab.value.element, 'dv-tab--shifting', false);
486
1391
  }
1392
+ // Final reposition after animation settles
1393
+ this._tabGroupManager.positionUnderlines();
487
1394
  }
488
1395
  };
1396
+ const cleanup = () => {
1397
+ this._tabsList.removeEventListener('transitionend', onTransitionEnd);
1398
+ this._flipTransitionCleanup = null;
1399
+ };
1400
+ this._flipTransitionCleanup = cleanup;
489
1401
  this._tabsList.addEventListener('transitionend', onTransitionEnd);
490
1402
  });
491
1403
  }