diff --git a/packages/core/src/components/tabs/tabs.tsx b/packages/core/src/components/tabs/tabs.tsx index 1183ecdb5d6..ba4a92f4e4f 100644 --- a/packages/core/src/components/tabs/tabs.tsx +++ b/packages/core/src/components/tabs/tabs.tsx @@ -25,6 +25,25 @@ import { iconChevronRightSmall, } from '@siemens/ix-icons/icons'; +type ManagedClass = + (typeof TAB_MANAGED_CLASSES)[keyof typeof TAB_MANAGED_CLASSES]; + +const TAB_MANAGED_CLASSES = { + SELECTED: 'selected', + DISABLED: 'disabled', + SMALL_TAB: 'small-tab', + ICON: 'icon', + STRETCHED: 'stretched', + BOTTOM: 'bottom', + TOP: 'top', + CIRCLE: 'circle', + HYDRATED: 'hydrated', +} as const; + +const MANAGED_CLASSES_SET = new Set( + Object.values(TAB_MANAGED_CLASSES) as ManagedClass[] +); + @Component({ tag: 'ix-tabs', styleUrl: 'tabs.scss', @@ -86,6 +105,8 @@ export class Tabs { private windowStartSize = window.innerWidth; private resizeObserver?: ResizeObserver; + private classObserver?: MutationObserver; + private updateScheduled = false; private clickAction: { timeout: NodeJS.Timeout | null; @@ -127,6 +148,130 @@ export class Tabs { this.resizeObserver.observe(parentElement); } + private observeSlotChanges() { + this.classObserver?.disconnect(); + + this.classObserver = new MutationObserver(() => { + this.scheduleTabUpdate(); + }); + + this.classObserver.observe(this.hostElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class'], + }); + } + + private scheduleTabUpdate() { + if (this.updateScheduled) return; + this.updateScheduled = true; + + requestAnimationFrame(() => { + this.updateTabAttributes(); + this.updateScheduled = false; + }); + } + + private setTabAttributes(element: HTMLIxTabItemElement, index: number) { + const isSelected = index === this.selected; + const isDisabled = element.disabled; + + if (this.small) element.setAttribute('small', 'true'); + + if (this.rounded) element.setAttribute('rounded', 'true'); + + element.setAttribute('layout', this.layout); + element.setAttribute('selected', isSelected.toString()); + element.setAttribute('placement', this.placement); + element.toggleAttribute('disabled', isDisabled); + + this.applyRequiredClasses(element, isSelected, isDisabled); + } + + private applyRequiredClasses( + element: HTMLIxTabItemElement, + isSelected: boolean, + isDisabled: boolean + ) { + const requiredClasses = new Set( + this.buildRequiredClasses(isSelected, isDisabled) + ); + const { classList } = element; + + for (const cls of requiredClasses) { + classList.add(cls); + } + + for (const managedClass of MANAGED_CLASSES_SET) { + if (!requiredClasses.has(managedClass)) { + classList.remove(managedClass); + } + } + } + + private buildRequiredClasses( + isSelected: boolean, + isDisabled: boolean + ): string[] { + const classConditions = { + [TAB_MANAGED_CLASSES.HYDRATED]: true, + [TAB_MANAGED_CLASSES.SELECTED]: isSelected, + [TAB_MANAGED_CLASSES.DISABLED]: isDisabled, + [TAB_MANAGED_CLASSES.SMALL_TAB]: this.small, + [TAB_MANAGED_CLASSES.STRETCHED]: this.layout === 'stretched', + [TAB_MANAGED_CLASSES.BOTTOM]: this.placement === 'bottom', + [TAB_MANAGED_CLASSES.TOP]: this.placement === 'top', + [TAB_MANAGED_CLASSES.CIRCLE]: this.rounded, + }; + + return Object.entries(classConditions) + .filter(([, condition]) => condition) + .map(([className]) => className); + } + + private ensureSelectedIndex() { + if (this.totalItems === 0) { + console.warn('ix-tabs: No tabs available for selection'); + this.selected = -1; + return; + } + + if (this.selected < this.totalItems) { + return; + } + + const originalIndex = this.selected; + const previousIndex = originalIndex - 1; + + if (previousIndex >= 0 && previousIndex < this.totalItems) { + this.updateSelected(previousIndex); + return; + } + + if (this.totalItems > 0) { + this.updateSelected(0); + } + } + + private updateSelected(index: number) { + this.selected = index; + this.selectedChange.emit(index); + } + + private updateTabAttributes() { + const tabs = this.getTabs(); + this.totalItems = tabs.length; + + this.ensureSelectedIndex(); + + for (const [index, element] of tabs.entries()) { + this.setTabAttributes(element, index); + } + + this.renderArrows(); + } + private showArrows() { try { const tabWrapper = this.getTabsWrapper(); @@ -270,35 +415,11 @@ export class Tabs { } componentWillLoad() { - const tabs = this.getTabs(); - - tabs.map((element, index) => { - if (this.small) element.setAttribute('small', 'true'); - - if (this.rounded) element.setAttribute('rounded', 'true'); - - element.setAttribute('layout', this.layout); - element.setAttribute( - 'selected', - index === this.selected ? 'true' : 'false' - ); - - element.setAttribute('placement', this.placement); - }); - this.initResizeObserver(); } componentDidRender() { - const tabs = this.getTabs(); - this.totalItems = tabs.length; - - tabs.map((element, index) => { - element.setAttribute( - 'selected', - index === this.selected ? 'true' : 'false' - ); - }); + this.updateTabAttributes(); } componentWillRender() { @@ -319,10 +440,13 @@ export class Tabs { this.dragStart(element, event) ); }); + + this.observeSlotChanges(); } disconnectedCallback() { this.resizeObserver?.disconnect(); + this.classObserver?.disconnect(); } @Listen('tabClick') diff --git a/packages/core/src/components/tabs/test/tabs.ct.ts b/packages/core/src/components/tabs/test/tabs.ct.ts index 23275dfb5b9..3e8bda72d4e 100644 --- a/packages/core/src/components/tabs/test/tabs.ct.ts +++ b/packages/core/src/components/tabs/test/tabs.ct.ts @@ -20,8 +20,8 @@ regressionTest('renders', async ({ mount, page }) => { const tabs = page.locator('ix-tabs'); const tab = page.locator('ix-tab-item').nth(0); - await expect(tabs).toHaveClass(/hydrated/); - await expect(tab).toHaveClass(/selected/); + await expect(tabs).toHaveClass(/\bhydrated\b/); + await expect(tab).toHaveClass(/\bselected\b/); }); regressionTest('should change tab', async ({ mount, page }) => { @@ -37,8 +37,8 @@ regressionTest('should change tab', async ({ mount, page }) => { await tab.click(); - await expect(tabs).toHaveClass(/hydrated/); - await expect(tab).toHaveClass(/selected/); + await expect(tabs).toHaveClass(/\bhydrated\b/); + await expect(tab).toHaveClass(/\bselected\b/); }); regressionTest( @@ -63,9 +63,9 @@ regressionTest( await lastTab.click(); - await expect(tabs).toHaveClass(/hydrated/); - await expect(firstTab).toHaveClass(/selected/); - await expect(lastTab).not.toHaveClass(/selected/); + await expect(tabs).toHaveClass(/\bhydrated\b/); + await expect(firstTab).toHaveClass(/\bselected\b/); + await expect(lastTab).not.toHaveClass(/\bselected\b/); } ); @@ -94,14 +94,14 @@ regressionTest( ); }); - await expect(tabs).toHaveClass(/hydrated/); - await expect(firstTab).toHaveClass(/selected/); + await expect(tabs).toHaveClass(/\bhydrated\b/); + await expect(firstTab).toHaveClass(/\bselected\b/); await secondTab.click(); await lastTab.click(); - await expect(secondTab).toHaveClass(/selected/); - await expect(lastTab).not.toHaveClass(/selected/); + await expect(secondTab).toHaveClass(/\bselected\b/); + await expect(lastTab).not.toHaveClass(/\bselected\b/); } ); @@ -127,9 +127,9 @@ regressionTest( await lastTab.click(); - await expect(tabs).toHaveClass(/hydrated/); - await expect(firstTab).toHaveClass(/selected/); - await expect(lastTab).not.toHaveClass(/selected/); + await expect(tabs).toHaveClass(/\bhydrated\b/); + await expect(firstTab).toHaveClass(/\bselected\b/); + await expect(lastTab).not.toHaveClass(/\bselected\b/); } ); @@ -172,3 +172,217 @@ regressionTest( await expect(lastTab).not.toBeInViewport(); } ); + +regressionTest( + 'dynamic tabs - should preserve default classes when adding custom classes during re-render', + async ({ mount, page }) => { + await mount(` + + Item 1 + Item 2 + + `); + + await page.evaluate(() => { + const tabsElement = document.querySelector('ix-tabs'); + tabsElement!.innerHTML = ` + Item 1 + Item 2 + `; + }); + + const tabs = page.locator('ix-tab-item'); + + for (const className of ['new', 'hydrated', 'bottom']) { + await expect(tabs.nth(0)).toHaveClass(new RegExp(`\\b${className}\\b`)); + await expect(tabs.nth(1)).toHaveClass(new RegExp(`\\b${className}\\b`)); + } + await expect(tabs.nth(0)).toHaveClass(/\bselected\b/); + + await tabs.nth(1).click(); + await expect(tabs.nth(0)).not.toHaveClass(/\bselected\b/); + await expect(tabs.nth(1)).toHaveClass(/\bselected\b/); + } +); + +regressionTest( + 'dynamic tabs - should preserve top placement classes when adding custom classes', + async ({ mount, page }) => { + await mount(` + + Item 1 + Item 2 + + `); + + await page.evaluate(() => { + const tabsElement = document.querySelector('ix-tabs'); + tabsElement!.innerHTML = ` + Item 1 + Item 2 + `; + }); + + const tabs = page.locator('ix-tab-item'); + + for (const className of ['new', 'hydrated', 'top']) { + await expect(tabs.nth(0)).toHaveClass(new RegExp(`\\b${className}\\b`)); + await expect(tabs.nth(1)).toHaveClass(new RegExp(`\\b${className}\\b`)); + } + } +); + +regressionTest( + 'dynamic tabs - should preserve stretched layout classes when adding custom classes', + async ({ mount, page }) => { + await mount(` + + Item 1 + Item 2 + + `); + + await page.evaluate(() => { + const tabsElement = document.querySelector('ix-tabs'); + tabsElement!.innerHTML = ` + Item 1 + Item 2 + `; + }); + + const tabs = page.locator('ix-tab-item'); + + for (const className of ['new', 'hydrated', 'bottom', 'stretched']) { + await expect(tabs.nth(0)).toHaveClass(new RegExp(`\\b${className}\\b`)); + await expect(tabs.nth(1)).toHaveClass(new RegExp(`\\b${className}\\b`)); + } + } +); + +regressionTest( + 'dynamic tabs - should preserve classes and reselect when reducing tab count with new class', + async ({ mount, page }) => { + await mount(` + + Tab 1 + Tab 2 + Tab 3 + + `); + + const thirdTab = page.locator('ix-tab-item').nth(2); + await thirdTab.click(); + await expect(thirdTab).toHaveClass(/\bselected\b/); + + await page.evaluate(() => { + const tabsElement = document.querySelector('ix-tabs'); + tabsElement!.innerHTML = ` + Tab 1 + Tab 2 + `; + }); + + const tabs = page.locator('ix-tab-item'); + + for (const className of [ + 'new', + 'hydrated', + 'bottom', + 'stretched', + 'selected', + ]) { + await expect(tabs.nth(1)).toHaveClass(new RegExp(`\\b${className}\\b`)); + } + } +); + +regressionTest( + 'dynamic tabs - should handle disabled state and class changes', + async ({ mount, page }) => { + await mount(` + + Tab 1 + Tab 2 + + `); + + await page.evaluate(() => { + const tabsElement = document.querySelector('ix-tabs'); + tabsElement!.innerHTML = ` + Tab 1 + Tab 2 + `; + }); + + const tabs = page.locator('ix-tab-item'); + + for (const className of ['new', 'hydrated', 'bottom']) { + await expect(tabs.nth(0)).toHaveClass(new RegExp(`\\b${className}\\b`)); + await expect(tabs.nth(1)).toHaveClass(new RegExp(`\\b${className}\\b`)); + } + + await expect(tabs.nth(0)).toHaveAttribute('disabled', ''); + await expect(tabs.nth(0)).toHaveClass(/\bdisabled\b/); + await expect(tabs.nth(0)).toHaveClass(/\bselected\b/); + + await expect(tabs.nth(1)).not.toHaveAttribute('disabled'); + await expect(tabs.nth(1)).not.toHaveClass(/\bdisabled\b/); + await expect(tabs.nth(1)).not.toHaveClass(/\bselected\b/); + + await tabs.nth(1).click(); + await expect(tabs.nth(0)).not.toHaveClass(/\bselected\b/); + await expect(tabs.nth(1)).toHaveClass(/\bselected\b/); + } +); + +regressionTest('tab re-selection algorithm', async ({ mount, page }) => { + await mount(` + + Tab 1 + Tab 2 + Tab 3 + Tab 4 + Tab 5 + + `); + + await page.locator('ix-tab-item').nth(2).click(); + + await page.evaluate(() => { + document.querySelector('ix-tabs')!.innerHTML = ` + Tab 1 + Tab 2 + Tab 4 + Tab 5 + `; + }); + + await expect(page.locator('ix-tab-item').nth(2)).toHaveClass(/\bselected\b/); + + await page.locator('ix-tab-item').nth(3).click(); + + await page.evaluate(() => { + document.querySelector('ix-tabs')!.innerHTML = ` + Tab 1 + Tab 2 + Tab 4 + `; + }); + + await expect(page.locator('ix-tab-item').nth(2)).toHaveClass(/\bselected\b/); + + await page.locator('ix-tab-item').nth(1).click(); + + await page.evaluate(() => { + document.querySelector('ix-tabs')!.innerHTML = + `Tab 1`; + }); + + await expect(page.locator('ix-tab-item').nth(0)).toHaveClass(/\bselected\b/); + + await page.evaluate(() => { + document.querySelector('ix-tabs')!.innerHTML = ''; + }); + + await expect(page.locator('ix-tab-item')).toHaveCount(0); +}); diff --git a/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-dark-linux.png b/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-dark-linux.png index e13b6670cdf..568c350f83d 100644 Binary files a/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-dark-linux.png and b/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-dark-linux.png differ diff --git a/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-light-linux.png b/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-light-linux.png index c8e73d73c0a..fd796d23d8d 100644 Binary files a/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-light-linux.png and b/testing/visual-testing/__screenshots__/tests/tabs/tabs.e2e.ts/tabs-basic-1-chromium---classic-light-linux.png differ