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