This commit is contained in:
2025-12-22 10:53:15 +00:00
commit 753a98c67b
63 changed files with 15976 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
.nogit/
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/
dist_*/
# custom

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

28
html/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!--gitzone element-->
<!-- made by Task Venture Capital GmbH -->
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
<html lang="en">
<head>
<!--Lets set some basic meta tags-->
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!--Lets load standard fonts-->
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
body {
margin: 0px;
background: #222222;
}
</style>
<script type="module" src="/bundle.js"></script>
</head>
<body>
</body>
</html>

10
html/index.ts Normal file
View File

@@ -0,0 +1,10 @@
// dees tools
import * as deesWccTools from '@design.estate/dees-wcctools';
import * as deesDomTools from '@design.estate/dees-domtools';
// elements and pages
import * as elements from '../ts_web/elements/index.js';
import * as pages from '../ts_web/pages/index.js';
deesWccTools.setupWccTools(elements as any, pages);
deesDomTools.elementBasic.setup();

37
npmextra.json Normal file
View File

@@ -0,0 +1,37 @@
{
"@git.zone/cli": {
"projectType": "wcc",
"module": {
"githost": "code.foss.global",
"gitscope": "design.estate",
"gitrepo": "dees-catalog-mobile",
"description": "A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.",
"npmPackagename": "@design.estate/dees-catalog-mobile",
"license": "MIT",
"projectDomain": "design.estate",
"keywords": [
"Mobile Components",
"Web Components",
"Touch UI",
"Cross-Platform",
"PWA Components",
"Mobile-First",
"TypeScript",
"Business Apps",
"iOS",
"Android",
"Touch Gestures",
"Bottom Navigation",
"Action Sheet",
"Mobile Forms"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
}
}

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "@design.estate/dees-catalog-mobile",
"version": "1.0.0",
"private": false,
"description": "A mobile-optimized component catalog for building cross-platform business applications with touch-first UI components.",
"main": "dist_ts_web/index.js",
"typings": "dist_ts_web/index.d.ts",
"type": "module",
"scripts": {
"test": "tstest test/ --web --verbose --timeout 30 --logfile",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
"watch": "tswatch element",
"buildDocs": "tsdoc"
},
"author": "Lossless GmbH",
"license": "MIT",
"dependencies": {
"@design.estate/dees-domtools": "^2.3.6",
"@design.estate/dees-element": "^2.1.3",
"@push.rocks/smartpromise": "^4.2.0",
"lit": "^3.3.1",
"lucide": "^0.562.0"
},
"devDependencies": {
"@design.estate/dees-wcctools": "^3.2.0",
"@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tstest": "^3.1.3",
"@git.zone/tswatch": "^2.3.13",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^25.0.3"
},
"files": [
"ts/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"npmextra.json",
"readme.md"
],
"browserslist": [
"last 1 chrome versions"
],
"keywords": [
"Mobile Components",
"Web Components",
"Touch UI",
"Cross-Platform",
"PWA Components",
"Mobile-First",
"TypeScript",
"Business Apps",
"iOS",
"Android",
"Touch Gestures",
"Bottom Navigation",
"Action Sheet",
"Mobile Forms"
],
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
}

10770
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
readme.hints.md Normal file
View File

@@ -0,0 +1,2 @@
1. dees-catalog: /mnt/data/lossless/design.estate/dees-catalog
2. shared_shoppinglist: /mnt/data/test/shared_shoppinglist

View File

@@ -0,0 +1,8 @@
/**
* This file contains commit information for the package
*/
export const commitinfo = {
name: '@design.estate/dees-catalog-mobile',
version: '1.0.0',
description: 'A mobile-optimized component catalog for cross-platform business applications',
};

View File

@@ -0,0 +1 @@
export * from './theme-controller.js';

View File

@@ -0,0 +1,87 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { themeService, type Theme } from '../services/theme-service.js';
export type { Theme };
/**
* Lit reactive controller for theme management
* Automatically updates the host component when theme changes
*
* Usage:
* ```typescript
* class MyComponent extends LitElement {
* private theme = new ThemeController(this);
*
* render() {
* return html`
* <div class=${this.theme.isDark ? 'dark' : 'light'}>
* Current theme: ${this.theme.theme}
* </div>
* `;
* }
* }
* ```
*/
export class ThemeController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
/**
* Whether dark mode is currently active
*/
isDark = false;
private _theme: Theme = 'system';
constructor(host: ReactiveControllerHost) {
this.host = host;
this.host.addController(this);
}
hostConnected() {
this.unsubscribe = themeService.subscribe((theme, isDark) => {
this._theme = theme;
this.isDark = isDark;
this.host.requestUpdate();
});
}
hostDisconnected() {
this.unsubscribe?.();
}
/**
* Get the current theme setting (light/dark/system)
*/
get theme(): Theme {
return this._theme;
}
/**
* Get the resolved theme (light or dark, never system)
*/
get resolvedTheme(): 'light' | 'dark' {
return this.isDark ? 'dark' : 'light';
}
/**
* Get the current theme from the service
*/
getTheme(): Theme {
return themeService.getTheme();
}
/**
* Set the theme
*/
setTheme(theme: Theme) {
themeService.setTheme(theme);
}
/**
* Toggle through themes
*/
toggleTheme() {
themeService.toggleTheme();
}
}

View File

@@ -0,0 +1,80 @@
/**
* Color definitions for light and dark themes
*/
export const dark = {
// Primary colors
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryForeground: '#ffffff',
// Secondary colors
secondary: '#27272a',
secondaryForeground: '#fafafa',
// Background colors
background: '#09090b',
card: '#18181b',
surface: '#27272a',
// Text colors
foreground: '#fafafa',
mutedForeground: '#a1a1aa',
// Border and input
border: '#27272a',
input: '#27272a',
ring: '#3b82f6',
// Semantic colors
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
destructive: '#dc2626',
destructiveForeground: '#ffffff',
// Accent
accent: '#27272a',
accentForeground: '#fafafa',
// Muted
muted: '#27272a',
};
export const light = {
// Primary colors
primary: '#3b82f6',
primaryDark: '#2563eb',
primaryForeground: '#ffffff',
// Secondary colors
secondary: '#f4f4f5',
secondaryForeground: '#18181b',
// Background colors
background: '#ffffff',
card: '#ffffff',
surface: '#f4f4f5',
// Text colors
foreground: '#09090b',
mutedForeground: '#71717a',
// Border and input
border: '#e4e4e7',
input: '#e4e4e7',
ring: '#3b82f6',
// Semantic colors
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
destructive: '#dc2626',
destructiveForeground: '#ffffff',
// Accent
accent: '#f4f4f5',
accentForeground: '#18181b',
// Muted
muted: '#f4f4f5',
};

View File

@@ -0,0 +1,84 @@
import { css } from '@design.estate/dees-element';
/**
* Global component styles for mobile-optimized components
* Include this in all component static styles arrays
*/
export const mobileComponentStyles = css`
/* Box sizing reset */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Prevent text selection on interactive elements */
button,
a,
[role="button"],
[role="tab"],
[draggable="true"] {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* Remove default button styles */
button {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
}
/* Smooth scrolling for containers */
.scrollable {
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
}
/* Remove tap highlight on mobile */
a,
button,
[role="button"],
[role="tab"] {
-webkit-tap-highlight-color: transparent;
}
/* Focus styles */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--dees-ring, #3b82f6);
outline-offset: 2px;
}
/* Prevent layout shift from scrollbars */
.scroll-stable {
scrollbar-gutter: stable;
}
/* Common transition for interactive elements */
.interactive {
transition: all var(--dees-transition-fast, 150ms ease);
}
/* Touch-friendly minimum sizes */
.touch-target {
min-width: 44px;
min-height: 44px;
}
/* Prevent iOS zoom on inputs (16px minimum) */
input,
textarea,
select {
font-size: 16px;
}
`;

View File

@@ -0,0 +1,24 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { injectCssVariables } from './00variables.js';
/**
* Wraps a demo template with CSS variable injection
* Ensures design system variables are available for component styling
*/
export function wrapDemo(templateFn: () => TemplateResult): () => TemplateResult {
return () => {
// Inject CSS variables into the document
injectCssVariables();
return templateFn();
};
}
/**
* Helper to create demo with automatic CSS injection
*/
export function createDemo(template: TemplateResult): () => TemplateResult {
return () => {
injectCssVariables();
return template;
};
}

View File

@@ -0,0 +1,35 @@
/**
* Font definitions for the mobile catalog
*/
// System font stack optimized for mobile
export const systemFontStack = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif`;
// Mono font stack
export const monoFontStack = `ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace`;
// Font sizes (following mobile-first approach)
export const fontSizes = {
xs: '0.75rem', // 12px
sm: '0.875rem', // 14px
base: '1rem', // 16px (prevents iOS zoom on inputs)
lg: '1.125rem', // 18px
xl: '1.25rem', // 20px
'2xl': '1.5rem', // 24px
'3xl': '1.875rem', // 30px
};
// Font weights
export const fontWeights = {
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
};
// Line heights
export const lineHeights = {
tight: '1.2',
normal: '1.5',
relaxed: '1.75',
};

View File

@@ -0,0 +1,117 @@
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;
}
.demo-grid {
display: grid;
gap: 1rem;
max-width: 400px;
}
.demo-row {
display: flex;
gap: 1rem;
}
</style>
<div class="demo-section">
<h3>Basic Inputs</h3>
<div class="demo-grid">
<dees-mobile-input
placeholder="Enter text..."
></dees-mobile-input>
<dees-mobile-input
label="Email Address"
type="email"
placeholder="you@example.com"
></dees-mobile-input>
<dees-mobile-input
label="Password"
type="password"
placeholder="Enter password"
></dees-mobile-input>
</div>
</div>
<div class="demo-section">
<h3>Input Types</h3>
<div class="demo-grid">
<dees-mobile-input
label="Phone Number"
type="tel"
placeholder="+1 (555) 000-0000"
></dees-mobile-input>
<dees-mobile-input
label="Quantity"
type="number"
placeholder="0"
></dees-mobile-input>
<dees-mobile-input
label="Search"
type="search"
placeholder="Search..."
></dees-mobile-input>
</div>
</div>
<div class="demo-section">
<h3>States</h3>
<div class="demo-grid">
<dees-mobile-input
label="Required Field"
placeholder="This field is required"
required
></dees-mobile-input>
<dees-mobile-input
label="Disabled Input"
placeholder="Cannot edit"
disabled
value="Read only value"
></dees-mobile-input>
<dees-mobile-input
label="With Error"
placeholder="Enter valid email"
type="email"
value="invalid-email"
error="Please enter a valid email address"
></dees-mobile-input>
</div>
</div>
<div class="demo-section">
<h3>Autocomplete</h3>
<div class="demo-grid">
<dees-mobile-input
label="Username"
autocomplete="username"
placeholder="Enter username"
></dees-mobile-input>
<dees-mobile-input
label="Current Password"
type="password"
autocomplete="current-password"
placeholder="Enter password"
></dees-mobile-input>
</div>
</div>
`;
};

View File

@@ -0,0 +1,228 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { mobileComponentStyles } from '../../00componentstyles.js';
import { demoFunc } from './dees-mobile-input.demo.js';
export type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search';
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-input': DeesMobileInput;
}
}
@customElement('dees-mobile-input')
export class DeesMobileInput extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
accessor type: InputType = 'text';
@property({ type: String })
accessor placeholder: string = '';
@property({ type: String })
accessor value: string = '';
@property({ type: Boolean })
accessor disabled: boolean = false;
@property({ type: String })
accessor id: string = '';
@property({ type: String })
accessor name: string = '';
@property({ type: String })
accessor label: string = '';
@property({ type: String })
accessor error: string = '';
@property({ type: Boolean })
accessor required: boolean = false;
@property({ type: String })
accessor autocomplete: string = '';
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
:host {
display: block;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
color: var(--dees-foreground);
}
label .required {
color: var(--dees-danger);
margin-left: 0.25rem;
}
input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
/* 16px minimum to prevent iOS zoom */
font-size: 1rem;
line-height: 1.25rem;
color: var(--dees-foreground);
background: var(--dees-background);
border: 1px solid var(--dees-input);
border-radius: calc(var(--dees-radius) - 2px);
outline: none;
transition: all var(--dees-transition-fast);
box-sizing: border-box;
-webkit-appearance: none;
font-family: inherit;
}
input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: var(--dees-ring);
box-shadow: 0 0 0 2px var(--dees-background), 0 0 0 4px var(--dees-ring);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--dees-muted);
}
input::placeholder {
color: var(--dees-muted-foreground);
}
/* Remove number input spinners */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
/* Error state */
:host([error]) input,
input.error {
border-color: var(--dees-danger);
}
:host([error]) input:focus,
input.error:focus {
box-shadow: 0 0 0 2px var(--dees-background), 0 0 0 4px var(--dees-danger);
}
.error-message {
font-size: 0.75rem;
color: var(--dees-danger);
margin-top: 0.25rem;
}
`,
];
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.dispatchEvent(new CustomEvent('input', {
detail: { value: input.value },
bubbles: true,
composed: true
}));
}
private handleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.value = input.value;
this.dispatchEvent(new CustomEvent('change', {
detail: { value: input.value },
bubbles: true,
composed: true
}));
}
private handleFocus() {
// Emit input-focus for keyboard detection
this.dispatchEvent(new CustomEvent('input-focus', {
bubbles: true,
composed: true
}));
}
private handleBlur() {
// Emit input-blur for keyboard detection
this.dispatchEvent(new CustomEvent('input-blur', {
bubbles: true,
composed: true
}));
}
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`
<label for=${this.id || 'input'}>
${this.label}
${this.required ? html`<span class="required">*</span>` : ''}
</label>
` : ''}
<input
.type=${this.type}
.placeholder=${this.placeholder}
.value=${this.value}
?disabled=${this.disabled}
?required=${this.required}
.id=${this.id || 'input'}
.name=${this.name}
.autocomplete=${this.autocomplete}
class=${this.error ? 'error' : ''}
@input=${this.handleInput}
@change=${this.handleChange}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
${this.error ? html`
<div class="error-message">${this.error}</div>
` : ''}
</div>
`;
}
/**
* Focus the input programmatically
*/
public focus() {
const input = this.shadowRoot?.querySelector('input');
input?.focus();
}
/**
* Blur the input programmatically
*/
public blur() {
const input = this.shadowRoot?.querySelector('input');
input?.blur();
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-input.js';

View File

@@ -0,0 +1,2 @@
// Input Components
export * from './dees-mobile-input/index.js';

View File

@@ -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>
`;
};

View File

@@ -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>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-applayout.js';

View File

@@ -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>
`;
};

View File

@@ -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>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-navigation.js';

View File

@@ -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>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-view.js';

View File

@@ -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,
];

View File

@@ -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>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-viewstack.js';

View 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';

View File

@@ -0,0 +1,98 @@
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;
}
.demo-note {
font-size: 0.875rem;
color: var(--dees-muted-foreground);
margin-top: 0.5rem;
}
</style>
<div class="demo-section">
<h3>Action Sheet</h3>
<dees-mobile-button
@click=${(e: Event) => {
const container = (e.target as HTMLElement).parentElement;
const existing = container?.querySelector('dees-mobile-actionsheet');
if (existing) existing.remove();
const sheet = document.createElement('dees-mobile-actionsheet');
(sheet as any).title = 'Add Photo';
(sheet as any).options = [
{
id: 'camera',
icon: 'camera',
iconColor: 'var(--dees-primary)',
iconBackground: 'rgba(59, 130, 246, 0.1)',
title: 'Take Photo',
subtitle: 'Use camera to capture a new photo'
},
{
id: 'gallery',
icon: 'image',
iconColor: '#16a34a',
iconBackground: '#dcfce7',
title: 'Choose from Gallery',
subtitle: 'Select an existing photo'
}
];
sheet.addEventListener('close', () => sheet.remove());
sheet.addEventListener('select', (ev: any) => {
console.log('Selected:', ev.detail);
sheet.remove();
});
document.body.appendChild(sheet);
}}
>Show Photo Options</dees-mobile-button>
<p class="demo-note">Opens an iOS-style action sheet from the bottom of the screen.</p>
</div>
<div class="demo-section">
<h3>Share Options</h3>
<dees-mobile-button
variant="outline"
@click=${(e: Event) => {
const sheet = document.createElement('dees-mobile-actionsheet');
(sheet as any).title = 'Share';
(sheet as any).options = [
{
id: 'copy',
icon: 'copy',
title: 'Copy Link'
},
{
id: 'email',
icon: 'mail',
title: 'Send via Email'
},
{
id: 'message',
icon: 'message-circle',
title: 'Send Message'
}
];
sheet.addEventListener('close', () => sheet.remove());
sheet.addEventListener('select', (ev: any) => {
console.log('Share via:', ev.detail);
sheet.remove();
});
document.body.appendChild(sheet);
}}
>Share Options</dees-mobile-button>
</div>
`;
};

View File

@@ -0,0 +1,223 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { mobileComponentStyles } from '../../00componentstyles.js';
import '../dees-mobile-icon/dees-mobile-icon.js';
import { demoFunc } from './dees-mobile-actionsheet.demo.js';
export interface IActionSheetOption {
id: string;
icon?: string;
iconColor?: string;
iconBackground?: string;
title: string;
subtitle?: string;
}
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-actionsheet': DeesMobileActionsheet;
}
}
@customElement('dees-mobile-actionsheet')
export class DeesMobileActionsheet extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
accessor title: string = '';
@property({ type: Array })
accessor options: IActionSheetOption[] = [];
@property({ type: String })
accessor cancelText: string = 'Cancel';
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
:host {
position: fixed;
inset: 0;
z-index: var(--dees-z-modal, 500);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
animation: fadeInBackdrop 0.2s ease-out forwards;
}
@keyframes fadeInBackdrop {
to {
background: rgba(0, 0, 0, 0.5);
}
}
.sheet {
position: relative;
background: var(--dees-card);
border-radius: var(--dees-radius-lg) var(--dees-radius-lg) 0 0;
padding: var(--dees-space-md);
padding-bottom: calc(var(--dees-space-md) + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
animation: slideUp 0.3s ease-out forwards;
}
@keyframes slideUp {
to {
transform: translateY(0);
}
}
.sheet-title {
text-align: center;
font-size: 0.875rem;
color: var(--dees-muted-foreground);
margin-bottom: var(--dees-space-md);
padding-bottom: var(--dees-space-sm);
border-bottom: 1px solid var(--dees-border);
}
.options {
display: flex;
flex-direction: column;
gap: var(--dees-space-xs);
}
.option {
display: flex;
align-items: center;
gap: var(--dees-space-md);
padding: var(--dees-space-md);
border: none;
background: var(--dees-background);
border-radius: var(--dees-radius-md);
cursor: pointer;
color: var(--dees-foreground);
font-size: 1rem;
font-weight: 500;
text-align: left;
transition: background var(--dees-transition-fast);
}
.option:hover {
background: var(--dees-muted);
}
.option:active {
background: var(--dees-accent);
}
.option-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--dees-radius-full);
flex-shrink: 0;
background: var(--dees-accent);
color: var(--dees-primary);
}
.option-text {
flex: 1;
}
.option-title {
font-weight: 500;
}
.option-subtitle {
font-size: 0.75rem;
color: var(--dees-muted-foreground);
font-weight: normal;
margin-top: 2px;
}
.cancel {
margin-top: var(--dees-space-sm);
padding: var(--dees-space-md);
border: none;
background: var(--dees-background);
border-radius: var(--dees-radius-md);
cursor: pointer;
color: var(--dees-danger);
font-size: 1rem;
font-weight: 500;
width: 100%;
transition: background var(--dees-transition-fast);
}
.cancel:hover {
background: rgba(220, 38, 38, 0.1);
}
`,
];
private handleSelect(option: IActionSheetOption) {
this.dispatchEvent(new CustomEvent('select', {
detail: option,
bubbles: true,
composed: true,
}));
}
private handleClose() {
this.dispatchEvent(new CustomEvent('close', {
bubbles: true,
composed: true,
}));
}
public render(): TemplateResult {
return html`
<div class="backdrop" @click=${this.handleClose}></div>
<div class="sheet">
${this.title ? html`<div class="sheet-title">${this.title}</div>` : ''}
<div class="options">
${this.options.map(option => html`
<button class="option" @click=${() => this.handleSelect(option)}>
${option.icon ? html`
<div
class="option-icon"
style=${option.iconBackground ? `background: ${option.iconBackground}` : ''}
>
<dees-mobile-icon
icon=${option.icon}
size="24"
color=${option.iconColor || 'currentColor'}
></dees-mobile-icon>
</div>
` : ''}
<div class="option-text">
<div class="option-title">${option.title}</div>
${option.subtitle ? html`
<div class="option-subtitle">${option.subtitle}</div>
` : ''}
</div>
</button>
`)}
</div>
<button class="cancel" @click=${this.handleClose}>
${this.cancelText}
</button>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-actionsheet.js';

View File

@@ -0,0 +1,72 @@
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;
}
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
</style>
<div class="demo-section">
<h3>Variants</h3>
<div class="demo-row">
<dees-mobile-button variant="default">Default</dees-mobile-button>
<dees-mobile-button variant="primary">Primary</dees-mobile-button>
<dees-mobile-button variant="secondary">Secondary</dees-mobile-button>
<dees-mobile-button variant="outline">Outline</dees-mobile-button>
<dees-mobile-button variant="ghost">Ghost</dees-mobile-button>
<dees-mobile-button variant="destructive">Destructive</dees-mobile-button>
<dees-mobile-button variant="link">Link</dees-mobile-button>
</div>
</div>
<div class="demo-section">
<h3>Sizes</h3>
<div class="demo-row">
<dees-mobile-button size="sm">Small</dees-mobile-button>
<dees-mobile-button size="md">Medium</dees-mobile-button>
<dees-mobile-button size="lg">Large</dees-mobile-button>
</div>
</div>
<div class="demo-section">
<h3>States</h3>
<div class="demo-row">
<dees-mobile-button>Normal</dees-mobile-button>
<dees-mobile-button disabled>Disabled</dees-mobile-button>
<dees-mobile-button loading>Loading</dees-mobile-button>
</div>
</div>
<div class="demo-section">
<h3>Icon Buttons</h3>
<div class="demo-row">
<dees-mobile-button icon size="sm">
<dees-mobile-icon icon="plus" size="16"></dees-mobile-icon>
</dees-mobile-button>
<dees-mobile-button icon>
<dees-mobile-icon icon="settings" size="18"></dees-mobile-icon>
</dees-mobile-button>
<dees-mobile-button icon size="lg">
<dees-mobile-icon icon="menu" size="20"></dees-mobile-icon>
</dees-mobile-button>
</div>
</div>
`;
};

View File

@@ -0,0 +1,224 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { mobileComponentStyles } from '../../00componentstyles.js';
import { demoFunc } from './dees-mobile-button.demo.js';
export type ButtonVariant = 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link';
export type ButtonSize = 'sm' | 'md' | 'lg';
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-button': DeesMobileButton;
}
}
@customElement('dees-mobile-button')
export class DeesMobileButton extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
accessor variant: ButtonVariant = 'default';
@property({ type: String })
accessor size: ButtonSize = 'md';
@property({ type: Boolean })
accessor disabled: boolean = false;
@property({ type: Boolean })
accessor loading: boolean = false;
@property({ type: Boolean, reflect: true })
accessor icon: boolean = false;
@property({ type: String })
accessor type: 'button' | 'submit' | 'reset' = 'button';
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
:host {
display: inline-block;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: inherit;
font-weight: 500;
border: none;
border-radius: calc(var(--dees-radius, 0.5rem) - 2px);
cursor: pointer;
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
position: relative;
white-space: nowrap;
text-decoration: none;
}
button:focus-visible {
outline: 2px solid var(--dees-primary);
outline-offset: 2px;
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Sizes */
button.sm {
height: 2rem;
padding: 0 0.75rem;
font-size: 0.75rem;
border-radius: calc(var(--dees-radius, 0.5rem) - 4px);
}
button.md {
height: 2.25rem;
padding: 0 1rem;
font-size: 0.875rem;
}
button.lg {
height: 2.75rem;
padding: 0 2rem;
font-size: 0.875rem;
}
/* Variants - using bdTheme for bright/dark support */
button.default,
button.primary {
background: #3b82f6;
color: #ffffff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
button.default:not(:disabled):hover,
button.primary:not(:disabled):hover {
background: #2563eb;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
button.secondary {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#18181b', '#fafafa')};
}
button.secondary:not(:disabled):hover {
background: ${cssManager.bdTheme('#e4e4e7', '#3f3f46')};
}
button.outline {
background: transparent;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#3f3f46')};
}
button.outline:not(:disabled):hover {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
border-color: ${cssManager.bdTheme('#d4d4d8', '#52525b')};
}
button.ghost {
background-color: transparent;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
button.ghost:not(:disabled):hover {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
}
button.destructive {
background: #dc2626;
color: #ffffff;
}
button.destructive:not(:disabled):hover {
background: #b91c1c;
}
button.link {
background: transparent;
color: #3b82f6;
text-decoration: underline;
text-underline-offset: 4px;
padding: 0;
height: auto;
}
button.link:not(:disabled):hover {
text-decoration: underline;
}
/* Loading state */
.spinner {
width: 1em;
height: 1em;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Icon-only button */
:host([icon]) button {
width: 2.25rem;
height: 2.25rem;
padding: 0;
}
:host([icon]) button.sm {
width: 2rem;
height: 2rem;
}
:host([icon]) button.lg {
width: 2.75rem;
height: 2.75rem;
}
`,
];
private handleClick(e: MouseEvent) {
if (this.disabled || this.loading) {
e.preventDefault();
e.stopPropagation();
return;
}
}
public render(): TemplateResult {
return html`
<button
type=${this.type}
class=${`${this.variant} ${this.size}`}
?disabled=${this.disabled || this.loading}
@click=${this.handleClick}
>
${this.loading ? html`<span class="spinner"></span>` : ''}
<slot></slot>
</button>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-button.js';

View File

@@ -0,0 +1,68 @@
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;
}
.header-container {
border: 1px solid var(--dees-border);
border-radius: var(--dees-radius);
overflow: hidden;
margin-bottom: 1rem;
}
</style>
<div class="demo-section">
<h3>Basic Header</h3>
<div class="header-container">
<dees-mobile-header title="Page Title"></dees-mobile-header>
</div>
</div>
<div class="demo-section">
<h3>Header with Subtitle</h3>
<div class="header-container">
<dees-mobile-header title="Settings" subtitle="Manage your preferences"></dees-mobile-header>
</div>
</div>
<div class="demo-section">
<h3>Header with Back Action</h3>
<div class="header-container">
<dees-mobile-header title="Item Details">
<dees-mobile-button slot="left-action" icon variant="ghost">
<dees-mobile-icon icon="arrow-left" size="20"></dees-mobile-icon>
</dees-mobile-button>
</dees-mobile-header>
</div>
</div>
<div class="demo-section">
<h3>Header with Actions</h3>
<div class="header-container">
<dees-mobile-header title="Shopping List">
<dees-mobile-button slot="left-action" icon variant="ghost">
<dees-mobile-icon icon="menu" size="20"></dees-mobile-icon>
</dees-mobile-button>
<dees-mobile-button slot="actions" icon variant="ghost">
<dees-mobile-icon icon="search" size="20"></dees-mobile-icon>
</dees-mobile-button>
<dees-mobile-button slot="actions" icon variant="ghost">
<dees-mobile-icon icon="plus" size="20"></dees-mobile-icon>
</dees-mobile-button>
</dees-mobile-header>
</div>
</div>
`;
};

View File

@@ -0,0 +1,171 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { mobileComponentStyles } from '../../00componentstyles.js';
import { demoFunc } from './dees-mobile-header.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-header': DeesMobileHeader;
}
}
@customElement('dees-mobile-header')
export class DeesMobileHeader extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
accessor title: string = '';
@property({ type: String })
accessor subtitle: string = '';
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
:host {
display: block;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
position: relative;
}
.header {
/* Mobile-first defaults */
height: 4rem;
padding: 0 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
max-width: 768px;
margin: 0 auto;
box-sizing: border-box;
}
/* Desktop enhancements */
@media (min-width: 641px) {
.header {
height: 5rem;
padding: 0 1.25rem;
gap: 1rem;
}
}
.left-action {
flex-shrink: 0;
margin-left: -0.5rem;
}
.left-action:empty {
display: none;
}
.left-action ::slotted(*) {
width: 2.5rem;
height: 2.5rem;
}
.content {
flex: 1;
min-width: 0;
}
.middle {
flex-shrink: 0;
display: flex;
align-items: center;
}
.middle:empty {
display: none;
}
h1 {
/* Mobile-first defaults */
font-size: 1rem;
font-weight: 600;
margin: 0;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Desktop enhancements */
@media (min-width: 641px) {
h1 {
font-size: 1.125rem;
}
}
.subtitle {
/* Mobile-first defaults */
font-size: 0.8125rem;
margin: 0.125rem 0 0;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
line-height: 1.3;
}
/* Desktop enhancements */
@media (min-width: 641px) {
.subtitle {
font-size: 0.875rem;
margin: 0.25rem 0 0;
}
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
::slotted([slot="actions"]) {
width: 2.5rem;
height: 2.5rem;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
display: block;
}
::slotted([slot="actions"]:hover) {
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
border-radius: 4px;
}
`,
];
public render(): TemplateResult {
return html`
<header class="header">
<div class="left-action">
<slot name="left-action"></slot>
</div>
<div class="content">
<slot name="content">
<h1>${this.title}</h1>
${this.subtitle ? html`<div class="subtitle">${this.subtitle}</div>` : ''}
</slot>
</div>
<div class="middle">
<slot name="middle"></slot>
</div>
<div class="actions">
<slot name="actions"></slot>
</div>
</header>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-header.js';

View File

@@ -0,0 +1,100 @@
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;
}
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.icon-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border-radius: var(--dees-radius);
background: var(--dees-surface);
min-width: 80px;
}
.icon-label {
font-size: 0.75rem;
color: var(--dees-muted-foreground);
}
</style>
<div class="demo-section">
<h3>Common Icons</h3>
<div class="demo-row">
<div class="icon-item">
<dees-mobile-icon icon="home" size="24"></dees-mobile-icon>
<span class="icon-label">home</span>
</div>
<div class="icon-item">
<dees-mobile-icon icon="settings" size="24"></dees-mobile-icon>
<span class="icon-label">settings</span>
</div>
<div class="icon-item">
<dees-mobile-icon icon="user" size="24"></dees-mobile-icon>
<span class="icon-label">user</span>
</div>
<div class="icon-item">
<dees-mobile-icon icon="search" size="24"></dees-mobile-icon>
<span class="icon-label">search</span>
</div>
<div class="icon-item">
<dees-mobile-icon icon="menu" size="24"></dees-mobile-icon>
<span class="icon-label">menu</span>
</div>
<div class="icon-item">
<dees-mobile-icon icon="x" size="24"></dees-mobile-icon>
<span class="icon-label">x</span>
</div>
</div>
</div>
<div class="demo-section">
<h3>Sizes</h3>
<div class="demo-row">
<dees-mobile-icon icon="star" size="16"></dees-mobile-icon>
<dees-mobile-icon icon="star" size="20"></dees-mobile-icon>
<dees-mobile-icon icon="star" size="24"></dees-mobile-icon>
<dees-mobile-icon icon="star" size="32"></dees-mobile-icon>
<dees-mobile-icon icon="star" size="48"></dees-mobile-icon>
</div>
</div>
<div class="demo-section">
<h3>Colors</h3>
<div class="demo-row">
<dees-mobile-icon icon="heart" size="24" color="var(--dees-danger)"></dees-mobile-icon>
<dees-mobile-icon icon="check-circle" size="24" color="var(--dees-success)"></dees-mobile-icon>
<dees-mobile-icon icon="alert-triangle" size="24" color="var(--dees-warning)"></dees-mobile-icon>
<dees-mobile-icon icon="info" size="24" color="var(--dees-primary)"></dees-mobile-icon>
</div>
</div>
<div class="demo-section">
<h3>Stroke Width</h3>
<div class="demo-row">
<dees-mobile-icon icon="circle" size="32" strokeWidth="1"></dees-mobile-icon>
<dees-mobile-icon icon="circle" size="32" strokeWidth="2"></dees-mobile-icon>
<dees-mobile-icon icon="circle" size="32" strokeWidth="3"></dees-mobile-icon>
</div>
</div>
`;
};

View File

@@ -0,0 +1,189 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import * as lucideIcons from 'lucide';
import { createElement } from 'lucide';
import { demoFunc } from './dees-mobile-icon.demo.js';
// Create a type-safe icon name type
export type LucideIconName = keyof typeof lucideIcons;
// Cache for rendered icons to improve performance
const iconCache = new Map<string, string>();
const MAX_CACHE_SIZE = 500;
function limitCacheSize() {
if (iconCache.size > MAX_CACHE_SIZE) {
const keysToDelete = Array.from(iconCache.keys()).slice(0, MAX_CACHE_SIZE / 5);
keysToDelete.forEach(key => iconCache.delete(key));
}
}
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-icon': DeesMobileIcon;
}
}
@customElement('dees-mobile-icon')
export class DeesMobileIcon extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
accessor icon: string = '';
@property({ type: Number })
accessor size: number = 20;
@property({ type: String })
accessor color: string = 'currentColor';
@property({ type: Number })
accessor strokeWidth: number = 2;
private lastIcon: string | null = null;
private lastSize: number | null = null;
private lastColor: string | null = null;
private lastStrokeWidth: number | null = null;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
#iconContainer {
display: flex;
align-items: center;
justify-content: center;
}
#iconContainer svg {
display: block;
height: 100%;
width: 100%;
}
`,
];
private renderLucideIcon(iconName: string): string {
const cacheKey = `${iconName}:${this.size}:${this.color}:${this.strokeWidth}`;
if (iconCache.has(cacheKey)) {
return iconCache.get(cacheKey) || '';
}
try {
// Convert kebab-case to PascalCase (e.g., "chevron-down" -> "ChevronDown")
const pascalCaseName = iconName
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
if (!(lucideIcons as any)[pascalCaseName]) {
console.warn(`Lucide icon '${pascalCaseName}' not found`);
return '';
}
const svgElement = createElement((lucideIcons as any)[pascalCaseName], {
color: this.color,
size: this.size,
strokeWidth: this.strokeWidth
});
if (!svgElement) {
console.warn(`createElement returned empty result for ${pascalCaseName}`);
return '';
}
const result = svgElement.outerHTML;
iconCache.set(cacheKey, result);
limitCacheSize();
return result;
} catch (error) {
console.error(`Error rendering Lucide icon ${iconName}:`, error);
return '';
}
}
public render(): TemplateResult {
return html`
<style>
#iconContainer {
width: ${this.size}px;
height: ${this.size}px;
}
</style>
<div id="iconContainer"></div>
`;
}
updated() {
// Check if we actually need to update the icon
if (this.lastIcon === this.icon &&
this.lastSize === this.size &&
this.lastColor === this.color &&
this.lastStrokeWidth === this.strokeWidth) {
return;
}
this.lastIcon = this.icon || null;
this.lastSize = this.size;
this.lastColor = this.color;
this.lastStrokeWidth = this.strokeWidth;
const container = this.shadowRoot?.querySelector('#iconContainer');
if (!container || !this.icon) return;
container.innerHTML = '';
try {
const pascalCaseName = this.icon
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
if ((lucideIcons as any)[pascalCaseName]) {
const svgElement = createElement((lucideIcons as any)[pascalCaseName], {
color: this.color,
size: this.size,
strokeWidth: this.strokeWidth
});
if (svgElement) {
container.appendChild(svgElement);
return;
}
}
// Fall back to string-based approach
const iconHtml = this.renderLucideIcon(this.icon);
if (iconHtml) {
container.innerHTML = iconHtml;
}
} catch (error) {
console.error(`Error updating icon ${this.icon}:`, error);
}
}
async disconnectedCallback() {
await super.disconnectedCallback();
this.lastIcon = null;
this.lastSize = null;
this.lastColor = null;
this.lastStrokeWidth = null;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-icon.js';

View File

@@ -0,0 +1,74 @@
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;
}
.modal-content {
padding: 1rem 0;
}
</style>
<div class="demo-section">
<h3>Modal (click button to open)</h3>
<dees-mobile-button
@click=${(e: Event) => {
const modal = (e.target as HTMLElement).parentElement?.querySelector('dees-mobile-modal');
if (modal) (modal as any).open = true;
}}
>Open Modal</dees-mobile-button>
<dees-mobile-modal
title="Confirm Action"
@close=${(e: Event) => {
(e.target as any).open = false;
}}
>
<div class="modal-content">
<p>Are you sure you want to proceed with this action?</p>
</div>
<div slot="footer">
<dees-mobile-button variant="ghost">Cancel</dees-mobile-button>
<dees-mobile-button variant="primary">Confirm</dees-mobile-button>
</div>
</dees-mobile-modal>
</div>
<div class="demo-section">
<h3>Modal without Close Button</h3>
<dees-mobile-button
variant="outline"
@click=${(e: Event) => {
const modal = (e.target as HTMLElement).parentElement?.querySelector('dees-mobile-modal');
if (modal) (modal as any).open = true;
}}
>Open Required Modal</dees-mobile-button>
<dees-mobile-modal
title="Terms & Conditions"
.showCloseButton=${false}
@close=${(e: Event) => {
(e.target as any).open = false;
}}
>
<div class="modal-content">
<p>You must accept the terms to continue.</p>
</div>
<div slot="footer">
<dees-mobile-button variant="primary">I Accept</dees-mobile-button>
</div>
</dees-mobile-modal>
</div>
`;
};

View File

@@ -0,0 +1,202 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { mobileComponentStyles } from '../../00componentstyles.js';
import '../dees-mobile-icon/dees-mobile-icon.js';
import { demoFunc } from './dees-mobile-modal.demo.js';
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-modal': DeesMobileModal;
}
}
@customElement('dees-mobile-modal')
export class DeesMobileModal extends DeesElement {
public static demo = demoFunc;
@property({ type: Boolean })
accessor open: boolean = false;
@property({ type: String })
accessor title: string = '';
@property({ type: Boolean })
accessor showCloseButton: boolean = true;
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--dees-z-modal, 500);
padding: 1rem;
animation: fadeIn 200ms ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Mobile-first defaults */
max-width: 100%;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slideUp 200ms ease-out;
}
/* Desktop enhancements */
@media (min-width: 641px) {
.modal {
max-width: 500px;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
/* Mobile-first defaults */
padding: 1rem;
border-bottom: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
}
/* Desktop enhancements */
@media (min-width: 641px) {
.modal-header {
padding: 1.5rem;
}
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
margin: 0;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
border-radius: 0.25rem;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
cursor: pointer;
transition: all 150ms ease;
}
.close-button:hover {
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.modal-content {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
@media (min-width: 641px) {
.modal-content {
padding: 1.5rem;
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')};
}
@media (min-width: 641px) {
.modal-footer {
padding: 1.5rem;
}
}
.modal-footer:empty {
display: none;
}
`,
];
private handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
this.handleClose();
}
}
private handleClose() {
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
}
public render(): TemplateResult {
if (!this.open) return html``;
return html`
<div class="modal-backdrop" @click=${this.handleBackdropClick}>
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">${this.title}</h2>
${this.showCloseButton ? html`
<button class="close-button" @click=${this.handleClose} aria-label="Close">
<dees-mobile-icon icon="x" size="20"></dees-mobile-icon>
</button>
` : ''}
</div>
<div class="modal-content">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-modal.js';

View File

@@ -0,0 +1,103 @@
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;
}
.demo-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
</style>
<div class="demo-section">
<h3>Toast Types</h3>
<div class="demo-row">
<dees-mobile-button
variant="outline"
@click=${() => {
const toast = document.createElement('dees-mobile-toast');
(toast as any).type = 'success';
(toast as any).message = 'Item saved successfully!';
toast.addEventListener('close', () => toast.remove());
document.body.appendChild(toast);
}}
>Success Toast</dees-mobile-button>
<dees-mobile-button
variant="outline"
@click=${() => {
const toast = document.createElement('dees-mobile-toast');
(toast as any).type = 'error';
(toast as any).message = 'Failed to save item. Please try again.';
toast.addEventListener('close', () => toast.remove());
document.body.appendChild(toast);
}}
>Error Toast</dees-mobile-button>
<dees-mobile-button
variant="outline"
@click=${() => {
const toast = document.createElement('dees-mobile-toast');
(toast as any).type = 'warning';
(toast as any).message = 'Your session will expire in 5 minutes.';
toast.addEventListener('close', () => toast.remove());
document.body.appendChild(toast);
}}
>Warning Toast</dees-mobile-button>
<dees-mobile-button
variant="outline"
@click=${() => {
const toast = document.createElement('dees-mobile-toast');
(toast as any).type = 'info';
(toast as any).message = 'New updates are available.';
toast.addEventListener('close', () => toast.remove());
document.body.appendChild(toast);
}}
>Info Toast</dees-mobile-button>
</div>
</div>
<div class="demo-section">
<h3>Custom Duration</h3>
<div class="demo-row">
<dees-mobile-button
variant="secondary"
@click=${() => {
const toast = document.createElement('dees-mobile-toast');
(toast as any).type = 'info';
(toast as any).message = 'This toast stays for 10 seconds.';
(toast as any).duration = 10000;
toast.addEventListener('close', () => toast.remove());
document.body.appendChild(toast);
}}
>Long Duration (10s)</dees-mobile-button>
<dees-mobile-button
variant="secondary"
@click=${() => {
const toast = document.createElement('dees-mobile-toast');
(toast as any).type = 'success';
(toast as any).message = 'Quick notification!';
(toast as any).duration = 1500;
toast.addEventListener('close', () => toast.remove());
document.body.appendChild(toast);
}}
>Short Duration (1.5s)</dees-mobile-button>
</div>
</div>
`;
};

View File

@@ -0,0 +1,339 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
type TemplateResult,
} from '@design.estate/dees-element';
import { mobileComponentStyles } from '../../00componentstyles.js';
import '../dees-mobile-icon/dees-mobile-icon.js';
import { demoFunc } from './dees-mobile-toast.demo.js';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
declare global {
interface HTMLElementTagNameMap {
'dees-mobile-toast': DeesMobileToast;
}
}
@customElement('dees-mobile-toast')
export class DeesMobileToast extends DeesElement {
public static demo = demoFunc;
@property({ type: String })
accessor message: string = '';
@property({ type: String })
accessor type: ToastType = 'info';
@property({ type: Number })
accessor duration: number = 0; // 0 means use default
private timeoutId?: number;
public static styles = [
cssManager.defaultStyles,
mobileComponentStyles,
css`
:host {
display: block;
position: fixed;
/* Mobile-first defaults */
bottom: 1rem;
left: 1rem;
right: 1rem;
transform: none;
z-index: var(--dees-z-notification, 900);
animation: slideUp 200ms var(--dees-spring);
}
/* Desktop enhancements */
@media (min-width: 641px) {
:host {
bottom: 2rem;
left: 50%;
right: auto;
transform: translateX(-50%);
}
}
/* Mobile-first animations */
@keyframes slideUp {
from {
transform: translateY(100%) scale(0.95);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(0) scale(1);
opacity: 1;
}
to {
transform: translateY(100%) scale(0.95);
opacity: 0;
}
}
/* Desktop-specific animations that include X translation */
@media (min-width: 641px) {
@keyframes slideUp {
from {
transform: translate(-50%, 100%) scale(0.95);
opacity: 0;
}
to {
transform: translate(-50%, 0) scale(1);
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translate(-50%, 0) scale(1);
opacity: 1;
}
to {
transform: translate(-50%, 100%) scale(0.95);
opacity: 0;
}
}
}
:host(.closing) {
animation: slideDown 200ms var(--dees-ease-in);
}
.toast {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-radius: var(--dees-radius);
box-shadow: var(--dees-shadow-lg);
/* Mobile-first defaults */
width: 100%;
min-width: auto;
max-width: none;
position: relative;
overflow: hidden;
}
/* Desktop enhancements */
@media (min-width: 641px) {
.toast {
width: auto;
min-width: 300px;
max-width: 500px;
}
}
/* Type-specific styles */
.toast.success {
background: var(--dees-card);
color: var(--dees-foreground);
border: 1px solid var(--dees-border);
}
.toast.error {
background: var(--dees-danger);
color: white;
border: 1px solid var(--dees-danger);
}
.toast.warning {
background: var(--dees-warning);
color: white;
border: 1px solid var(--dees-warning);
}
.toast.info {
background: var(--dees-primary);
color: white;
border: 1px solid var(--dees-primary);
}
.icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
position: relative;
z-index: 1;
}
.icon.success {
color: var(--dees-success);
}
.icon.error,
.icon.warning,
.icon.info {
color: currentColor;
}
.message {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
position: relative;
z-index: 1;
}
.close {
flex-shrink: 0;
width: 2rem;
height: 2rem;
padding: 0.375rem;
margin: -0.375rem;
background: none;
border: none;
cursor: pointer;
transition: all var(--dees-transition-fast);
opacity: 0.8;
position: relative;
z-index: 2;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
}
.close.success {
color: var(--dees-muted-foreground);
}
.close.error,
.close.warning,
.close.info {
color: currentColor;
}
.close:hover {
opacity: 1;
transform: scale(1.1);
}
/* Progress bar */
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: currentColor;
opacity: 0.3;
transform-origin: left;
animation: progress linear forwards;
pointer-events: none;
transform: scaleX(1);
}
.toast.success .progress-bar {
background: var(--dees-success);
}
.toast.error .progress-bar,
.toast.warning .progress-bar,
.toast.info .progress-bar {
background: rgba(255, 255, 255, 0.5);
}
@keyframes progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
`,
];
private get defaultDuration(): number {
switch (this.type) {
case 'success': return 3000;
case 'error': return 5000;
case 'warning': return 4000;
case 'info': return 4000;
}
}
async connectedCallback() {
await super.connectedCallback();
// Auto-dismiss after duration
const duration = this.duration || this.defaultDuration;
this.timeoutId = window.setTimeout(() => {
this.handleClose();
}, duration);
}
async disconnectedCallback() {
await super.disconnectedCallback();
// Clear the timeout when the element is removed
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
private handleClose() {
// Cancel the auto-dismiss timer
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
// Prevent double-triggering
if (this.classList.contains('closing')) return;
// Add closing animation
this.classList.add('closing');
// Wait for closing animation to complete
setTimeout(() => {
this.dispatchEvent(new CustomEvent('close', {
bubbles: true,
composed: true,
}));
}, 200);
}
private getIcon(): string {
switch (this.type) {
case 'success': return 'check-circle';
case 'error': return 'alert-circle';
case 'warning': return 'alert-triangle';
case 'info': return 'info';
}
}
public render(): TemplateResult {
const duration = this.duration || this.defaultDuration;
return html`
<div class="toast ${this.type}">
<div class="icon ${this.type}">
<dees-mobile-icon icon=${this.getIcon()} size="20"></dees-mobile-icon>
</div>
<span class="message">${this.message}</span>
<button
class="close ${this.type}"
@click=${() => this.handleClose()}
aria-label="Close"
type="button"
>
<dees-mobile-icon icon="x" size="20"></dees-mobile-icon>
</button>
<div class="progress-bar" style="animation-duration: ${duration}ms"></div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './dees-mobile-toast.js';

View File

@@ -0,0 +1,7 @@
// Core UI Components
export * from './dees-mobile-button/index.js';
export * from './dees-mobile-icon/index.js';
export * from './dees-mobile-header/index.js';
export * from './dees-mobile-modal/index.js';
export * from './dees-mobile-actionsheet/index.js';
export * from './dees-mobile-toast/index.js';

View File

@@ -0,0 +1,149 @@
import { css } from '@design.estate/dees-element';
/**
* CSS custom properties (variables) for the design system
* Using --dees-* prefix for consistency with dees-catalog
*/
export const cssVariables = css`
:root {
/* Primary colors */
--dees-primary: #3b82f6;
--dees-primary-dark: #2563eb;
--dees-primary-foreground: #ffffff;
/* Secondary colors */
--dees-secondary: #f4f4f5;
--dees-secondary-foreground: #18181b;
--dees-secondary-dark: #e4e4e7;
/* Background */
--dees-background: #ffffff;
--dees-card: #ffffff;
--dees-surface: #f4f4f5;
/* Text */
--dees-foreground: #09090b;
--dees-muted-foreground: #71717a;
/* Borders */
--dees-border: #e4e4e7;
--dees-input: #e4e4e7;
--dees-ring: #3b82f6;
/* Semantic colors */
--dees-success: #22c55e;
--dees-warning: #f59e0b;
--dees-danger: #ef4444;
--dees-danger-dark: #dc2626;
--dees-destructive: #dc2626;
--dees-destructive-foreground: #ffffff;
/* Accent */
--dees-accent: #f4f4f5;
--dees-accent-foreground: #18181b;
/* Muted */
--dees-muted: #f4f4f5;
/* Border radius */
--dees-radius: 0.5rem;
--dees-radius-sm: 0.25rem;
--dees-radius-md: 0.375rem;
--dees-radius-lg: 0.75rem;
--dees-radius-xl: 1rem;
--dees-radius-full: 9999px;
/* Shadows */
--dees-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--dees-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--dees-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Transitions */
--dees-transition-fast: 150ms ease;
--dees-transition-normal: 300ms ease;
--dees-transition-slow: 500ms ease;
--dees-spring: cubic-bezier(0.4, 0, 0.2, 1);
--dees-ease-in: cubic-bezier(0.4, 0, 1, 1);
--dees-ease-out: cubic-bezier(0, 0, 0.2, 1);
/* Spacing */
--dees-space-xs: 0.25rem;
--dees-space-sm: 0.5rem;
--dees-space-md: 1rem;
--dees-space-lg: 1.5rem;
--dees-space-xl: 2rem;
/* Mobile-specific */
--dees-safe-area-inset-top: env(safe-area-inset-top, 0px);
--dees-safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--dees-safe-area-inset-left: env(safe-area-inset-left, 0px);
--dees-safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
/* Page padding */
--dees-padding-page: 1rem;
/* Z-index scale */
--dees-z-base: 0;
--dees-z-dropdown: 100;
--dees-z-sticky: 200;
--dees-z-fixed: 300;
--dees-z-overlay: 400;
--dees-z-modal: 500;
--dees-z-popover: 600;
--dees-z-toast: 700;
--dees-z-tooltip: 800;
--dees-z-notification: 900;
--dees-z-max: 9999;
}
/* Dark theme */
:root[data-theme="dark"] {
/* Primary colors (same) */
--dees-primary: #3b82f6;
--dees-primary-dark: #2563eb;
--dees-primary-foreground: #ffffff;
/* Secondary colors */
--dees-secondary: #27272a;
--dees-secondary-foreground: #fafafa;
--dees-secondary-dark: #18181b;
/* Background */
--dees-background: #09090b;
--dees-card: #18181b;
--dees-surface: #27272a;
/* Text */
--dees-foreground: #fafafa;
--dees-muted-foreground: #a1a1aa;
/* Borders */
--dees-border: #27272a;
--dees-input: #27272a;
--dees-ring: #3b82f6;
/* Accent */
--dees-accent: #27272a;
--dees-accent-foreground: #fafafa;
/* Muted */
--dees-muted: #27272a;
}
`;
/**
* Inject CSS variables into the document
* Call this once at app initialization
*/
export function injectCssVariables(): void {
if (typeof document === 'undefined') return;
const styleId = 'dees-mobile-variables';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = cssVariables.cssText;
document.head.appendChild(style);
}

View File

@@ -0,0 +1,31 @@
/**
* Z-index scale for consistent layering
*/
export const zIndex = {
base: 0,
dropdown: 100,
sticky: 200,
fixed: 300,
overlay: 400,
modal: 500,
popover: 600,
toast: 700,
tooltip: 800,
notification: 900,
max: 9999,
};
// CSS custom property values
export const zIndexVars = `
--dees-z-base: ${zIndex.base};
--dees-z-dropdown: ${zIndex.dropdown};
--dees-z-sticky: ${zIndex.sticky};
--dees-z-fixed: ${zIndex.fixed};
--dees-z-overlay: ${zIndex.overlay};
--dees-z-modal: ${zIndex.modal};
--dees-z-popover: ${zIndex.popover};
--dees-z-toast: ${zIndex.toast};
--dees-z-tooltip: ${zIndex.tooltip};
--dees-z-notification: ${zIndex.notification};
--dees-z-max: ${zIndex.max};
`;

11
ts_web/elements/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// Design System
export * from './00colors.js';
export * from './00fonts.js';
export * from './00zindex.js';
export * from './00variables.js';
export * from './00componentstyles.js';
// Component Groups
export * from './00group-ui/index.js';
export * from './00group-layout/index.js';
export * from './00group-input/index.js';

6
ts_web/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './elements/index.js';
export * from './services/index.js';
export * from './controllers/index.js';
import * as colors from './elements/00colors.js';
export { colors };
export { commitinfo } from './00_commitinfo_data.js';

View File

@@ -0,0 +1,202 @@
import { html, css, DeesElement, customElement, state } from '@design.estate/dees-element';
// Import all components
import '../elements/index.js';
// Import demo functions
import { demoFunc as buttonDemo } from '../elements/00group-ui/dees-mobile-button/dees-mobile-button.demo.js';
import { demoFunc as iconDemo } from '../elements/00group-ui/dees-mobile-icon/dees-mobile-icon.demo.js';
import { demoFunc as headerDemo } from '../elements/00group-ui/dees-mobile-header/dees-mobile-header.demo.js';
import { demoFunc as modalDemo } from '../elements/00group-ui/dees-mobile-modal/dees-mobile-modal.demo.js';
import { demoFunc as actionsheetDemo } from '../elements/00group-ui/dees-mobile-actionsheet/dees-mobile-actionsheet.demo.js';
import { demoFunc as toastDemo } from '../elements/00group-ui/dees-mobile-toast/dees-mobile-toast.demo.js';
import { demoFunc as navigationDemo } from '../elements/00group-layout/dees-mobile-navigation/dees-mobile-navigation.demo.js';
import { demoFunc as applayoutDemo } from '../elements/00group-layout/dees-mobile-applayout/dees-mobile-applayout.demo.js';
import { demoFunc as inputDemo } from '../elements/00group-input/dees-mobile-input/dees-mobile-input.demo.js';
interface IComponentDemo {
name: string;
tag: string;
category: string;
demo: () => ReturnType<typeof html>;
}
const components: IComponentDemo[] = [
{ name: 'Button', tag: 'dees-mobile-button', category: 'UI', demo: buttonDemo },
{ name: 'Icon', tag: 'dees-mobile-icon', category: 'UI', demo: iconDemo },
{ name: 'Header', tag: 'dees-mobile-header', category: 'UI', demo: headerDemo },
{ name: 'Modal', tag: 'dees-mobile-modal', category: 'UI', demo: modalDemo },
{ name: 'Action Sheet', tag: 'dees-mobile-actionsheet', category: 'UI', demo: actionsheetDemo },
{ name: 'Toast', tag: 'dees-mobile-toast', category: 'UI', demo: toastDemo },
{ name: 'Navigation', tag: 'dees-mobile-navigation', category: 'Layout', demo: navigationDemo },
{ name: 'App Layout', tag: 'dees-mobile-applayout', category: 'Layout', demo: applayoutDemo },
{ name: 'Input', tag: 'dees-mobile-input', category: 'Input', demo: inputDemo },
];
@customElement('component-showcase')
export class ComponentShowcase extends DeesElement {
@state()
accessor selectedComponent: string = 'dees-mobile-button';
public static styles = [
css`
:host {
display: block;
min-height: 100vh;
background: var(--dees-background);
}
.showcase {
display: grid;
grid-template-columns: 250px 1fr;
min-height: 100vh;
}
@media (max-width: 768px) {
.showcase {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
}
.sidebar {
background: var(--dees-surface);
border-right: 1px solid var(--dees-border);
padding: 1.5rem;
overflow-y: auto;
}
.sidebar h1 {
font-size: 1.125rem;
font-weight: 700;
margin: 0 0 1.5rem 0;
color: var(--dees-foreground);
}
.category {
margin-bottom: 1.5rem;
}
.category-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--dees-muted-foreground);
margin-bottom: 0.5rem;
}
.component-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.component-item {
padding: 0.5rem 0.75rem;
border-radius: var(--dees-radius-sm);
font-size: 0.875rem;
color: var(--dees-foreground);
cursor: pointer;
transition: background 150ms;
}
.component-item:hover {
background: var(--dees-accent);
}
.component-item.active {
background: var(--dees-primary);
color: var(--dees-primary-foreground);
}
.main-content {
padding: 2rem;
overflow-y: auto;
}
.component-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--dees-border);
}
.component-header h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
color: var(--dees-foreground);
}
.component-tag {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
color: var(--dees-muted-foreground);
}
.demo-container {
background: var(--dees-card);
border: 1px solid var(--dees-border);
border-radius: var(--dees-radius-lg);
padding: 2rem;
}
`,
];
private getCategories(): string[] {
return [...new Set(components.map(c => c.category))];
}
private getComponentsByCategory(category: string): IComponentDemo[] {
return components.filter(c => c.category === category);
}
private getSelectedComponent(): IComponentDemo | undefined {
return components.find(c => c.tag === this.selectedComponent);
}
public render() {
const selected = this.getSelectedComponent();
return html`
<div class="showcase">
<aside class="sidebar">
<h1>Components</h1>
${this.getCategories().map(category => html`
<div class="category">
<div class="category-title">${category}</div>
<div class="component-list">
${this.getComponentsByCategory(category).map(comp => html`
<div
class="component-item ${this.selectedComponent === comp.tag ? 'active' : ''}"
@click=${() => this.selectedComponent = comp.tag}
>
${comp.name}
</div>
`)}
</div>
</div>
`)}
</aside>
<main class="main-content">
${selected ? html`
<div class="component-header">
<h2>${selected.name}</h2>
<code class="component-tag">&lt;${selected.tag}&gt;</code>
</div>
<div class="demo-container">
${selected.demo()}
</div>
` : html`
<p>Select a component from the sidebar</p>
`}
</main>
</div>
`;
}
}
export const componentShowcase = () => html`<component-showcase></component-showcase>`;

2
ts_web/pages/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './mainpage.js';
export * from './component-showcase.js';

78
ts_web/pages/mainpage.ts Normal file
View File

@@ -0,0 +1,78 @@
import { html } from '@design.estate/dees-element';
// Import all components to register them
import '../elements/index.js';
export const mainPage = () => html`
<style>
.main-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem 1rem;
}
.hero {
text-align: center;
margin-bottom: 3rem;
}
.hero h1 {
font-size: 2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: var(--dees-foreground);
}
.hero p {
color: var(--dees-muted-foreground);
margin: 0;
}
.demo-section {
margin-bottom: 2rem;
}
.demo-section h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1rem 0;
color: var(--dees-foreground);
}
.demo-grid {
display: grid;
gap: 1rem;
}
</style>
<div class="main-container">
<div class="hero">
<h1>dees-catalog-mobile</h1>
<p>Mobile-optimized components for cross-platform apps</p>
</div>
<div class="demo-section">
<h2>Quick Start</h2>
<div class="demo-grid">
<dees-mobile-input
label="Your Name"
placeholder="Enter your name"
></dees-mobile-input>
<dees-mobile-input
label="Email"
type="email"
placeholder="you@example.com"
></dees-mobile-input>
<dees-mobile-button variant="primary" style="width: 100%">
Get Started
</dees-mobile-button>
</div>
</div>
<div class="demo-section">
<h2>Components</h2>
<div class="demo-grid">
<dees-mobile-button variant="outline">
<dees-mobile-icon icon="layout-grid" size="18"></dees-mobile-icon>
View All Components
</dees-mobile-button>
</div>
</div>
</div>
`;

1
ts_web/services/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './theme-service.js';

View File

@@ -0,0 +1,112 @@
/**
* Theme types
*/
export type Theme = 'light' | 'dark' | 'system';
/**
* Theme service for managing light/dark mode
* Singleton pattern with subscription support
*/
class ThemeService {
private static instance: ThemeService;
private currentTheme: Theme = 'system';
private listeners: Set<(theme: Theme, isDark: boolean) => void> = new Set();
private mediaQuery: MediaQueryList;
private storageKey = 'dees-theme';
private constructor() {
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.loadTheme();
this.mediaQuery.addEventListener('change', () => this.applyTheme());
}
/**
* Get the singleton instance
*/
static getInstance(): ThemeService {
if (!ThemeService.instance) {
ThemeService.instance = new ThemeService();
}
return ThemeService.instance;
}
/**
* Load theme from localStorage
*/
private loadTheme() {
const saved = localStorage.getItem(this.storageKey) as Theme;
this.currentTheme = saved || 'system';
this.applyTheme();
}
/**
* Apply the current theme to the document
*/
private applyTheme() {
const isDark = this.isDark();
if (isDark) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
this.notifyListeners();
}
/**
* Check if dark mode is currently active
*/
isDark(): boolean {
if (this.currentTheme === 'dark') return true;
if (this.currentTheme === 'light') return false;
return this.mediaQuery.matches;
}
/**
* Get the current theme setting
*/
getTheme(): Theme {
return this.currentTheme;
}
/**
* Set the theme
*/
setTheme(theme: Theme) {
this.currentTheme = theme;
localStorage.setItem(this.storageKey, theme);
this.applyTheme();
}
/**
* Toggle through themes: light -> dark -> system -> light
*/
toggleTheme() {
const themes: Theme[] = ['light', 'dark', 'system'];
const currentIndex = themes.indexOf(this.currentTheme);
const nextIndex = (currentIndex + 1) % themes.length;
this.setTheme(themes[nextIndex]);
}
/**
* Subscribe to theme changes
* Returns unsubscribe function
*/
subscribe(callback: (theme: Theme, isDark: boolean) => void): () => void {
this.listeners.add(callback);
// Call immediately with current state
callback(this.currentTheme, this.isDark());
return () => this.listeners.delete(callback);
}
/**
* Notify all listeners of theme change
*/
private notifyListeners() {
this.listeners.forEach(callback => callback(this.currentTheme, this.isDark()));
}
}
/**
* Singleton theme service instance
*/
export const themeService = ThemeService.getInstance();

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
},
"exclude": [
"dist_*/**/*.d.ts"
]
}