import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit'; import {deepQuerySelectorAll} from "../../utilities/query"; import { endVisitsToOtherLocations, getHistoryTab, isRestorableLocation, locationScopedKey, pushHistoryTab, replaceHistoryTab, TAB_STORE_PREFIX } from './tabs-navigation'; import {getScrolledAncestors, scrollToTop} from "../../internal/scroll"; 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'; // Tabs load in such a way that the global sheet has to be re-applied inside the shadow root. import bootStyles from '../../../scss/boot.scss'; import styles from './tabs.scss'; // Every element that manages its own tabs (ZnTabs and its subclasses). Tab // registration must stop at these so an outer container never claims a nested // container's tabs. const tabContainerSelector = 'zn-tabs, zn-page, zn-page-nav'; /** * @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(bootStyles), 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 _defaultTab = ''; 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.getStoredTab(); if (storedValue !== null) { this.restoreStoredTab(storedValue); } } }); }); }, }); 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, TAB_STORE_PREFIX, this.storeTtl); Array.from(this.children).forEach((element) => { if (element.slot === '') { this._panels.set(element.getAttribute('id') || defaultID, [element]); } }); this.observerDom(); this.monitorDom(); window.addEventListener('popstate', this.handlePopState, {passive: true}); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('popstate', this.handlePopState); // The container is torn down before the url it is being replaced by is // pushed, so the check waits a task: by then a navigation has changed the // location and this visit is over, while a re-render is back on screen. setTimeout(() => { if (!this.isConnected) { endVisitsToOtherLocations(); } }); } monitorDom() { if (this.monitor) { this._monitorObserver.observe(this); } } /** The key the active tab is persisted under. Null disables persistence. */ protected getTabStoreKey(): string | null { return this.storeKey || null; } protected getStoredTab(): string | null { const key = this.getTabStoreKey(); if (key === null || !this._store) { return null; } // Local storage persists across fresh navigation and browser sessions. if (this.localStorage) { return this._store.get(key); } // Nothing is remembered for a location that has been navigated away from: // its stored tab went with the visit, and so did the tabs on its entries. const stored = this._store.get(locationScopedKey(key)); if (stored === null) { return null; } // The history entry is authoritative where it survived; a reload wipes it, // leaving the selection the location was last left on. const fromHistory = getHistoryTab(key); if (fromHistory !== null) { return fromHistory; } return isRestorableLocation() ? stored : null; } protected storeActiveTab(tabName: string) { const key = this.getTabStoreKey(); if (key === null || !this._store) { return; } // Uri tab ids are derived from the master id, so store the uri itself. const value = this.getUriForTabId(tabName) ?? tabName; this._store.set(this.localStorage ? key : locationScopedKey(key), value); if (!this.localStorage) { replaceHistoryTab(key, value); } } protected restoreStoredTab(stored: string) { const uriTab = this._tabs.find(tab => tab.getAttribute('tab-uri') === stored); if (uriTab) { this.clickTab(uriTab, false); return; } this._prepareTab(stored); this.setActiveTab(stored, true, false); } /** Activates the tab the container starts on, ignoring any stored selection. */ protected activateDefaultTab() { if (!this._panels.has(this._defaultTab) && this._tabs.length > 0) { const tabUri = this._tabs[0].getAttribute('tab-uri'); if (tabUri) { this.clickTab(this._tabs[0], false); return; } } this.setActiveTab(this._defaultTab, true, false); } // Back and forward move between recorded tabs. private readonly handlePopState = () => { const key = this.getTabStoreKey(); if (key === null) { return; } // Only entries that name a tab move the container. Entries that say nothing // about it - the extra one the console pushes for every document load, one // pushed before this container was on screen, one belonging to another page, // one left by a visit that has ended - leave the open tab where it is. // Overwriting it with the default tab would show the first tab on the way // through, in place of the tab the entry being stepped onto stands for. const tab = getHistoryTab(key); if (tab === null) { return; } this.restoreStoredTab(tab); }; private pushTabHistory(tabName: string) { const key = this.getTabStoreKey(); if (key === null || this.localStorage) { return; } pushHistoryTab(key, this.getUriForTabId(tabName) ?? tabName); } private getUriForTabId(tabId: string): string | null { for (const [uri, id] of this._knownUri) { if (id === tabId) { return uri; } } return null; } _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(); this._defaultTab = this._current || ''; const storedValue = this.getStoredTab(); if (storedValue !== null) { this.restoreStoredTab(storedValue); return; } this.activateDefaultTab(); }, 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, null, true); } 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, tabContainerSelector)) { 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) { return; } if (!('startViewTransition' in document)) { this.clickTab(target, event.altKey, true); return; } const transition = document.startViewTransition(() => this.clickTab(target, event.altKey, true)); void transition.ready.catch(() => undefined); void transition.finished.catch(() => undefined); void transition.updateCallbackDone.catch(() => undefined); } 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, pushHistory = false) { 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), pushHistory); }, 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, pushHistory = false) { const previous = this._current; 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 // The entry must be pushed before the tab is recorded, so the entry being // left keeps the tab it was showing. if (pushHistory && this._current === tabName && previous !== tabName) { this.pushTabHistory(tabName); } if (store) { this.storeActiveTab(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; }; const scrolled = sameTab ? [] : getScrolledAncestors(this._panel); updateTabs(); scrollToTop(scrolled); 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, tabContainerSelector).forEach(ele => { this._addTab(ele as HTMLElement); }); deepQuerySelectorAll('[tab-uri]', this, tabContainerSelector).forEach(ele => { if (ele.getAttribute('tab-uri') === "") { ele.setAttribute('tab', ""); ele.removeAttribute('tab-uri'); } this._addTab(ele as HTMLElement); }); deepQuerySelectorAll('[ref-tab-uri]', this, tabContainerSelector).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}
`; } }