import { TailwindElement } from "@/classes/TailwindElement"; import { LitElement, html, css } from "lit"; import { property, queryAsync, customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; const DEFAULT_PANEL_ID = "default-panel"; // Match witch tailwind 4xl max width // https://tailwindcss.com/docs/max-width const SCREEN_LG_PX = 896; /** * Tab list * * Usage example: * ```ts * * One * Two * * * Tab one content * Tab two content * ``` */ @customElement("btrix-tab-panel") export class TabPanel extends TailwindElement { @property({ type: String }) name?: string; @property({ type: Boolean }) active = false; render() { return html`
`; } } @customElement("btrix-tab") export class Tab extends TailwindElement { // ID of panel the tab labels/controls @property({ type: String }) name?: string; @property({ type: Boolean }) active = false; @property({ type: Boolean }) disabled = false; render() { return html` `; } } type TabElement = Tab & HTMLElement; type TabPanelElement = TabPanel & HTMLElement; @customElement("btrix-tab-list") export class TabList extends LitElement { static styles = css` :host { --track-width: 4px; } .container { display: grid; grid-template-areas: "menu" "header" "main"; grid-template-columns: 1fr; grid-gap: 1rem; } @media only screen and (min-width: ${SCREEN_LG_PX}px) { .container { grid-template-areas: ". header" "menu main"; grid-template-columns: auto minmax(auto, 70rem); } } .navWrapper { grid-area: menu; } @media only screen and (min-width: ${SCREEN_LG_PX}px) { .navWrapper { overflow: initial; } } .header { grid-area: header; font-size: var(--sl-font-size-large); font-weight: 500; line-height: 1; } .content { grid-area: main; } .nav { position: relative; position: -webkit-sticky; position: sticky; top: var(--sl-spacing-medium); } .tablist { display: flex; margin: 0; list-style: none; padding: 0; } .show-indicator .tablist { margin-left: var(--track-width); } @media only screen and (min-width: ${SCREEN_LG_PX}px) { .tablist { display: block; } } .track { display: none; position: absolute; top: 0; height: 100%; width: var(--track-width); border-radius: var(--track-width); background-color: var(--sl-color-neutral-100); box-shadow: inset 0 0 2px var(--sl-color-neutral-300); } .indicator { display: none; position: absolute; width: var(--track-width); border-radius: var(--track-width); background-color: var(--sl-color-blue-500); } @media only screen and (min-width: ${SCREEN_LG_PX}px) { .tablist, .show-indicator .track, .show-indicator .indicator { display: block; } } `; // ID of visible panel @property({ type: String }) activePanel: string = DEFAULT_PANEL_ID; // If panels are linear, the current panel in progress @property({ type: String }) progressPanel?: string; @property({ type: Boolean }) hideIndicator = false; @queryAsync(".track") private trackElem!: HTMLElement; @queryAsync(".indicator") private indicatorElem!: HTMLElement; updated(changedProperties: Map) { if (changedProperties.has("activePanel") && this.activePanel) { this.onActiveChange(!changedProperties.get("activePanel")); } if (changedProperties.has("progressPanel") && this.progressPanel) { this.onProgressChange(!changedProperties.get("progressPanel")); } } private async repositionIndicator(activeTab?: TabElement, animate = true) { if (!activeTab || this.hideIndicator) return; const trackElem = await this.trackElem; const indicatorElem = await this.indicatorElem; const { top: tabTop, height: tabHeight } = activeTab.getBoundingClientRect(); const top = tabTop - trackElem.getBoundingClientRect().top; if (animate) { indicatorElem.style.transition = "var(--sl-transition-fast) transform ease, var(--sl-transition-fast) height ease"; } else { indicatorElem.style.transition = ""; } if (this.progressPanel) { indicatorElem.style.height = `${top + tabHeight}px`; } else { indicatorElem.style.height = `${tabHeight}px`; indicatorElem.style.transform = `translateY(${top}px)`; } } render() { return html`
`; } renderNav() { return html` this.repositionIndicator(this.getTab(this.progressPanel))} > `; } private getPanels(): TabPanelElement[] { const slotElems = ( this.renderRoot.querySelector( ".content slot:not([name])" ) as HTMLSlotElement ).assignedElements(); return ([...slotElems] as TabPanelElement[]).filter( (el) => el.tagName.toLowerCase() === "btrix-tab-panel" ); } private getTabs(): TabElement[] { const slotElems = ( this.renderRoot.querySelector("slot[name='nav']") as HTMLSlotElement ).assignedElements(); return ([...slotElems] as TabElement[]).filter( (el) => el.tagName.toLowerCase() === "btrix-tab" ); } private getTab(tabName?: string): TabElement | undefined { if (!tabName) return; const tabs = this.getTabs(); return tabs.find(({ name }) => name === tabName); } private onProgressChange(isFirstChange: boolean) { const progressTab = this.getTabs().find( (el) => el.name === this.progressPanel ); if (progressTab) { this.repositionIndicator(progressTab, !isFirstChange); } } private onActiveChange(isFirstChange: boolean) { this.getTabs().forEach((tab) => { if (tab.name === this.activePanel) { tab.active = true; if (!this.progressPanel) { this.repositionIndicator(tab, !isFirstChange); } } else { tab.active = false; } }); this.getPanels().forEach((panel) => { panel.active = panel.name === this.activePanel; if (panel.active) { panel.style.display = "flex"; panel.setAttribute("aria-hidden", "false"); } else { panel.style.display = "none"; panel.setAttribute("aria-hidden", "true"); } }); } }