import {classMap} from 'lit/directives/class-map.js'; import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit'; import {HasSlotController} from '../../internal/slot'; import {property, state} from 'lit/decorators.js'; import ZnButton from '../button'; import ZnCopyButton from '../copy-button'; import ZnExpandingAction from '../expanding-action'; import ZnIcon from '../icon'; import ZnNavbar from '../navbar'; import ZnTab from '../tab'; import ZnTabs from '../tabs'; import type {ZnSelectEvent} from '../../events/zn-select'; import styles from './page.scss'; interface TabDefinition { id: string; caption: string; priority: number | null; sourceIndex: number; uri: string | null; slotName: string | null; selected: boolean; } /** * @summary Combines a page header with tab navigation and tab panels. * @documentation https://zinc.style/components/page * @status experimental * @since 1.0 * * @slot - Page content. Use zn-tab for named tabs and header-action/header-actions for header actions. * @slot bottom - Content rendered below the navbar row (e.g. chips, filters). Forwarded to the navbar's bottom slot. */ export default class ZnPage extends ZnTabs { static styles: CSSResultGroup = [ZnTabs.styles, unsafeCSS(styles)]; static dependencies = { 'zn-button': ZnButton, 'zn-copy-button': ZnCopyButton, 'zn-expanding-action': ZnExpandingAction, 'zn-icon': ZnIcon, 'zn-navbar': ZnNavbar, 'zn-tab': ZnTab }; private readonly pageSlotController = new HasSlotController(this, 'breadcrumb', 'actions', 'caption'); @property() caption: string; @property({attribute: 'entity-id'}) entityId: string; @property({attribute: 'entity-id-show', type: Boolean}) entityIdShow: boolean; @property({attribute: 'full-location'}) fullLocation: string; @property({type: Boolean, reflect: true}) modal = false; @property({type: Boolean, reflect: true}) nested = false; @property({type: Boolean, reflect: true}) primary = false; // margin @property({attribute: 'previous-path'}) previousPath: string; @property({attribute: 'previous-target'}) previousTarget: string; @property() summary: string; @state() private scrolled = false; @state() private tabDefinitions: TabDefinition[] = []; @state() private hasExpandingActions = false; private actionObserver: MutationObserver | null = null; private tabObserver: MutationObserver | null = null; async connectedCallback() { const connected = super.connectedCallback(); window.addEventListener('alt-press', this.handleAltPress); window.addEventListener('alt-up', this.handleAltUp); this.prepareTabs(); this.refreshExpandingActionsState(); this.tabObserver = new MutationObserver((mutations) => { if (mutations.some(mutation => mutation.type === 'childList')) { this.prepareTabs(); } }); this.tabObserver.observe(this, {childList: true}); this.actionObserver = new MutationObserver((mutations) => { if (mutations.some(mutation => mutation.type === 'childList')) { this.refreshExpandingActionsState(); this.syncExpandingActionsToNavbar(); } }); this.actionObserver.observe(this, {childList: true, subtree: true}); await connected; } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('alt-press', this.handleAltPress); window.removeEventListener('alt-up', this.handleAltUp); this.tabObserver?.disconnect(); this.tabObserver = null; this.actionObserver?.disconnect(); this.actionObserver = null; } private handleAltPress = () => { this.classList.toggle('alt-pressed', true); }; private handleAltUp = () => { this.classList.toggle('alt-pressed', false); }; _registerTabs = () => { this.registerPageNavigationTabs(); }; firstUpdated(changedProperties: PropertyValues) { super.firstUpdated(changedProperties); this.registerPagePanels(); this.syncExpandingActionsToNavbar(); setTimeout(() => this.activateInitialPageTab(), 20); } private getOwnExpandingActions() { return Array.from(this.querySelectorAll('zn-expanding-action')) .filter(action => action.closest('zn-page') === this); } private getNavbar() { return this.shadowRoot?.querySelector('zn-navbar') || null; } private refreshExpandingActionsState() { const navbar = this.getNavbar(); const hasNavbarActions = Boolean( navbar?.querySelector('zn-expanding-action') || navbar?.shadowRoot?.querySelector('zn-expanding-action') ); const hasExpandingActions = this.getOwnExpandingActions().length > 0 || hasNavbarActions; if (this.hasExpandingActions !== hasExpandingActions) { this.hasExpandingActions = hasExpandingActions; } } private syncExpandingActionsToNavbar() { const navbar = this.getNavbar(); if (!navbar) { return; } this.getOwnExpandingActions().forEach(action => navbar.addExpandingAction(action)); this.refreshExpandingActionsState(); } private prepareTabs() { const tabs: TabDefinition[] = []; const usedIds = new Set(); const tabElements = Array.from(this.children).filter((node): node is HTMLElement => node.tagName === 'ZN-TAB'); tabElements.forEach((tab, index) => { const caption = tab.getAttribute('caption') || 'Tab'; const explicitId = tab.getAttribute('id'); const id = this.uniqueId(explicitId !== null ? explicitId : this.captionToId(caption), usedIds); const priority = this.parsePriority(tab.getAttribute('priority')); const uri = tab.getAttribute('uri'); const slotName = uri ? null : `page-tab-${index}`; const selected = tab.hasAttribute('selected'); tab.id = id; if (!uri) { tab.slot = slotName!; } tabs.push({id, caption, priority, sourceIndex: index, uri, slotName, selected}); }); this.tabDefinitions = tabs.sort((a, b) => this.compareTabs(a, b)); } private parsePriority(value: string | null): number | null { if (value === null || value.trim() === '') { return null; } const priority = Number(value); return Number.isFinite(priority) ? priority : null; } private compareTabs(a: TabDefinition, b: TabDefinition): number { if (a.id === '' && b.id !== '') { return -1; } if (a.id !== '' && b.id === '') { return 1; } if (a.priority !== null && b.priority !== null && a.priority !== b.priority) { return a.priority - b.priority; } if (a.priority !== null && b.priority === null) { return -1; } if (a.priority === null && b.priority !== null) { return 1; } return a.sourceIndex - b.sourceIndex; } private captionToId(caption: string): string { if (caption.trim().toLowerCase() === 'overview') { return ''; } const id = caption.trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return id || 'tab'; } private uniqueId(id: string, usedIds: Set): string { let nextId = id; let count = 2; while (usedIds.has(nextId)) { nextId = id === '' ? `tab-${count}` : `${id}-${count}`; count += 1; } usedIds.add(nextId); return nextId; } private handlePageScroll(event: Event) { const scrolled = (event.currentTarget as HTMLElement).scrollTop > 24; if (this.scrolled !== scrolled) { this.scrolled = scrolled; } } updated(changedProperties: PropertyValues) { super.updated(changedProperties); if (changedProperties.has('tabDefinitions')) { this.registerPagePanels(); } if (changedProperties.has('tabDefinitions') || changedProperties.has('hasExpandingActions')) { this.syncExpandingActionsToNavbar(); } } private handleNavigationSelect(event: ZnSelectEvent) { const item = event.detail.item as HTMLElement; const tabUri = item.getAttribute('tab-uri'); const tabId = item.getAttribute('tab'); if (tabUri) { this.clickTab(item, false); this.syncNavigationActive(item); return; } if (tabId !== null) { this.activateTab(tabId, true); } } private activateTab(tabId: string, store: boolean) { this.setActiveTab(tabId, store, false); const navItem = this.shadowRoot?.querySelector(`zn-navbar li[tab="${CSS.escape(tabId)}"]`); if (navItem) { this.syncNavigationActive(navItem); } } private activateInitialPageTab() { const preselected = this.tabDefinitions.find(tab => tab.selected); if (preselected) { this.activateTabDefinition(preselected); return; } const selectedPanel = this.shadowRoot?.querySelector('#content > div[selected]'); const firstTab = this.tabDefinitions[0]; if (!selectedPanel && firstTab) { this.activateTab(firstTab.id, false); } } private activateTabDefinition(tab: TabDefinition) { if (tab.uri) { const navItem = this.findNavItemForUri(tab.uri); if (navItem) { this.clickTab(navItem, false); this.syncNavigationActive(navItem); return; } } this.activateTab(tab.id, false); } private findNavItemForUri(uri: string): HTMLElement | null { const items = this.shadowRoot?.querySelectorAll('zn-navbar li[tab-uri]') ?? []; for (const item of Array.from(items)) { if (item.getAttribute('tab-uri') === uri) { return item; } } return null; } private registerPagePanels() { this.shadowRoot?.querySelectorAll('#content > div[id]').forEach(panel => { this._addPanel(panel); }); this.registerPageNavigationTabs(); setTimeout(() => this.registerPageNavigationTabs()); } private registerPageNavigationTabs() { this.shadowRoot ?.querySelectorAll('zn-navbar li[tab], zn-navbar li[tab-uri]') .forEach(tab => this._addTab(tab)); this.shadowRoot ?.querySelectorAll('zn-navbar') .forEach(navbar => { navbar.shadowRoot ?.querySelectorAll('li[tab], li[tab-uri]') .forEach(tab => this._addTab(tab)); }); } private syncNavigationActive(activeItem: HTMLElement) { const navItems = this.shadowRoot?.querySelectorAll('zn-navbar li[tab], zn-navbar li[tab-uri]') || []; navItems.forEach(item => { const active = item === activeItem; item.classList.toggle('active', active); item.classList.toggle('zn-tb-active', active); }); } render() { const hasBreadcrumb = !this.modal && !this.nested && this.pageSlotController.test('breadcrumb'); const hasNavigation = this.tabDefinitions.length > 1 || this.hasExpandingActions; const hasEntityId = this.entityId; const hasFullLocation = this.fullLocation; const hasPreviousPath = this.previousPath; return html`
${hasFullLocation || hasEntityId ? html`
${hasFullLocation ? html` ` : null} ${hasEntityId ? html` ` : null} ${hasFullLocation ? html` ` : null}
` : null}
${hasPreviousPath ? html` ` : null}
${hasBreadcrumb ? html` ` : null} ${this.caption} ${this.summary ? html` ${this.summary}` : null}
${hasNavigation ? html` ${this.tabDefinitions.map(tab => tab.uri ? html`
  • ${tab.caption}
  • ` : html`
  • ${tab.caption}
  • `)}
    ` : null}
    ${this.tabDefinitions .filter(tab => tab.slotName !== null) .map(tab => html`
    `)}
    `; } }