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({});