initial
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user