feat(dees-appui): Rename DeesAppuiBase to DeesAppui and migrate related API, exports, demos and docs
This commit is contained in:
697
ts_web/elements/00group-appui/dees-appui/dees-appui.demo.ts
Normal file
697
ts_web/elements/00group-appui/dees-appui/dees-appui.demo.ts
Normal file
@@ -0,0 +1,697 @@
|
||||
import { html, css, DeesElement, customElement, state } from '@design.estate/dees-element';
|
||||
import type { DeesAppui } from './dees-appui.js';
|
||||
import type { IAppConfig, IViewActivationContext } from '../../interfaces/appconfig.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
// Demo view component with lifecycle hooks
|
||||
@customElement('demo-dashboard-view')
|
||||
class DemoDashboardView extends DeesElement {
|
||||
@state()
|
||||
accessor activated: boolean = false;
|
||||
|
||||
private ctx: IViewActivationContext;
|
||||
|
||||
onActivate(context: IViewActivationContext) {
|
||||
this.ctx = context;
|
||||
this.activated = true;
|
||||
console.log('Dashboard activated with context:', context);
|
||||
|
||||
// Set view-specific secondary menu
|
||||
context.appui.setSecondaryMenu({
|
||||
heading: 'Dashboard',
|
||||
groups: [
|
||||
{
|
||||
name: 'Quick Access',
|
||||
iconName: 'lucide:zap',
|
||||
items: [
|
||||
{ key: 'overview', iconName: 'layoutDashboard', action: () => console.log('Overview') },
|
||||
{ key: 'recent', iconName: 'clock', badge: 5, action: () => console.log('Recent') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
iconName: 'lucide:barChart3',
|
||||
items: [
|
||||
{ key: 'metrics', iconName: 'activity', action: () => console.log('Metrics') },
|
||||
{ key: 'reports', iconName: 'fileText', badge: 'new', badgeVariant: 'success', action: () => console.log('Reports') },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Set content tabs for dashboard
|
||||
context.appui.setContentTabs([
|
||||
{ key: 'Overview', iconName: 'lucide:layoutDashboard', action: () => console.log('Overview tab') },
|
||||
{ key: 'Analytics', iconName: 'lucide:barChart', action: () => console.log('Analytics tab') },
|
||||
{ key: 'Reports', iconName: 'lucide:fileText', action: () => console.log('Reports tab') },
|
||||
]);
|
||||
}
|
||||
|
||||
onDeactivate() {
|
||||
this.activated = false;
|
||||
console.log('Dashboard deactivated');
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
padding: 40px;
|
||||
color: #a3a3a3;
|
||||
font-family: 'Geist Sans', 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
h1 { color: #fafafa; font-weight: 600; font-size: 24px; margin-bottom: 8px; }
|
||||
p { color: #737373; margin-bottom: 32px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.card h3 { color: #fafafa; font-size: 14px; font-weight: 600; margin-bottom: 8px; }
|
||||
.metric { font-size: 32px; font-weight: 700; color: #fafafa; }
|
||||
.status { display: inline-block; padding: 2px 8px; border-radius: 9px; font-size: 12px; }
|
||||
.status.active { background: #14532d; color: #4ade80; }
|
||||
|
||||
.ctx-actions {
|
||||
margin-top: 32px;
|
||||
padding: 24px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ctx-actions h2 { color: #fafafa; font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
||||
.button-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.ctx-btn {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
color: #60a5fa;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.ctx-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
.ctx-btn.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.ctx-btn.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.ctx-btn.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.ctx-btn.success:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
</style>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome back! Here's an overview of your system.</p>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Active Users</h3>
|
||||
<div class="metric">1,234</div>
|
||||
<span class="status active">Online</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>API Calls</h3>
|
||||
<div class="metric">45.2K</div>
|
||||
<p style="color: #4ade80; font-size: 12px; margin: 0;">+12% from last hour</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>System Health</h3>
|
||||
<div class="metric">99.9%</div>
|
||||
<p style="color: #737373; font-size: 12px; margin: 0;">All systems operational</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ctx-actions">
|
||||
<h2>Context Actions (ctx.appui)</h2>
|
||||
<div class="button-grid">
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setMainMenuVisible(false)}>Hide Main Menu</button>
|
||||
<button class="ctx-btn success" @click=${() => this.ctx?.appui.setMainMenuVisible(true)}>Show Main Menu</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setSecondaryMenuVisible(false)}>Hide Secondary Menu</button>
|
||||
<button class="ctx-btn success" @click=${() => this.ctx?.appui.setSecondaryMenuVisible(true)}>Show Secondary Menu</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setContentTabsVisible(false)}>Hide Content Tabs</button>
|
||||
<button class="ctx-btn success" @click=${() => this.ctx?.appui.setContentTabsVisible(true)}>Show Content Tabs</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setMainMenuCollapsed(true)}>Collapse Main Menu</button>
|
||||
<button class="ctx-btn success" @click=${() => this.ctx?.appui.setMainMenuCollapsed(false)}>Expand Main Menu</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setBreadcrumbs(['Dashboard', 'Overview', 'Stats'])}>Set Breadcrumbs</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.navigateToView('projects')}>Go to Projects</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.navigateToView('settings', { section: 'security' })}>Go to Settings/Security</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.activityLog.add({ type: 'custom', user: 'Demo User', message: 'Button clicked from ctx!', iconName: 'lucide:mouse-pointer-click' })}>Add Activity Entry</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setMainMenuBadge('tasks', 99)}>Set Tasks Badge to 99</button>
|
||||
<button class="ctx-btn danger" @click=${() => this.ctx?.appui.clearMainMenuBadge('tasks')}>Clear Tasks Badge</button>
|
||||
<button class="ctx-btn" @click=${() => this.ctx?.appui.setContentTabsAutoHide(true, 1)}>Auto-hide Tabs (≤1)</button>
|
||||
<button class="ctx-btn danger" @click=${() => this.ctx?.appui.setContentTabsAutoHide(false)}>Disable Auto-hide</button>
|
||||
<button class="ctx-btn success" @click=${() => this.addCloseableTab()}>Add Closeable Tab</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private tabCounter = 0;
|
||||
|
||||
private addCloseableTab() {
|
||||
if (!this.ctx) return;
|
||||
this.tabCounter++;
|
||||
const tabKey = `Tab ${this.tabCounter}`;
|
||||
this.ctx.appui.addContentTab({
|
||||
key: tabKey,
|
||||
iconName: 'lucide:file',
|
||||
action: () => console.log(`Selected ${tabKey}`),
|
||||
closeable: true,
|
||||
onClose: () => {
|
||||
this.ctx?.appui.removeContentTab(tabKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Settings view with route params and canDeactivate guard
|
||||
@customElement('demo-settings-view')
|
||||
class DemoSettingsView extends DeesElement {
|
||||
@state()
|
||||
accessor section: string = 'general';
|
||||
|
||||
@state()
|
||||
accessor hasChanges: boolean = false;
|
||||
|
||||
private appui: DeesAppui;
|
||||
|
||||
onActivate(context: IViewActivationContext) {
|
||||
this.appui = context.appui as any;
|
||||
console.log('Settings activated with params:', context.params);
|
||||
|
||||
if (context.params?.section) {
|
||||
this.section = context.params.section;
|
||||
}
|
||||
|
||||
// Set settings-specific secondary menu
|
||||
context.appui.setSecondaryMenu({
|
||||
heading: 'Settings',
|
||||
groups: [
|
||||
{
|
||||
name: 'Account',
|
||||
iconName: 'lucide:user',
|
||||
items: [
|
||||
{ key: 'general', iconName: 'settings', action: () => this.showSection('general') },
|
||||
{ key: 'profile', iconName: 'user', action: () => this.showSection('profile') },
|
||||
{ key: 'security', iconName: 'shield', action: () => this.showSection('security') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Preferences',
|
||||
iconName: 'lucide:sliders',
|
||||
items: [
|
||||
{ key: 'notifications', iconName: 'bell', badge: 3, action: () => this.showSection('notifications') },
|
||||
{ key: 'appearance', iconName: 'palette', action: () => this.showSection('appearance') },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
context.appui.setSecondaryMenuSelection(this.section);
|
||||
|
||||
// Clear content tabs for settings
|
||||
context.appui.setContentTabs([]);
|
||||
}
|
||||
|
||||
onDeactivate() {
|
||||
console.log('Settings deactivated');
|
||||
this.hasChanges = false;
|
||||
}
|
||||
|
||||
canDeactivate(): boolean | string {
|
||||
if (this.hasChanges) {
|
||||
return 'You have unsaved changes. Leave anyway?';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
showSection(section: string) {
|
||||
this.section = section;
|
||||
this.appui?.setSecondaryMenuSelection(section);
|
||||
}
|
||||
|
||||
simulateChange() {
|
||||
this.hasChanges = true;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
padding: 40px;
|
||||
color: #a3a3a3;
|
||||
font-family: 'Geist Sans', 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
h1 { color: #fafafa; font-weight: 600; font-size: 24px; margin-bottom: 8px; }
|
||||
p { color: #737373; margin-bottom: 24px; }
|
||||
.section-name {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
font-size: 18px;
|
||||
color: #fafafa;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.warning {
|
||||
color: #fbbf24;
|
||||
font-size: 13px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
<h1>Settings</h1>
|
||||
<p>Manage your account and application preferences.</p>
|
||||
<div class="section-name">
|
||||
Current section: <strong>${this.section}</strong>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click=${() => this.simulateChange()}>Make Changes</button>
|
||||
</div>
|
||||
${this.hasChanges ? html`<p class="warning">You have unsaved changes. Navigation will prompt for confirmation.</p>` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Projects view
|
||||
@customElement('demo-projects-view')
|
||||
class DemoProjectsView extends DeesElement {
|
||||
onActivate(context: IViewActivationContext) {
|
||||
context.appui.setSecondaryMenu({
|
||||
heading: 'Projects',
|
||||
groups: [
|
||||
{
|
||||
name: 'My Projects',
|
||||
items: [
|
||||
{ key: 'active', iconName: 'folder', badge: 3, action: () => console.log('Active') },
|
||||
{ key: 'archived', iconName: 'archive', action: () => console.log('Archived') },
|
||||
{ key: 'shared', iconName: 'users', badge: 2, badgeVariant: 'warning', action: () => console.log('Shared') },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
context.appui.setContentTabs([
|
||||
{ key: 'Grid', iconName: 'lucide:grid', action: () => console.log('Grid view') },
|
||||
{ key: 'List', iconName: 'lucide:list', action: () => console.log('List view') },
|
||||
{ key: 'Board', iconName: 'lucide:kanban', action: () => console.log('Board view') },
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
padding: 40px;
|
||||
color: #a3a3a3;
|
||||
font-family: 'Geist Sans', 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
h1 { color: #fafafa; font-weight: 600; font-size: 24px; margin-bottom: 24px; }
|
||||
.projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.project {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.project:hover {
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
.project h3 { color: #fafafa; margin: 0 0 8px 0; font-size: 16px; }
|
||||
.project p { color: #737373; margin: 0; font-size: 13px; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #14532d;
|
||||
color: #4ade80;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
<h1>Projects</h1>
|
||||
<div class="projects">
|
||||
<div class="project">
|
||||
<h3>Frontend App <span class="badge">Active</span></h3>
|
||||
<p>React-based dashboard application</p>
|
||||
</div>
|
||||
<div class="project">
|
||||
<h3>API Server <span class="badge">Active</span></h3>
|
||||
<p>Node.js REST API backend</p>
|
||||
</div>
|
||||
<div class="project">
|
||||
<h3>Mobile App <span class="badge">Active</span></h3>
|
||||
<p>React Native iOS/Android app</p>
|
||||
</div>
|
||||
<div class="project">
|
||||
<h3>Documentation</h3>
|
||||
<p>Technical documentation site</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks view showing inline template content
|
||||
@customElement('demo-tasks-view')
|
||||
class DemoTasksView extends DeesElement {
|
||||
onActivate(context: IViewActivationContext) {
|
||||
context.appui.setSecondaryMenu({
|
||||
heading: 'Tasks',
|
||||
groups: [
|
||||
{
|
||||
name: 'Filters',
|
||||
items: [
|
||||
{ key: 'all', iconName: 'list', badge: 12, action: () => console.log('All') },
|
||||
{ key: 'today', iconName: 'calendar', badge: 3, action: () => console.log('Today') },
|
||||
{ key: 'upcoming', iconName: 'clock', action: () => console.log('Upcoming') },
|
||||
{ key: 'completed', iconName: 'checkCircle', action: () => console.log('Completed') },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
context.appui.setContentTabs([
|
||||
{ key: 'List', iconName: 'lucide:list', action: () => console.log('List') },
|
||||
{ key: 'Calendar', iconName: 'lucide:calendar', action: () => console.log('Calendar') },
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
padding: 40px;
|
||||
color: #a3a3a3;
|
||||
font-family: 'Geist Sans', 'Inter', -apple-system, sans-serif;
|
||||
}
|
||||
h1 { color: #fafafa; font-weight: 600; font-size: 24px; margin-bottom: 24px; }
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #525252;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-text { color: #fafafa; flex: 1; }
|
||||
.due-date { color: #737373; font-size: 12px; }
|
||||
.priority {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.priority.high { background: #450a0a; color: #f87171; }
|
||||
.priority.medium { background: #451a03; color: #fbbf24; }
|
||||
</style>
|
||||
<h1>Tasks</h1>
|
||||
<div class="task-list">
|
||||
<div class="task">
|
||||
<div class="checkbox"></div>
|
||||
<span class="task-text">Review pull request #42</span>
|
||||
<span class="due-date">Today</span>
|
||||
<span class="priority high">High</span>
|
||||
</div>
|
||||
<div class="task">
|
||||
<div class="checkbox"></div>
|
||||
<span class="task-text">Update documentation</span>
|
||||
<span class="due-date">Tomorrow</span>
|
||||
<span class="priority medium">Medium</span>
|
||||
</div>
|
||||
<div class="task">
|
||||
<div class="checkbox"></div>
|
||||
<span class="task-text">Write unit tests</span>
|
||||
<span class="due-date">Dec 20</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const demoFunc = () => {
|
||||
// App configuration using the new unified API
|
||||
const appConfig: IAppConfig = {
|
||||
branding: {
|
||||
logoIcon: 'lucide:box',
|
||||
logoText: 'Acme App'
|
||||
},
|
||||
|
||||
appBar: {
|
||||
menuItems: [
|
||||
{
|
||||
name: 'File',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'New Project', shortcut: 'Cmd+N', iconName: 'filePlus', action: async () => console.log('New') },
|
||||
{ name: 'Open...', shortcut: 'Cmd+O', iconName: 'folderOpen', action: async () => console.log('Open') },
|
||||
{ name: 'Recent Projects', action: async () => {}, submenu: [
|
||||
{ name: 'my-app', action: async () => console.log('Open my-app') },
|
||||
{ name: 'component-lib', action: async () => console.log('Open component-lib') },
|
||||
]},
|
||||
{ divider: true },
|
||||
{ name: 'Save All', shortcut: 'Cmd+S', iconName: 'save', action: async () => console.log('Save') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => console.log('Undo') },
|
||||
{ name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => console.log('Redo') },
|
||||
{ divider: true },
|
||||
{ name: 'Cut', shortcut: 'Cmd+X', iconName: 'scissors', action: async () => console.log('Cut') },
|
||||
{ name: 'Copy', shortcut: 'Cmd+C', iconName: 'copy', action: async () => console.log('Copy') },
|
||||
{ name: 'Paste', shortcut: 'Cmd+V', iconName: 'clipboard', action: async () => console.log('Paste') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'View',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') },
|
||||
{ name: 'Toggle Activity Log', shortcut: 'Cmd+Shift+A', action: async () => console.log('Toggle activity') },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Help',
|
||||
action: async () => {},
|
||||
submenu: [
|
||||
{ name: 'Documentation', iconName: 'book', action: async () => console.log('Docs') },
|
||||
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+/', action: async () => console.log('Shortcuts') },
|
||||
{ divider: true },
|
||||
{ name: 'About', iconName: 'info', action: async () => console.log('About') },
|
||||
]
|
||||
}
|
||||
],
|
||||
breadcrumbs: 'Dashboard',
|
||||
showWindowControls: true,
|
||||
showSearch: true,
|
||||
user: {
|
||||
name: 'Jane Smith',
|
||||
email: 'jane.smith@example.com',
|
||||
status: 'online'
|
||||
},
|
||||
profileMenuItems: [
|
||||
{ name: 'Profile', iconName: 'user', action: async () => console.log('Profile') },
|
||||
{ name: 'Account Settings', iconName: 'settings', action: async () => console.log('Settings') },
|
||||
{ divider: true },
|
||||
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
|
||||
{ divider: true },
|
||||
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
|
||||
]
|
||||
},
|
||||
|
||||
views: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
iconName: 'lucide:home',
|
||||
content: 'demo-dashboard-view',
|
||||
route: 'dashboard'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
name: 'Projects',
|
||||
iconName: 'lucide:folder',
|
||||
content: 'demo-projects-view',
|
||||
route: 'projects',
|
||||
badge: 3
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
name: 'Tasks',
|
||||
iconName: 'lucide:checkSquare',
|
||||
content: 'demo-tasks-view',
|
||||
route: 'tasks',
|
||||
badge: 12
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
content: 'demo-settings-view',
|
||||
route: 'settings/:section?'
|
||||
},
|
||||
],
|
||||
|
||||
mainMenu: {
|
||||
sections: [
|
||||
{ name: 'Main', views: ['dashboard'] },
|
||||
{ name: 'Workspace', views: ['projects', 'tasks'] },
|
||||
],
|
||||
bottomItems: ['settings']
|
||||
},
|
||||
|
||||
defaultView: 'dashboard',
|
||||
|
||||
onViewChange: (viewId, view) => {
|
||||
console.log(`View changed to: ${viewId} (${view.name})`);
|
||||
},
|
||||
|
||||
onSearch: (query) => {
|
||||
console.log('Search query:', query);
|
||||
}
|
||||
};
|
||||
|
||||
// Use a container element to properly initialize the demo
|
||||
const containerElement = document.createElement('div');
|
||||
containerElement.className = 'demo-container';
|
||||
containerElement.style.cssText = 'position: absolute; top: 0; left: 0; height: 100%; width: 100%; overflow: hidden;';
|
||||
|
||||
const appuiElement = document.createElement('dees-appui') as DeesAppui;
|
||||
containerElement.appendChild(appuiElement);
|
||||
|
||||
// Initialize after element is connected
|
||||
setTimeout(async () => {
|
||||
await appuiElement.updateComplete;
|
||||
|
||||
// Configure using the unified API
|
||||
appuiElement.configure(appConfig);
|
||||
|
||||
// Add demo activity entries
|
||||
setTimeout(() => {
|
||||
appuiElement.activityLog.addMany([
|
||||
{
|
||||
type: 'login',
|
||||
user: 'Jane Smith',
|
||||
message: 'logged in from Chrome on macOS'
|
||||
},
|
||||
{
|
||||
type: 'create',
|
||||
user: 'Jane Smith',
|
||||
message: 'created project "Frontend App"'
|
||||
},
|
||||
{
|
||||
type: 'update',
|
||||
user: 'John Doe',
|
||||
message: 'updated API documentation'
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
user: 'Jane Smith',
|
||||
message: 'viewed dashboard analytics'
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
user: 'Admin',
|
||||
message: 'removed deprecated endpoint'
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
user: 'System',
|
||||
message: 'scheduled backup completed',
|
||||
iconName: 'lucide:database'
|
||||
}
|
||||
]);
|
||||
}, 500);
|
||||
|
||||
// Subscribe to view changes
|
||||
appuiElement.viewChanged$.subscribe((event) => {
|
||||
console.log('View changed event:', event);
|
||||
// Update breadcrumbs based on view
|
||||
appuiElement.setBreadcrumbs(event.view.name);
|
||||
});
|
||||
|
||||
// Subscribe to lifecycle events
|
||||
appuiElement.viewLifecycle$.subscribe((event) => {
|
||||
console.log('Lifecycle event:', event.type, event.viewId);
|
||||
});
|
||||
|
||||
// Demo: Dynamically update a badge after 5 seconds
|
||||
setTimeout(() => {
|
||||
appuiElement.setMainMenuBadge('tasks', 15);
|
||||
appuiElement.activityLog.add({
|
||||
type: 'update',
|
||||
user: 'System',
|
||||
message: 'new tasks added'
|
||||
});
|
||||
}, 5000);
|
||||
}, 0);
|
||||
|
||||
return html`
|
||||
<dees-demowrapper>
|
||||
${containerElement}
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
1050
ts_web/elements/00group-appui/dees-appui/dees-appui.ts
Normal file
1050
ts_web/elements/00group-appui/dees-appui/dees-appui.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
ts_web/elements/00group-appui/dees-appui/index.ts
Normal file
2
ts_web/elements/00group-appui/dees-appui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dees-appui.js';
|
||||
export * from './view.registry.js';
|
||||
560
ts_web/elements/00group-appui/dees-appui/readme.md
Normal file
560
ts_web/elements/00group-appui/dees-appui/readme.md
Normal file
@@ -0,0 +1,560 @@
|
||||
# DeesAppui
|
||||
|
||||
A comprehensive application shell component providing a complete UI framework with navigation, menus, activity logging, and view management.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { html, DeesElement, customElement } from '@design.estate/dees-element';
|
||||
import { DeesAppui } from '@design.estate/dees-catalog';
|
||||
|
||||
@customElement('my-app')
|
||||
class MyApp extends DeesElement {
|
||||
private appui: DeesAppui;
|
||||
|
||||
async firstUpdated() {
|
||||
this.appui = this.shadowRoot.querySelector('dees-appui');
|
||||
|
||||
// Configure with views and menu
|
||||
this.appui.configure({
|
||||
branding: { logoIcon: 'lucide:box', logoText: 'My App' },
|
||||
views: [
|
||||
{ id: 'dashboard', name: 'Dashboard', iconName: 'lucide:home', content: 'my-dashboard' },
|
||||
{ id: 'settings', name: 'Settings', iconName: 'lucide:settings', content: 'my-settings' },
|
||||
],
|
||||
mainMenu: {
|
||||
sections: [{ name: 'Main', views: ['dashboard', 'settings'] }]
|
||||
},
|
||||
defaultView: 'dashboard'
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<dees-appui></dees-appui>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration API
|
||||
|
||||
### `configure(config: IAppConfig)`
|
||||
|
||||
Configure the entire application shell with a single configuration object.
|
||||
|
||||
```typescript
|
||||
interface IAppConfig {
|
||||
branding?: IBrandingConfig;
|
||||
appBar?: IAppBarConfig;
|
||||
views: IViewDefinition[];
|
||||
mainMenu?: IMainMenuConfig;
|
||||
defaultView?: string;
|
||||
activityLog?: IActivityLogConfig;
|
||||
onViewChange?: (viewId: string, view: IViewDefinition) => void;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### View Definition
|
||||
|
||||
```typescript
|
||||
interface IViewDefinition {
|
||||
id: string; // Unique identifier
|
||||
name: string; // Display name
|
||||
iconName?: string; // Icon (e.g., 'lucide:home')
|
||||
content: // View content
|
||||
| string // Tag name ('my-component')
|
||||
| (new () => HTMLElement) // Class constructor
|
||||
| (() => TemplateResult) // Template function
|
||||
| (() => Promise<...>); // Async for lazy loading
|
||||
secondaryMenu?: ISecondaryMenuGroup[];
|
||||
contentTabs?: ITab[];
|
||||
route?: string; // URL route (default: id)
|
||||
badge?: string | number;
|
||||
cache?: boolean; // Cache view instance (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Programmatic APIs
|
||||
|
||||
### App Bar API
|
||||
|
||||
Control the top application bar.
|
||||
|
||||
```typescript
|
||||
// Set menu items (File, Edit, View, etc.)
|
||||
appui.setAppBarMenus([
|
||||
{
|
||||
name: 'File',
|
||||
submenu: [
|
||||
{ name: 'New', shortcut: 'Cmd+N', action: () => {} },
|
||||
{ name: 'Save', shortcut: 'Cmd+S', action: () => {} },
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
// Update single menu
|
||||
appui.updateAppBarMenu('File', { submenu: [...newItems] });
|
||||
|
||||
// Breadcrumbs
|
||||
appui.setBreadcrumbs('Dashboard > Settings > Profile');
|
||||
appui.setBreadcrumbs(['Dashboard', 'Settings', 'Profile']);
|
||||
|
||||
// User profile
|
||||
appui.setUser({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
avatar: '/avatars/john.png',
|
||||
status: 'online' // 'online' | 'offline' | 'busy' | 'away'
|
||||
});
|
||||
|
||||
appui.setProfileMenuItems([
|
||||
{ name: 'Profile', iconName: 'lucide:user', action: () => {} },
|
||||
{ divider: true },
|
||||
{ name: 'Sign Out', iconName: 'lucide:log-out', action: () => {} }
|
||||
]);
|
||||
|
||||
// Search
|
||||
appui.setSearchVisible(true);
|
||||
appui.onSearch((query) => console.log('Search:', query));
|
||||
|
||||
// Window controls (for Electron/Tauri apps)
|
||||
appui.setWindowControlsVisible(false);
|
||||
```
|
||||
|
||||
### Main Menu API (Left Sidebar)
|
||||
|
||||
Control the main navigation menu.
|
||||
|
||||
```typescript
|
||||
// Set entire menu
|
||||
appui.setMainMenu({
|
||||
logoIcon: 'lucide:box',
|
||||
logoText: 'My App',
|
||||
groups: [
|
||||
{
|
||||
name: 'Main',
|
||||
tabs: [
|
||||
{ key: 'dashboard', iconName: 'lucide:home', action: () => {} },
|
||||
{ key: 'inbox', iconName: 'lucide:inbox', badge: 5, action: () => {} },
|
||||
]
|
||||
}
|
||||
],
|
||||
bottomTabs: [
|
||||
{ key: 'settings', iconName: 'lucide:settings', action: () => {} }
|
||||
]
|
||||
});
|
||||
|
||||
// Update specific group
|
||||
appui.updateMainMenuGroup('Main', { tabs: [...newTabs] });
|
||||
|
||||
// Add/remove items
|
||||
appui.addMainMenuItem('Main', { key: 'tasks', iconName: 'lucide:check', action: () => {} });
|
||||
appui.removeMainMenuItem('Main', 'tasks');
|
||||
|
||||
// Selection
|
||||
appui.setMainMenuSelection('dashboard');
|
||||
appui.setMainMenuCollapsed(true);
|
||||
|
||||
// Badges
|
||||
appui.setMainMenuBadge('inbox', 12);
|
||||
appui.clearMainMenuBadge('inbox');
|
||||
```
|
||||
|
||||
### Secondary Menu API
|
||||
|
||||
Views can control the secondary (contextual) menu.
|
||||
|
||||
```typescript
|
||||
// Set menu
|
||||
appui.setSecondaryMenu({
|
||||
heading: 'Settings',
|
||||
groups: [
|
||||
{
|
||||
name: 'Account',
|
||||
items: [
|
||||
{ key: 'profile', iconName: 'lucide:user', action: () => {} },
|
||||
{ key: 'security', iconName: 'lucide:shield', action: () => {} },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Update group
|
||||
appui.updateSecondaryMenuGroup('Account', { items: newItems });
|
||||
|
||||
// Add item
|
||||
appui.addSecondaryMenuItem('Account', {
|
||||
key: 'notifications',
|
||||
iconName: 'lucide:bell',
|
||||
action: () => {}
|
||||
});
|
||||
|
||||
// Selection
|
||||
appui.setSecondaryMenuSelection('profile');
|
||||
|
||||
// Clear
|
||||
appui.clearSecondaryMenu();
|
||||
```
|
||||
|
||||
### Content Tabs API
|
||||
|
||||
Control tabs in the main content area.
|
||||
|
||||
```typescript
|
||||
// Set tabs
|
||||
appui.setContentTabs([
|
||||
{ key: 'code', iconName: 'lucide:code', action: () => {} },
|
||||
{ key: 'preview', iconName: 'lucide:eye', action: () => {} }
|
||||
]);
|
||||
|
||||
// Add/remove
|
||||
appui.addContentTab({ key: 'debug', iconName: 'lucide:bug', action: () => {} });
|
||||
appui.removeContentTab('debug');
|
||||
|
||||
// Select
|
||||
appui.selectContentTab('preview');
|
||||
|
||||
// Get current
|
||||
const current = appui.getSelectedContentTab();
|
||||
```
|
||||
|
||||
### Activity Log API
|
||||
|
||||
Add activity entries to the right-side activity log.
|
||||
|
||||
```typescript
|
||||
// Add single entry
|
||||
appui.activityLog.add({
|
||||
type: 'create', // 'login' | 'logout' | 'view' | 'create' | 'update' | 'delete' | 'custom'
|
||||
user: 'John Doe',
|
||||
message: 'created a new invoice',
|
||||
iconName: 'lucide:file-plus', // Optional custom icon
|
||||
data: { invoiceId: '123' } // Optional metadata
|
||||
});
|
||||
|
||||
// Add multiple
|
||||
appui.activityLog.addMany([...entries]);
|
||||
|
||||
// Clear
|
||||
appui.activityLog.clear();
|
||||
|
||||
// Query
|
||||
const entries = appui.activityLog.getEntries();
|
||||
const filtered = appui.activityLog.filter({ user: 'John', type: 'create' });
|
||||
const searched = appui.activityLog.search('invoice');
|
||||
```
|
||||
|
||||
### Navigation API
|
||||
|
||||
Navigate between views programmatically.
|
||||
|
||||
```typescript
|
||||
// Navigate to view
|
||||
await appui.navigateToView('settings');
|
||||
await appui.navigateToView('settings', { section: 'profile' });
|
||||
|
||||
// Get current view
|
||||
const current = appui.getCurrentView();
|
||||
|
||||
// Subscribe to view changes
|
||||
appui.viewChanged$.subscribe((event) => {
|
||||
console.log(`Navigated to: ${event.viewId}`);
|
||||
});
|
||||
|
||||
// Subscribe to lifecycle events
|
||||
appui.viewLifecycle$.subscribe((event) => {
|
||||
if (event.type === 'activated') {
|
||||
console.log(`View ${event.viewId} activated`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Lifecycle Hooks
|
||||
|
||||
Views can implement lifecycle hooks to respond to activation/deactivation.
|
||||
|
||||
```typescript
|
||||
import { DeesElement, customElement } from '@design.estate/dees-element';
|
||||
import type { IViewActivationContext, IViewLifecycle } from '@design.estate/dees-catalog';
|
||||
|
||||
@customElement('my-settings-view')
|
||||
class MySettingsView extends DeesElement implements IViewLifecycle {
|
||||
/**
|
||||
* Called when view is activated (displayed)
|
||||
* Receives typed context with appui reference
|
||||
*/
|
||||
async onActivate(context: IViewActivationContext) {
|
||||
const { appui, viewId, params } = context;
|
||||
|
||||
// Set view-specific secondary menu
|
||||
appui.setSecondaryMenu({
|
||||
heading: 'Settings',
|
||||
groups: [{ name: 'Options', items: [...] }]
|
||||
});
|
||||
|
||||
// Set view-specific tabs
|
||||
appui.setContentTabs([...]);
|
||||
|
||||
// Load data based on route params
|
||||
if (params?.section) {
|
||||
await this.loadSection(params.section);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when view is deactivated (hidden)
|
||||
*/
|
||||
onDeactivate() {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before navigation away
|
||||
* Return false or a message string to block navigation
|
||||
*/
|
||||
canDeactivate(): boolean | string {
|
||||
if (this.hasUnsavedChanges) {
|
||||
return 'You have unsaved changes. Leave anyway?';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### IViewActivationContext
|
||||
|
||||
```typescript
|
||||
interface IViewActivationContext {
|
||||
appui: DeesAppui; // Reference to the app shell
|
||||
viewId: string; // The view ID being activated
|
||||
params?: Record<string, string>; // Route parameters
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Routing
|
||||
|
||||
Routes are automatically registered from view definitions using `domtools.router`.
|
||||
|
||||
```typescript
|
||||
const views = [
|
||||
{ id: 'dashboard', route: 'dashboard', ... },
|
||||
{ id: 'settings', route: 'settings/:section?', ... }, // Parameterized
|
||||
{ id: 'user', route: 'users/:id', ... },
|
||||
];
|
||||
|
||||
// URL: #dashboard → navigates to dashboard view
|
||||
// URL: #settings/profile → navigates to settings with params.section = 'profile'
|
||||
// URL: #users/123 → navigates to user with params.id = '123'
|
||||
```
|
||||
|
||||
### Hash-based Routing
|
||||
|
||||
The router uses hash-based routing by default (`#viewId`). URLs are automatically synchronized when navigating via `navigateToView()`.
|
||||
|
||||
---
|
||||
|
||||
## View Caching
|
||||
|
||||
Views are cached by default. When navigating away and back, the same DOM element is reused (hidden/shown) rather than destroyed and recreated.
|
||||
|
||||
```typescript
|
||||
// Disable caching for a specific view
|
||||
{
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
content: 'my-reports-view',
|
||||
cache: false // Always recreate this view
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lazy Loading
|
||||
|
||||
Use async content functions for lazy loading views.
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'analytics',
|
||||
name: 'Analytics',
|
||||
content: async () => {
|
||||
const module = await import('./views/analytics.js');
|
||||
return module.AnalyticsView;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RxJS Observables
|
||||
|
||||
The component exposes RxJS Subjects for reactive programming.
|
||||
|
||||
```typescript
|
||||
// View lifecycle events
|
||||
appui.viewLifecycle$.subscribe((event) => {
|
||||
// event.type: 'loading' | 'activated' | 'deactivated' | 'loaded' | 'loadError'
|
||||
// event.viewId: string
|
||||
// event.element?: HTMLElement
|
||||
// event.params?: Record<string, string>
|
||||
// event.error?: unknown
|
||||
});
|
||||
|
||||
// View change events
|
||||
appui.viewChanged$.subscribe((event) => {
|
||||
// event.viewId: string
|
||||
// event.view: IViewDefinition
|
||||
// event.previousView?: IViewDefinition
|
||||
// event.params?: Record<string, string>
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { html, DeesElement, customElement } from '@design.estate/dees-element';
|
||||
import { DeesAppui, IViewActivationContext } from '@design.estate/dees-catalog';
|
||||
|
||||
@customElement('my-app')
|
||||
class MyApp extends DeesElement {
|
||||
private appui: DeesAppui;
|
||||
|
||||
async firstUpdated() {
|
||||
this.appui = this.shadowRoot.querySelector('dees-appui');
|
||||
|
||||
this.appui.configure({
|
||||
branding: {
|
||||
logoIcon: 'lucide:briefcase',
|
||||
logoText: 'CRM Pro'
|
||||
},
|
||||
|
||||
appBar: {
|
||||
menuItems: [
|
||||
{ name: 'File', submenu: [...] },
|
||||
{ name: 'Edit', submenu: [...] }
|
||||
],
|
||||
showSearch: true,
|
||||
user: { name: 'Jane Smith', status: 'online' }
|
||||
},
|
||||
|
||||
views: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
iconName: 'lucide:home',
|
||||
content: 'crm-dashboard',
|
||||
route: 'dashboard'
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'Contacts',
|
||||
iconName: 'lucide:users',
|
||||
content: 'crm-contacts',
|
||||
route: 'contacts',
|
||||
badge: 42
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
content: 'crm-settings',
|
||||
route: 'settings/:section?'
|
||||
}
|
||||
],
|
||||
|
||||
mainMenu: {
|
||||
sections: [
|
||||
{ name: 'Main', views: ['dashboard', 'contacts'] }
|
||||
],
|
||||
bottomItems: ['settings']
|
||||
},
|
||||
|
||||
defaultView: 'dashboard',
|
||||
|
||||
onViewChange: (viewId, view) => {
|
||||
console.log(`Navigated to: ${view.name}`);
|
||||
},
|
||||
|
||||
onSearch: (query) => {
|
||||
console.log(`Search: ${query}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Load activity from backend
|
||||
const activities = await fetch('/api/activities').then(r => r.json());
|
||||
this.appui.activityLog.addMany(activities);
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<dees-appui></dees-appui>`;
|
||||
}
|
||||
}
|
||||
|
||||
// View with lifecycle hooks
|
||||
@customElement('crm-settings')
|
||||
class CrmSettings extends DeesElement {
|
||||
private appui: DeesAppui;
|
||||
|
||||
onActivate(context: IViewActivationContext) {
|
||||
this.appui = context.appui;
|
||||
|
||||
// Set secondary menu for settings
|
||||
this.appui.setSecondaryMenu({
|
||||
heading: 'Settings',
|
||||
groups: [
|
||||
{
|
||||
name: 'Account',
|
||||
items: [
|
||||
{ key: 'profile', iconName: 'lucide:user', action: () => this.showSection('profile') },
|
||||
{ key: 'security', iconName: 'lucide:shield', action: () => this.showSection('security') }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Preferences',
|
||||
items: [
|
||||
{ key: 'notifications', iconName: 'lucide:bell', action: () => this.showSection('notifications') }
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Navigate to section from URL params
|
||||
if (context.params?.section) {
|
||||
this.showSection(context.params.section);
|
||||
}
|
||||
}
|
||||
|
||||
showSection(section: string) {
|
||||
this.appui.setSecondaryMenuSelection(section);
|
||||
// ... load section content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
All interfaces are exported from `@design.estate/dees-catalog`:
|
||||
|
||||
- `IAppConfig` - Main configuration
|
||||
- `IViewDefinition` - View definition
|
||||
- `IViewActivationContext` - Context passed to `onActivate`
|
||||
- `IViewLifecycle` - Lifecycle hooks interface
|
||||
- `IViewLifecycleEvent` - Lifecycle event for rxjs Subject
|
||||
- `IViewChangeEvent` - View change event
|
||||
- `IAppUser` - User configuration
|
||||
- `IActivityEntry` - Activity log entry
|
||||
- `IActivityLogAPI` - Activity log methods
|
||||
- `IAppBarMenuItem` - App bar menu item
|
||||
- `IMainMenuConfig` - Main menu configuration
|
||||
- `ISecondaryMenuGroup` - Secondary menu group
|
||||
- `ITab` - Tab definition
|
||||
402
ts_web/elements/00group-appui/dees-appui/view.registry.ts
Normal file
402
ts_web/elements/00group-appui/dees-appui/view.registry.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { html, render, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type {
|
||||
IViewDefinition,
|
||||
IViewActivationContext,
|
||||
IViewLifecycle,
|
||||
TDeesAppui
|
||||
} from '../../interfaces/appconfig.js';
|
||||
|
||||
/**
|
||||
* Registry for managing views and their lifecycle
|
||||
*
|
||||
* Key features:
|
||||
* - View caching with hide/show pattern (not destroy/create)
|
||||
* - Async content loading support (lazy loading)
|
||||
* - View lifecycle hooks (onActivate, onDeactivate, canDeactivate)
|
||||
*/
|
||||
export class ViewRegistry {
|
||||
private views: Map<string, IViewDefinition> = new Map();
|
||||
private instances: Map<string, HTMLElement> = new Map();
|
||||
private currentViewId: string | null = null;
|
||||
private appui: TDeesAppui | null = null;
|
||||
|
||||
/**
|
||||
* Set the appui reference for view activation context
|
||||
*/
|
||||
public setAppuiRef(appui: TDeesAppui): void {
|
||||
this.appui = appui;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single view
|
||||
*/
|
||||
public register(view: IViewDefinition): void {
|
||||
if (this.views.has(view.id)) {
|
||||
console.warn(`View with id "${view.id}" already registered. Overwriting.`);
|
||||
}
|
||||
this.views.set(view.id, view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register multiple views
|
||||
*/
|
||||
public registerAll(views: IViewDefinition[]): void {
|
||||
views.forEach((view) => this.register(view));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a view definition by ID
|
||||
*/
|
||||
public get(viewId: string): IViewDefinition | undefined {
|
||||
return this.views.get(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered view IDs
|
||||
*/
|
||||
public getViewIds(): string[] {
|
||||
return Array.from(this.views.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all views
|
||||
*/
|
||||
public getAll(): IViewDefinition[] {
|
||||
return Array.from(this.views.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route for a view
|
||||
*/
|
||||
public getRoute(viewId: string): string {
|
||||
const view = this.views.get(viewId);
|
||||
return view?.route || view?.id || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find view by route (supports parameterized routes like 'settings/:section')
|
||||
*/
|
||||
public findByRoute(route: string): { view: IViewDefinition; params: Record<string, string> } | undefined {
|
||||
for (const view of this.views.values()) {
|
||||
const viewRoute = view.route || view.id;
|
||||
const params = this.matchRoute(viewRoute, route);
|
||||
if (params !== null) {
|
||||
return { view, params };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a route pattern against an actual route
|
||||
* Returns params if matched, null otherwise
|
||||
*/
|
||||
private matchRoute(pattern: string, route: string): Record<string, string> | null {
|
||||
const patternParts = pattern.split('/');
|
||||
const routeParts = route.split('/');
|
||||
|
||||
// Check for optional trailing param (ends with ?)
|
||||
const hasOptionalParam = patternParts.length > 0 &&
|
||||
patternParts[patternParts.length - 1].endsWith('?');
|
||||
|
||||
if (hasOptionalParam) {
|
||||
// Allow route to be shorter by 1
|
||||
if (routeParts.length < patternParts.length - 1 || routeParts.length > patternParts.length) {
|
||||
return null;
|
||||
}
|
||||
} else if (patternParts.length !== routeParts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
let part = patternParts[i];
|
||||
const isOptional = part.endsWith('?');
|
||||
if (isOptional) {
|
||||
part = part.slice(0, -1);
|
||||
}
|
||||
|
||||
if (part.startsWith(':')) {
|
||||
// This is a parameter
|
||||
const paramName = part.slice(1);
|
||||
if (routeParts[i] !== undefined) {
|
||||
params[paramName] = routeParts[i];
|
||||
} else if (!isOptional) {
|
||||
return null;
|
||||
}
|
||||
} else if (routeParts[i] !== part) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if navigation away from current view is allowed
|
||||
*/
|
||||
public async canLeaveCurrentView(): Promise<boolean | string> {
|
||||
if (!this.currentViewId) return true;
|
||||
|
||||
const instance = this.instances.get(this.currentViewId);
|
||||
if (!instance) return true;
|
||||
|
||||
const lifecycle = instance as unknown as IViewLifecycle;
|
||||
if (typeof lifecycle.canDeactivate === 'function') {
|
||||
return await lifecycle.canDeactivate();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a view - handles caching, lifecycle, and rendering
|
||||
*/
|
||||
public async activateView(
|
||||
viewId: string,
|
||||
container: HTMLElement,
|
||||
params?: Record<string, string>
|
||||
): Promise<HTMLElement | null> {
|
||||
const view = this.views.get(viewId);
|
||||
if (!view) {
|
||||
console.error(`View "${viewId}" not found in registry`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if caching is enabled for this view (default: true)
|
||||
const shouldCache = view.cache !== false;
|
||||
|
||||
// Deactivate current view
|
||||
if (this.currentViewId && this.currentViewId !== viewId) {
|
||||
await this.deactivateView(this.currentViewId);
|
||||
}
|
||||
|
||||
// Check for cached instance
|
||||
let element = shouldCache ? this.instances.get(viewId) : undefined;
|
||||
|
||||
if (element) {
|
||||
// Reuse cached instance - just show it
|
||||
element.style.display = '';
|
||||
} else {
|
||||
// Create new instance
|
||||
element = await this.createViewElement(view);
|
||||
if (!element) {
|
||||
console.error(`Failed to create element for view "${viewId}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add to container
|
||||
container.appendChild(element);
|
||||
|
||||
// Cache if enabled
|
||||
if (shouldCache) {
|
||||
this.instances.set(viewId, element);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentViewId = viewId;
|
||||
|
||||
// Call onActivate lifecycle hook
|
||||
await this.callOnActivate(element, viewId, params);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a view (hide and call lifecycle hook)
|
||||
*/
|
||||
private async deactivateView(viewId: string): Promise<void> {
|
||||
const instance = this.instances.get(viewId);
|
||||
if (!instance) return;
|
||||
|
||||
// Call onDeactivate lifecycle hook
|
||||
const lifecycle = instance as unknown as IViewLifecycle;
|
||||
if (typeof lifecycle.onDeactivate === 'function') {
|
||||
await lifecycle.onDeactivate();
|
||||
}
|
||||
|
||||
// Hide the element
|
||||
instance.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a view element from its definition (supports async content)
|
||||
*/
|
||||
private async createViewElement(view: IViewDefinition): Promise<HTMLElement | null> {
|
||||
let content = view.content;
|
||||
|
||||
// Handle async content (lazy loading)
|
||||
if (typeof content === 'function' &&
|
||||
!(content.prototype instanceof HTMLElement) &&
|
||||
content.constructor.name === 'AsyncFunction') {
|
||||
try {
|
||||
content = await (content as () => Promise<string | (new () => HTMLElement) | (() => TemplateResult)>)();
|
||||
} catch (error) {
|
||||
console.error(`Failed to load async content for view "${view.id}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let element: HTMLElement;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
// Tag name string
|
||||
element = document.createElement(content);
|
||||
} else if (typeof content === 'function') {
|
||||
// Check if it's a class constructor or template function
|
||||
if (content.prototype instanceof HTMLElement) {
|
||||
// Element class constructor
|
||||
element = new (content as new () => HTMLElement)();
|
||||
} else {
|
||||
// Template function - wrap in a container and use Lit's render
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'view-content-wrapper';
|
||||
wrapper.style.cssText = 'display: contents;';
|
||||
const template = (content as () => TemplateResult)();
|
||||
render(template, wrapper);
|
||||
element = wrapper;
|
||||
}
|
||||
} else {
|
||||
console.error(`Invalid content type for view "${view.id}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add view ID as data attribute for debugging
|
||||
element.dataset.viewId = view.id;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call onActivate lifecycle hook on a view element
|
||||
*/
|
||||
private async callOnActivate(
|
||||
element: HTMLElement,
|
||||
viewId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<void> {
|
||||
const lifecycle = element as unknown as IViewLifecycle;
|
||||
if (typeof lifecycle.onActivate === 'function') {
|
||||
const context: IViewActivationContext = {
|
||||
appui: this.appui!,
|
||||
viewId,
|
||||
params,
|
||||
};
|
||||
await lifecycle.onActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method - renders view without caching
|
||||
* @deprecated Use activateView instead
|
||||
*/
|
||||
public renderView(viewId: string, container: HTMLElement): HTMLElement | null {
|
||||
const view = this.views.get(viewId);
|
||||
if (!view) {
|
||||
console.error(`View "${viewId}" not found in registry`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// For legacy compatibility, clear container
|
||||
container.innerHTML = '';
|
||||
|
||||
let element: HTMLElement;
|
||||
const content = view.content;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
element = document.createElement(content);
|
||||
} else if (typeof content === 'function') {
|
||||
if ((content as any).prototype instanceof HTMLElement) {
|
||||
element = new (content as new () => HTMLElement)();
|
||||
} else {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'view-content-wrapper';
|
||||
wrapper.style.cssText = 'display: contents;';
|
||||
const template = (content as () => TemplateResult)();
|
||||
render(template, wrapper);
|
||||
element = wrapper;
|
||||
}
|
||||
} else {
|
||||
console.error(`Invalid content type for view "${viewId}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
container.appendChild(element);
|
||||
this.instances.set(viewId, element);
|
||||
this.currentViewId = viewId;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currently active view ID
|
||||
*/
|
||||
public getCurrentViewId(): string | null {
|
||||
return this.currentViewId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached instance of a view
|
||||
*/
|
||||
public getInstance(viewId: string): HTMLElement | undefined {
|
||||
return this.instances.get(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific cached instance
|
||||
*/
|
||||
public clearInstance(viewId: string): void {
|
||||
const instance = this.instances.get(viewId);
|
||||
if (instance && instance.parentNode) {
|
||||
instance.parentNode.removeChild(instance);
|
||||
}
|
||||
this.instances.delete(viewId);
|
||||
if (this.currentViewId === viewId) {
|
||||
this.currentViewId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all instances
|
||||
*/
|
||||
public clearInstances(): void {
|
||||
for (const [viewId, instance] of this.instances) {
|
||||
if (instance.parentNode) {
|
||||
instance.parentNode.removeChild(instance);
|
||||
}
|
||||
}
|
||||
this.instances.clear();
|
||||
this.currentViewId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a view
|
||||
*/
|
||||
public unregister(viewId: string): boolean {
|
||||
this.clearInstance(viewId);
|
||||
return this.views.delete(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the registry
|
||||
*/
|
||||
public clear(): void {
|
||||
this.views.clear();
|
||||
this.clearInstances();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a view is registered
|
||||
*/
|
||||
public has(viewId: string): boolean {
|
||||
return this.views.has(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of registered views
|
||||
*/
|
||||
public get size(): number {
|
||||
return this.views.size;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user