import { DeesElement, css, cssManager, customElement, html, property, state, type TemplateResult, } from '@design.estate/dees-element'; import { mobileComponentStyles } from '../../00componentstyles.js'; import '../dees-mobile-view/dees-mobile-view.js'; import type { DeesMobileView } from '../dees-mobile-view/dees-mobile-view.js'; import { demoFunc } from './dees-mobile-viewstack.demo.js'; export interface IViewChangeEvent { currentView: string; previousView: string | null; direction: 'forward' | 'back' | 'none'; stackDepth: number; } export interface IRouterConfig { [route: string]: string; } declare global { interface HTMLElementTagNameMap { 'dees-mobile-viewstack': DeesMobileViewstack; } } /** * A programmatic view stack component for managing nested navigation with sliding transitions. * * @example * ```html * * * * * * * * * * * * ``` * * @fires view-changed - Fired when navigation completes * @fires transition-start - Fired when animation begins * @fires transition-end - Fired when animation completes */ @customElement('dees-mobile-viewstack') export class DeesMobileViewstack extends DeesElement { public static demo = demoFunc; @property({ type: String, attribute: 'initial-view' }) accessor initialView: string = ''; @state() accessor viewStack: string[] = []; @state() accessor navigationDirection: 'forward' | 'back' | 'none' = 'none'; @state() accessor isTransitioning: boolean = false; @state() accessor currentView: string | null = null; @state() accessor previousView: string | null = null; private viewRegistry: Map = new Map(); private animationDuration = 300; private connectedRouter: any = null; private routerConfig: IRouterConfig = {}; public static styles = [ cssManager.defaultStyles, mobileComponentStyles, css` :host { display: block; position: relative; width: 100%; height: 100%; overflow: hidden; background: ${cssManager.bdTheme('#ffffff', '#09090b')}; } .viewstack-container { position: relative; width: 100%; height: 100%; overflow: hidden; } ::slotted(dees-mobile-view) { position: absolute; top: 0; left: 0; right: 0; bottom: 0; } `, ]; async connectedCallback(): Promise { await super.connectedCallback(); // Wait for slot content to be available await this.updateComplete; // Register child views this.registerChildViews(); // Set initial view if (this.initialView && !this.currentView) { this.viewStack = [this.initialView]; this.currentView = this.initialView; this.activateView(this.initialView); } } private registerChildViews(): void { const slot = this.shadowRoot?.querySelector('slot'); if (!slot) return; const views = slot.assignedElements().filter( (el): el is DeesMobileView => el.tagName.toLowerCase() === 'dees-mobile-view' ); this.viewRegistry.clear(); for (const view of views) { if (view.viewId) { this.viewRegistry.set(view.viewId, view); view.active = false; } } } private activateView(viewId: string): void { const view = this.viewRegistry.get(viewId); if (view) { view.active = true; } } private deactivateView(viewId: string): void { const view = this.viewRegistry.get(viewId); if (view) { view.active = false; view.endAnimation(); } } /** * Get the current stack depth */ public get stackDepth(): number { return this.viewStack.length; } /** * Check if navigation back is possible */ public get canGoBack(): boolean { return this.viewStack.length > 1; } /** * Push a new view onto the stack (slide forward animation) */ public async pushView(viewId: string): Promise { if (this.isTransitioning) return; if (!this.viewRegistry.has(viewId)) { console.warn(`View "${viewId}" not found in viewstack`); return; } if (this.currentView === viewId) return; this.isTransitioning = true; this.navigationDirection = 'forward'; this.previousView = this.currentView; this.dispatchEvent(new CustomEvent('transition-start', { bubbles: true, composed: true, detail: { direction: 'forward', from: this.currentView, to: viewId } })); // Get view elements const currentViewEl = this.previousView ? this.viewRegistry.get(this.previousView) : null; const newViewEl = this.viewRegistry.get(viewId); // Start animations if (currentViewEl) { currentViewEl.startAnimation('leaving', 'forward'); } if (newViewEl) { newViewEl.active = true; newViewEl.startAnimation('entering', 'forward'); } // Update stack this.viewStack = [...this.viewStack, viewId]; this.currentView = viewId; // Wait for animation await this.waitForAnimation(); // Cleanup if (currentViewEl) { currentViewEl.active = false; currentViewEl.endAnimation(); } if (newViewEl) { newViewEl.endAnimation(); } this.isTransitioning = false; this.navigationDirection = 'none'; this.dispatchViewChangedEvent(); this.dispatchEvent(new CustomEvent('transition-end', { bubbles: true, composed: true })); } /** * Pop the current view and return to previous (slide back animation) */ public async popView(): Promise { if (this.isTransitioning) return; if (!this.canGoBack) return; this.isTransitioning = true; this.navigationDirection = 'back'; this.previousView = this.currentView; const previousViewId = this.viewStack[this.viewStack.length - 2]; this.dispatchEvent(new CustomEvent('transition-start', { bubbles: true, composed: true, detail: { direction: 'back', from: this.currentView, to: previousViewId } })); // Get view elements const currentViewEl = this.currentView ? this.viewRegistry.get(this.currentView) : null; const previousViewEl = this.viewRegistry.get(previousViewId); // Start animations if (currentViewEl) { currentViewEl.startAnimation('leaving', 'back'); } if (previousViewEl) { previousViewEl.active = true; previousViewEl.startAnimation('entering', 'back'); } // Update stack this.viewStack = this.viewStack.slice(0, -1); this.currentView = previousViewId; // Wait for animation await this.waitForAnimation(); // Cleanup if (currentViewEl) { currentViewEl.active = false; currentViewEl.endAnimation(); } if (previousViewEl) { previousViewEl.endAnimation(); } this.isTransitioning = false; this.navigationDirection = 'none'; this.dispatchViewChangedEvent(); this.dispatchEvent(new CustomEvent('transition-end', { bubbles: true, composed: true })); // Emit navigate-back for header integration this.dispatchEvent(new CustomEvent('navigate-back', { bubbles: true, composed: true, detail: { canGoBack: this.canGoBack } })); } /** * Replace current view without animation */ public replaceView(viewId: string): void { if (!this.viewRegistry.has(viewId)) { console.warn(`View "${viewId}" not found in viewstack`); return; } // Deactivate current view if (this.currentView) { this.deactivateView(this.currentView); } // Update stack (replace last item) if (this.viewStack.length > 0) { this.viewStack = [...this.viewStack.slice(0, -1), viewId]; } else { this.viewStack = [viewId]; } this.previousView = this.currentView; this.currentView = viewId; this.activateView(viewId); this.dispatchViewChangedEvent(); } /** * Go to root view (first in stack) */ public async goToRoot(animate: boolean = true): Promise { if (this.viewStack.length <= 1) return; const rootViewId = this.viewStack[0]; if (animate) { // Animate back to root while (this.viewStack.length > 1) { await this.popView(); } } else { // Instant navigation to root if (this.currentView) { this.deactivateView(this.currentView); } this.previousView = this.currentView; this.viewStack = [rootViewId]; this.currentView = rootViewId; this.activateView(rootViewId); this.dispatchViewChangedEvent(); } } /** * Connect an optional router for URL-based navigation */ public connectRouter(router: any, config: IRouterConfig): void { this.connectedRouter = router; this.routerConfig = config; // Listen for route changes if (router && typeof router.on === 'function') { router.on('routeChange', (route: string) => { const viewId = this.routerConfig[route]; if (viewId && viewId !== this.currentView) { this.pushView(viewId); } }); } } /** * Disconnect the router */ public disconnectRouter(): void { this.connectedRouter = null; this.routerConfig = {}; } private async waitForAnimation(): Promise { return new Promise((resolve) => setTimeout(resolve, this.animationDuration)); } private dispatchViewChangedEvent(): void { const event: IViewChangeEvent = { currentView: this.currentView || '', previousView: this.previousView, direction: this.navigationDirection, stackDepth: this.stackDepth }; this.dispatchEvent(new CustomEvent('view-changed', { bubbles: true, composed: true, detail: event })); } public render(): TemplateResult { return html`
`; } }