Merge pull request #961 from mathuo/950-tabs-have-draggabletrue-when-disabledndtrue

fix: respect disableDnd option for tab draggable attribute
This commit is contained in:
mathuo 2025-07-17 20:36:20 +01:00 committed by GitHub
commit eec5380897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 375 additions and 97 deletions

View File

@ -12,12 +12,14 @@ import { fromPartial } from '@total-typescript/shoehorn';
describe('tab', () => {
test('that empty tab has inactive-tab class', () => {
const accessorMock = jest.fn();
const accessor = fromPartial<DockviewComponent>({
options: {}
});
const groupMock = jest.fn();
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
new accessorMock(),
accessor,
new groupMock()
);
@ -25,12 +27,14 @@ describe('tab', () => {
});
test('that active tab has active-tab class', () => {
const accessorMock = jest.fn();
const accessor = fromPartial<DockviewComponent>({
options: {}
});
const groupMock = jest.fn();
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
new accessorMock(),
accessor,
new groupMock()
);
@ -42,26 +46,20 @@ describe('tab', () => {
});
test('that an external event does not render a drop target and calls through to the group model', () => {
const accessorMock = jest.fn<Partial<DockviewComponent>, []>(() => {
return {
id: 'testcomponentid',
};
const accessor = fromPartial<DockviewComponent>({
id: 'testcomponentid',
options: {}
});
const groupView = fromPartial<DockviewGroupPanelModel>({
canDisplayOverlay: jest.fn(),
});
const groupPanelMock = jest.fn<Partial<DockviewGroupPanel>, []>(() => {
return {
id: 'testgroupid',
model: groupView,
};
const groupPanel = fromPartial<DockviewGroupPanel>({
id: 'testgroupid',
model: groupView,
});
const accessor = new accessorMock() as DockviewComponent;
const groupPanel = new groupPanelMock() as DockviewGroupPanel;
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
accessor,
@ -86,26 +84,20 @@ describe('tab', () => {
});
test('that if you drag over yourself a drop target is shown', () => {
const accessorMock = jest.fn<Partial<DockviewComponent>, []>(() => {
return {
id: 'testcomponentid',
};
const accessor = fromPartial<DockviewComponent>({
id: 'testcomponentid',
options: {}
});
const groupView = fromPartial<DockviewGroupPanelModel>({
canDisplayOverlay: jest.fn(),
});
const groupPanelMock = jest.fn<Partial<DockviewGroupPanel>, []>(() => {
return {
id: 'testgroupid',
model: groupView,
};
const groupPanel = fromPartial<DockviewGroupPanel>({
id: 'testgroupid',
model: groupView,
});
const accessor = new accessorMock() as DockviewComponent;
const groupPanel = new groupPanelMock() as DockviewGroupPanel;
const cut = new Tab(
{ id: 'panel1' } as IDockviewPanel,
accessor,
@ -135,30 +127,19 @@ describe('tab', () => {
});
test('that if you drag over another tab a drop target is shown', () => {
const accessorMock = jest.fn<Partial<DockviewComponent>, []>(() => {
return {
id: 'testcomponentid',
};
});
const groupviewMock = jest.fn<Partial<DockviewGroupPanelModel>, []>(
() => {
return {
canDisplayOverlay: jest.fn(),
};
}
);
const groupView = new groupviewMock() as DockviewGroupPanelModel;
const groupPanelMock = jest.fn<Partial<DockviewGroupPanel>, []>(() => {
return {
id: 'testgroupid',
model: groupView,
};
const accessor = fromPartial<DockviewComponent>({
id: 'testcomponentid',
options: {}
});
const accessor = new accessorMock() as DockviewComponent;
const groupPanel = new groupPanelMock() as DockviewGroupPanel;
const groupView = fromPartial<DockviewGroupPanelModel>({
canDisplayOverlay: jest.fn(),
});
const groupPanel = fromPartial<DockviewGroupPanel>({
id: 'testgroupid',
model: groupView,
});
const cut = new Tab(
{ id: 'panel1' } as IDockviewPanel,
@ -189,30 +170,19 @@ describe('tab', () => {
});
test('that dropping on a tab with the same id but from a different component should not render a drop over and call through to the group model', () => {
const accessorMock = jest.fn<Partial<DockviewComponent>, []>(() => {
return {
id: 'testcomponentid',
};
});
const groupviewMock = jest.fn<Partial<DockviewGroupPanelModel>, []>(
() => {
return {
canDisplayOverlay: jest.fn(),
};
}
);
const groupView = new groupviewMock() as DockviewGroupPanelModel;
const groupPanelMock = jest.fn<Partial<DockviewGroupPanel>, []>(() => {
return {
id: 'testgroupid',
model: groupView,
};
const accessor = fromPartial<DockviewComponent>({
id: 'testcomponentid',
options: {}
});
const accessor = new accessorMock() as DockviewComponent;
const groupPanel = new groupPanelMock() as DockviewGroupPanel;
const groupView = fromPartial<DockviewGroupPanelModel>({
canDisplayOverlay: jest.fn(),
});
const groupPanel = fromPartial<DockviewGroupPanel>({
id: 'testgroupid',
model: groupView,
});
const cut = new Tab(
{ id: 'panel1' } as IDockviewPanel,
@ -249,30 +219,19 @@ describe('tab', () => {
});
test('that dropping on a tab from a different component should not render a drop over and call through to the group model', () => {
const accessorMock = jest.fn<Partial<DockviewComponent>, []>(() => {
return {
id: 'testcomponentid',
};
});
const groupviewMock = jest.fn<Partial<DockviewGroupPanelModel>, []>(
() => {
return {
canDisplayOverlay: jest.fn(),
};
}
);
const groupView = new groupviewMock() as DockviewGroupPanelModel;
const groupPanelMock = jest.fn<Partial<DockviewGroupPanel>, []>(() => {
return {
id: 'testgroupid',
model: groupView,
};
const accessor = fromPartial<DockviewComponent>({
id: 'testcomponentid',
options: {}
});
const accessor = new accessorMock() as DockviewComponent;
const groupPanel = new groupPanelMock() as DockviewGroupPanel;
const groupView = fromPartial<DockviewGroupPanelModel>({
canDisplayOverlay: jest.fn(),
});
const groupPanel = fromPartial<DockviewGroupPanel>({
id: 'testgroupid',
model: groupView,
});
const cut = new Tab(
{ id: 'panel1' } as IDockviewPanel,
@ -307,4 +266,77 @@ describe('tab', () => {
cut.element.getElementsByClassName('dv-drop-target-dropzone').length
).toBe(0);
});
describe('disableDnd option', () => {
test('that tab is draggable by default (disableDnd not set)', () => {
const accessor = fromPartial<DockviewComponent>({
options: {}
});
const groupMock = jest.fn();
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
accessor,
new groupMock()
);
expect(cut.element.draggable).toBe(true);
});
test('that tab is draggable when disableDnd is false', () => {
const accessor = fromPartial<DockviewComponent>({
options: { disableDnd: false }
});
const groupMock = jest.fn();
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
accessor,
new groupMock()
);
expect(cut.element.draggable).toBe(true);
});
test('that tab is not draggable when disableDnd is true', () => {
const accessor = fromPartial<DockviewComponent>({
options: { disableDnd: true }
});
const groupMock = jest.fn();
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
accessor,
new groupMock()
);
expect(cut.element.draggable).toBe(false);
});
test('that updateDragAndDropState updates draggable attribute based on disableDnd option', () => {
const options = { disableDnd: false };
const accessor = fromPartial<DockviewComponent>({
options
});
const groupMock = jest.fn();
const cut = new Tab(
{ id: 'panelId' } as IDockviewPanel,
accessor,
new groupMock()
);
expect(cut.element.draggable).toBe(true);
// Simulate option change
options.disableDnd = true;
cut.updateDragAndDropState();
expect(cut.element.draggable).toBe(false);
// Change back
options.disableDnd = false;
cut.updateDragAndDropState();
expect(cut.element.draggable).toBe(true);
});
});
});

View File

@ -63,4 +63,33 @@ describe('tabs', () => {
).toBe(0);
});
});
describe('updateDragAndDropState', () => {
test('that updateDragAndDropState calls updateDragAndDropState on all tabs', () => {
const cut = new Tabs(
fromPartial<DockviewGroupPanel>({}),
fromPartial<DockviewComponent>({
options: {},
}),
{
showTabsOverflowControl: true,
}
);
// Mock tab to verify the method is called
const mockTab1 = { updateDragAndDropState: jest.fn() };
const mockTab2 = { updateDragAndDropState: jest.fn() };
// Add mock tabs to the internal tabs array
(cut as any)._tabs = [
{ value: mockTab1 },
{ value: mockTab2 }
];
cut.updateDragAndDropState();
expect(mockTab1.updateDragAndDropState).toHaveBeenCalledTimes(1);
expect(mockTab2.updateDragAndDropState).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -864,4 +864,34 @@ describe('tabsContainer', () => {
cut.closePanel(panel2);
expect(cut.element.classList.contains('dv-single-tab')).toBeFalsy();
});
describe('updateDragAndDropState', () => {
test('that updateDragAndDropState calls updateDragAndDropState on tabs and voidContainer', () => {
const accessor = fromPartial<DockviewComponent>({
onDidAddPanel: jest.fn(),
onDidRemovePanel: jest.fn(),
options: {},
onDidOptionsChange: jest.fn(),
});
const groupPanel = fromPartial<DockviewGroupPanel>({
id: 'testgroupid',
model: fromPartial<DockviewGroupPanelModel>({}),
});
const cut = new TabsContainer(accessor, groupPanel);
// Mock the tabs and voidContainer to verify methods are called
const mockTabs = { updateDragAndDropState: jest.fn() };
const mockVoidContainer = { updateDragAndDropState: jest.fn() };
(cut as any).tabs = mockTabs;
(cut as any).voidContainer = mockVoidContainer;
cut.updateDragAndDropState();
expect(mockTabs.updateDragAndDropState).toHaveBeenCalledTimes(1);
expect(mockVoidContainer.updateDragAndDropState).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -8,6 +8,7 @@ describe('voidContainer', () => {
test('that `pointerDown` triggers activation', () => {
const accessor = fromPartial<DockviewComponent>({
doSetGroupActive: jest.fn(),
options: {}
});
const group = fromPartial<DockviewGroupPanel>({});
const cut = new VoidContainer(accessor, group);
@ -17,4 +18,57 @@ describe('voidContainer', () => {
fireEvent.pointerDown(cut.element);
expect(accessor.doSetGroupActive).toHaveBeenCalledWith(group);
});
describe('disableDnd option', () => {
test('that void container is draggable by default (disableDnd not set)', () => {
const accessor = fromPartial<DockviewComponent>({
options: {}
});
const group = fromPartial<DockviewGroupPanel>({});
const cut = new VoidContainer(accessor, group);
expect(cut.element.draggable).toBe(true);
});
test('that void container is draggable when disableDnd is false', () => {
const accessor = fromPartial<DockviewComponent>({
options: { disableDnd: false }
});
const group = fromPartial<DockviewGroupPanel>({});
const cut = new VoidContainer(accessor, group);
expect(cut.element.draggable).toBe(true);
});
test('that void container is not draggable when disableDnd is true', () => {
const accessor = fromPartial<DockviewComponent>({
options: { disableDnd: true }
});
const group = fromPartial<DockviewGroupPanel>({});
const cut = new VoidContainer(accessor, group);
expect(cut.element.draggable).toBe(false);
});
test('that updateDragAndDropState updates draggable attribute based on disableDnd option', () => {
const options = { disableDnd: false };
const accessor = fromPartial<DockviewComponent>({
options
});
const group = fromPartial<DockviewGroupPanel>({});
const cut = new VoidContainer(accessor, group);
expect(cut.element.draggable).toBe(true);
// Simulate option change
options.disableDnd = true;
cut.updateDragAndDropState();
expect(cut.element.draggable).toBe(false);
// Change back
options.disableDnd = false;
cut.updateDragAndDropState();
expect(cut.element.draggable).toBe(true);
});
});
});

View File

@ -144,6 +144,102 @@ describe('dockviewComponent', () => {
);
});
describe('disableDnd option integration', () => {
test('that updateOptions with disableDnd updates all tabs and void containers', () => {
dockview = new DockviewComponent(container, {
createComponent(options) {
switch (options.name) {
case 'default':
return new PanelContentPartTest(
options.id,
options.name
);
default:
throw new Error(`unsupported`);
}
},
disableDnd: false,
});
// Add some panels to create tabs
const panel1 = dockview.addPanel({
id: 'panel1',
component: 'default',
});
const panel2 = dockview.addPanel({
id: 'panel2',
component: 'default',
});
// Get all tab elements and void containers
const tabElements = Array.from(dockview.element.querySelectorAll('.dv-tab')) as HTMLElement[];
const voidContainers = Array.from(dockview.element.querySelectorAll('.dv-void-container')) as HTMLElement[];
// Initially tabs should be draggable (disableDnd: false)
tabElements.forEach(tab => {
expect(tab.draggable).toBe(true);
});
voidContainers.forEach(container => {
expect(container.draggable).toBe(true);
});
// Update options to disable DnD
dockview.updateOptions({ disableDnd: true });
// Now tabs should not be draggable
tabElements.forEach(tab => {
expect(tab.draggable).toBe(false);
});
voidContainers.forEach(container => {
expect(container.draggable).toBe(false);
});
// Update options to enable DnD again
dockview.updateOptions({ disableDnd: false });
// Tabs should be draggable again
tabElements.forEach(tab => {
expect(tab.draggable).toBe(true);
});
voidContainers.forEach(container => {
expect(container.draggable).toBe(true);
});
});
test('that new tabs respect current disableDnd option when added after option change', () => {
dockview = new DockviewComponent(container, {
createComponent(options) {
switch (options.name) {
case 'default':
return new PanelContentPartTest(
options.id,
options.name
);
default:
throw new Error(`unsupported`);
}
},
disableDnd: false,
});
// Set disableDnd to true
dockview.updateOptions({ disableDnd: true });
// Add a panel after the option change
const panel = dockview.addPanel({
id: 'panel1',
component: 'default',
});
// New tab should not be draggable
const tabElement = dockview.element.querySelector('.dv-tab') as HTMLElement;
const voidContainer = dockview.element.querySelector('.dv-void-container') as HTMLElement;
expect(tabElement.draggable).toBe(false);
expect(voidContainer.draggable).toBe(false);
});
});
describe('memory leakage', () => {
beforeEach(() => {
window.open = () => setupMockWindow();

View File

@ -75,7 +75,7 @@ export class Tab extends CompositeDisposable {
this._element = document.createElement('div');
this._element.className = 'dv-tab';
this._element.tabIndex = 0;
this._element.draggable = true;
this._element.draggable = !this.accessor.options.disableDnd;
toggleClass(this.element, 'dv-inactive-tab', true);
@ -159,6 +159,10 @@ export class Tab extends CompositeDisposable {
this._element.appendChild(this.content.element);
}
public updateDragAndDropState(): void {
this._element.draggable = !this.accessor.options.disableDnd;
}
public dispose(): void {
super.dispose();
}

View File

@ -298,4 +298,10 @@ export class Tabs extends CompositeDisposable {
this._onOverflowTabsChange.fire({ tabs, reset: options.reset });
}
updateDragAndDropState(): void {
for (const tab of this._tabs) {
tab.value.updateDragAndDropState();
}
}
}

View File

@ -55,6 +55,7 @@ export interface ITabsContainer extends IDisposable {
setPrefixActionsElement(element: HTMLElement | undefined): void;
show(): void;
hide(): void;
updateDragAndDropState(): void;
}
export class TabsContainer
@ -400,4 +401,9 @@ export class TabsContainer
})
);
}
updateDragAndDropState(): void {
this.tabs.updateDragAndDropState();
this.voidContainer.updateDragAndDropState();
}
}

View File

@ -36,7 +36,7 @@ export class VoidContainer extends CompositeDisposable {
this._element = document.createElement('div');
this._element.className = 'dv-void-container';
this._element.draggable = true;
this._element.draggable = !this.accessor.options.disableDnd;
this.addDisposables(
this._onDrop,
@ -79,4 +79,8 @@ export class VoidContainer extends CompositeDisposable {
this.dropTarget
);
}
updateDragAndDropState(): void {
this._element.draggable = !this.accessor.options.disableDnd;
}
}

View File

@ -1272,7 +1272,13 @@ export class DockviewComponent
this.updateDropTargetModel(options);
const oldDisableDnd = this.options.disableDnd;
this._options = { ...this.options, ...options };
const newDisableDnd = this.options.disableDnd;
if (oldDisableDnd !== newDisableDnd) {
this.updateDragAndDropState();
}
if ('theme' in options) {
this.updateTheme();
@ -1296,6 +1302,13 @@ export class DockviewComponent
}
}
private updateDragAndDropState(): void {
// Update draggable state for all tabs and void containers
for (const group of this.groups) {
group.model.updateDragAndDropState();
}
}
focus(): void {
this.activeGroup?.focus();
}

View File

@ -1136,6 +1136,10 @@ export class DockviewGroupPanelModel
}
}
updateDragAndDropState(): void {
this.tabsContainer.updateDragAndDropState();
}
public dispose(): void {
super.dispose();

View File

@ -19,7 +19,7 @@ function useTitle(api: DockviewPanelApi): string | undefined {
}
export type IDockviewDefaultTabProps = IDockviewPanelHeaderProps &
React.DOMAttributes<HTMLDivElement> & {
React.HtmlHTMLAttributes<HTMLDivElement> & {
hideClose?: boolean;
closeActionOverride?: () => void;
};