Files
dees-catalog-mobile/ts_web/elements/00group-layout/dees-mobile-viewstack/dees-mobile-viewstack.ts

410 lines
10 KiB
TypeScript
Raw Normal View History

2025-12-22 10:53:15 +00:00
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
* <dees-mobile-viewstack initial-view="lists">
* <dees-mobile-view view-id="lists">
* <view-lists></view-lists>
* </dees-mobile-view>
* <dees-mobile-view view-id="list">
* <view-list></view-list>
* </dees-mobile-view>
* <dees-mobile-view view-id="item">
* <view-item-details></view-item-details>
* </dees-mobile-view>
* </dees-mobile-viewstack>
* ```
*
* @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<string, DeesMobileView> = 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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`
<div class="viewstack-container">
<slot @slotchange=${this.registerChildViews}></slot>
</div>
`;
}
}