initial
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import { injectCssVariables } from '../../00variables.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
injectCssVariables();
|
||||
return html`
|
||||
<style>
|
||||
.demo-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.demo-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--dees-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.layout-container {
|
||||
border: 1px solid var(--dees-border);
|
||||
border-radius: var(--dees-radius);
|
||||
overflow: hidden;
|
||||
height: 400px;
|
||||
}
|
||||
.demo-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
.demo-card {
|
||||
background: var(--dees-card);
|
||||
border: 1px solid var(--dees-border);
|
||||
border-radius: var(--dees-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.demo-note {
|
||||
font-size: 0.875rem;
|
||||
color: var(--dees-muted-foreground);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>App Layout with Navigation</h3>
|
||||
<div class="layout-container">
|
||||
<dees-mobile-applayout>
|
||||
<dees-mobile-header title="My App">
|
||||
<dees-mobile-button slot="actions" icon variant="ghost">
|
||||
<dees-mobile-icon icon="bell" size="20"></dees-mobile-icon>
|
||||
</dees-mobile-button>
|
||||
</dees-mobile-header>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="demo-card">
|
||||
<h4 style="margin: 0 0 0.5rem 0;">Welcome</h4>
|
||||
<p style="margin: 0; color: var(--dees-muted-foreground);">
|
||||
This is a demo of the app layout component with header and bottom navigation.
|
||||
</p>
|
||||
</div>
|
||||
<div class="demo-card">
|
||||
<h4 style="margin: 0 0 0.5rem 0;">Features</h4>
|
||||
<ul style="margin: 0; padding-left: 1.25rem; color: var(--dees-muted-foreground);">
|
||||
<li>iOS keyboard handling</li>
|
||||
<li>View transitions</li>
|
||||
<li>Safe area support</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dees-mobile-navigation
|
||||
slot="navigation"
|
||||
activeTab="home"
|
||||
.tabs=${[
|
||||
{ id: 'home', icon: 'home', label: 'Home' },
|
||||
{ id: 'explore', icon: 'compass', label: 'Explore' },
|
||||
{ id: 'settings', icon: 'settings', label: 'Settings' }
|
||||
]}
|
||||
></dees-mobile-navigation>
|
||||
</dees-mobile-applayout>
|
||||
</div>
|
||||
<p class="demo-note">
|
||||
The app layout provides a grid structure with content area and bottom navigation.
|
||||
It automatically hides navigation when keyboard is visible on mobile.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { mobileComponentStyles } from '../../00componentstyles.js';
|
||||
import { demoFunc } from './dees-mobile-applayout.demo.js';
|
||||
|
||||
export type TNavigationDirection = 'forward' | 'back' | 'none';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-mobile-applayout': DeesMobileApplayout;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-mobile-applayout')
|
||||
export class DeesMobileApplayout extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: 'keyboard-visible' })
|
||||
accessor keyboardVisible: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor showNavigation: boolean = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor isIOS: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor isPWA: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor navigationDirection: TNavigationDirection = 'none';
|
||||
|
||||
@state()
|
||||
accessor isTransitioning: boolean = false;
|
||||
|
||||
private keyboardBlurTimeout?: number;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
mobileComponentStyles,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--dees-background);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"spacer"
|
||||
"content"
|
||||
"navigation";
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ios-keyboard-spacer {
|
||||
grid-area: spacer;
|
||||
height: 0;
|
||||
transition: height 300ms ease-out;
|
||||
background: var(--dees-background);
|
||||
}
|
||||
|
||||
.ios-keyboard-spacer.active {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
/* Mobile-first: smooth scrolling behavior for keyboard visibility */
|
||||
:host([keyboard-visible]) .main-content {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.main-content {
|
||||
grid-area: content;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* View transition container */
|
||||
.view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Forward navigation: entering view slides from right */
|
||||
.view-wrapper.entering.forward {
|
||||
animation: slideInFromRight 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Forward navigation: exiting view slides to left with fade */
|
||||
.view-wrapper.exiting.forward {
|
||||
animation: slideOutToLeft 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Back navigation: entering view slides from left */
|
||||
.view-wrapper.entering.back {
|
||||
animation: slideInFromLeft 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Back navigation: exiting view slides to right */
|
||||
.view-wrapper.exiting.back {
|
||||
animation: slideOutToRight 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* No animation */
|
||||
.view-wrapper.entering.none {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@keyframes slideInFromRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToLeft {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-30%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom navigation */
|
||||
.navigation-slot {
|
||||
grid-area: navigation;
|
||||
z-index: var(--dees-z-sticky, 200);
|
||||
}
|
||||
|
||||
/* Mobile-first: hide bottom navigation when keyboard is visible */
|
||||
:host([keyboard-visible]) .navigation-slot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Desktop: show navigation even with keyboard */
|
||||
@media (min-width: 641px) {
|
||||
:host([keyboard-visible]) .navigation-slot {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-first: allow overflow during drag */
|
||||
:host-context(body.dragging) .app-layout {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
:host-context(body.dragging) .main-content {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Desktop: maintain normal overflow behavior during drag */
|
||||
@media (min-width: 641px) {
|
||||
:host-context(body.dragging) .app-layout {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host-context(body.dragging) .main-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
color: var(--dees-muted-foreground);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 3px solid var(--dees-border);
|
||||
border-top-color: var(--dees-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
|
||||
// Listen for keyboard events
|
||||
this.addEventListener('input-focus', this.handleInputFocus as EventListener);
|
||||
this.addEventListener('input-blur', this.handleInputBlur as EventListener);
|
||||
|
||||
// Detect iOS PWA
|
||||
this.detectEnvironment();
|
||||
|
||||
// Listen for viewport changes to detect keyboard (iOS PWA only)
|
||||
if (this.isIOS && this.isPWA && 'visualViewport' in window) {
|
||||
window.visualViewport?.addEventListener('resize', this.handleViewportResize);
|
||||
this.handleViewportResize();
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
this.removeEventListener('input-focus', this.handleInputFocus as EventListener);
|
||||
this.removeEventListener('input-blur', this.handleInputBlur as EventListener);
|
||||
|
||||
if (this.isIOS && this.isPWA && 'visualViewport' in window) {
|
||||
window.visualViewport?.removeEventListener('resize', this.handleViewportResize);
|
||||
}
|
||||
}
|
||||
|
||||
private detectEnvironment() {
|
||||
// Detect iOS
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
// Detect PWA mode
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
|
||||
this.isIOS = isIOS;
|
||||
this.isPWA = isPWA;
|
||||
}
|
||||
|
||||
private handleInputFocus = () => {
|
||||
if (this.keyboardBlurTimeout) {
|
||||
clearTimeout(this.keyboardBlurTimeout);
|
||||
this.keyboardBlurTimeout = undefined;
|
||||
}
|
||||
|
||||
if (window.innerWidth <= 640) {
|
||||
this.keyboardVisible = true;
|
||||
}
|
||||
};
|
||||
|
||||
private handleInputBlur = () => {
|
||||
if (this.keyboardBlurTimeout) {
|
||||
clearTimeout(this.keyboardBlurTimeout);
|
||||
}
|
||||
|
||||
if (window.innerWidth <= 640) {
|
||||
this.keyboardBlurTimeout = window.setTimeout(() => {
|
||||
this.keyboardVisible = false;
|
||||
this.keyboardBlurTimeout = undefined;
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
private handleViewportResize = () => {
|
||||
if (window.visualViewport) {
|
||||
const viewport = window.visualViewport;
|
||||
const keyboardHeight = window.innerHeight - viewport.height;
|
||||
|
||||
if (keyboardHeight > 50) {
|
||||
this.keyboardVisible = true;
|
||||
} else {
|
||||
this.keyboardVisible = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate with animation transition
|
||||
* Call this method when changing views
|
||||
*/
|
||||
public navigateWithTransition(direction: TNavigationDirection) {
|
||||
if (this.isTransitioning) return;
|
||||
|
||||
this.navigationDirection = direction;
|
||||
this.isTransitioning = true;
|
||||
|
||||
// Clear transition state after animation
|
||||
setTimeout(() => {
|
||||
this.isTransitioning = false;
|
||||
this.navigationDirection = 'none';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const showKeyboardSpacer = this.keyboardVisible && this.isIOS && this.isPWA;
|
||||
|
||||
return html`
|
||||
<div class="app-layout">
|
||||
<div class="ios-keyboard-spacer ${showKeyboardSpacer ? 'active' : ''}"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="view-container">
|
||||
<div class="view-wrapper entering ${this.navigationDirection}">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
${this.showNavigation ? html`
|
||||
<div class="navigation-slot">
|
||||
<slot name="navigation"></slot>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-mobile-applayout.js';
|
||||
@@ -0,0 +1,87 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import { injectCssVariables } from '../../00variables.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
injectCssVariables();
|
||||
return html`
|
||||
<style>
|
||||
.demo-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.demo-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--dees-muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.nav-container {
|
||||
border: 1px solid var(--dees-border);
|
||||
border-radius: var(--dees-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.demo-note {
|
||||
font-size: 0.875rem;
|
||||
color: var(--dees-muted-foreground);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Bottom Navigation</h3>
|
||||
<div class="nav-container">
|
||||
<dees-mobile-navigation
|
||||
activeTab="home"
|
||||
.tabs=${[
|
||||
{ id: 'home', icon: 'home', label: 'Home' },
|
||||
{ id: 'search', icon: 'search', label: 'Search' },
|
||||
{ id: 'favorites', icon: 'heart', label: 'Favorites' },
|
||||
{ id: 'profile', icon: 'user', label: 'Profile' }
|
||||
]}
|
||||
@tab-change=${(e: CustomEvent) => {
|
||||
const nav = e.target as any;
|
||||
nav.activeTab = e.detail.tab;
|
||||
}}
|
||||
></dees-mobile-navigation>
|
||||
</div>
|
||||
<p class="demo-note">Click tabs to switch between them.</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Navigation with Badges</h3>
|
||||
<div class="nav-container">
|
||||
<dees-mobile-navigation
|
||||
activeTab="inbox"
|
||||
.tabs=${[
|
||||
{ id: 'inbox', icon: 'inbox', label: 'Inbox', badge: 3 },
|
||||
{ id: 'sent', icon: 'send', label: 'Sent' },
|
||||
{ id: 'drafts', icon: 'file-text', label: 'Drafts', badge: 1 },
|
||||
{ id: 'trash', icon: 'trash-2', label: 'Trash' }
|
||||
]}
|
||||
@tab-change=${(e: CustomEvent) => {
|
||||
const nav = e.target as any;
|
||||
nav.activeTab = e.detail.tab;
|
||||
}}
|
||||
></dees-mobile-navigation>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Three Tab Navigation</h3>
|
||||
<div class="nav-container">
|
||||
<dees-mobile-navigation
|
||||
activeTab="lists"
|
||||
.tabs=${[
|
||||
{ id: 'lists', icon: 'list', label: 'Lists' },
|
||||
{ id: 'coupons', icon: 'ticket', label: 'Coupons' },
|
||||
{ id: 'settings', icon: 'settings', label: 'Settings' }
|
||||
]}
|
||||
@tab-change=${(e: CustomEvent) => {
|
||||
const nav = e.target as any;
|
||||
nav.activeTab = e.detail.tab;
|
||||
}}
|
||||
></dees-mobile-navigation>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { mobileComponentStyles } from '../../00componentstyles.js';
|
||||
import '../../00group-ui/dees-mobile-icon/dees-mobile-icon.js';
|
||||
import { demoFunc } from './dees-mobile-navigation.demo.js';
|
||||
|
||||
export interface INavigationTab {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
badge?: number | string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-mobile-navigation': DeesMobileNavigation;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-mobile-navigation')
|
||||
export class DeesMobileNavigation extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
accessor activeTab: string = '';
|
||||
|
||||
@property({ type: Array })
|
||||
accessor tabs: INavigationTab[] = [];
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
mobileComponentStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
/* Mobile-first defaults */
|
||||
padding: 0.375rem 0;
|
||||
padding-bottom: calc(0.375rem + var(--safe-area-inset-bottom, 0px));
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
|
||||
/* Desktop enhancements */
|
||||
@media (min-width: 641px) {
|
||||
.container {
|
||||
padding: 0.5rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
text-decoration: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
/* Mobile-first: 44px touch target */
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Desktop enhancements */
|
||||
@media (min-width: 641px) {
|
||||
.tab {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -8px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Hover effect */
|
||||
@media (hover: hover) {
|
||||
.tab:hover:not(.active) {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
private handleTabClick(tabId: string) {
|
||||
this.dispatchEvent(new CustomEvent('tab-change', {
|
||||
detail: { tab: tabId },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<nav class="tabs" role="tablist">
|
||||
${this.tabs.map(tab => html`
|
||||
<button
|
||||
class="tab ${this.activeTab === tab.id ? 'active' : ''}"
|
||||
role="tab"
|
||||
aria-selected=${this.activeTab === tab.id}
|
||||
@click=${() => this.handleTabClick(tab.id)}
|
||||
>
|
||||
<div class="tab-icon">
|
||||
<dees-mobile-icon icon=${tab.icon} size="24"></dees-mobile-icon>
|
||||
${tab.badge !== undefined ? html`
|
||||
<span class="badge">${tab.badge}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<span class="tab-label">${tab.label}</span>
|
||||
</button>
|
||||
`)}
|
||||
</nav>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-mobile-navigation.js';
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { mobileComponentStyles } from '../../00componentstyles.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-mobile-view': DeesMobileView;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A view container component that works with dees-mobile-viewstack.
|
||||
* Each view has a unique ID and is shown/hidden based on the viewstack's current state.
|
||||
*/
|
||||
@customElement('dees-mobile-view')
|
||||
export class DeesMobileView extends DeesElement {
|
||||
@property({ type: String, attribute: 'view-id' })
|
||||
accessor viewId: string = '';
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
accessor active: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor animationState: 'none' | 'entering' | 'leaving' = 'none';
|
||||
|
||||
@property({ type: String })
|
||||
accessor animationDirection: 'forward' | 'back' = 'forward';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
mobileComponentStyles,
|
||||
css`
|
||||
:host {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
|
||||
:host([active]) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Forward animations (new view slides in from right) */
|
||||
:host(.entering-forward) {
|
||||
display: block;
|
||||
animation: slideInFromRight 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
:host(.leaving-forward) {
|
||||
display: block;
|
||||
animation: slideOutToLeft 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* Back animations (returning to previous view) */
|
||||
:host(.entering-back) {
|
||||
display: block;
|
||||
animation: slideInFromLeft 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
:host(.leaving-back) {
|
||||
display: block;
|
||||
animation: slideOutToRight 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideInFromRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToLeft {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(-30%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromLeft {
|
||||
from {
|
||||
transform: translateX(-30%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/**
|
||||
* Start an animation on this view
|
||||
*/
|
||||
public startAnimation(type: 'entering' | 'leaving', direction: 'forward' | 'back'): void {
|
||||
this.animationState = type;
|
||||
this.animationDirection = direction;
|
||||
this.classList.add(`${type}-${direction}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current animation
|
||||
*/
|
||||
public endAnimation(): void {
|
||||
this.classList.remove(
|
||||
'entering-forward',
|
||||
'leaving-forward',
|
||||
'entering-back',
|
||||
'leaving-back'
|
||||
);
|
||||
this.animationState = 'none';
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="view-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-layout/dees-mobile-view/index.ts
Normal file
1
ts_web/elements/00group-layout/dees-mobile-view/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-mobile-view.js';
|
||||
@@ -0,0 +1,622 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import { injectCssVariables } from '../../00variables.js';
|
||||
import type { DeesMobileViewstack } from './dees-mobile-viewstack.js';
|
||||
|
||||
// Shared styles for demos
|
||||
const sharedStyles = html`
|
||||
<style>
|
||||
.demo-container {
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
}
|
||||
|
||||
.view-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
cursor: pointer;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.list-item:active {
|
||||
background: ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-subtitle {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
padding: 24px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.item-detail h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.item-detail p {
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#3f3f46')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#3f3f46')};
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.control-button.primary {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.control-button.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Helper functions
|
||||
const handleListClick = (viewstack: DeesMobileViewstack, listName: string) => {
|
||||
const listView = viewstack.querySelector('[view-id="list"]');
|
||||
if (listView) {
|
||||
(listView as HTMLElement).dataset.listName = listName;
|
||||
}
|
||||
viewstack.pushView('list');
|
||||
};
|
||||
|
||||
const handleItemClick = (viewstack: DeesMobileViewstack, itemName: string) => {
|
||||
const itemView = viewstack.querySelector('[view-id="item"]');
|
||||
if (itemView) {
|
||||
(itemView as HTMLElement).dataset.itemName = itemName;
|
||||
}
|
||||
viewstack.pushView('item');
|
||||
};
|
||||
|
||||
const handleBack = (viewstack: DeesMobileViewstack) => {
|
||||
viewstack.popView();
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo 1: Mobile Phone Layout
|
||||
* Simulates a typical mobile app navigation pattern
|
||||
*/
|
||||
const mobileDemo = () => {
|
||||
injectCssVariables();
|
||||
|
||||
return html`
|
||||
${sharedStyles}
|
||||
<style>
|
||||
.mobile-frame {
|
||||
width: 375px;
|
||||
height: 667px;
|
||||
border: 8px solid ${cssManager.bdTheme('#1f1f1f', '#404040')};
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
<h3 style="margin: 0 0 16px; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">Mobile Phone Layout (375x667)</h3>
|
||||
<p style="margin: 0 0 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
|
||||
Simulates iPhone SE dimensions. Tap items to navigate forward, use back button to return.
|
||||
</p>
|
||||
|
||||
<div class="mobile-frame">
|
||||
<div class="demo-container" style="height: 100%; border: none; border-radius: 0;">
|
||||
<dees-mobile-viewstack
|
||||
initial-view="lists"
|
||||
style="height: calc(100% - 44px);"
|
||||
@view-changed=${(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const status = target?.closest('.demo-container')?.querySelector('.status-bar');
|
||||
if (status) {
|
||||
status.textContent = `${e.detail.currentView} (depth: ${e.detail.stackDepth})`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<dees-mobile-view view-id="lists">
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">My Lists</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
if (viewstack) handleListClick(viewstack, 'Shopping');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Shopping List</div>
|
||||
<div class="item-subtitle">12 items</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
if (viewstack) handleListClick(viewstack, 'Todo');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Todo List</div>
|
||||
<div class="item-subtitle">5 items</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="list">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
if (viewstack) handleBack(viewstack);
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Items</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
if (viewstack) handleItemClick(viewstack, 'Milk');
|
||||
}}>
|
||||
<div><div class="item-title">Milk</div></div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
if (viewstack) handleItemClick(viewstack, 'Bread');
|
||||
}}>
|
||||
<div><div class="item-title">Bread</div></div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="item">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
if (viewstack) handleBack(viewstack);
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Details</h1>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<h2>Item Details</h2>
|
||||
<p>Full item information would appear here.</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
</dees-mobile-viewstack>
|
||||
|
||||
<div class="status-bar">lists (depth: 1)</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo 2: Desktop/Tablet Layout
|
||||
* Wider layout suitable for tablets and desktop embedded views
|
||||
*/
|
||||
const desktopDemo = () => {
|
||||
injectCssVariables();
|
||||
|
||||
return html`
|
||||
${sharedStyles}
|
||||
|
||||
<h3 style="margin: 0 0 16px; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">Desktop/Tablet Layout</h3>
|
||||
<p style="margin: 0 0 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
|
||||
Wider container suitable for tablet or embedded desktop use. Same navigation behavior.
|
||||
</p>
|
||||
|
||||
<div class="demo-container" style="width: 100%; max-width: 600px; height: 500px;">
|
||||
<dees-mobile-viewstack
|
||||
initial-view="categories"
|
||||
style="height: calc(100% - 44px);"
|
||||
@view-changed=${(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const status = target?.closest('.demo-container')?.querySelector('.status-bar');
|
||||
if (status) {
|
||||
status.textContent = `View: ${e.detail.currentView} | Stack: ${e.detail.stackDepth} | Direction: ${e.detail.direction}`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<dees-mobile-view view-id="categories">
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">Categories</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.pushView('products');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Electronics</div>
|
||||
<div class="item-subtitle">248 products</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.pushView('products');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Clothing</div>
|
||||
<div class="item-subtitle">512 products</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.pushView('products');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Home & Garden</div>
|
||||
<div class="item-subtitle">189 products</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="products">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.popView();
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Products</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.pushView('product-detail');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Wireless Headphones</div>
|
||||
<div class="item-subtitle">$149.99</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.pushView('product-detail');
|
||||
}}>
|
||||
<div>
|
||||
<div class="item-title">Smart Watch</div>
|
||||
<div class="item-subtitle">$299.99</div>
|
||||
</div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="product-detail">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const viewstack = target?.closest('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
viewstack?.popView();
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Product Details</h1>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<h2>Wireless Headphones</h2>
|
||||
<p>Premium noise-cancelling headphones with 30-hour battery life.</p>
|
||||
<p style="margin-top: 16px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$149.99</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
</dees-mobile-viewstack>
|
||||
|
||||
<div class="status-bar">View: categories | Stack: 1 | Direction: none</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo 3: Programmatic Control
|
||||
* Demonstrates API methods for controlling navigation
|
||||
* Uses dees-demowrapper for proper scoped element access in wcctools
|
||||
*/
|
||||
const programmaticDemo = () => {
|
||||
injectCssVariables();
|
||||
|
||||
return html`
|
||||
${sharedStyles}
|
||||
|
||||
<h3 style="margin: 0 0 16px; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">Programmatic Control</h3>
|
||||
<p style="margin: 0 0 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
|
||||
Use the control panel to navigate programmatically via the viewstack API.
|
||||
</p>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (wrapper: HTMLElement) => {
|
||||
const viewstack = wrapper.querySelector('dees-mobile-viewstack') as DeesMobileViewstack;
|
||||
const backBtn = wrapper.querySelector('.btn-back') as HTMLButtonElement;
|
||||
const rootBtn = wrapper.querySelector('.btn-root') as HTMLButtonElement;
|
||||
const statusBar = wrapper.querySelector('.status-bar') as HTMLElement;
|
||||
const pushABtn = wrapper.querySelector('.btn-push-a') as HTMLButtonElement;
|
||||
const pushBBtn = wrapper.querySelector('.btn-push-b') as HTMLButtonElement;
|
||||
const pushCBtn = wrapper.querySelector('.btn-push-c') as HTMLButtonElement;
|
||||
|
||||
if (!viewstack) return;
|
||||
|
||||
const updateButtons = () => {
|
||||
if (backBtn) backBtn.disabled = !viewstack.canGoBack;
|
||||
if (rootBtn) rootBtn.disabled = viewstack.stackDepth <= 1;
|
||||
};
|
||||
|
||||
const updateStatus = () => {
|
||||
if (statusBar) {
|
||||
statusBar.textContent = `Current: ${viewstack.currentView} | Stack: [${viewstack.viewStack.join(' → ')}] | canGoBack: ${viewstack.canGoBack}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Set up button click handlers
|
||||
pushABtn?.addEventListener('click', () => viewstack.pushView('view-a'));
|
||||
pushBBtn?.addEventListener('click', () => viewstack.pushView('view-b'));
|
||||
pushCBtn?.addEventListener('click', () => viewstack.pushView('view-c'));
|
||||
backBtn?.addEventListener('click', () => viewstack.popView());
|
||||
rootBtn?.addEventListener('click', () => viewstack.goToRoot(false));
|
||||
|
||||
// Listen for view changes to update UI
|
||||
viewstack.addEventListener('view-changed', () => {
|
||||
updateButtons();
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
// Initial state
|
||||
updateButtons();
|
||||
updateStatus();
|
||||
}}>
|
||||
<div class="demo-container" style="width: 100%; max-width: 500px; height: 450px;">
|
||||
<div class="control-panel">
|
||||
<button class="control-button primary btn-push-a">Push View A</button>
|
||||
<button class="control-button primary btn-push-b">Push View B</button>
|
||||
<button class="control-button primary btn-push-c">Push View C</button>
|
||||
<button class="control-button btn-back" disabled>Pop View</button>
|
||||
<button class="control-button btn-root" disabled>Go to Root</button>
|
||||
</div>
|
||||
|
||||
<dees-mobile-viewstack initial-view="home" style="height: calc(100% - 100px);">
|
||||
<dees-mobile-view view-id="home">
|
||||
<div class="item-detail" style="text-align: center; padding-top: 60px;">
|
||||
<h2>Home View</h2>
|
||||
<p>This is the root view. Use the buttons above to push views onto the stack.</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="view-a">
|
||||
<div class="item-detail" style="text-align: center; padding-top: 60px; background: ${cssManager.bdTheme('#fef2f2', '#1c1917')};">
|
||||
<h2 style="color: #ef4444;">View A</h2>
|
||||
<p>You navigated to View A</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="view-b">
|
||||
<div class="item-detail" style="text-align: center; padding-top: 60px; background: ${cssManager.bdTheme('#f0fdf4', '#14532d')};">
|
||||
<h2 style="color: #22c55e;">View B</h2>
|
||||
<p>You navigated to View B</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="view-c">
|
||||
<div class="item-detail" style="text-align: center; padding-top: 60px; background: ${cssManager.bdTheme('#eff6ff', '#1e3a5f')};">
|
||||
<h2 style="color: #3b82f6;">View C</h2>
|
||||
<p>You navigated to View C</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
</dees-mobile-viewstack>
|
||||
|
||||
<div class="status-bar">Current: home | Stack: [home] | canGoBack: false</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo 4: Deep Navigation (4+ levels)
|
||||
* Shows handling of deeply nested navigation
|
||||
*/
|
||||
const deepNavigationDemo = () => {
|
||||
injectCssVariables();
|
||||
|
||||
return html`
|
||||
${sharedStyles}
|
||||
|
||||
<h3 style="margin: 0 0 16px; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">Deep Navigation (5 Levels)</h3>
|
||||
<p style="margin: 0 0 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
|
||||
Navigate through 5 levels deep: Region → Country → City → District → Location
|
||||
</p>
|
||||
|
||||
<div class="demo-container" style="width: 100%; max-width: 450px; height: 500px;">
|
||||
<dees-mobile-viewstack
|
||||
initial-view="regions"
|
||||
style="height: calc(100% - 44px);"
|
||||
@view-changed=${(e: CustomEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const status = target?.closest('.demo-container')?.querySelector('.status-bar');
|
||||
if (status) {
|
||||
const depth = e.detail.stackDepth;
|
||||
const levels = ['Regions', 'Country', 'City', 'District', 'Location'];
|
||||
status.textContent = `Level ${depth}/5: ${levels[depth - 1] || 'Unknown'}`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<dees-mobile-view view-id="regions">
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">Regions</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
(target?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.pushView('country');
|
||||
}}>
|
||||
<div><div class="item-title">Europe</div><div class="item-subtitle">44 countries</div></div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="country">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
(e.target as HTMLElement)?.closest('dees-mobile-viewstack')?.dispatchEvent(new CustomEvent('pop-request'));
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.popView();
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Germany</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.pushView('city');
|
||||
}}>
|
||||
<div><div class="item-title">Berlin</div><div class="item-subtitle">12 districts</div></div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="city">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.popView();
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Berlin</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.pushView('district');
|
||||
}}>
|
||||
<div><div class="item-title">Mitte</div><div class="item-subtitle">Central district</div></div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="district">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.popView();
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Mitte</h1>
|
||||
</div>
|
||||
<div class="list-item" @click=${(e: Event) => {
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.pushView('location');
|
||||
}}>
|
||||
<div><div class="item-title">Brandenburg Gate</div><div class="item-subtitle">Historic landmark</div></div>
|
||||
<span class="chevron">›</span>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
|
||||
<dees-mobile-view view-id="location">
|
||||
<div class="view-header">
|
||||
<button class="back-button" @click=${(e: Event) => {
|
||||
((e.target as HTMLElement)?.closest('dees-mobile-viewstack') as DeesMobileViewstack)?.popView();
|
||||
}}>‹</button>
|
||||
<h1 class="view-title">Brandenburg Gate</h1>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<h2>Brandenburg Gate</h2>
|
||||
<p>An 18th-century neoclassical monument in Berlin. One of the best-known landmarks of Germany.</p>
|
||||
<p style="margin-top: 16px;">
|
||||
<strong>You've reached the deepest level!</strong><br>
|
||||
Use the back button to navigate up through the hierarchy.
|
||||
</p>
|
||||
</div>
|
||||
</dees-mobile-view>
|
||||
</dees-mobile-viewstack>
|
||||
|
||||
<div class="status-bar">Level 1/5: Regions</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
// Export array of demo functions
|
||||
export const demoFunc = [
|
||||
mobileDemo,
|
||||
desktopDemo,
|
||||
programmaticDemo,
|
||||
deepNavigationDemo,
|
||||
];
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-mobile-viewstack.js';
|
||||
5
ts_web/elements/00group-layout/index.ts
Normal file
5
ts_web/elements/00group-layout/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Layout Components
|
||||
export * from './dees-mobile-navigation/index.js';
|
||||
export * from './dees-mobile-applayout/index.js';
|
||||
export * from './dees-mobile-view/index.js';
|
||||
export * from './dees-mobile-viewstack/index.js';
|
||||
Reference in New Issue
Block a user