diff --git a/e2e/issues/issue-786.spec.ts b/e2e/issues/issue-786.spec.ts new file mode 100644 index 0000000000..65f2697225 --- /dev/null +++ b/e2e/issues/issue-786.spec.ts @@ -0,0 +1,177 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Issue #786: Tab removal cancellation functionality', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.click('text=Tabs'); + }); + + test('should show removable tabs with close buttons', async ({ page }) => { + // Wait for tabs to load + await page.waitForSelector('.nav-tabs'); + + // Look for removable tabs (ones with close buttons) + const closeButtons = page.locator('.bs-remove-tab'); + const closeButtonCount = await closeButtons.count(); + + if (closeButtonCount > 0) { + // Should have visible close buttons + expect(await closeButtons.first().isVisible()).toBe(true); + } else { + console.log('No removable tabs found in demo - this is acceptable'); + } + }); + + test('should handle tab removal with beforeRemove event', async ({ page }) => { + // Test that the beforeRemove functionality works + await page.waitForSelector('.nav-tabs'); + + // Look for tabs + const tabs = page.locator('.nav-item'); + const tabCount = await tabs.count(); + + if (tabCount > 0) { + // Tabs should be present and functional + expect(tabCount).toBeGreaterThan(0); + + // Try clicking on tabs to ensure they work + await tabs.first().click(); + + // Should have active tab + const activeTabs = page.locator('.nav-item.active, .nav-link.active'); + expect(await activeTabs.count()).toBeGreaterThan(0); + } + }); + + test('should maintain tab functionality after beforeRemove implementation', async ({ page }) => { + // Ensure basic tab functionality still works + await page.waitForSelector('.nav-tabs'); + + const tabs = page.locator('.nav-link'); + const tabCount = await tabs.count(); + + if (tabCount >= 2) { + // Click on first tab + await tabs.first().click(); + + // First tab should be active + expect(await tabs.first().getAttribute('class')).toContain('active'); + + // Click on second tab + await tabs.nth(1).click(); + + // Second tab should be active + expect(await tabs.nth(1).getAttribute('class')).toContain('active'); + + // First tab should no longer be active + expect(await tabs.first().getAttribute('class')).not.toContain('active'); + } + }); + + test('should handle keyboard navigation with removable tabs', async ({ page }) => { + await page.waitForSelector('.nav-tabs'); + + const tabs = page.locator('.nav-link'); + const tabCount = await tabs.count(); + + if (tabCount > 0) { + // Focus on first tab + await tabs.first().focus(); + + // Navigate with arrow keys + await page.keyboard.press('ArrowRight'); + + // Tab navigation should work + const focusedElement = page.locator(':focus'); + expect(await focusedElement.count()).toBeGreaterThan(0); + } + }); + + test('should support dynamic tab addition and removal', async ({ page }) => { + // Test dynamic tab operations don't break with beforeRemove + await page.waitForSelector('.nav-tabs'); + + // Look for any buttons that might add/remove tabs + const addButtons = page.locator('button:has-text("Add"), button:has-text("New")'); + const removeButtons = page.locator('button:has-text("Remove"), .bs-remove-tab'); + + const initialTabCount = await page.locator('.nav-item').count(); + + // If there are add buttons, try adding a tab + if (await addButtons.count() > 0) { + await addButtons.first().click(); + + // Tab count might increase + const newTabCount = await page.locator('.nav-item').count(); + expect(newTabCount).toBeGreaterThanOrEqual(initialTabCount); + } + + // If there are remove buttons, they should be clickable + if (await removeButtons.count() > 0) { + expect(await removeButtons.first().isVisible()).toBe(true); + } + }); + + test('should handle tab content visibility correctly', async ({ page }) => { + // Ensure tab content shows/hides properly + await page.waitForSelector('.nav-tabs'); + + const tabs = page.locator('.nav-link'); + const tabContent = page.locator('.tab-content, .tab-pane'); + + if (await tabs.count() > 0 && await tabContent.count() > 0) { + // Click on a tab + await tabs.first().click(); + + // Content should be visible + expect(await tabContent.first().isVisible()).toBe(true); + } + }); + + test('should handle edge cases for tab removal', async ({ page }) => { + // Test edge cases that might arise with beforeRemove implementation + await page.waitForSelector('.nav-tabs'); + + const tabs = page.locator('.nav-item'); + const initialTabCount = await tabs.count(); + + // Try rapid clicking on tabs + if (initialTabCount >= 2) { + await tabs.first().click(); + await tabs.nth(1).click(); + await tabs.first().click(); + + // Should still function normally + expect(await tabs.count()).toBe(initialTabCount); + } + + // Test tab focus handling + if (initialTabCount > 0) { + await tabs.first().focus(); + await page.keyboard.press('Tab'); + + // Should handle focus correctly + const focusedElement = page.locator(':focus'); + expect(await focusedElement.count()).toBeGreaterThan(0); + } + }); + + test('should preserve tab state across interactions', async ({ page }) => { + // Ensure tab state is preserved with beforeRemove functionality + await page.waitForSelector('.nav-tabs'); + + const tabs = page.locator('.nav-link'); + + if (await tabs.count() >= 2) { + // Set initial state + await tabs.first().click(); + const firstTabClass = await tabs.first().getAttribute('class'); + + // Interact with other elements + await page.click('body'); + + // Tab state should be preserved + expect(await tabs.first().getAttribute('class')).toBe(firstTabClass); + } + }); +}); \ No newline at end of file diff --git a/src/tabs/tab.directive.ts b/src/tabs/tab.directive.ts index 9de24b444c..0b2780722e 100644 --- a/src/tabs/tab.directive.ts +++ b/src/tabs/tab.directive.ts @@ -83,6 +83,8 @@ export class TabDirective implements OnInit, OnDestroy { @Output() selectTab: EventEmitter = new EventEmitter(); /** fired when tab became inactive, $event:Tab equals to deselected instance of Tab component */ @Output() deselect: EventEmitter = new EventEmitter(); + /** fired before tab will be removed, can be canceled by calling event.preventDefault() */ + @Output() beforeRemove: EventEmitter = new EventEmitter(); /** fired before tab will be removed, $event:Tab equals to instance of removed tab */ @Output() removed: EventEmitter = new EventEmitter(); diff --git a/src/tabs/tabset.component.ts b/src/tabs/tabset.component.ts index e37400092a..7512787e90 100644 --- a/src/tabs/tabset.component.ts +++ b/src/tabs/tabset.component.ts @@ -126,6 +126,23 @@ export class TabsetComponent implements OnDestroy { if (index === -1 || this.isDestroyed) { return; } + + // Emit beforeRemove event and allow cancellation + if (options.emit) { + const beforeRemoveEvent = { tab, preventDefault: false }; + // Create a preventDefault function + (beforeRemoveEvent as any).preventDefault = () => { + beforeRemoveEvent.preventDefault = true; + }; + + tab.beforeRemove.emit(beforeRemoveEvent); + + // If removal was prevented, don't continue + if (beforeRemoveEvent.preventDefault) { + return; + } + } + // Select a new tab if the tab to be removed is selected and not destroyed if (options.reselect && tab.active && this.hasAvailableTabs(index)) { const newActiveIndex = this.getClosestTabIndex(index); diff --git a/src/tabs/testing/tab-removal-cancellation.spec.ts b/src/tabs/testing/tab-removal-cancellation.spec.ts new file mode 100644 index 0000000000..fb1edfa7b6 --- /dev/null +++ b/src/tabs/testing/tab-removal-cancellation.spec.ts @@ -0,0 +1,254 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, ViewChild } from '@angular/core'; +import { TabDirective } from '../tab.directive'; +import { TabsetComponent } from '../tabset.component'; +import { TabsModule } from '../tabs.module'; + +@Component({ + template: ` + + + Tab 1 content + + + Tab 2 content + + + Tab 3 content + + + ` +}) +class TestTabRemovalComponent { + @ViewChild(TabsetComponent, { static: true }) tabset!: TabsetComponent; + + beforeRemoveCalled = false; + beforeRemoveEvent: any = null; + + onBeforeRemove(event: any) { + this.beforeRemoveCalled = true; + this.beforeRemoveEvent = event; + // Allow removal (don't call preventDefault) + } + + onBeforeRemovePrevent(event: any) { + this.beforeRemoveCalled = true; + this.beforeRemoveEvent = event; + // Prevent removal + event.preventDefault(); + } +} + +@Component({ + template: ` + + + Content + + + ` +}) +class TestTabEventsComponent { + @ViewChild(TabsetComponent, { static: true }) tabset!: TabsetComponent; + + beforeRemoveCallCount = 0; + removedCallCount = 0; + lastBeforeRemoveEvent: any = null; + lastRemovedTab: TabDirective | null = null; + + onBeforeRemove(event: any) { + this.beforeRemoveCallCount++; + this.lastBeforeRemoveEvent = event; + } + + onRemoved(tab: TabDirective) { + this.removedCallCount++; + this.lastRemovedTab = tab; + } +} + +describe('TabDirective - Removal Cancellation (Issue #786)', () => { + let component: TestTabRemovalComponent; + let fixture: ComponentFixture; + let eventsComponent: TestTabEventsComponent; + let eventsFixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TestTabRemovalComponent, TestTabEventsComponent], + imports: [TabsModule.forRoot()] + }).compileComponents(); + }); + + describe('Tab Removal Cancellation', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestTabRemovalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create tabs with beforeRemove event support', () => { + expect(component.tabset).toBeTruthy(); + expect(component.tabset.tabs.length).toBe(3); + expect(component.tabset.tabs[0].removable).toBe(true); + expect(component.tabset.tabs[1].removable).toBe(true); + expect(component.tabset.tabs[2].removable).toBe(true); + }); + + it('should emit beforeRemove event when attempting to remove tab', () => { + const firstTab = component.tabset.tabs[0]; + + // Attempt to remove first tab + component.tabset.removeTab(firstTab); + + expect(component.beforeRemoveCalled).toBe(true); + expect(component.beforeRemoveEvent).toBeTruthy(); + expect(component.beforeRemoveEvent.tab).toBe(firstTab); + }); + + it('should allow tab removal when beforeRemove event is not prevented', () => { + const initialTabCount = component.tabset.tabs.length; + const firstTab = component.tabset.tabs[0]; + + // Remove first tab (should succeed) + component.tabset.removeTab(firstTab); + + expect(component.tabset.tabs.length).toBe(initialTabCount - 1); + expect(component.tabset.tabs.indexOf(firstTab)).toBe(-1); + }); + + it('should prevent tab removal when beforeRemove event calls preventDefault', () => { + const initialTabCount = component.tabset.tabs.length; + const secondTab = component.tabset.tabs[1]; // This one prevents removal + + // Attempt to remove second tab (should be prevented) + component.tabset.removeTab(secondTab); + + expect(component.tabset.tabs.length).toBe(initialTabCount); + expect(component.tabset.tabs.indexOf(secondTab)).toBeGreaterThan(-1); + }); + + it('should provide preventDefault function in beforeRemove event', () => { + const secondTab = component.tabset.tabs[1]; + + // Attempt to remove tab that prevents removal + component.tabset.removeTab(secondTab); + + expect(component.beforeRemoveEvent).toBeTruthy(); + expect(typeof component.beforeRemoveEvent.preventDefault).toBe('function'); + expect(component.beforeRemoveEvent.preventDefault).toBe(true); // Should be set to true after calling + }); + + it('should work with keyboard deletion (Delete key)', () => { + const tabset = component.tabset; + const initialTabCount = tabset.tabs.length; + + // Simulate Delete key on first tab (index 0) - should succeed + const event = new KeyboardEvent('keydown', { key: 'Delete', keyCode: 46 }); + tabset.keyNavActions(event, 0); + + expect(tabset.tabs.length).toBe(initialTabCount - 1); + }); + + it('should prevent keyboard deletion when beforeRemove is prevented', () => { + const tabset = component.tabset; + const initialTabCount = tabset.tabs.length; + + // Simulate Delete key on second tab (index 1) - should be prevented + const event = new KeyboardEvent('keydown', { key: 'Delete', keyCode: 46 }); + tabset.keyNavActions(event, 1); + + expect(tabset.tabs.length).toBe(initialTabCount); + }); + }); + + describe('Event Sequence and Backward Compatibility', () => { + beforeEach(() => { + eventsFixture = TestBed.createComponent(TestTabEventsComponent); + eventsComponent = eventsFixture.componentInstance; + eventsFixture.detectChanges(); + }); + + it('should emit beforeRemove before removed event', () => { + const tab = eventsComponent.tabset.tabs[0]; + + eventsComponent.tabset.removeTab(tab); + + expect(eventsComponent.beforeRemoveCallCount).toBe(1); + expect(eventsComponent.removedCallCount).toBe(1); + expect(eventsComponent.lastRemovedTab).toBe(tab); + }); + + it('should not emit removed event if beforeRemove prevents removal', () => { + const tab = eventsComponent.tabset.tabs[0]; + + // Modify the handler to prevent removal + eventsComponent.onBeforeRemove = (event: any) => { + eventsComponent.beforeRemoveCallCount++; + eventsComponent.lastBeforeRemoveEvent = event; + event.preventDefault(); + }; + + eventsComponent.tabset.removeTab(tab); + + expect(eventsComponent.beforeRemoveCallCount).toBe(1); + expect(eventsComponent.removedCallCount).toBe(0); // Should not be called + expect(eventsComponent.lastRemovedTab).toBe(null); + }); + + it('should maintain backward compatibility when no beforeRemove handler is provided', () => { + // Create a tab without beforeRemove handler + const tabWithoutHandler = eventsComponent.tabset.tabs[0]; + + // Should still work normally + const initialCount = eventsComponent.tabset.tabs.length; + eventsComponent.tabset.removeTab(tabWithoutHandler); + + expect(eventsComponent.tabset.tabs.length).toBe(initialCount - 1); + }); + + it('should handle options.emit = false correctly', () => { + const tab = eventsComponent.tabset.tabs[0]; + + // Remove tab with emit = false + eventsComponent.tabset.removeTab(tab, { emit: false }); + + expect(eventsComponent.beforeRemoveCallCount).toBe(0); + expect(eventsComponent.removedCallCount).toBe(0); + expect(eventsComponent.tabset.tabs.length).toBe(0); // Tab should still be removed + }); + }); + + describe('Edge Cases', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestTabRemovalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should handle removing non-existent tab gracefully', () => { + const fakeTab = new TabDirective(component.tabset, { nativeElement: {} } as any, {} as any); + const initialCount = component.tabset.tabs.length; + + // Should not throw error or change tab count + component.tabset.removeTab(fakeTab); + + expect(component.tabset.tabs.length).toBe(initialCount); + }); + + it('should handle multiple preventDefault calls correctly', () => { + const secondTab = component.tabset.tabs[1]; + const initialCount = component.tabset.tabs.length; + + // Modify handler to call preventDefault multiple times + component.onBeforeRemovePrevent = (event: any) => { + event.preventDefault(); + event.preventDefault(); // Second call should not cause issues + }; + + component.tabset.removeTab(secondTab); + + expect(component.tabset.tabs.length).toBe(initialCount); + }); + }); +}); \ No newline at end of file