diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts
index 3d0c621a8..e152792ef 100644
--- a/src/components/common/definitions/defineAllComponents.ts
+++ b/src/components/common/definitions/defineAllComponents.ts
@@ -37,6 +37,9 @@ import IgcSliderComponent from '../../slider/slider.js';
import IgcSnackbarComponent from '../../snackbar/snackbar.js';
import IgcToastComponent from '../../toast/toast.js';
import IgcSliderLabelComponent from '../../slider/slider-label.js';
+import IgcTabsComponent from '../../tabs/tabs.js';
+import IgcTabComponent from '../../tabs/tab.js';
+import IgcTabPanelComponent from '../../tabs/tab-panel.js';
import { defineComponents } from './defineComponents.js';
import IgcCircularGradientComponent from '../../progress/circular-gradient.js';
import IgcDateTimeInputComponent from '../../date-time-input/date-time-input.js';
@@ -86,6 +89,9 @@ const allComponents: CustomElementConstructor[] = [
IgcToastComponent,
IgcSliderLabelComponent,
IgcRangeSliderComponent,
+ IgcTabsComponent,
+ IgcTabComponent,
+ IgcTabPanelComponent,
IgcCircularProgressComponent,
IgcLinearProgressComponent,
IgcCircularGradientComponent,
diff --git a/src/components/common/util.ts b/src/components/common/util.ts
index 3fb8eb9c6..36755083c 100644
--- a/src/components/common/util.ts
+++ b/src/components/common/util.ts
@@ -12,3 +12,39 @@ export const asPercent = (part: number, whole: number) => (part / whole) * 100;
export const clamp = (number: number, min: number, max: number) =>
Math.max(min, Math.min(number, max));
+
+/**
+ *
+ * Returns an element's offset relative to its parent. Similar to element.offsetTop and element.offsetLeft, except the
+ * parent doesn't have to be positioned relative or absolute.
+ *
+ * Work around for the following issues in Chromium based browsers:
+ *
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=1330819
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=1334556
+ *
+ */
+export function getOffset(element: HTMLElement, parent: HTMLElement) {
+ const { top, left, bottom, right } = element.getBoundingClientRect();
+ const {
+ top: pTop,
+ left: pLeft,
+ bottom: pBottom,
+ right: pRight,
+ } = parent.getBoundingClientRect();
+
+ return {
+ top: Math.round(top - pTop),
+ left: Math.round(left - pLeft),
+ right: Math.round(right - pRight),
+ bottom: Math.round(bottom - pBottom),
+ };
+}
+
+export function createCounter(start = 0) {
+ let i = start;
+ return function () {
+ i++;
+ return i;
+ };
+}
diff --git a/src/components/tabs/tab-panel.ts b/src/components/tabs/tab-panel.ts
new file mode 100644
index 000000000..899af1d98
--- /dev/null
+++ b/src/components/tabs/tab-panel.ts
@@ -0,0 +1,38 @@
+import { html, LitElement } from 'lit';
+import { createCounter } from '../common/util.js';
+import { styles } from './themes/light/tab-panel.base.css.js';
+
+/**
+ * Represents the content of a tab
+ *
+ * @element igc-tab-panel
+ *
+ * @slot - Renders the content.
+ */
+export default class IgcTabPanelComponent extends LitElement {
+ public static readonly tagName = 'igc-tab-panel';
+
+ public static override styles = styles;
+
+ private static readonly increment = createCounter();
+
+ public override connectedCallback() {
+ this.setAttribute('role', 'tabpanel');
+ this.tabIndex = this.hasAttribute('tabindex') ? this.tabIndex : 0;
+ this.slot = this.slot.length > 0 ? this.slot : 'panel';
+ this.id =
+ this.id.length > 0
+ ? this.id
+ : `igc-tab-panel-${IgcTabPanelComponent.increment()}`;
+ }
+
+ protected override render() {
+ return html``;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'igc-tab-panel': IgcTabPanelComponent;
+ }
+}
diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts
new file mode 100644
index 000000000..8bee31161
--- /dev/null
+++ b/src/components/tabs/tab.ts
@@ -0,0 +1,85 @@
+import { html, LitElement } from 'lit';
+import { property, query } from 'lit/decorators.js';
+import { themes } from '../../theming/theming-decorator.js';
+import { createCounter } from '../common/util.js';
+import { styles } from './themes/light/tab.base.css.js';
+import { styles as bootstrap } from './themes/light/tab.bootstrap.css.js';
+import { styles as fluent } from './themes/light/tab.fluent.css.js';
+import { styles as indigo } from './themes/light/tab.indigo.css.js';
+
+/**
+ * Represents the tab header.
+ *
+ * @element igc-tab
+ *
+ * @slot prefix - Renders before the tab header content.
+ * @slot - Renders the tab header content.
+ * @slot suffix - Renders after the tab header content.
+ *
+ * @csspart content - The content wrapper.
+ * @csspart prefix - The prefix wrapper.
+ * @csspart suffix - The suffix wrapper.
+ */
+@themes({ bootstrap, fluent, indigo })
+export default class IgcTabComponent extends LitElement {
+ public static readonly tagName = 'igc-tab';
+
+ public static override styles = styles;
+
+ private static readonly increment = createCounter();
+
+ @query('[part="base"]', true)
+ private tab!: HTMLElement;
+
+ /** The id of the tab panel which will be controlled by the tab. */
+ @property({ type: String })
+ public panel = '';
+
+ /** Determines whether the tab is selected. */
+ @property({ type: Boolean, reflect: true })
+ public selected = false;
+
+ /** Determines whether the tab is disabled. */
+ @property({ type: Boolean, reflect: true })
+ public disabled = false;
+
+ public override connectedCallback(): void {
+ super.connectedCallback();
+ this.id =
+ this.id.length > 0 ? this.id : `igc-tab-${IgcTabComponent.increment()}`;
+ }
+
+ /** Sets focus to the tab. */
+ public override focus(options?: FocusOptions) {
+ this.tab.focus(options);
+ }
+
+ /** Removes focus from the tab. */
+ public override blur() {
+ this.tab.blur();
+ }
+
+ protected override render() {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'igc-tab': IgcTabComponent;
+ }
+}
diff --git a/src/components/tabs/tabs.spec.ts b/src/components/tabs/tabs.spec.ts
new file mode 100644
index 000000000..07a90edfd
--- /dev/null
+++ b/src/components/tabs/tabs.spec.ts
@@ -0,0 +1,631 @@
+import {
+ elementUpdated,
+ expect,
+ fixture,
+ html,
+ waitUntil,
+} from '@open-wc/testing';
+import { TemplateResult } from 'lit';
+import sinon from 'sinon';
+import {
+ defineComponents,
+ IgcTabsComponent,
+ IgcTabComponent,
+ IgcTabPanelComponent,
+ IgcIconButtonComponent,
+} from '../../index.js';
+
+describe('Tabs component', () => {
+ // Helper functions
+ const getTabs = (tabs: IgcTabsComponent) =>
+ Array.from(tabs.querySelectorAll(IgcTabComponent.tagName));
+
+ const getPanels = (tabs: IgcTabsComponent) =>
+ Array.from(tabs.querySelectorAll(IgcTabPanelComponent.tagName));
+
+ const getSelectedTab = (tabs: IgcTabsComponent) => {
+ const collection = getTabs(tabs).filter((tab) => tab.selected);
+ expect(collection.length).to.equal(1);
+ return collection.at(0) as IgcTabComponent;
+ };
+
+ const getSelectedPanel = (tabs: IgcTabsComponent) => {
+ const collection = getPanels(tabs).filter(
+ (panel) => panel.style.display === 'block'
+ );
+ expect(collection.length).to.equal(1);
+ return collection.at(0) as IgcTabPanelComponent;
+ };
+
+ const getScrollContainer = (tabs: IgcTabsComponent) =>
+ tabs.renderRoot.querySelector('[part="headers-scroll"]') as HTMLElement;
+
+ const fireKeyboardEvent = (key: string) =>
+ new KeyboardEvent('keydown', { key, bubbles: true, composed: true });
+
+ const verifySelection = (element: IgcTabsComponent, selection: string) => {
+ expect(element.selected).to.equal(selection);
+ expect(getSelectedTab(element).panel).to.equal(selection);
+ expect(getSelectedPanel(element).id).to.equal(selection);
+ };
+
+ before(() => {
+ defineComponents(IgcTabsComponent, IgcTabComponent, IgcTabPanelComponent);
+ });
+
+ let element: IgcTabsComponent;
+
+ describe('', () => {
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('is initialized with the proper default values', async () => {
+ expect(document.querySelector('igc-tabs')).to.exist;
+ expect(element.selected).to.be.empty;
+ expect(element.alignment).to.eq('start');
+ expect(element.activation).to.eq('auto');
+ });
+ });
+
+ describe('', () => {
+ beforeEach(async () => {
+ element = await fixture(html`
+ Tab 1
+ Tab 2
+ Tab 3
+ Tab 4
+ Content 1
+ Content 2
+ Content 2
+ `);
+ });
+
+ it('is accessible', async () => {
+ await expect(element).to.be.accessible();
+ });
+
+ it('selects the first enabled tab when nothing else is specified', async () => {
+ verifySelection(element, 'second');
+ });
+
+ it('selects a tab on mouse click if it is not disabled', async () => {
+ getTabs(element)[0].click();
+ await elementUpdated(element);
+
+ verifySelection(element, 'second');
+
+ getTabs(element)[2].click();
+ await elementUpdated(element);
+
+ verifySelection(element, 'third');
+ });
+
+ it('`select` method selects the specified tab', async () => {
+ element.select('third');
+ await elementUpdated(element);
+
+ verifySelection(element, 'third');
+
+ element.select(getTabs(element)[1].panel);
+ await elementUpdated(element);
+
+ verifySelection(element, 'second');
+ });
+
+ it('`select` method does not change currently selected tab if the specified value does not exist.', async () => {
+ element.select('test');
+ await elementUpdated(element);
+
+ verifySelection(element, 'second');
+ });
+
+ it('selects next/previous tab when pressing right/left arrow', async () => {
+ getTabs(element)[1].click();
+ getScrollContainer(element).dispatchEvent(
+ fireKeyboardEvent('ArrowRight')
+ );
+ await elementUpdated(element);
+ verifySelection(element, 'third');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('ArrowLeft'));
+ await elementUpdated(element);
+
+ verifySelection(element, 'second');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('ArrowLeft'));
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq('forth');
+ expect(getSelectedTab(element).panel).to.eq('forth');
+ });
+
+ it('selects next/previous tab when pressing right/left arrow (RTL)', async () => {
+ element.dir = 'rtl';
+ getTabs(element)[1].focus();
+ getScrollContainer(element).dispatchEvent(
+ fireKeyboardEvent('ArrowRight')
+ );
+ await elementUpdated(element);
+ expect(element.selected).to.eq('forth');
+ expect(getSelectedTab(element).panel).to.eq('forth');
+
+ getScrollContainer(element).dispatchEvent(
+ fireKeyboardEvent('ArrowRight')
+ );
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq('third');
+ expect(getSelectedTab(element).panel).to.eq('third');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('ArrowLeft'));
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq('forth');
+ expect(getSelectedTab(element).panel).to.eq('forth');
+ });
+
+ it('selects first/last enabled tab when pressing home/end keys', async () => {
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('End'));
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq('forth');
+ expect(getSelectedTab(element).panel).to.eq('forth');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('Home'));
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq('second');
+ expect(getSelectedTab(element).panel).to.eq('second');
+ });
+
+ it('only focuses the corresponding tab when activation is manual and navigating with keyboard', async () => {
+ element.activation = 'manual';
+ await elementUpdated(element);
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('End'));
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq('second');
+ expect(element.querySelectorAll('igc-tab')[3]).to.eq(
+ document.activeElement
+ );
+ });
+
+ it('selects the focused tab when activation is set to `manual` and space/enter is pressed', async () => {
+ element.activation = 'manual';
+ await elementUpdated(element);
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('End'));
+
+ expect(element.selected).to.eq('second');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent(' '));
+ await elementUpdated(element);
+
+ expect(getSelectedTab(element).panel).to.eq('forth');
+ expect(element.selected).to.eq('forth');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('Home'));
+
+ expect(element.selected).to.eq('forth');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('Enter'));
+ await elementUpdated(element);
+
+ expect(getSelectedTab(element).panel).to.eq('second');
+ expect(element.selected).to.eq('second');
+ });
+
+ it('selected indicator align with the selected tab', async () => {
+ const indicator = element.shadowRoot?.querySelector(
+ '[part = "selected-indicator"]'
+ ) as HTMLElement;
+
+ expect(indicator.style.transform).to.eq('translate(90px)');
+ expect(indicator.style.width).to.eq(
+ getSelectedTab(element).offsetWidth + 'px'
+ );
+
+ element.alignment = 'justify';
+ await elementUpdated(element);
+
+ element.select('forth');
+ await elementUpdated(element);
+
+ const offsetLeft = getSelectedTab(element).offsetLeft + 'px';
+ expect(indicator.style.transform).to.eq(`translate(${offsetLeft})`);
+ expect(indicator.style.width).to.eq(
+ getSelectedTab(element).offsetWidth + 'px'
+ );
+ });
+
+ it('selected indicator align with the selected tab (RTL)', async () => {
+ element.dir = 'rtl';
+ await elementUpdated(element);
+
+ const indicator = element.shadowRoot?.querySelector(
+ '[part = "selected-indicator"]'
+ ) as HTMLElement;
+
+ expect(indicator.style.transform).to.eq('translate(90px)');
+ expect(indicator.style.width).to.eq(
+ getSelectedTab(element).offsetWidth + 'px'
+ );
+
+ element.alignment = 'justify';
+ await elementUpdated(element);
+
+ element.select('forth');
+ await elementUpdated(element);
+
+ const offsetLeft =
+ getSelectedTab(element).offsetLeft +
+ getSelectedTab(element).offsetWidth -
+ element.clientWidth +
+ 'px';
+ expect(indicator.style.transform).to.eq(`translate(${offsetLeft})`);
+ expect(indicator.style.width).to.eq(
+ getSelectedTab(element).offsetWidth + 'px'
+ );
+ });
+
+ it('emits `igcChange` when selecting item via mouse click', async () => {
+ const eventSpy = sinon.spy(element, 'emitEvent');
+
+ getTabs(element)[3].click();
+ await elementUpdated(element);
+
+ expect(eventSpy).calledWithExactly('igcChange', {
+ detail: getSelectedTab(element),
+ });
+ });
+
+ it('emits `igcChange` when selecting item via arrow key press', async () => {
+ const eventSpy = sinon.spy(element, 'emitEvent');
+
+ getScrollContainer(element).dispatchEvent(fireKeyboardEvent('ArrowLeft'));
+ await elementUpdated(element);
+
+ expect(eventSpy).calledWithExactly('igcChange', {
+ detail: getSelectedTab(element),
+ });
+ });
+
+ it('does not display scroll buttons if alignment is justify', async () => {
+ const startScrollButton = element.shadowRoot?.querySelector(
+ 'igc-icon-button[part="start-scroll-button"]'
+ );
+
+ const endScrollButton = element.shadowRoot?.querySelector(
+ 'igc-icon-button[part="end-scroll-button"]'
+ );
+
+ element.alignment = 'justify';
+ await elementUpdated(element);
+
+ expect(startScrollButton).to.be.null;
+ expect(endScrollButton).to.be.null;
+ });
+
+ it('aligns tab headers properly when `alignment` is set to justify', async () => {
+ element.alignment = 'justify';
+ await elementUpdated(element);
+
+ const diffs: number[] = [];
+ const expectedWidth = Math.round(
+ getScrollContainer(element).offsetWidth / getTabs(element).length
+ );
+ getTabs(element).map((elem) =>
+ diffs.push(elem.offsetWidth - expectedWidth)
+ );
+ const result = diffs.reduce((a, b) => a - b);
+ expect(result).to.eq(0);
+ });
+
+ it('aligns tab headers properly when `alignment` is set to start', async () => {
+ element.alignment = 'start';
+ await elementUpdated(element);
+
+ const widths: number[] = [];
+ getTabs(element).map((elem) => widths.push(elem.offsetWidth));
+
+ const result = widths.reduce((a, b) => a + b);
+ const noTabsAreaWidth = getScrollContainer(element).offsetWidth - result;
+ const offsetRight =
+ getScrollContainer(element).offsetWidth -
+ getTabs(element)[3].offsetLeft -
+ getTabs(element)[3].offsetWidth;
+
+ expect(getTabs(element)[0].offsetLeft).to.eq(0);
+ expect(offsetRight - noTabsAreaWidth).to.eq(0);
+ expect(Math.abs(90 - widths[0])).to.eq(0);
+ expect(Math.abs(90 - widths[1])).to.eq(0);
+ expect(Math.abs(90 - widths[2])).to.eq(0);
+ expect(Math.abs(90 - widths[3])).to.eq(0);
+ });
+
+ it('aligns tab headers properly when `alignment` is set to center', async () => {
+ element.alignment = 'center';
+ await elementUpdated(element);
+
+ const widths: number[] = [];
+ getTabs(element).map((elem) => widths.push(elem.offsetWidth));
+
+ const result = widths.reduce((a, b) => a + b);
+ const noTabsAreaWidth = getScrollContainer(element).offsetWidth - result;
+ const offsetRight =
+ getScrollContainer(element).offsetWidth -
+ getTabs(element)[3].offsetLeft -
+ getTabs(element)[3].offsetWidth;
+
+ expect(
+ Math.round(noTabsAreaWidth / 2) - getTabs(element)[0].offsetLeft
+ ).to.eq(0);
+ expect(offsetRight - getTabs(element)[0].offsetLeft).to.eq(0);
+ expect(Math.abs(90 - widths[0])).to.eq(0);
+ expect(Math.abs(90 - widths[1])).to.eq(0);
+ expect(Math.abs(90 - widths[2])).to.eq(0);
+ expect(Math.abs(90 - widths[3])).to.eq(0);
+ });
+
+ it('aligns tab headers properly when `alignment` is set to end', async () => {
+ element.alignment = 'end';
+ await elementUpdated(element);
+
+ const widths: number[] = [];
+ getTabs(element).map((elem) => widths.push(elem.offsetWidth));
+
+ const result = widths.reduce((a, b) => a + b);
+ const noTabsAreaWidth = getScrollContainer(element).offsetWidth - result;
+ const offsetRight =
+ getScrollContainer(element).offsetWidth -
+ getTabs(element)[3].offsetLeft -
+ getTabs(element)[3].offsetWidth;
+
+ expect(offsetRight).to.eq(0);
+ expect(getTabs(element)[0].offsetLeft - noTabsAreaWidth).to.eq(0);
+ expect(Math.abs(90 - widths[0])).to.eq(0);
+ expect(Math.abs(90 - widths[1])).to.eq(0);
+ expect(Math.abs(90 - widths[2])).to.eq(0);
+ expect(Math.abs(90 - widths[3])).to.eq(0);
+ });
+
+ it('updates selection through tab element `selected` attribute', async () => {
+ getTabs(element).at(2)!.selected = true;
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq(getTabs(element)[2].panel);
+ });
+
+ it('updates selection state when removing selected tab', async () => {
+ element.select('third');
+ await elementUpdated(element);
+
+ getSelectedTab(element).remove();
+ await elementUpdated(element);
+
+ expect(element.selected).to.equal('');
+ expect(
+ element.querySelectorAll(`${IgcTabComponent.tagName}[selected]`).length
+ ).to.equal(0);
+ });
+
+ it('updates selected state when adding tabs at runtime', async () => {
+ let tab = document.createElement(IgcTabComponent.tagName);
+ tab.panel = 'new-selection';
+ tab.selected = true;
+
+ element.appendChild(tab);
+ await elementUpdated(element);
+
+ expect(element.selected).to.eq(tab.panel);
+
+ tab = document.createElement(IgcTabComponent.tagName);
+ tab.panel = 'new-selection-2';
+
+ element.appendChild(tab);
+ await elementUpdated(element);
+
+ expect(element.selected).not.to.eq(tab.panel);
+ });
+
+ it('keeps current selection when removing other tabs', async () => {
+ element.select('third');
+ await elementUpdated(element);
+
+ getTabs(element)
+ .slice(0, 2)
+ .forEach((el) => el.remove());
+ await elementUpdated(element);
+
+ expect(element.selected).to.equal('third');
+ expect(getSelectedTab(element).panel).to.equal(element.selected);
+ });
+ });
+
+ describe('Scrolling', () => {
+ beforeEach(async () => {
+ const headers: TemplateResult[] = [];
+ const panels: TemplateResult[] = [];
+ for (let i = 1; i <= 18; i++) {
+ headers.push(
+ html`Item ${i}`
+ );
+ panels.push(html`Content ${i}`);
+ }
+ element = await fixture(
+ html`${headers}${panels}`
+ );
+ });
+
+ const startScrollButton = (el: IgcTabsComponent) =>
+ el.shadowRoot?.querySelector(
+ 'igc-icon-button[part="start-scroll-button"]'
+ ) as IgcIconButtonComponent;
+
+ const endScrollButton = (el: IgcTabsComponent) =>
+ el.shadowRoot?.querySelector(
+ 'igc-icon-button[part="end-scroll-button"]'
+ ) as IgcIconButtonComponent;
+
+ it('displays scroll buttons', async () => {
+ expect(startScrollButton(element)).to.not.be.null;
+ expect(endScrollButton(element)).to.not.be.null;
+
+ element.select('18');
+ await elementUpdated(element);
+
+ expect(startScrollButton(element)).to.not.be.null;
+ expect(endScrollButton(element)).to.not.be.null;
+
+ element.select('9');
+ await elementUpdated(element);
+ expect(startScrollButton(element)).to.not.be.null;
+ expect(endScrollButton(element)).to.not.be.null;
+ });
+
+ it('scrolls to start when start scroll button is clicked', async () => {
+ element.select('18');
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => endScrollButton(element).disabled,
+ 'End scroll button is not disabled at end of scroll'
+ );
+
+ startScrollButton(element).click();
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => !endScrollButton(element).disabled,
+ 'End scroll button is disabled on opposite scroll'
+ );
+ });
+
+ it('scrolls to end when end scroll button is clicked', async () => {
+ element.select('1');
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => startScrollButton(element).disabled,
+ 'Start scroll button is not disabled at end of scroll'
+ );
+
+ endScrollButton(element).click();
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => !startScrollButton(element).disabled,
+ 'Start scroll button is disabled on opposite scroll'
+ );
+ });
+
+ it('scrolls when tab is partially visible', async () => {
+ const header = element.querySelector('igc-tab')!;
+ header.textContent =
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit';
+ element.style.width = '300px';
+ await elementUpdated(element);
+
+ endScrollButton(element).click();
+ await elementUpdated(element);
+ await waitUntil(
+ () => !startScrollButton(element).disabled,
+ 'Start scroll button is disabled on opposite scroll'
+ );
+ });
+
+ it('displays scroll buttons (RTL)', async () => {
+ element.setAttribute('dir', 'rtl');
+ await elementUpdated(element);
+
+ expect(startScrollButton(element)).to.not.be.null;
+ expect(endScrollButton(element)).to.not.be.null;
+
+ element.select('18');
+ await elementUpdated(element);
+
+ expect(startScrollButton(element)).to.not.be.null;
+ expect(endScrollButton(element)).to.not.be.null;
+
+ element.select('9');
+ await elementUpdated(element);
+ expect(startScrollButton(element)).to.not.be.null;
+ expect(endScrollButton(element)).to.not.be.null;
+ });
+
+ it('scrolls to start when start scroll button is clicked (RTL)', async () => {
+ element.setAttribute('dir', 'rtl');
+ await elementUpdated(element);
+
+ element.select('18');
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => endScrollButton(element).disabled,
+ 'End scroll button is not disabled at end of scroll'
+ );
+
+ startScrollButton(element).click();
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => !endScrollButton(element).disabled,
+ 'End scroll button is disabled on opposite scroll'
+ );
+ });
+
+ it('scrolls to end when end scroll button is clicked (RTL)', async () => {
+ element.setAttribute('dir', 'rtl');
+ await elementUpdated(element);
+
+ element.select('1');
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => startScrollButton(element).disabled,
+ 'Start scroll button is not disabled at end of scroll'
+ );
+
+ endScrollButton(element).click();
+
+ await elementUpdated(element);
+ await waitUntil(
+ () => !startScrollButton(element).disabled,
+ 'Start scroll button is disabled on opposite scroll'
+ );
+ });
+ });
+
+ describe('', () => {
+ beforeEach(async () => {
+ element = await fixture(html`
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+ });
+
+ it('correctly wires tab to panel relations based on provided attributes', async () => {
+ const [tabs, panels] = [getTabs(element), getPanels(element)];
+
+ // Check all but the last since it has no panel
+ tabs.slice(0, -1).forEach((tab, index) => {
+ expect(tab.panel).to.equal(panels.at(index)!.id);
+ });
+
+ // Check the last one for auto-generated `panel` prop.
+ expect(tabs.at(-1)?.panel).to.not.equal('');
+ });
+ });
+});
diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts
new file mode 100644
index 000000000..eacd73834
--- /dev/null
+++ b/src/components/tabs/tabs.ts
@@ -0,0 +1,411 @@
+import '../button/icon-button';
+import { html, LitElement, nothing } from 'lit';
+import {
+ eventOptions,
+ property,
+ query,
+ queryAssignedElements,
+ state,
+} from 'lit/decorators.js';
+import { watch } from '../common/decorators/watch.js';
+import { themes } from '../../theming/theming-decorator.js';
+import type IgcTabComponent from './tab';
+import type IgcTabPanelComponent from './tab-panel';
+import { styles } from './themes/light/tabs.base.css.js';
+import { styles as bootstrap } from './themes/light/tabs.bootstrap.css.js';
+import { styles as fluent } from './themes/light/tabs.fluent.css.js';
+import { styles as indigo } from './themes/light/tabs.indigo.css.js';
+import { EventEmitterMixin } from '../common/mixins/event-emitter.js';
+import { Constructor } from '../common/mixins/constructor.js';
+import { createCounter, getOffset } from '../common/util.js';
+import {
+ getAttributesForTags,
+ getNodesForTags,
+ observerConfig,
+} from './utils.js';
+
+export interface IgcTabsEventMap {
+ igcChange: CustomEvent;
+}
+
+/**
+ * Represents tabs component
+ *
+ * @element igc-tabs
+ *
+ * @fires igcChange - Emitted when the selected tab changes.
+ *
+ * @slot - Renders the tab header.
+ * @slot panel - Renders the tab content.
+ *
+ * @csspart headers - The wrapper of the tabs including the headers content and the scroll buttons.
+ * @csspart headers-content - The container for the tab headers.
+ * @csspart headers-wrapper - The wrapper for the tab headers and the selected indicator.
+ * @csspart headers-scroll - The container for the headers.
+ * @csspart selected-indicator - The selected indicator.
+ * @csspart start-scroll-button - The start scroll button displayed when the tabs overflow.
+ * @csspart end-scroll-button - The end scroll button displayed when the tabs overflow.
+ * @csspart content - The container for the tabs content.
+ */
+@themes({ bootstrap, fluent, indigo })
+export default class IgcTabsComponent extends EventEmitterMixin<
+ IgcTabsEventMap,
+ Constructor
+>(LitElement) {
+ public static readonly tagName = 'igc-tabs';
+
+ public static styles = styles;
+ private static readonly increment = createCounter();
+
+ @queryAssignedElements({ selector: 'igc-tab' })
+ protected tabs!: Array;
+
+ @queryAssignedElements({ slot: 'panel' })
+ protected panels!: Array;
+
+ @query('[part="headers-wrapper"]', true)
+ protected wrapper!: HTMLElement;
+
+ @query('[part="headers-content"]', true)
+ protected container!: HTMLElement;
+
+ @query('[part="selected-indicator"]', true)
+ protected selectedIndicator!: HTMLElement;
+
+ @state()
+ protected showScrollButtons = false;
+
+ @state()
+ protected disableStartScrollButton = true;
+
+ @state()
+ protected disableEndScrollButton = false;
+
+ @state()
+ protected activeTab?: IgcTabComponent;
+
+ protected resizeObserver!: ResizeObserver;
+ protected mutationObserver!: MutationObserver;
+
+ protected get enabledTabs() {
+ return this.tabs.filter((tab) => !tab.disabled);
+ }
+
+ protected get isLTR() {
+ return (
+ window.getComputedStyle(this).getPropertyValue('direction') === 'ltr'
+ );
+ }
+
+ /** Returns the currently selected tab. */
+ public get selected(): string {
+ return this.activeTab?.panel ?? '';
+ }
+
+ /** Sets the alignment for the tab headers */
+ @property({ reflect: true })
+ public alignment: 'start' | 'end' | 'center' | 'justify' = 'start';
+
+ /**
+ * Determines the tab activation. When set to auto,
+ * the tab is instantly selected while navigating with the Left/Right Arrows, Home or End keys
+ * and the corresponding panel is displayed.
+ * When set to manual, the tab is only focused. The selection happens after pressing Space or Enter.
+ */
+ @property()
+ public activation: 'auto' | 'manual' = 'auto';
+
+ @watch('alignment', { waitUntilFirstUpdate: true })
+ protected alignIndicator() {
+ const styles: Partial = {
+ visibility: this.activeTab ? 'visible' : 'hidden',
+ transitionDuration: '0.3s',
+ };
+
+ if (this.activeTab) {
+ Object.assign(styles, {
+ width: `${this.activeTab!.offsetWidth}px`,
+ transform: `translate(${
+ this.isLTR
+ ? getOffset(this.activeTab!, this.wrapper).left
+ : getOffset(this.activeTab!, this.wrapper).right
+ }px)`,
+ });
+ }
+
+ Object.assign(this.selectedIndicator.style, styles);
+ }
+
+ protected override async firstUpdated() {
+ this.showScrollButtons =
+ this.container.scrollWidth > this.container.clientWidth;
+
+ await this.updateComplete;
+
+ this.syncAttributes();
+ this.setupObserver();
+ this.setSelectedTab(
+ this.tabs.filter((tab) => tab.selected).at(-1) ?? this.enabledTabs.at(0)
+ );
+ }
+
+ public override disconnectedCallback() {
+ this.resizeObserver?.disconnect();
+ this.mutationObserver?.disconnect();
+ super.disconnectedCallback();
+ }
+
+ protected updateButtonsOnResize() {
+ // Hide the buttons in the resize observer callback and synchronously update the DOM
+ // in order to get the actual size
+ this.showScrollButtons = false;
+ this.performUpdate();
+
+ this.showScrollButtons =
+ this.container.scrollWidth > this.container.clientWidth;
+
+ this.updateScrollButtons();
+ }
+
+ protected updateScrollButtons() {
+ const { scrollLeft, offsetWidth } = this.container,
+ { scrollWidth } = this.wrapper;
+
+ this.disableEndScrollButton =
+ scrollWidth <= Math.abs(scrollLeft) + offsetWidth;
+ this.disableStartScrollButton = scrollLeft === 0;
+ }
+
+ protected setupObserver() {
+ this.resizeObserver = new ResizeObserver(() => {
+ this.updateButtonsOnResize();
+ this.alignIndicator();
+ });
+
+ [this.container, this.wrapper, ...this.tabs].forEach((element) =>
+ this.resizeObserver.observe(element)
+ );
+
+ this.mutationObserver = new MutationObserver(async (records, observer) => {
+ // Stop observing while handling changes
+ observer.disconnect();
+
+ const attributes = getAttributesForTags(
+ records,
+ 'igc-tab'
+ );
+ const changed = getNodesForTags(
+ records,
+ this,
+ 'igc-tab'
+ );
+
+ if (attributes.length > 0) {
+ this.activeTab = attributes.find((tab) => tab.selected);
+ }
+
+ if (changed) {
+ changed.addedNodes.forEach((tab) => {
+ this.resizeObserver.observe(tab);
+ if (tab.selected) {
+ this.activeTab = tab;
+ }
+ });
+ changed.removedNodes.forEach((tab) => {
+ this.resizeObserver.unobserve(tab);
+ if (tab.selected || this.activeTab === tab) {
+ this.activeTab = undefined;
+ }
+ });
+
+ this.syncAttributes();
+ }
+
+ this.updateSelectedTab();
+ this.activeTab?.scrollIntoView({ block: 'nearest' });
+ this.alignIndicator();
+
+ // Watch for changes again
+ await this.updateComplete;
+ observer.observe(this, observerConfig);
+ });
+
+ this.mutationObserver.observe(this, observerConfig);
+ }
+
+ protected updateSelectedTab() {
+ this.tabs.forEach((tab) => (tab.selected = tab === this.activeTab));
+ this.panels.forEach((panel) =>
+ Object.assign(panel.style, {
+ display: panel.id === this.activeTab?.panel ? 'block' : 'none',
+ })
+ );
+ }
+
+ protected syncAttributes() {
+ const prefix = this.id ? `${this.id}-` : '';
+ this.tabs.forEach((tab, index) => {
+ if (!tab.panel) {
+ tab.panel =
+ this.panels.at(index)?.id ??
+ `${prefix}tab-${IgcTabsComponent.increment()}`;
+ }
+ this.panels
+ .find((panel) => panel.id === tab.panel)
+ ?.setAttribute('aria-labelledby', tab.id);
+ });
+ }
+
+ private setSelectedTab(tab?: IgcTabComponent) {
+ if (!tab || tab === this.activeTab) {
+ return;
+ }
+
+ if (this.activeTab) {
+ this.activeTab.selected = false;
+ }
+
+ this.activeTab = tab;
+ this.activeTab.selected = true;
+ }
+
+ protected scrollByTabOffset(direction: 'start' | 'end') {
+ const { scrollLeft, offsetWidth } = this.container;
+ const LTR = this.isLTR,
+ next = direction === 'end';
+
+ const pivot = Math.abs(next ? offsetWidth + scrollLeft : scrollLeft);
+
+ let amount = this.tabs
+ .map((tab) => ({
+ start: LTR
+ ? getOffset(tab, this.wrapper).left
+ : Math.abs(getOffset(tab, this.wrapper).right),
+ width: tab.offsetWidth,
+ }))
+ .filter((offset) =>
+ next ? offset.start + offset.width > pivot : offset.start < pivot
+ )
+ .at(next ? 0 : -1)!.width;
+
+ amount *= next ? 1 : -1;
+ this.container.scrollBy({ left: this.isLTR ? amount : -amount });
+ }
+
+ protected handleClick(event: MouseEvent) {
+ const target = event.target as HTMLElement;
+ const tab = target.closest('igc-tab');
+
+ if (!(tab && this.contains(tab)) || tab.disabled) {
+ return;
+ }
+
+ tab.focus();
+ this.setSelectedTab(tab);
+ this.emitEvent('igcChange', { detail: this.activeTab });
+ }
+
+ protected handleKeyDown = (event: KeyboardEvent) => {
+ const { key } = event;
+ const enabledTabs = this.enabledTabs;
+
+ let index = enabledTabs.indexOf(
+ document.activeElement?.closest('igc-tab') as IgcTabComponent
+ );
+
+ switch (key) {
+ case 'ArrowLeft':
+ index = this.isLTR
+ ? (enabledTabs.length + index - 1) % enabledTabs.length
+ : (index + 1) % enabledTabs.length;
+ break;
+ case 'ArrowRight':
+ index = this.isLTR
+ ? (index + 1) % enabledTabs.length
+ : (enabledTabs.length + index - 1) % enabledTabs.length;
+ break;
+ case 'Home':
+ index = 0;
+ break;
+ case 'End':
+ index = enabledTabs.length - 1;
+ break;
+ case 'Enter':
+ case ' ':
+ this.setSelectedTab(enabledTabs[index]);
+ break;
+ default:
+ return;
+ }
+
+ enabledTabs[index].focus({ preventScroll: true });
+
+ if (this.activation === 'auto') {
+ this.setSelectedTab(enabledTabs[index]);
+ this.emitEvent('igcChange', { detail: this.activeTab });
+ } else {
+ enabledTabs[index].scrollIntoView({ block: 'nearest' });
+ }
+
+ event.preventDefault();
+ };
+
+ @eventOptions({ passive: true })
+ protected handleScroll() {
+ this.updateScrollButtons();
+ }
+
+ /** Selects the specified tab and displays the corresponding panel. */
+ public select(name: string) {
+ this.setSelectedTab(this.tabs.find((el) => el.panel === name));
+ }
+
+ protected renderScrollButton(direction: 'start' | 'end') {
+ const start = direction === 'start';
+
+ return this.showScrollButtons
+ ? html` this.scrollByTabOffset(direction)}
+ >`
+ : nothing;
+ }
+
+ protected override render() {
+ return html`
+
+ ${this.renderScrollButton('start')}
+
+ ${this.renderScrollButton('end')}
+
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'igc-tabs': IgcTabsComponent;
+ }
+}
diff --git a/src/components/tabs/themes/dark/tab.dark.base.scss b/src/components/tabs/themes/dark/tab.dark.base.scss
new file mode 100644
index 000000000..94788bad9
--- /dev/null
+++ b/src/components/tabs/themes/dark/tab.dark.base.scss
@@ -0,0 +1,7 @@
+@use '../../../../styles/utilities' as utils;
+
+@mixin theme() {
+ igc-tab {
+ --hover-background: #{utils.color(gray, 100, .5)};
+ }
+}
diff --git a/src/components/tabs/themes/dark/tab.dark.bootstrap.scss b/src/components/tabs/themes/dark/tab.dark.bootstrap.scss
new file mode 100644
index 000000000..448e602b3
--- /dev/null
+++ b/src/components/tabs/themes/dark/tab.dark.bootstrap.scss
@@ -0,0 +1,7 @@
+@use '../../../../styles/utilities' as utils;
+
+@mixin theme() {
+ igc-tab {
+ --hover-color: #{utils.color(primary, 400)};
+ }
+}
diff --git a/src/components/tabs/themes/light/tab-panel.base.scss b/src/components/tabs/themes/light/tab-panel.base.scss
new file mode 100644
index 000000000..559981039
--- /dev/null
+++ b/src/components/tabs/themes/light/tab-panel.base.scss
@@ -0,0 +1,8 @@
+@use '../../../../styles/common/component';
+@use '../../../../styles/utilities' as *;
+
+:host {
+ position: relative;
+ overflow: hidden;
+ flex-grow: 1;
+}
diff --git a/src/components/tabs/themes/light/tab.base.scss b/src/components/tabs/themes/light/tab.base.scss
new file mode 100644
index 000000000..eb0ad680d
--- /dev/null
+++ b/src/components/tabs/themes/light/tab.base.scss
@@ -0,0 +1,78 @@
+@use '../../../../styles/common/component';
+@use '../../../../styles/utilities' as *;
+
+$hover-background: var(--hover-background, color(gray, 200)) !default;
+
+:host {
+ display: inline-flex;
+ flex-shrink: 0;
+ justify-content: center;
+ align-items: center;
+ min-width: rem(90px);
+ max-width: rem(360px);
+ word-wrap: break-word;
+ padding: rem(12px) rem(16px);
+ overflow: hidden;
+ cursor: pointer;
+ position: relative;
+ transition: all .3s cubic-bezier(.35, 0, .25, 1);
+ user-select: none;
+ background: color(surface, 500);
+ color: color(gray, 700);
+}
+
+:host(:hover),
+:host(:focus-within) {
+ background: $hover-background;
+ color: color(gray, 700);
+}
+
+:host([selected]) {
+ color: color(primary, 500);
+
+ ::slotted(igc-icon) {
+ color: color(primary, 500);
+ }
+}
+
+:host([selected]:hover),
+:host([selected]:focus-within) {
+ color: color(primary, 500);
+}
+
+:host([disabled]) {
+ pointer-events: none;
+ cursor: initial;
+ color: color(gray, 500);
+}
+
+[part='base'] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ outline-style: none;
+ gap: rem(8px);
+}
+
+[part='content'] {
+ @include type-category('button');
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
+ outline-style: none;
+
+ ::slotted(*) {
+ @include line-clamp(2, true, true);
+ }
+}
+
+::slotted(igc-icon) {
+ --size: #{rem(24px)};
+}
+
+::slotted(igc-icon:not(:only-child)) {
+ margin-bottom: rem(8px);
+}
diff --git a/src/components/tabs/themes/light/tab.bootstrap.scss b/src/components/tabs/themes/light/tab.bootstrap.scss
new file mode 100644
index 000000000..e998dd7a8
--- /dev/null
+++ b/src/components/tabs/themes/light/tab.bootstrap.scss
@@ -0,0 +1,66 @@
+@use '../../../../styles/utilities' as *;
+
+$hover-color: var(--hover-color, color(primary, 700)) !default;
+
+:host {
+ background: transparent;
+ transition: none;
+ border-start-start-radius: rem(4px);
+ border-start-end-radius: rem(4px);
+ color: color(primary, 500);
+}
+
+:host(:hover) {
+ background: transparent;
+ color: $hover-color;
+ box-shadow: inset 0 0 0 rem(1px) color(gray, 200);
+}
+
+:host([selected]) {
+ color: color(gray, 800);
+ position: relative;
+ box-shadow: inset 0 0 0 rem(1px) color(gray, 300);
+
+ ::slotted(igc-icon) {
+ color: color(gray, 800);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ inset-inline-start: 0;
+ width: 100%;
+ height: rem(1px);
+ background: color(surface, 500);
+ z-index: 1;
+ }
+}
+
+:host([selected]:hover) {
+ color: color(gray, 800);
+ border-top-color: color(gray, 300);
+ border-inline-color: color(gray, 300);
+ border-bottom-color: color(surface, 500);
+}
+
+:host(:focus-within),
+:host([selected]:focus-within) {
+ color: $hover-color;
+ background: transparent;
+ box-shadow: inset 0 0 0 rem(2px) color(primary, 700);
+ border-radius: rem(4px);
+ z-index: 1;
+
+ &::after {
+ display: none;
+ }
+}
+
+:host([selected]:focus-within) {
+ color: color(gray, 800);
+}
+
+:host([disabled]) {
+ color: color(primary, 500, .5);
+}
diff --git a/src/components/tabs/themes/light/tab.fluent.scss b/src/components/tabs/themes/light/tab.fluent.scss
new file mode 100644
index 000000000..7edac38b9
--- /dev/null
+++ b/src/components/tabs/themes/light/tab.fluent.scss
@@ -0,0 +1,22 @@
+@use '../../../../styles/utilities' as *;
+
+:host {
+ color: color(gray, 900);
+
+ ::slotted(igc-icon) {
+ color: color(gray, 700);
+ }
+}
+
+:host(:hover),
+:host(:focus-within) {
+ color: color(gray, 900);
+}
+
+:host([disabled]) {
+ color: color(gray, 400);
+
+ ::slotted(igc-icon) {
+ color: color(gray, 400);
+ }
+}
diff --git a/src/components/tabs/themes/light/tab.indigo.scss b/src/components/tabs/themes/light/tab.indigo.scss
new file mode 100644
index 000000000..4a0173f03
--- /dev/null
+++ b/src/components/tabs/themes/light/tab.indigo.scss
@@ -0,0 +1,24 @@
+@use '../../../../styles/utilities' as *;
+
+:host {
+ background: transparent;
+}
+
+:host(:hover),
+:host(:focus-within) {
+ background: transparent;
+ color: color(gray, 900);
+}
+
+:host([selected]) {
+ color: color(gray, 900);
+
+ ::slotted(igc-icon) {
+ color: color(primary, 400);
+ }
+}
+
+:host([selected]:hover),
+:host([selected]:focus-within) {
+ color: color(gray, 900);
+}
diff --git a/src/components/tabs/themes/light/tabs.base.scss b/src/components/tabs/themes/light/tabs.base.scss
new file mode 100644
index 000000000..8fb198b62
--- /dev/null
+++ b/src/components/tabs/themes/light/tabs.base.scss
@@ -0,0 +1,119 @@
+@use '../../../../styles/common/component';
+@use '../../../../styles/utilities' as *;
+
+$tabs-animation-function: cubic-bezier(.35, 0, .25, 1);
+
+:host {
+ display: block;
+}
+
+:host([alignment='start']) [part='headers-scroll'] {
+ ::slotted(igc-tab:last-of-type) {
+ margin-inline-end: auto;
+ }
+}
+
+:host([alignment='end']) [part='headers-scroll'] {
+ ::slotted(igc-tab:first-of-type) {
+ margin-inline-start: auto;
+ }
+}
+
+:host([alignment='center']) [part='headers-scroll'] {
+ ::slotted(igc-tab:first-of-type) {
+ margin-inline-start: auto;
+ }
+
+ ::slotted(igc-tab:last-of-type) {
+ margin-inline-end: auto;
+ }
+}
+
+:host([alignment='justify']) [part='headers-scroll'] {
+ ::slotted(igc-tab) {
+ flex-basis: 100px;
+ flex-grow: 1;
+ max-width: 100%;
+ }
+}
+
+[part='headers'] {
+ display: flex;
+ overflow: hidden;
+ min-height: rem(42px);
+ background: color(surface, 500);
+}
+
+[part='headers-content'] {
+ display: flex;
+ flex: 1 1 auto;
+ scroll-behavior: smooth;
+ overflow: hidden;
+}
+
+[part='headers-wrapper'] {
+ position: relative;
+ flex-grow: 1;
+}
+
+[part='headers-scroll'] {
+ display: flex;
+ height: 100%;
+}
+
+[part='selected-indicator'] {
+ position: absolute;
+ inset-inline-start: 0;
+ bottom: 0;
+ transform: translateX(0);
+ height: rem(2px);
+ min-width: rem(90px);
+ background: color(primary, 500);
+ transition: transform .3s $tabs-animation-function, width .2s $tabs-animation-function;
+}
+
+[part='start-scroll-button'],
+[part='end-scroll-button'] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ border: none;
+ padding: 0;
+ min-width: rem(48px);
+ width: rem(48px);
+ cursor: pointer;
+ position: relative;
+ background: color(surface, 500);
+
+ &:hover,
+ &:focus {
+ background: color(gray, 100);
+ }
+
+ ::slotted(*) {
+ box-shadow: none;
+ }
+}
+
+igc-icon-button::part(base) {
+ color: color(gray, 700);
+
+ &:hover,
+ &:focus-within {
+ color: color(gray, 900);
+ background: transparent;
+ box-shadow: none;
+ }
+}
+
+igc-icon-button[disabled]::part(base) {
+ color: color(gray, 500);
+}
+
+:host([dir='rtl']) {
+ [part='start-scroll-button'],
+ [part='end-scroll-button'] {
+ transform: scaleX(-1);
+ }
+}
diff --git a/src/components/tabs/themes/light/tabs.bootstrap.scss b/src/components/tabs/themes/light/tabs.bootstrap.scss
new file mode 100644
index 000000000..e403c7301
--- /dev/null
+++ b/src/components/tabs/themes/light/tabs.bootstrap.scss
@@ -0,0 +1,35 @@
+@use '../../../../styles/utilities' as *;
+
+[part='headers'] {
+ background: color(surface, 500);
+}
+
+[part='headers-wrapper'] {
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ inset-inline-start: 0;
+ width: 100%;
+ height: rem(1px);
+ background: color(gray, 300);
+ z-index: 0;
+ }
+}
+
+igc-icon-button::part(base) {
+ color: color(gray, 500);
+
+ &:hover,
+ &:focus-within {
+ color: color(gray, 600);
+ }
+}
+
+igc-icon-button[disabled]::part(base) {
+ color: color(primary, 500, .5);
+}
+
+[part='selected-indicator'] {
+ display: none;
+}
diff --git a/src/components/tabs/themes/light/tabs.fluent.scss b/src/components/tabs/themes/light/tabs.fluent.scss
new file mode 100644
index 000000000..2af4192d0
--- /dev/null
+++ b/src/components/tabs/themes/light/tabs.fluent.scss
@@ -0,0 +1,15 @@
+@use '../../../../styles/utilities' as *;
+
+igc-icon-button::part(base) {
+ color: color(gray, 500);
+
+ &:hover,
+ &:focus-within {
+ color: color(gray, 600);
+ }
+}
+
+igc-icon-button[disabled]::part(base) {
+ color: color(gray, 400);
+ background: inherit;
+}
diff --git a/src/components/tabs/themes/light/tabs.indigo.scss b/src/components/tabs/themes/light/tabs.indigo.scss
new file mode 100644
index 000000000..8fee3ed3e
--- /dev/null
+++ b/src/components/tabs/themes/light/tabs.indigo.scss
@@ -0,0 +1,19 @@
+@use '../../../../styles/utilities' as *;
+
+[part='headers'] {
+ background: transparent;
+}
+
+[part='start-scroll-button'],
+[part='end-scroll-button'] {
+ background: transparent;
+
+ &:hover,
+ &:focus {
+ background: transparent;
+ }
+}
+
+[part='selected-indicator'] {
+ background: color(primary, 400);
+}
diff --git a/src/components/tabs/utils.ts b/src/components/tabs/utils.ts
new file mode 100644
index 000000000..aee2648f1
--- /dev/null
+++ b/src/components/tabs/utils.ts
@@ -0,0 +1,64 @@
+export const observerConfig: MutationObserverInit = {
+ attributes: true,
+ attributeFilter: ['selected'],
+ childList: true,
+ subtree: true,
+};
+
+function isMatchingTag(node: Node, ...tags: string[]) {
+ return tags ? tags.includes(node.nodeName.toLowerCase()) : true;
+}
+
+/**
+ * Returns all targets with attribute mutations matching `tags`
+ */
+export function getAttributesForTags(
+ records: MutationRecord[],
+ ...tags: string[]
+) {
+ return records
+ .filter(
+ ({ type, target }) =>
+ type === 'attributes' && isMatchingTag(target, ...tags)
+ )
+ .map(({ target }) => target as T);
+}
+
+/**
+ * Returns all targets with childList mutations matching tags.
+ * If `root` is specified, returns only targets that are direct children
+ * of `root`.
+ */
+export function getNodesForTags(
+ records: MutationRecord[],
+ root?: Element,
+ ...tags: string[]
+) {
+ const collected: {
+ addedNodes: T[];
+ removedNodes: T[];
+ } = { addedNodes: [], removedNodes: [] };
+
+ records
+ .filter(
+ ({ type, target }) =>
+ type === 'childList' && (root ? target.isSameNode(root) : true)
+ )
+ .reduce((prev, curr) => {
+ prev.addedNodes = prev.addedNodes.concat(
+ Array.from(curr.addedNodes)
+ .filter((node) => isMatchingTag(node, ...tags))
+ .map((node) => node as T)
+ );
+ prev.removedNodes = prev.removedNodes.concat(
+ Array.from(curr.removedNodes)
+ .filter((node) => isMatchingTag(node, ...tags))
+ .map((node) => node as T)
+ );
+ return prev;
+ }, collected);
+
+ return collected.addedNodes.length || collected.removedNodes.length
+ ? collected
+ : null;
+}
diff --git a/src/index.ts b/src/index.ts
index a266e8900..c7fa63e8b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -40,6 +40,9 @@ export { default as IgcRangeSliderComponent } from './components/slider/range-sl
export { default as IgcSnackbarComponent } from './components/snackbar/snackbar.js';
export { default as IgcSliderComponent } from './components/slider/slider.js';
export { default as IgcSliderLabelComponent } from './components/slider/slider-label.js';
+export { default as IgcTabsComponent } from './components/tabs/tabs';
+export { default as IgcTabComponent } from './components/tabs/tab';
+export { default as IgcTabPanelComponent } from './components/tabs/tab-panel';
export { default as IgcToastComponent } from './components/toast/toast.js';
export { default as IgcSwitchComponent } from './components/checkbox/switch.js';
export { default as IgcTreeComponent } from './components/tree/tree.js';
diff --git a/src/styles/themes/dark/bootstrap.scss b/src/styles/themes/dark/bootstrap.scss
index 400283dc3..3879f02ae 100644
--- a/src/styles/themes/dark/bootstrap.scss
+++ b/src/styles/themes/dark/bootstrap.scss
@@ -4,6 +4,7 @@
@use '../../../components/input/themes/dark/input.dark.bootstrap' as input;
@use '../../../components/slider/themes/dark/slider.bootstrap' as slider;
@use '../../../components/list/themes/dark/list.bootstrap' as list;
+@use '../../../components/tabs/themes/dark/tab.dark.bootstrap' as tab;
@use '../../../components/tree/themes/dark/tree-item.bootstrap' as tree-item;
@use '../../../components/nav-drawer/themes/dark/nav-drawer-item.bootstrap' as nav-drawer-item;
@@ -24,5 +25,6 @@ $palette: utils.palette(
@include input.theme();
@include slider.theme();
@include list.theme();
+@include tab.theme();
@include tree-item.theme();
@include nav-drawer-item.theme();
diff --git a/src/styles/themes/dark/fluent.scss b/src/styles/themes/dark/fluent.scss
index a0b866d25..67d67bd01 100644
--- a/src/styles/themes/dark/fluent.scss
+++ b/src/styles/themes/dark/fluent.scss
@@ -9,6 +9,7 @@
@use '../../../components/radio/themes/dark/radio.fluent' as radio;
@use '../../../components/slider/themes/dark/slider.fluent.scss' as slider;
@use '../../../components/list/themes/dark/list.fluent' as list;
+@use '../../../components/tabs/themes/dark/tab.dark.base' as tab;
@use '../../../components/tree/themes/dark/tree-item.fluent' as tree-item;
@use '../../../components/nav-drawer/themes/dark/nav-drawer-item.fluent' as nav-drawer-item;
@@ -33,5 +34,6 @@ $palette: utils.palette(
@include radio.theme();
@include slider.theme();
@include list.theme();
+@include tab.theme();
@include tree-item.theme();
@include nav-drawer-item.theme();
diff --git a/src/styles/themes/dark/material.scss b/src/styles/themes/dark/material.scss
index cfa24bf0c..63bb201af 100644
--- a/src/styles/themes/dark/material.scss
+++ b/src/styles/themes/dark/material.scss
@@ -1,6 +1,7 @@
@use '../../utilities' as utils;
@use '../base/material' as base;
@use '../../../components/input/themes/dark/input.dark.material' as input;
+@use '../../../components/tabs/themes/dark/tab.dark.base' as tab;
@use '../../../components/nav-drawer/themes/dark/nav-drawer-item.base.scss' as nav-drawer-item;
$palette: utils.palette(
@@ -12,4 +13,5 @@ $palette: utils.palette(
@include base.root-styles($palette);
@include input.theme();
+@include tab.theme();
@include nav-drawer-item.theme();
diff --git a/stories/tabs.stories.ts b/stories/tabs.stories.ts
new file mode 100644
index 000000000..427d95969
--- /dev/null
+++ b/stories/tabs.stories.ts
@@ -0,0 +1,164 @@
+import { html } from 'lit';
+import { ifDefined } from 'lit/directives/if-defined.js';
+import { map } from 'lit/directives/map.js';
+import { range } from 'lit/directives/range.js';
+import { Context, Story } from './story.js';
+
+// region default
+const metadata = {
+ title: 'Tabs',
+ component: 'igc-tabs',
+ argTypes: {
+ selected: {
+ type: 'string',
+ description: 'Returns the currently selected tab.',
+ control: 'text',
+ },
+ alignment: {
+ type: '"start" | "end" | "center" | "justify"',
+ description: 'Sets the alignment for the tab headers',
+ options: ['start', 'end', 'center', 'justify'],
+ control: {
+ type: 'inline-radio',
+ },
+ defaultValue: 'start',
+ },
+ activation: {
+ type: '"auto" | "manual"',
+ description:
+ 'Determines the tab activation. When set to auto,\nthe tab is instantly selected while navigating with the Left/Right Arrows, Home or End keys\nand the corresponding panel is displayed.\nWhen set to manual, the tab is only focused. The selection happens after pressing Space or Enter.',
+ options: ['auto', 'manual'],
+ control: {
+ type: 'inline-radio',
+ },
+ defaultValue: 'auto',
+ },
+ },
+};
+export default metadata;
+interface ArgTypes {
+ selected: string;
+ alignment: 'start' | 'end' | 'center' | 'justify';
+ activation: 'auto' | 'manual';
+}
+// endregion
+
+(metadata as any).parameters = {
+ actions: {
+ handles: ['igcChange'],
+ },
+};
+
+const remove = (e: MouseEvent) => {
+ (e.target as HTMLElement).closest('igc-tab')?.remove();
+};
+
+const removableTabs = Array.from(
+ map(
+ range(10),
+ (i) =>
+ html`
+ Item ${i + 1}
+
+
+ Content for ${i + 1}
`
+ )
+);
+
+const tabs = Array.from(
+ map(
+ range(18),
+ (i) =>
+ html` Item ${i + 1}
+ Content ${i + 1}`
+ )
+);
+
+const Template: Story = (
+ { activation, alignment }: ArgTypes,
+ { globals: { direction } }: Context
+) => html`
+
+ ${tabs}
+
+
+
+
+
+
+
+
+
+
+
+
+ Content 1
+ Content 2
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
+ tempor incididunt ut labore et dolore magna aliqua.
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
+ tempor incididunt ut labore et dolore magna aliqua.
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
+ tempor incididunt ut labore et dolore magna aliqua.
+
+ Content 1
+ Content 2
+
+`;
+
+const TabStrip: Story = (
+ { activation, alignment }: ArgTypes,
+ { globals: { direction } }: Context
+) => html`
+
+ ${Array.from(range(1, 11)).map((i) => html` ${i} `)}
+
+`;
+
+const RemovableTabs: Story = (
+ { activation, alignment }: ArgTypes,
+ { globals: { direction } }: Context
+) => html`
+
+ ${removableTabs}
+
+`;
+
+export const Basic = Template.bind({});
+export const Removable = RemovableTabs.bind({});
+export const Strip = TabStrip.bind({});