diff --git a/src/components/common/controllers/resize-observer.ts b/src/components/common/controllers/resize-observer.ts new file mode 100644 index 000000000..563fce78b --- /dev/null +++ b/src/components/common/controllers/resize-observer.ts @@ -0,0 +1,90 @@ +import { + type ReactiveController, + type ReactiveControllerHost, + isServer, +} from 'lit'; + +type ResizeControllerCallback = ( + ...args: Parameters +) => unknown; + +/** Configuration for initializing a resize controller. */ +export interface ResizeControllerConfig { + /** The callback function to run when a resize mutation is triggered. */ + callback: ResizeControllerCallback; + /** Configuration options passed to the underlying ResizeObserver. */ + options?: ResizeObserverOptions; + /** + * The initial target element to observe for resize mutations. + * + * If not provided, the host element will be set as initial target. + * Pass in `null` to skip setting an initial target. + */ + target?: Element | null; +} + +class ResizeController implements ReactiveController { + private readonly _host: ReactiveControllerHost & Element; + private readonly _targets = new Set(); + private readonly _observer!: ResizeObserver; + private readonly _config: ResizeControllerConfig; + + constructor( + host: ReactiveControllerHost & Element, + config: ResizeControllerConfig + ) { + this._host = host; + this._config = config; + + if (this._config.target !== null) { + this._targets.add(this._config.target ?? host); + } + + /* c8 ignore next 3 */ + if (isServer) { + return; + } + + this._observer = new ResizeObserver((entries) => + this._config.callback.call(this._host, entries, this._observer) + ); + + host.addController(this); + } + + /** Starts observing the `targe` element. */ + public observe(target: Element): void { + this._targets.add(target); + this._observer.observe(target, this._config.options); + this._host.requestUpdate(); + } + + /** Stops observing the `target` element. */ + public unobserve(target: Element): void { + this._targets.delete(target); + this._observer.unobserve(target); + } + + /** @internal */ + public hostConnected(): void { + for (const target of this._targets) { + this.observe(target); + } + } + + /** @internal */ + public hostDisconnected(): void { + this._observer.disconnect(); + } +} + +/** + * Creates a new resize controller bound to the given `host` + * with {@link ResizeControllerConfig | `config`}. + */ +export function createResizeController( + host: ReactiveControllerHost & Element, + config: ResizeControllerConfig +): ResizeController { + return new ResizeController(host, config); +} diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 54a88bb73..14da35572 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -56,7 +56,6 @@ import IgcSliderComponent from '../../slider/slider.js'; import IgcSnackbarComponent from '../../snackbar/snackbar.js'; import IgcStepComponent from '../../stepper/step.js'; import IgcStepperComponent from '../../stepper/stepper.js'; -import IgcTabPanelComponent from '../../tabs/tab-panel.js'; import IgcTabComponent from '../../tabs/tab.js'; import IgcTabsComponent from '../../tabs/tabs.js'; import IgcTextareaComponent from '../../textarea/textarea.js'; @@ -126,7 +125,6 @@ const allComponents: IgniteComponent[] = [ IgcRangeSliderComponent, IgcTabsComponent, IgcTabComponent, - IgcTabPanelComponent, IgcCircularProgressComponent, IgcLinearProgressComponent, IgcCircularGradientComponent, diff --git a/src/components/common/util.ts b/src/components/common/util.ts index 06ddf320c..201a9f4f4 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -33,34 +33,6 @@ export function numberInRangeInclusive( return value >= min && value <= 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() { let i = 0; return () => { @@ -361,6 +333,26 @@ export function roundByDPR(value: number): number { return Math.round(value * dpr) / dpr; } +export function scrollIntoView( + element?: HTMLElement, + config?: ScrollIntoViewOptions +): void { + if (!element) { + return; + } + + element.scrollIntoView( + Object.assign( + { + behavior: 'auto', + block: 'nearest', + inline: 'nearest', + }, + config + ) + ); +} + export function isRegExp(value: unknown): value is RegExp { return value != null && value.constructor === RegExp; } diff --git a/src/components/tabs/tab-dom.ts b/src/components/tabs/tab-dom.ts new file mode 100644 index 000000000..65e8c1496 --- /dev/null +++ b/src/components/tabs/tab-dom.ts @@ -0,0 +1,167 @@ +import type { Ref } from 'lit/directives/ref.js'; +import { isLTR } from '../common/util.js'; +import type IgcTabComponent from './tab.js'; +import type IgcTabsComponent from './tabs.js'; + +class TabsHelpers { + private static readonly SCROLL_AMOUNT = 180; + private readonly _host: IgcTabsComponent; + private readonly _container: Ref; + private readonly _indicator: Ref; + + private _styleProperties = { + '--_tabs-count': '', + '--_ig-tabs-width': '', + }; + + private _hasScrollButtons = false; + private _scrollButtonsDisabled = { start: true, end: false }; + + private _isLeftToRight = false; + + /** + * Returns the DOM container holding the tabs headers. + */ + public get container(): HTMLElement { + return this._container.value!; + } + + /** + * Returns the selected indicator DOM element. + */ + public get indicator(): HTMLElement { + return this._indicator.value!; + } + + /** + * Returns the internal CSS variables used for the layout of the tabs component. + */ + public get styleProperties() { + return this._styleProperties; + } + + /** + * Whether the scroll buttons of the tabs header strip should be shown. + */ + public get hasScrollButtons(): boolean { + return this._hasScrollButtons; + } + + /** + * Returns the disabled state of the tabs header strip scroll buttons. + */ + public get scrollButtonsDisabled() { + return this._scrollButtonsDisabled; + } + + public get isLeftToRightChanged(): boolean { + const isLeftToRight = isLTR(this._host); + + if (this._isLeftToRight !== isLeftToRight) { + this._isLeftToRight = isLeftToRight; + return true; + } + + return false; + } + + constructor( + host: IgcTabsComponent, + container: Ref, + indicator: Ref + ) { + this._host = host; + this._container = container; + this._indicator = indicator; + } + + /** + * Sets the internal CSS variables used for the layout of the tabs component. + * Triggers an update cycle (rerender) of the `igc-tabs` component. + */ + public setStyleProperties(): void { + this._styleProperties = { + '--_tabs-count': this._host.tabs.length.toString(), + '--_ig-tabs-width': `${this.container.getBoundingClientRect().width}px`, + }; + this._host.requestUpdate(); + } + + /** + * Sets the type of the `scroll-snap-align` CSS property for the tabs header strip. + */ + public setScrollSnap(type?: 'start' | 'end'): void { + this.container.style.setProperty('--_ig-tab-snap', type || 'unset'); + } + + /** + * Scrolls the tabs header strip in the given direction with `scroll-snap-align` set. + */ + public scrollTabs(direction: 'start' | 'end'): void { + const factor = isLTR(this._host) ? 1 : -1; + const amount = + direction === 'start' + ? -TabsHelpers.SCROLL_AMOUNT + : TabsHelpers.SCROLL_AMOUNT; + + this.setScrollSnap(direction); + this.container.scrollBy({ left: factor * amount, behavior: 'smooth' }); + } + + /** + * Updates the state of the tabs header strip scroll buttons - visibility and active state. + * Triggers an update cycle (rerender) of the `igc-tabs` component. + */ + public setScrollButtonState(): void { + const { scrollLeft, scrollWidth, clientWidth } = this.container; + + this._hasScrollButtons = scrollWidth > clientWidth; + this._scrollButtonsDisabled = { + start: scrollLeft === 0, + end: Math.abs(Math.abs(scrollLeft) + clientWidth - scrollWidth) <= 1, + }; + + this._host.requestUpdate(); + } + + /** + * Updates the indicator DOM element styles based on the current "active" tab. + */ + public async setIndicator(active?: IgcTabComponent): Promise { + const styles = { + visibility: active ? 'visible' : 'hidden', + } satisfies Partial; + + await this._host.updateComplete; + + if (active) { + const tabHeader = getTabHeader(active); + const { width } = tabHeader.getBoundingClientRect(); + + const offset = this._isLeftToRight + ? tabHeader.offsetLeft - this.container.offsetLeft + : width + + tabHeader.offsetLeft - + this.container.getBoundingClientRect().width; + + Object.assign(styles, { + width: `${width}px`, + transform: `translateX(${offset}px)`, + }); + } + + Object.assign(this.indicator.style, styles); + } +} + +export function createTabHelpers( + host: IgcTabsComponent, + container: Ref, + indicator: Ref +) { + return new TabsHelpers(host, container, indicator); +} + +export function getTabHeader(tab: IgcTabComponent): HTMLElement { + return tab.renderRoot.querySelector('[part~="tab-header"]')!; +} diff --git a/src/components/tabs/tab-panel.ts b/src/components/tabs/tab-panel.ts deleted file mode 100644 index d799c63cd..000000000 --- a/src/components/tabs/tab-panel.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { LitElement, html } from 'lit'; - -import { registerComponent } from '../common/definitions/register.js'; -import { createCounter } from '../common/util.js'; -import { styles } from './themes/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; - - /* blazorSuppress */ - public static register() { - registerComponent(IgcTabPanelComponent); - } - - private static readonly increment = createCounter(); - - public override connectedCallback() { - super.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.getAttribute('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 index 61a65d222..338867e4a 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -1,5 +1,5 @@ import { LitElement, html } from 'lit'; -import { property, query } from 'lit/decorators.js'; +import { property } from 'lit/decorators.js'; import { themes } from '../../theming/theming-decorator.js'; import { registerComponent } from '../common/definitions/register.js'; @@ -9,17 +9,20 @@ import { all } from './themes/tab-themes.js'; import { styles } from './themes/tab.base.css.js'; /** - * Represents the tab header. + * A tab element slotted into an `igc-tabs` container. * * @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. + * @slot - Renders the tab's content. + * @slot label - Renders the tab header's label. + * @slot prefix - Renders the tab header's prefix. + * @slot suffix - Renders the tab header's suffix. * - * @csspart content - The content wrapper. - * @csspart prefix - The prefix wrapper. - * @csspart suffix - The suffix wrapper. + * @csspart tab-header - The header of a single tab. + * @csspart prefix - Tab header's label prefix. + * @csspart content - Tab header's label slot container. + * @csspart suffix - Tab header's label suffix. + * @csspart tab-body - Holds the body content of a single tab, only the body of the selected tab is visible. */ @themes(all) @@ -28,21 +31,18 @@ export default class IgcTabComponent extends LitElement { public static override styles = [styles, shared]; /* blazorSuppress */ - public static register() { + public static register(): void { registerComponent(IgcTabComponent); } - private static readonly increment = createCounter(); - - @query('[part="base"]', true) - private tab!: HTMLElement; + private static increment = createCounter(); /** - * The id of the tab panel which will be controlled by the tab. + * The tab item label. * @attr */ @property() - public panel = ''; + public label = ''; /** * Determines whether the tab is selected. @@ -58,36 +58,42 @@ export default class IgcTabComponent extends LitElement { @property({ type: Boolean, reflect: true }) public disabled = false; + /** @internal */ public override connectedCallback(): void { super.connectedCallback(); - this.id = - this.getAttribute('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(); + this.id = this.id || `igc-tab-${IgcTabComponent.increment()}`; } protected override render() { + const headerId = `${this.id}-header`; + const contentId = `${this.id}-content`; + return html`