import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit'; import { deepQuerySelectorAll } from "../../utilities/query"; import { HasSlotController } from "../../internal/slot"; import { ifDefined } from "lit/directives/if-defined.js"; import { md5 } from "../../utilities/md5"; import { MutationController } from '@lit-labs/observers/mutation-controller.js'; import { property } from 'lit/decorators.js'; import { Store } from "../../internal/storage"; import ZincElement from '../../internal/zinc-element'; import styles from './tabs.scss'; /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/tabs * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - The default slot. * @slot example - An example slot. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnTabs extends ZincElement { static styles: CSSResultGroup = unsafeCSS(styles); @property({ attribute: 'master-id', reflect: true }) masterId: string; @property({ attribute: 'default-uri', reflect: true }) defaultUri = ''; @property({ attribute: 'active', reflect: true }) _current = ''; @property({ attribute: 'split', type: Number, reflect: true }) _split: number; @property({ attribute: 'split-min', type: Number, reflect: true }) _splitMin = 60; @property({ attribute: 'split-min-secondary', type: Number, reflect: true }) _splitMinSecondary: number; @property({ attribute: 'split-max', type: Number, reflect: true }) _splitMax: number; @property({ attribute: 'primary-caption', reflect: true }) primaryCaption = 'Navigation'; @property({ attribute: 'secondary-caption', reflect: true }) secondaryCaption = 'Content'; @property({ attribute: 'no-prefetch', type: Boolean, reflect: true }) noPrefetch = false; @property({ attribute: 'no-cache', type: Boolean, reflect: true }) noCache = false; // session storage if not local @property({ attribute: 'local-storage', type: Boolean, reflect: true }) localStorage: boolean; @property({ attribute: 'store-key' }) storeKey: string; @property({ attribute: 'store-ttl', type: Number, reflect: true }) storeTtl = 0; @property({ attribute: 'padded', type: Boolean, reflect: true }) padded = false; @property({ attribute: 'fetch-style', type: String, reflect: true }) fetchStyle = ""; @property({ attribute: 'full-width', type: Boolean, reflect: true }) fullWidth = false; @property({ attribute: 'padded-right', type: Boolean, reflect: true }) paddedRight = false; @property() monitor: string; // Creating a header @property() caption: string; @property() description: string; protected preload = true; protected _store: Store; protected _activeClicks = 0; private _panel: Element | null | undefined; private _panels: Map; private _activeTab: Element | null = null; private _tabs: HTMLElement[] = []; private _actions: HTMLElement[] = []; private _knownUri: Map = new Map(); private readonly hasSlotController = new HasSlotController(this, '[default]', 'bottom', 'right', 'left', 'top', 'actions'); private readonly _domObserver = new MutationController(this, { target: null, config: { childList: true, subtree: true }, callback: mutations => { mutations.forEach(mutation => { if (mutation.type !== 'childList') return; if (mutation.addedNodes.length > 0) { this._registerTabs(); } if (mutation.removedNodes.length > 0) { mutation.removedNodes.forEach(node => { const id = node instanceof HTMLElement ? node.id : ''; if (id) this.removeTabAndPanel(id); }); } }); }, }); private readonly _monitorObserver = new MutationController(this, { target: null, config: { childList: true, subtree: true }, callback: mutations => { mutations.forEach(mutation => { if (mutation.type !== 'childList') return; mutation.addedNodes.forEach(node => { if (node instanceof HTMLElement && node.id === this.monitor) { this.reRegisterTabs(); const storedValue = this._store.get(this.storeKey); if (storedValue !== null) { this._prepareTab(storedValue); this.setActiveTab(storedValue, false, false); } } }); }); }, }); constructor() { super(); this._panels = new Map(); } async connectedCallback() { super.connectedCallback(); if (!this.masterId) { this.masterId = this.storeKey || Math.floor(Math.random() * 1000000).toString(); } this.preload = !this.noPrefetch && !this.noCache; await this.updateComplete; this._panel = this.shadowRoot?.querySelector('#content'); this.observerDom(); this._registerTabs(); if (this.storeKey && this.storeTtl === 0) { // Default tab storage to 5 minutes this.storeTtl = 300; } const defaultID = this.defaultUri ? this._uriToId(this.defaultUri) : ''; this._store = new Store(this.localStorage ? window.localStorage : window.sessionStorage, "zntab:", this.storeTtl); Array.from(this.children).forEach((element) => { if (element.slot === '') { this._panels.set(element.getAttribute('id') || defaultID, [element]); } }); this.observerDom(); this.monitorDom(); } monitorDom() { if (this.monitor) { this._monitorObserver.observe(this); } } _addPanel(panel: HTMLElement) { if (this._panels.has(panel.getAttribute('id')!)) { return; } this._panels.set(panel.getAttribute('id')!, [panel]); } _addTab(tab: HTMLElement) { if (this._tabs.includes(tab)) { return; } this._tabs.push(tab); if (this.preload) { tab.addEventListener('mouseover', this.fetchUriTab.bind(this, tab)); } tab.addEventListener('click', this._handleClick.bind(this)); } reRegisterTabs = () => { this._registerTabs(); } firstUpdated(_changedProperties: PropertyValues) { super.firstUpdated(_changedProperties); setTimeout(() => { this._registerTabs(); const storedValue = this._store.get(this.storeKey); if (storedValue !== null) { this._prepareTab(storedValue); this.setActiveTab(storedValue, false, false); return; } const defaultTab = this._current || ''; if (!this._panels.has(defaultTab) && this._tabs.length > 0) { const tabUri = this._tabs[0].getAttribute('tab-uri'); if (tabUri) { this.clickTab(this._tabs[0], false); return; } } this.setActiveTab(defaultTab, false, false); }, 10); this.addEventListener('zn-menu-select', () => { setTimeout(this.reRegisterTabs, 200); }, { passive: true }); } switchTab(inc: number) { const panSize = this._panels.size; if (panSize < 2) { return } const currentIndex = Array.from(this._panels.keys()).indexOf(this._current); let nextIndex = currentIndex + inc; if (nextIndex < 0) { nextIndex = panSize - 1; // wrap around to the last tab } else if (nextIndex >= panSize) { nextIndex = 0; // wrap around to the first tab } const nextTabId = Array.from(this._panels.keys())[nextIndex]; this.setActiveTab(nextTabId, true, false); } nextTab() { this.switchTab(1); } previousTab() { this.switchTab(-1); } _prepareTab(tabId: string) { for (const tab of this._tabs) { if (tab.getAttribute('tab') === tabId) { return; } } for (const uriTab of deepQuerySelectorAll("[tab-uri]", this, '')) { const uri: string = uriTab.getAttribute("tab-uri")!; const eleTabId = this._uriToId(uri); if (eleTabId === tabId) { this._createUriPanel(uriTab, uri, eleTabId); // do not break, as multiple tabs can have the same uri } } } _uriToId(tabUri: string): string { return "tab-" + md5(tabUri).substr(0, 8) + "-" + this.masterId; } _createUriPanel(tabEle: Element, tabUri: string, tabId: string): HTMLDivElement { if (!tabEle.hasAttribute('tab')) { tabEle.setAttribute('tab', tabId); this._setTabEleActive(tabEle, this._current === tabId); } if (!this._knownUri.has(tabUri)) { this._knownUri.set(tabUri, tabId); } if (this._panels.has(tabId) && this._panels.get(tabId) !== undefined) { return this._panels.get(tabId)![0] as HTMLDivElement; } const tabNode = document.createElement('div'); tabNode.setAttribute("id", tabId); if (this.fetchStyle !== "") { tabNode.setAttribute("data-fetch-style", this.fetchStyle); } else if (tabEle.hasAttribute('data-fetch-style')) { tabNode.setAttribute("data-fetch-style", tabEle.getAttribute('data-fetch-style') ?? ""); } tabNode.setAttribute('data-self-uri', tabUri); tabNode.textContent = "Loading ..."; if (this._panel instanceof HTMLElement) { // Append the tab if the panel has not yet been constructed this._panel.appendChild(tabNode); this._panels.set(tabId, [tabNode]); } document.dispatchEvent(new CustomEvent('zn-new-element', { detail: { element: tabNode, source: tabEle } })); return tabNode; } _handleClick(event: PointerEvent) { // ts-ignore const target = (event.relatedTarget ?? event.target) as HTMLElement; if (target) { if ('startViewTransition' in document) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (document as any).startViewTransition(() => this.clickTab(target, event.altKey)); } else { this.clickTab(target, event.altKey) } } } fetchUriTab(target: HTMLElement) { if (!target.hasAttribute('tab') && target.hasAttribute('tab-uri')) { const tabUri: string | null = target.getAttribute("tab-uri") ?? ""; this._createUriPanel(target, tabUri, this._uriToId(tabUri)); target.setAttribute('tab-id', this._uriToId(tabUri)); } } clickTab(target: HTMLElement, refresh: boolean) { const tabUri = target.getAttribute('tab-uri'); const wasCached = !!tabUri && this._panels.has(this._uriToId(tabUri)); this.fetchUriTab(target); if (target.hasAttribute('tab')) { const forceRefresh = refresh || (this.noCache && wasCached); setTimeout(() => { this.setActiveTab(target.getAttribute('tab') || '', true, forceRefresh, this.getRefTab(target)); }, 10); } } getRefTab(target: HTMLElement) { let parent: Element = target; while (parent) { if (parent === this) { return null; } if (parent.hasAttribute('ref-tab')) { return parent.getAttribute('ref-tab'); } // @ts-expect-error host might exist parent = (parent?.parentElement as Element) || parent?.getRootNode()?.host; } return null; } setActiveTab(tabName: string, store: boolean, refresh: boolean, refTab: string | null = null) { let hasActive = false; this._tabs.forEach(tab => { if (tab.hasAttribute('tab-uri') && this._knownUri.has(tab.getAttribute('tab-uri')!)) { tab.setAttribute('tab', this._knownUri.get(tab.getAttribute('tab-uri')!)!); } let setActive = tabName === tab.getAttribute('tab'); if (!setActive && refTab && !this.getRefTab(tab)) { setActive = refTab === tab.getAttribute('tab'); } hasActive = hasActive || setActive; this._setTabEleActive(tab, setActive); }); if (!hasActive && this._tabs.length > 0) { this._setTabEleActive(this._tabs[0], true); } this._actions.forEach(action => this._setTabEleActive(action, action.getAttribute('ref-tab') === (refTab || tabName))); this.selectTab(tabName, refresh); //Set on the element as a failsafe before TabPanel is loaded //This must be done AFTER selectTab to avoid panel bugs if (store && this._store !== null) { this._store.set(this.storeKey, tabName); } } _setTabEleActive(ele: Element, active: boolean) { if (active) { this._activeTab = ele } ele.classList.toggle('zn-tb-active', active); ele.classList.toggle('active', active); } selectTab(tabName: string, refresh: boolean): boolean { if (tabName && !this._panels.has(tabName)) { return false; } const sameTab = this._current === tabName; // Multi click on an active tab will refresh the tab if (sameTab) { this._activeClicks++; if (this._activeClicks > 2) { refresh = true; this._activeClicks = 0; } } else { this._activeClicks = 0; } let inSlot = true; const updateTabs = () => { this._panels.forEach((elements, key) => { const isActive = key === tabName; elements.forEach((element) => { if (isActive && element.parentNode !== this) { inSlot = false; } element.toggleAttribute('selected', isActive); if (isActive && refresh) { let uri = ""; let gaid = ""; if (this._activeTab && this._activeTab.hasAttribute('tab-uri')) { uri = this._activeTab.getAttribute('tab-uri')!; gaid = this._activeTab.getAttribute('gaid')!; } document.dispatchEvent(new CustomEvent('zn-refresh-element', { detail: { element: element, uri: uri, gaid: gaid } })); } }); }); if (this._panel) { this._panel.classList.toggle('contents-slot', !inSlot); } this._current = tabName; }; updateTabs(); return true; } getActiveTab(): Element[] { return this._panels.get(this._current) || []; } observerDom() { this._domObserver.observe(this); } removeTabAndPanel(tabId: string) { if (this._current === tabId) { this.setActiveTab('', true, false); } for (const tab of this._tabs) { if (tab.getAttribute('tab') === tabId) { if (tab.hasAttribute('tab-uri')) { const tabUri = tab.getAttribute('tab-uri')!; if (this._knownUri.has(tabUri)) { this._knownUri.delete(tabUri); } } //tab.remove(); tab.parentElement?.removeChild(tab) this._tabs.splice(this._tabs.indexOf(tab), 1); } } if (this._panels.has(tabId)) { const panel = this._panels.get(tabId); if (panel instanceof HTMLElement) { panel.remove(); } else if (panel instanceof Array) { panel.forEach((item) => { if (item instanceof HTMLElement) { item.remove(); } }) } this._panels.delete(tabId); } } _registerTabs = () => { deepQuerySelectorAll('[tab]', this, 'zn-tabs').forEach(ele => { this._addTab(ele as HTMLElement); }); deepQuerySelectorAll('[tab-uri]', this, 'zn-tabs').forEach(ele => { if (ele.getAttribute('tab-uri') === "") { ele.setAttribute('tab', ""); ele.removeAttribute('tab-uri'); } this._addTab(ele as HTMLElement); }); deepQuerySelectorAll('[ref-tab-uri]', this, 'zn-tabs').forEach(ele => { if (!ele.hasAttribute('ref-tab')) { ele.setAttribute('ref-tab', this._uriToId(ele.getAttribute('ref-tab-uri')!)); ele.removeAttribute('ref-tab-uri'); this._actions.push(ele as HTMLElement); } }); } render() { const hasActionSlot = this.hasSlotController.test('actions'); const hasCaption = this.caption && this.caption.length > 0; const hasDescription = this.description && this.description.length > 0; const hasHeader = hasCaption || hasActionSlot || hasDescription; if (this._split > 0) { let storeKey: string | null = this.storeKey; if (storeKey) { storeKey += "-split"; } let contentSlot = 'secondary'; if (this.querySelectorAll('[slot="right"]').length > 0) { contentSlot = 'primary'; } return html`
`; } return html`
${hasHeader ? html` ` : null}
`; } }