diff --git a/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts b/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts index 6dcbdc15de..2aa26601b1 100644 --- a/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts +++ b/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts @@ -98,6 +98,7 @@ export class DockviewAngularComponent implements OnInit, OnDestroy, OnChanges { @Input() locked?: boolean; @Input() disableAutoResizing?: boolean; @Input() singleTabMode?: 'fullwidth' | 'default'; + @Input() revealActiveTab?: boolean; @Input() getTabContextMenuItems?: ( params: GetTabContextMenuItemsParams ) => (ContextMenuItem | { component: Type | TemplateRef })[]; diff --git a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabs.spec.ts b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabs.spec.ts index 314240042b..7f1bfcf966 100644 --- a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabs.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabs.spec.ts @@ -149,6 +149,150 @@ describe('tabs', () => { }); }); + describe('active tab reveal', () => { + function createTabs( + options: Partial = {} + ): Tabs { + const accessor = fromPartial({ + options, + onDidOptionsChange: jest + .fn() + .mockReturnValue({ dispose: jest.fn() }), + }); + const group = fromPartial({}); + + return new Tabs(group, accessor, { + showTabsOverflowControl: false, + }); + } + + test('reveals active tab using its offset in the tab strip', () => { + const panel1 = createMockPanel('panel1'); + const panel2 = createMockPanel('panel2'); + const cut = createTabs(); + + cut.openPanel(panel1); + cut.openPanel(panel2); + + const tabsList = cut.element.querySelector( + '.dv-tabs-container' + ) as HTMLElement; + const [tab1Element, tab2Element] = getTabElements(cut); + const chipElement = document.createElement('div'); + chipElement.className = 'dv-tab-group-chip'; + tabsList.insertBefore(chipElement, tab2Element); + + // These tab widths make the previous tab-width accumulation miss the clipped tab. + jest.spyOn(tabsList, 'clientWidth', 'get').mockReturnValue(100); + jest.spyOn(tab1Element, 'clientWidth', 'get').mockReturnValue(50); + jest.spyOn(tab2Element, 'clientWidth', 'get').mockReturnValue(50); + jest.spyOn(tab2Element, 'offsetLeft', 'get').mockReturnValue(120); + jest.spyOn(tab2Element, 'offsetWidth', 'get').mockReturnValue(50); + + cut.setActivePanel(panel2); + + expect(tabsList.scrollLeft).toBe(120); + }); + + test('reveals active tab using its offset in a vertical tab strip', () => { + const panel1 = createMockPanel('panel1'); + const panel2 = createMockPanel('panel2'); + const cut = createTabs(); + cut.direction = 'vertical'; + + cut.openPanel(panel1); + cut.openPanel(panel2); + + const tabsList = cut.element.querySelector( + '.dv-tabs-container' + ) as HTMLElement; + const [tab1Element, tab2Element] = getTabElements(cut); + const chipElement = document.createElement('div'); + chipElement.className = 'dv-tab-group-chip'; + tabsList.insertBefore(chipElement, tab2Element); + + // These tab heights make the previous tab-height accumulation miss the clipped tab. + jest.spyOn(tabsList, 'clientHeight', 'get').mockReturnValue(100); + jest.spyOn(tab1Element, 'clientHeight', 'get').mockReturnValue(50); + jest.spyOn(tab2Element, 'clientHeight', 'get').mockReturnValue(50); + jest.spyOn(tab2Element, 'offsetTop', 'get').mockReturnValue(120); + jest.spyOn(tab2Element, 'offsetHeight', 'get').mockReturnValue(50); + + cut.setActivePanel(panel2); + + expect(tabsList.scrollTop).toBe(120); + }); + + test('does not reveal partially visible tab on pointerdown when revealActiveTab is false', () => { + const panel1 = createMockPanel('panel1'); + const panel2 = createMockPanel('panel2'); + let activePanel = panel1; + let openPanelHandler = (_panel: IDockviewPanel): void => { + void _panel; + }; + const openPanelMock = jest.fn((panel: IDockviewPanel) => { + openPanelHandler(panel); + }); + + const accessor = fromPartial({ + options: { revealActiveTab: false }, + doSetGroupActive: jest.fn(), + onDidOptionsChange: jest + .fn() + .mockReturnValue({ dispose: jest.fn() }), + }); + const group = fromPartial({ + get activePanel() { + return activePanel; + }, + api: { + location: { type: 'grid' }, + }, + model: { + openPanel: openPanelMock, + }, + }); + const cut = new Tabs(group, accessor, { + showTabsOverflowControl: false, + }); + // Simulate the group model activation path used by tab pointerdown: + // Tabs -> group.model.openPanel(panel) -> Tabs.setActivePanel(panel). + openPanelHandler = (panel: IDockviewPanel): void => { + activePanel = panel; + cut.setActivePanel(panel); + }; + + cut.openPanel(panel1); + cut.openPanel(panel2); + cut.setActivePanel(panel1); + + const tabsList = cut.element.querySelector( + '.dv-tabs-container' + ) as HTMLElement; + const [tab1Element, tab2Element] = getTabElements(cut); + + // The tab strip viewport is [0, 100]. The second tab sits at + // [80, 130], so only its left 20px is visible and the right 30px + // is clipped by the viewport edge. + jest.spyOn(tabsList, 'clientWidth', 'get').mockReturnValue(100); + jest.spyOn(tab1Element, 'clientWidth', 'get').mockReturnValue(80); + jest.spyOn(tab2Element, 'clientWidth', 'get').mockReturnValue(50); + jest.spyOn(tab2Element, 'offsetLeft', 'get').mockReturnValue(80); + jest.spyOn(tab2Element, 'offsetWidth', 'get').mockReturnValue(50); + + tab2Element.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + }) + ); + + expect(openPanelMock).toHaveBeenCalledWith(panel2); + expect(tabsList.scrollLeft).toBe(0); + }); + }); + describe('updateDragAndDropState', () => { test('that updateDragAndDropState calls updateDragAndDropState on all tabs', () => { const cut = new Tabs( diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts index 7f11cff793..6c0faca952 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabs.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabs.ts @@ -563,38 +563,38 @@ export class Tabs extends CompositeDisposable { setActivePanel(panel: IDockviewPanel): void { const isVertical = this._direction === 'vertical'; - let running = 0; for (const tab of this._tabs) { const isActivePanel = panel.id === tab.value.panel.id; tab.value.setActive(isActivePanel); - if (isActivePanel) { + if ( + isActivePanel && + this.accessor.options.revealActiveTab !== false + ) { const element = tab.value.element; const parentElement = element.parentElement!; if (isVertical) { + const running = element.offsetTop; if ( running < parentElement.scrollTop || - running + element.clientHeight > + running + element.offsetHeight > parentElement.scrollTop + parentElement.clientHeight ) { parentElement.scrollTop = running; } } else { + const running = element.offsetLeft; if ( running < parentElement.scrollLeft || - running + element.clientWidth > + running + element.offsetWidth > parentElement.scrollLeft + parentElement.clientWidth ) { parentElement.scrollLeft = running; } } } - - running += isVertical - ? tab.value.element.clientHeight - : tab.value.element.clientWidth; } // Reposition underlines so the wrap-around follows the new active tab diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 625afcf221..f9ad16e7a3 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -134,6 +134,12 @@ export interface DockviewOptions { * This is only applied to the tab header section. Defaults to `custom`. */ scrollbars?: 'native' | 'custom'; + /** + * Scroll the tab header to the active tab when selection changes. + * When enabled, a clipped active tab is aligned to the start of the + * tab strip. Defaults to `true`. + */ + revealActiveTab?: boolean; /** * Return the items to display in the tab context menu on right-click. * @@ -240,6 +246,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => { theme: undefined, disableTabsOverflowList: undefined, scrollbars: undefined, + revealActiveTab: undefined, getTabContextMenuItems: undefined, getTabGroupChipContextMenuItems: undefined, createTabGroupChipComponent: undefined, diff --git a/packages/docs/src/generated/api.output.json b/packages/docs/src/generated/api.output.json index 3217e87253..b88f48ae36 100644 --- a/packages/docs/src/generated/api.output.json +++ b/packages/docs/src/generated/api.output.json @@ -28811,6 +28811,34 @@ ] } }, + { + "name": "revealActiveTab", + "code": "boolean", + "kind": "property", + "type": { + "type": "intrinsic", + "value": "boolean" + }, + "flags": { + "isOptional": true + }, + "comment": { + "summary": [ + { + "kind": "text", + "text": "Scroll the tab header to the active tab when selection changes.\nWhen enabled, a clipped active tab is aligned to the start of the\ntab strip. Defaults to " + }, + { + "kind": "code", + "text": "`true`" + }, + { + "kind": "text", + "text": "." + } + ] + } + }, { "name": "singleTabMode", "code": "'fullwidth' | 'default'",