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 {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-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;
// 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');
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;
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) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
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);
}
}
});
}
});
});
observer.observe(this, {childList: true, subtree: true});
}
}
_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);
}
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) {
this.fetchUriTab(target);
if (target.hasAttribute('tab')) {
setTimeout(() => {
this.setActiveTab(target.getAttribute('tab') || '', true, refresh, 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() {
// observe the DOM for changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
if (mutation.addedNodes.length > 0) {
this._registerTabs();
}
if (mutation.removedNodes.length > 0) {
for (let i = 0; i < mutation.removedNodes.length; i++) {
const node = mutation.removedNodes[i] as HTMLElement;
if (node.id) {
this.removeTabAndPanel(node.id);
}
}
}
}
});
});
observer.observe(this, {
childList: true,
subtree: true
});
}
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}
`;
}
}