diff --git a/changelog.md b/changelog.md index a111a73..48503d2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-12-19 - 3.4.0 - feat(dees-appui-base) +overhaul AppUI core: replace simple view rendering with a full-featured ViewRegistry (caching, hide/show lifecycle, async lazy-loading), introduce view lifecycle hooks and activation context, add activity log API/component, remove built-in router and state manager, and update configuration interfaces and demos + +- Removed files: app.router.ts and state.manager.ts — routing and state-persistence internals were removed (breaking). +- ViewRegistry rewritten: supports cached instances, activate/deactivate lifecycle, canDeactivate checks, async content loading, parameterized routes, and legacy renderView kept as deprecated compatibility. +- New interfaces added/changed: IViewActivationContext, IViewLifecycle, IActivityEntry, IActivityLogAPI, IViewLifecycleEvent; IViewDefinition.content now accepts async loaders and a cache flag; IMainMenuConfig and ITab expanded (logo, groups, badges). +- Activity log: dees-appui-activitylog now implements IActivityLogAPI and exposes reactive entries; demo and readme updated with usage and examples. +- App config changed: routing and statePersistence config entries removed/adjusted; defaultView moved into IAppConfig; view change and lifecycle event shapes changed (breaking). +- Demos and documentation: dees-appui-base demo and readme added/updated to showcase new lifecycle hooks, secondary menu behavior, activity log and new APIs. + ## 2025-12-19 - 3.3.3 - fix(tests) update test imports to new dees-input-wysiwyg paths diff --git a/readme.md b/readme.md index bd5956d..0f2be48 100644 --- a/readme.md +++ b/readme.md @@ -582,78 +582,58 @@ Submit button component specifically designed for `DeesForm`. ### Layout Components #### `DeesAppuiBase` -Base container component for application layout structure with integrated appbar, menu system, and content areas. +A comprehensive application shell component providing a complete UI framework with navigation, menus, activity logging, and view management. + +> **Full API Documentation**: See [ts_web/elements/00group-appui/dees-appui-base/readme.md](./ts_web/elements/00group-appui/dees-appui-base/readme.md) for complete documentation including all programmatic APIs, view lifecycle hooks, and TypeScript interfaces. + +**Quick Start:** ```typescript - {}, // No-op for parent menu items - submenu: [ - { name: 'New File', shortcut: 'Cmd+N', iconName: 'file-plus', action: async () => {} }, - { name: 'Open...', shortcut: 'Cmd+O', iconName: 'folder-open', action: async () => {} }, - { divider: true }, - { name: 'Save', shortcut: 'Cmd+S', iconName: 'save', action: async () => {} } - ] - }, - { - name: 'Edit', - action: async () => {}, - submenu: [ - { name: 'Undo', shortcut: 'Cmd+Z', iconName: 'undo', action: async () => {} }, - { name: 'Redo', shortcut: 'Cmd+Shift+Z', iconName: 'redo', action: async () => {} } - ] - } - ]} - .appbarBreadcrumbs=${'Dashboard > Overview'} - .appbarTheme=${'dark'} - .appbarUser=${{ name: 'John Doe', status: 'online' }} - .appbarShowSearch=${true} - .appbarShowWindowControls=${true} - - // Main menu configuration (left sidebar) - .mainmenuTabs=${[ - { key: 'dashboard', iconName: 'lucide:home', action: () => {} }, - { key: 'projects', iconName: 'lucide:folder', action: () => {} }, - { key: 'settings', iconName: 'lucide:settings', action: () => {} } - ]} - .mainmenuSelectedTab=${selectedTab} - - // Selector configuration (second sidebar) - .mainselectorOptions=${[ - { key: 'Overview', action: () => {} }, - { key: 'Components', action: () => {} }, - { key: 'Services', action: () => {} } - ]} - .mainselectorSelectedOption=${selectedOption} - - // Main content tabs - .maincontentTabs=${[ - { key: 'tab1', iconName: 'lucide:file', action: () => {} } - ]} - - // Event handlers - @appbar-menu-select=${(e) => handleMenuSelect(e.detail)} - @appbar-breadcrumb-navigate=${(e) => handleBreadcrumbNav(e.detail)} - @appbar-search-click=${() => handleSearch()} - @appbar-user-menu-open=${() => handleUserMenu()} - @mainmenu-tab-select=${(e) => handleTabSelect(e.detail)} - @mainselector-option-select=${(e) => handleOptionSelect(e.detail)} -> -
- -
-
+import { html, DeesElement, customElement } from '@design.estate/dees-element'; +import { DeesAppuiBase } from '@design.estate/dees-catalog'; + +@customElement('my-app') +class MyApp extends DeesElement { + private appui: DeesAppuiBase; + + async firstUpdated() { + this.appui = this.shadowRoot.querySelector('dees-appui-base'); + + // 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``; + } +} ``` -Key Features: -- **Integrated Layout System**: Automatically arranges appbar, sidebars, and content area -- **Centralized Configuration**: Pass properties to all child components from one place -- **Event Propagation**: All child component events are re-emitted for easy handling -- **Responsive Grid**: Uses CSS Grid for flexible, responsive layout -- **Slot Support**: Main content area supports custom content via slots +**Key Features:** +- **Configure API**: Single `configure()` method for complete app setup +- **View Management**: Automatic view caching, lazy loading, and lifecycle hooks +- **Programmatic APIs**: Full control over AppBar, Main Menu, Secondary Menu, Content Tabs, and Activity Log +- **View Lifecycle Hooks**: `onActivate()`, `onDeactivate()`, and `canDeactivate()` for view components +- **Hash-based Routing**: Automatic URL synchronization with view navigation +- **RxJS Observables**: `viewChanged$` and `viewLifecycle$` for reactive programming +- **TypeScript-first**: Typed `IViewActivationContext` passed to views on activation + +**Programmatic APIs include:** +- `navigateToView(viewId, params?)` - Navigate between views +- `setAppBarMenus()`, `setBreadcrumbs()`, `setUser()` - Control the app bar +- `setMainMenu()`, `setMainMenuSelection()`, `setMainMenuBadge()` - Control main navigation +- `setSecondaryMenu()`, `setContentTabs()` - Control view-specific UI +- `activityLog.add()`, `activityLog.addMany()`, `activityLog.clear()` - Manage activity entries #### `DeesAppuiMainmenu` Main navigation menu component for application-wide navigation. diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 3eedfc8..7b94d92 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.3.3', + version: '3.4.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-appui/dees-appui-activitylog/dees-appui-activitylog.ts b/ts_web/elements/00group-appui/dees-appui-activitylog/dees-appui-activitylog.ts index 96281f3..bf04386 100644 --- a/ts_web/elements/00group-appui/dees-appui-activitylog/dees-appui-activitylog.ts +++ b/ts_web/elements/00group-appui/dees-appui-activitylog/dees-appui-activitylog.ts @@ -1,4 +1,3 @@ -import * as plugins from '../../00plugins.js'; import { DeesElement, type TemplateResult, @@ -7,32 +6,74 @@ import { html, css, cssManager, + state, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; import '../../dees-icon/dees-icon.js'; +import '@design.estate/dees-wcctools/demotools'; +import type { IActivityEntry, IActivityLogAPI } from '../../interfaces/appconfig.js'; @customElement('dees-appui-activitylog') -export class DeesAppuiActivitylog extends DeesElement { +export class DeesAppuiActivitylog extends DeesElement implements IActivityLogAPI { // STATIC - public static demo = () => html` - -
- -
- `; + public static demo = () => { + // Create the activity log element + const activityLog = document.createElement('dees-appui-activitylog') as DeesAppuiActivitylog; - // INSTANCE + // Add demo entries after the element is connected + setTimeout(() => { + activityLog.addMany([ + { type: 'login', user: 'John Doe', message: 'logged in from Chrome on macOS' }, + { type: 'create', user: 'John Doe', message: 'created a new project "Frontend App"' }, + { type: 'update', user: 'Jane Smith', message: 'updated API documentation' }, + { type: 'view', user: 'John Doe', message: 'viewed dashboard analytics' }, + { type: 'delete', user: 'Admin', message: 'removed deprecated endpoint' }, + { type: 'custom', user: 'System', message: 'scheduled backup completed', iconName: 'lucide:database' }, + { type: 'logout', user: 'Alice Brown', message: 'logged out' }, + { type: 'create', user: 'Jane Smith', message: 'created invoice #1234' }, + ]); + + // Subscribe to updates + activityLog.entries$.subscribe((entries) => { + console.log('Activity log updated:', entries.length, 'entries'); + }); + }, 100); + + return html` + + +
+ ${activityLog} +
+
+ `; + }; + + // INSTANCE PROPERTIES + @state() + accessor entries: IActivityEntry[] = []; + + @state() + accessor searchQuery: string = ''; + + @state() + accessor filterCriteria: { user?: string; type?: IActivityEntry['type'] } = {}; + + // RxJS Subject for reactive updates + public entries$ = new domtools.plugins.smartrx.rxjs.Subject(); + + // STYLES public static styles = [ cssManager.defaultStyles, css` @@ -90,24 +131,32 @@ export class DeesAppuiActivitylog extends DeesElement { scrollbar-width: thin; scrollbar-color: ${cssManager.bdTheme('#e5e7eb', '#27272a')} transparent; } - + .activityContainer::-webkit-scrollbar { width: 6px; } - + .activityContainer::-webkit-scrollbar-track { background: transparent; } - + .activityContainer::-webkit-scrollbar-thumb { background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; border-radius: 3px; } - + .activityContainer::-webkit-scrollbar-thumb:hover { background: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')}; } + .empty-state { + font-size: 13px; + text-align: center; + padding: 32px 16px; + color: ${cssManager.bdTheme('#71717a', '#71717a')}; + font-family: 'Geist Sans', sans-serif; + } + .streamingIndicator { font-size: 11px; text-align: center; @@ -122,7 +171,7 @@ export class DeesAppuiActivitylog extends DeesElement { justify-content: center; gap: 8px; } - + .streamingIndicator::before { content: ''; width: 6px; @@ -131,15 +180,24 @@ export class DeesAppuiActivitylog extends DeesElement { border-radius: 50%; animation: pulse 2s ease-in-out infinite; } - + @keyframes pulse { 0%, 100% { opacity: 0.4; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.2); } } - .streamingIndicator.bottom { - padding-top: 8px; - padding-bottom: 16px; + .date-separator { + padding: 12px 16px 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: ${cssManager.bdTheme('#71717a', '#71717a')}; + background: ${cssManager.bdTheme('#f9fafb', '#09090b')}; + border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#18181b')}; + position: sticky; + top: 0; + z-index: 1; } .activityentry { @@ -154,7 +212,7 @@ export class DeesAppuiActivitylog extends DeesElement { line-height: 1.4; animation: fadeIn 0.3s ease-out; } - + @keyframes fadeIn { from { opacity: 0; @@ -182,7 +240,7 @@ export class DeesAppuiActivitylog extends DeesElement { flex-shrink: 0; min-width: 45px; } - + .activity-icon { width: 28px; height: 28px; @@ -194,55 +252,51 @@ export class DeesAppuiActivitylog extends DeesElement { flex-shrink: 0; font-size: 14px; } - + .activity-icon.login { background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.1)', 'rgba(34, 197, 94, 0.1)')}; color: ${cssManager.bdTheme('#16a34a', '#22c55e')}; } - + .activity-icon.logout { background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.1)', 'rgba(239, 68, 68, 0.1)')}; color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; } - + .activity-icon.view { background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')}; color: ${cssManager.bdTheme('#2563eb', '#3b82f6')}; } - + .activity-icon.create { background: ${cssManager.bdTheme('rgba(168, 85, 247, 0.1)', 'rgba(168, 85, 247, 0.1)')}; color: ${cssManager.bdTheme('#9333ea', '#a855f7')}; } - + .activity-icon.update { background: ${cssManager.bdTheme('rgba(251, 146, 60, 0.1)', 'rgba(251, 146, 60, 0.1)')}; color: ${cssManager.bdTheme('#ea580c', '#fb923c')}; } - + + .activity-icon.delete { + background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.1)', 'rgba(239, 68, 68, 0.1)')}; + color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; + } + + .activity-icon.custom { + background: ${cssManager.bdTheme('rgba(100, 116, 139, 0.1)', 'rgba(100, 116, 139, 0.1)')}; + color: ${cssManager.bdTheme('#475569', '#94a3b8')}; + } + .activity-text { flex: 1; color: ${cssManager.bdTheme('#18181b', '#e4e4e7')}; } - + .activity-user { font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')}; } - - .date-separator { - padding: 12px 16px 8px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: ${cssManager.bdTheme('#71717a', '#71717a')}; - background: ${cssManager.bdTheme('#f9fafb', '#09090b')}; - border-bottom: 1px solid ${cssManager.bdTheme('#f4f4f5', '#18181b')}; - position: sticky; - top: 0; - z-index: 1; - } .searchbox { position: absolute; @@ -253,13 +307,13 @@ export class DeesAppuiActivitylog extends DeesElement { border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; padding: 8px; } - + .search-wrapper { position: relative; width: 100%; height: 32px; } - + .search-icon { position: absolute; left: 10px; @@ -270,7 +324,7 @@ export class DeesAppuiActivitylog extends DeesElement { pointer-events: none; transition: color 0.15s ease; } - + .searchbox input { color: ${cssManager.bdTheme('#09090b', '#fafafa')}; background: ${cssManager.bdTheme('#f4f4f5', '#18181b')}; @@ -293,7 +347,7 @@ export class DeesAppuiActivitylog extends DeesElement { border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')}; } - + .searchbox input:focus ~ .search-icon, .search-wrapper:has(input:focus) .search-icon { color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; @@ -311,7 +365,7 @@ export class DeesAppuiActivitylog extends DeesElement { pointer-events: none; opacity: 0.8; } - + .topShadow { position: absolute; width: 100%; @@ -327,7 +381,11 @@ export class DeesAppuiActivitylog extends DeesElement { `, ]; + // RENDER public render(): TemplateResult { + const filteredEntries = this.getFilteredEntries(); + const groupedEntries = this.groupEntriesByDate(filteredEntries); + return html` ${domtools.elementBasic.styles} @@ -336,173 +394,28 @@ export class DeesAppuiActivitylog extends DeesElement {
Activity Log
-
Live Updates
- -
Today
- -
{ - DeesContextmenu.openContextMenuWithOptions(eventArg, [ - { - name: 'Copy activity', - action: async () => {}, - }, - { - name: 'View details', - action: async () => {}, - }, - { - name: 'Filter by user', - action: async () => {}, - }, - ]); - }}> - 22:20 -
- -
-
- Max Mustermann logged out -
-
- -
- 22:19 -
- -
-
- Max Mustermann approved a payment -
-
- -
- 22:18 -
- -
-
- Max Mustermann archived an invoice -
-
- -
- 22:17 - -
- Max Mustermann logged in -
-
- -
- 22:16 -
- -
-
- Max Mustermann logged out -
-
- -
- 22:15 -
- -
-
- Max Mustermann changed password -
-
- -
- 22:14 -
- -
-
- Max Mustermann added a new user -
-
- -
- 22:13 -
- -
-
- Max Mustermann contacted support -
-
- -
Yesterday
- -
- 18:45 -
- -
-
- Max Mustermann deleted an invoice -
-
- -
- 17:30 - -
- Max Mustermann logged in -
-
- -
- 16:15 -
- -
-
- Max Mustermann logged out -
-
- -
- 14:20 -
- -
-
- Max Mustermann viewed reports -
-
- -
- 13:45 -
- -
-
- Max Mustermann sent an invoice -
-
- -
- 13:30 -
- -
-
- Max Mustermann created a new invoice -
-
- -
Loading History
+ ${filteredEntries.length > 0 + ? html`
Live Updates
` + : ''} + + ${filteredEntries.length === 0 + ? html`
No activity entries
` + : groupedEntries.map( + (group) => html` +
${group.label}
+ ${group.entries.map((entry) => this.renderActivityEntry(entry))} + ` + )}
@@ -510,4 +423,205 @@ export class DeesAppuiActivitylog extends DeesElement { `; } + + private renderActivityEntry(entry: IActivityEntry): TemplateResult { + const timestamp = entry.timestamp || new Date(); + const timeStr = this.formatTime(timestamp); + const iconName = entry.iconName || this.getIconForType(entry.type); + + return html` +
this.handleContextMenu(e, entry)} + > + ${timeStr} +
+ +
+
+ ${entry.user} ${entry.message} +
+
+ `; + } + + // API METHODS + public add(entry: IActivityEntry): void { + const newEntry: IActivityEntry = { + ...entry, + id: entry.id || this.generateId(), + timestamp: entry.timestamp || new Date(), + }; + this.entries = [newEntry, ...this.entries]; + this.entries$.next(this.entries); + } + + public addMany(entries: IActivityEntry[]): void { + const newEntries = entries.map((entry) => ({ + ...entry, + id: entry.id || this.generateId(), + timestamp: entry.timestamp || new Date(), + })); + this.entries = [...newEntries.reverse(), ...this.entries]; + this.entries$.next(this.entries); + } + + public clear(): void { + this.entries = []; + this.entries$.next(this.entries); + } + + public getEntries(): IActivityEntry[] { + return [...this.entries]; + } + + public filter(criteria: { user?: string; type?: IActivityEntry['type'] }): IActivityEntry[] { + return this.entries.filter((entry) => { + if (criteria.user && entry.user !== criteria.user) return false; + if (criteria.type && entry.type !== criteria.type) return false; + return true; + }); + } + + public search(query: string): IActivityEntry[] { + const lowerQuery = query.toLowerCase(); + return this.entries.filter( + (entry) => + entry.message.toLowerCase().includes(lowerQuery) || + entry.user.toLowerCase().includes(lowerQuery) + ); + } + + // PRIVATE HELPERS + private generateId(): string { + return `activity-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + private getFilteredEntries(): IActivityEntry[] { + let result = this.entries; + + if (this.searchQuery) { + const lowerQuery = this.searchQuery.toLowerCase(); + result = result.filter( + (entry) => + entry.message.toLowerCase().includes(lowerQuery) || + entry.user.toLowerCase().includes(lowerQuery) + ); + } + + if (this.filterCriteria.user || this.filterCriteria.type) { + result = result.filter((entry) => { + if (this.filterCriteria.user && entry.user !== this.filterCriteria.user) return false; + if (this.filterCriteria.type && entry.type !== this.filterCriteria.type) return false; + return true; + }); + } + + return result; + } + + private groupEntriesByDate( + entries: IActivityEntry[] + ): Array<{ label: string; entries: IActivityEntry[] }> { + const groups: Map = new Map(); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + for (const entry of entries) { + const date = entry.timestamp || new Date(); + let label: string; + + if (this.isSameDay(date, today)) { + label = 'Today'; + } else if (this.isSameDay(date, yesterday)) { + label = 'Yesterday'; + } else { + label = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined, + }); + } + + if (!groups.has(label)) { + groups.set(label, []); + } + groups.get(label)!.push(entry); + } + + return Array.from(groups.entries()).map(([label, entries]) => ({ + label, + entries, + })); + } + + private isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); + } + + private formatTime(date: Date): string { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } + + private getIconForType(type: IActivityEntry['type']): string { + const icons: Record = { + login: 'lucide:logIn', + logout: 'lucide:logOut', + view: 'lucide:eye', + create: 'lucide:plus', + update: 'lucide:edit', + delete: 'lucide:trash2', + custom: 'lucide:activity', + }; + return icons[type] || icons.custom; + } + + private handleSearchInput(e: InputEvent): void { + const target = e.target as HTMLInputElement; + this.searchQuery = target.value; + } + + private handleContextMenu(e: MouseEvent, entry: IActivityEntry): void { + e.preventDefault(); + DeesContextmenu.openContextMenuWithOptions(e, [ + { + name: 'Copy activity', + iconName: 'lucide:copy', + action: async () => { + await navigator.clipboard.writeText(`${entry.user} ${entry.message}`); + }, + }, + { + name: 'Filter by user', + iconName: 'lucide:user', + action: async () => { + this.filterCriteria = { user: entry.user }; + }, + }, + { + name: 'Filter by type', + iconName: 'lucide:filter', + action: async () => { + this.filterCriteria = { type: entry.type }; + }, + }, + { + name: 'Clear filters', + iconName: 'lucide:x', + action: async () => { + this.filterCriteria = {}; + this.searchQuery = ''; + }, + }, + ]); + } } diff --git a/ts_web/elements/00group-appui/dees-appui-base/app.router.ts b/ts_web/elements/00group-appui/dees-appui-base/app.router.ts deleted file mode 100644 index 35137dc..0000000 --- a/ts_web/elements/00group-appui/dees-appui-base/app.router.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { IRoutingConfig, IViewDefinition } from '../../interfaces/appconfig.js'; -import type { ViewRegistry } from './view.registry.js'; - -export type TRouteChangeCallback = (viewId: string, params?: Record) => void; - -/** - * Router for managing view navigation and URL synchronization - */ -export class AppRouter { - private config: Required> & Pick; - private viewRegistry: ViewRegistry; - private listeners: Set = new Set(); - private currentViewId: string | null = null; - private isInitialized: boolean = false; - - constructor(config: IRoutingConfig, viewRegistry: ViewRegistry) { - this.config = { - mode: config.mode, - basePath: config.basePath || '', - defaultView: config.defaultView || '', - syncUrl: config.syncUrl ?? true, - notFound: config.notFound, - }; - this.viewRegistry = viewRegistry; - } - - /** - * Initialize the router - */ - public init(): void { - if (this.isInitialized) return; - - if (this.config.mode === 'hash') { - window.addEventListener('hashchange', this.handleHashChange); - // Check initial hash - const initialView = this.getViewFromHash(); - if (initialView) { - this.navigate(initialView, { source: 'initial' }); - } else if (this.config.defaultView) { - this.navigate(this.config.defaultView, { source: 'initial' }); - } - } else if (this.config.mode === 'history') { - window.addEventListener('popstate', this.handlePopState); - // Check initial path - const initialView = this.getViewFromPath(); - if (initialView) { - this.navigate(initialView, { source: 'initial' }); - } else if (this.config.defaultView) { - this.navigate(this.config.defaultView, { source: 'initial' }); - } - } else if (this.config.mode === 'none' && this.config.defaultView) { - this.navigate(this.config.defaultView, { source: 'initial' }); - } - // For 'external' mode, we don't set up listeners - the external router handles it - - this.isInitialized = true; - } - - /** - * Navigate to a view by ID - */ - public navigate( - viewId: string, - options: { - source?: 'navigation' | 'popstate' | 'initial' | 'programmatic'; - replace?: boolean; - params?: Record; - } = {} - ): boolean { - const { source = 'programmatic', replace = false, params } = options; - - const view = this.viewRegistry.get(viewId); - if (!view) { - console.warn(`Cannot navigate to unknown view: ${viewId}`); - if (this.config.notFound) { - if (typeof this.config.notFound === 'function') { - this.config.notFound(); - } else { - return this.navigate(this.config.notFound, { source, replace: true }); - } - } - return false; - } - - const previousViewId = this.currentViewId; - this.currentViewId = viewId; - - // Update URL if configured - if (this.config.syncUrl && this.config.mode !== 'none' && this.config.mode !== 'external') { - this.updateUrl(view, replace); - } - - // Notify listeners - this.notifyListeners(viewId, params); - - return true; - } - - /** - * Navigate back in history - */ - public back(): void { - if (this.config.mode === 'hash' || this.config.mode === 'history') { - window.history.back(); - } - } - - /** - * Navigate forward in history - */ - public forward(): void { - if (this.config.mode === 'hash' || this.config.mode === 'history') { - window.history.forward(); - } - } - - /** - * Get current view ID - */ - public getCurrentViewId(): string | null { - return this.currentViewId; - } - - /** - * Add a route change listener - */ - public onRouteChange(callback: TRouteChangeCallback): () => void { - this.listeners.add(callback); - return () => this.listeners.delete(callback); - } - - /** - * Handle external navigation (for external router mode) - */ - public handleExternalNavigation(viewId: string, params?: Record): void { - if (this.config.mode !== 'external') { - console.warn('handleExternalNavigation should only be used in external mode'); - } - - const previousViewId = this.currentViewId; - this.currentViewId = viewId; - this.notifyListeners(viewId, params); - } - - /** - * Sync state with URL (for external router integration) - */ - public syncWithUrl(): string | null { - if (this.config.mode === 'hash') { - return this.getViewFromHash(); - } else if (this.config.mode === 'history') { - return this.getViewFromPath(); - } - return null; - } - - /** - * Get the current route from the URL - */ - public getCurrentRoute(): string { - if (this.config.mode === 'hash') { - return window.location.hash.slice(1) || ''; - } else if (this.config.mode === 'history') { - let path = window.location.pathname; - if (this.config.basePath && path.startsWith(this.config.basePath)) { - path = path.slice(this.config.basePath.length); - } - return path.replace(/^\//, ''); - } - return ''; - } - - /** - * Build a URL for a view - */ - public buildUrl(viewId: string): string { - const view = this.viewRegistry.get(viewId); - const route = view?.route || viewId; - - if (this.config.mode === 'hash') { - return `#${route}`; - } else if (this.config.mode === 'history') { - return `${this.config.basePath}/${route}`; - } - return ''; - } - - /** - * Destroy the router - */ - public destroy(): void { - if (this.config.mode === 'hash') { - window.removeEventListener('hashchange', this.handleHashChange); - } else if (this.config.mode === 'history') { - window.removeEventListener('popstate', this.handlePopState); - } - this.listeners.clear(); - this.isInitialized = false; - } - - // Private methods - - private handleHashChange = (): void => { - const viewId = this.getViewFromHash(); - if (viewId && viewId !== this.currentViewId) { - this.navigate(viewId, { source: 'popstate' }); - } - }; - - private handlePopState = (): void => { - const viewId = this.getViewFromPath(); - if (viewId && viewId !== this.currentViewId) { - this.navigate(viewId, { source: 'popstate' }); - } - }; - - private getViewFromHash(): string | null { - const hash = window.location.hash.slice(1); // Remove # - if (!hash) return null; - - // Try to find view by route - const view = this.viewRegistry.findByRoute(hash); - return view?.id || null; - } - - private getViewFromPath(): string | null { - let path = window.location.pathname; - - // Remove base path if configured - if (this.config.basePath) { - if (path.startsWith(this.config.basePath)) { - path = path.slice(this.config.basePath.length); - } - } - - // Remove leading slash - path = path.replace(/^\//, ''); - - if (!path) return null; - - const view = this.viewRegistry.findByRoute(path); - return view?.id || null; - } - - private updateUrl(view: IViewDefinition, replace: boolean): void { - const route = view.route || view.id; - - if (this.config.mode === 'hash') { - const newHash = `#${route}`; - if (replace) { - window.history.replaceState(null, '', newHash); - } else { - window.history.pushState(null, '', newHash); - } - } else if (this.config.mode === 'history') { - const basePath = this.config.basePath || ''; - const newPath = `${basePath}/${route}`; - if (replace) { - window.history.replaceState({ viewId: view.id }, '', newPath); - } else { - window.history.pushState({ viewId: view.id }, '', newPath); - } - } - } - - private notifyListeners(viewId: string, params?: Record): void { - for (const listener of this.listeners) { - listener(viewId, params); - } - } -} diff --git a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts index d25ee60..3ab9edb 100644 --- a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts +++ b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts @@ -1,238 +1,608 @@ -import { html, css } from '@design.estate/dees-element'; -import type { DeesAppuiBase } from '../dees-appui-base/dees-appui-base.js'; -import type { IAppBarMenuItem } from '../../interfaces/appbarmenuitem.js'; -import type { ITab } from '../../interfaces/tab.js'; -import type { ISelectionOption } from '../../interfaces/selectionoption.js'; -import type { IMenuGroup } from '../../interfaces/menugroup.js'; -import type { ISecondaryMenuGroup } from '../../interfaces/secondarymenu.js'; -import * as plugins from '../../00plugins.js'; +import { html, css, DeesElement, customElement, state } from '@design.estate/dees-element'; +import type { DeesAppuiBase } from './dees-appui-base.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; + + onActivate(context: IViewActivationContext) { + 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` + +

Dashboard

+

Welcome back! Here's an overview of your system.

+
+
+

Active Users

+
1,234
+ Online +
+
+

API Calls

+
45.2K
+

+12% from last hour

+
+
+

System Health

+
99.9%
+

All systems operational

+
+
+ `; + } +} + +// 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: DeesAppuiBase; + + 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` + +

Settings

+

Manage your account and application preferences.

+
+ Current section: ${this.section} +
+
+ +
+ ${this.hasChanges ? html`

You have unsaved changes. Navigation will prompt for confirmation.

` : ''} + `; + } +} + +// 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` + +

Projects

+
+
+

Frontend App Active

+

React-based dashboard application

+
+
+

API Server Active

+

Node.js REST API backend

+
+
+

Mobile App Active

+

React Native iOS/Android app

+
+
+

Documentation

+

Technical documentation site

+
+
+ `; + } +} + +// 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` + +

Tasks

+
+
+
+ Review pull request #42 + Today + High +
+
+
+ Update documentation + Tomorrow + Medium +
+
+
+ Write unit tests + Dec 20 +
+
+ `; + } +} + export const demoFunc = () => { - // Menu items for the appbar - const menuItems: IAppBarMenuItem[] = [ - { - name: 'File', - action: async () => {}, - submenu: [ - { name: 'New Project', shortcut: 'Cmd+N', iconName: 'filePlus', action: async () => console.log('New project') }, - { name: 'Open Project...', shortcut: 'Cmd+O', iconName: 'folderOpen', action: async () => console.log('Open project') }, - { 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') }, - { name: 'api-server', action: async () => console.log('Open api-server') }, - ]}, + // 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: 'Save All', shortcut: 'Cmd+Shift+S', iconName: 'save', action: async () => console.log('Save all') }, + { name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') }, { divider: true }, - { name: 'Close Project', action: async () => console.log('Close project') }, + { name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') } ] }, - { - 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') }, - ] + + 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'] }, - { - name: 'View', - action: async () => {}, - submenu: [ - { name: 'Toggle Sidebar', shortcut: 'Cmd+B', action: async () => console.log('Toggle sidebar') }, - { name: 'Toggle Terminal', shortcut: 'Cmd+J', iconName: 'terminal', action: async () => console.log('Toggle terminal') }, - { divider: true }, - { name: 'Zoom In', shortcut: 'Cmd++', iconName: 'zoomIn', action: async () => console.log('Zoom in') }, - { name: 'Zoom Out', shortcut: 'Cmd+-', iconName: 'zoomOut', action: async () => console.log('Zoom out') }, - { name: 'Reset Zoom', shortcut: 'Cmd+0', action: async () => console.log('Reset zoom') }, - ] + + defaultView: 'dashboard', + + onViewChange: (viewId, view) => { + console.log(`View changed to: ${viewId} (${view.name})`); }, - { - name: 'Help', - action: async () => {}, - submenu: [ - { name: 'Documentation', iconName: 'book', action: async () => console.log('Documentation') }, - { name: 'Release Notes', iconName: 'fileText', action: async () => console.log('Release notes') }, - { divider: true }, - { name: 'Report Issue', iconName: 'bug', action: async () => console.log('Report issue') }, - { name: 'About', iconName: 'info', action: async () => console.log('About') }, - ] + + onSearch: (query) => { + console.log('Search query:', query); } - ]; + }; - // Main menu groups (left sidebar) - const mainMenuGroups: IMenuGroup[] = [ - { - tabs: [ - { key: 'Dashboard', iconName: 'lucide:home', action: () => console.log('Dashboard selected') }, - { key: 'Inbox', iconName: 'lucide:inbox', action: () => console.log('Inbox selected') }, - ] - }, - { - name: 'Workspace', - tabs: [ - { key: 'Projects', iconName: 'lucide:folder', action: () => console.log('Projects selected') }, - { key: 'Tasks', iconName: 'lucide:checkSquare', action: () => console.log('Tasks selected') }, - { key: 'Documents', iconName: 'lucide:fileText', action: () => console.log('Documents selected') }, - ] - }, - { - name: 'Analytics', - tabs: [ - { key: 'Reports', iconName: 'lucide:barChart3', action: () => console.log('Reports selected') }, - { key: 'Insights', iconName: 'lucide:lightbulb', action: () => console.log('Insights selected') }, - ] - } - ]; + // 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;'; - // Main menu bottom tabs (pinned to bottom) - const mainMenuBottomTabs: ITab[] = [ - { key: 'Settings', iconName: 'lucide:settings', action: () => console.log('Settings selected') }, - { key: 'Help', iconName: 'lucide:helpCircle', action: () => console.log('Help selected') }, - ]; + const appuiElement = document.createElement('dees-appui-base') as DeesAppuiBase; + containerElement.appendChild(appuiElement); - // Secondary menu groups (second sidebar with collapsible groups) - // These showcase the new shadcn-style design with badges and collapsible sections - const secondaryMenuGroups: ISecondaryMenuGroup[] = [ - { - name: 'Quick Access', - iconName: 'lucide:zap', - items: [ - { key: 'Overview', iconName: 'layoutDashboard', action: () => console.log('Overview selected') }, - { key: 'Recent Activity', iconName: 'clock', action: () => console.log('Recent Activity selected'), badge: 5 }, - { key: 'Favorites', iconName: 'star', action: () => console.log('Favorites selected') }, - ] - }, - { - name: 'Resources', - iconName: 'lucide:layers', - items: [ - { key: 'Components', iconName: 'package', action: () => console.log('Components selected'), badge: 24 }, - { key: 'Services', iconName: 'server', action: () => console.log('Services selected'), badge: 'new', badgeVariant: 'success' }, - { key: 'APIs', iconName: 'globe', action: () => console.log('APIs selected'), badge: 3, badgeVariant: 'warning' }, - { key: 'Webhooks', iconName: 'webhook', action: () => console.log('Webhooks selected') }, - ] - }, - { - name: 'Data Management', - iconName: 'lucide:database', - items: [ - { key: 'Database', iconName: 'database', action: () => console.log('Database selected') }, - { key: 'Storage', iconName: 'hardDrive', action: () => console.log('Storage selected'), badge: '85%', badgeVariant: 'warning' }, - { key: 'Backups', iconName: 'archive', action: () => console.log('Backups selected'), badge: 'OK', badgeVariant: 'success' }, - ] - }, - { - name: 'System', - iconName: 'lucide:settings', - collapsed: true, - items: [ - { key: 'Configuration', iconName: 'sliders', action: () => console.log('Configuration selected') }, - { key: 'Integrations', iconName: 'plug', action: () => console.log('Integrations selected'), badge: 2, badgeVariant: 'error' }, - { key: 'Permissions', iconName: 'shield', action: () => console.log('Permissions selected') }, - { key: 'Logs', iconName: 'fileText', action: () => console.log('Logs selected') }, - ] - } - ]; + // Initialize after element is connected + setTimeout(async () => { + await appuiElement.updateComplete; - // Main content tabs - const mainContentTabs: ITab[] = [ - { key: 'Details', iconName: 'lucide:file', action: () => console.log('Details tab') }, - { key: 'Logs', iconName: 'lucide:list', action: () => console.log('Logs tab') }, - { key: 'Metrics', iconName: 'lucide:lineChart', action: () => console.log('Metrics tab') }, - ]; + // Configure using the unified API + appuiElement.configure(appConfig); - // Profile menu items - const profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [ - { name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile settings') }, - { name: 'Account', iconName: 'settings', action: async () => console.log('Account settings') }, - { divider: true }, - { name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') }, - { name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') }, - { divider: true }, - { name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') } - ]; + // 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` - - -
- console.log('Menu selected:', e.detail)} - @appbar-breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb:', e.detail)} - @appbar-search-click=${() => console.log('Search clicked')} - @appbar-user-menu-open=${() => console.log('User menu opened')} - @appbar-profile-menu-select=${(e: CustomEvent) => console.log('Profile menu selected:', e.detail)} - @mainmenu-tab-select=${(e: CustomEvent) => console.log('Tab selected:', e.detail)} - @secondarymenu-item-select=${(e: CustomEvent) => console.log('Item selected:', e.detail)} - > -
-

Welcome to Acme App

-

This demo showcases the AppUI component system with the new SecondaryMenu.

- -
-
-

SecondaryMenu Features

-
    -
  • Collapsible groups with smooth animations
  • -
  • Badge support (counts, status, variants)
  • -
  • Dynamic heading from MainMenu selection
  • -
  • shadcn-inspired modern design
  • -
-
-
-

Badge Variants

-
- default - success - warning - error -
-
-
- -

- Try clicking items in the MainMenu (left) - the SecondaryMenu heading updates automatically. - Click group headers in the SecondaryMenu to collapse/expand sections. -

-
-
-
+ ${containerElement}
`; -}; \ No newline at end of file +}; diff --git a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts index 2076510..0d788c4 100644 --- a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts +++ b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts @@ -8,8 +8,8 @@ import { cssManager, state, } from '@design.estate/dees-element'; +import * as domtools from '@design.estate/dees-domtools'; import * as interfaces from '../../interfaces/index.js'; -import * as plugins from '../../00plugins.js'; import type { DeesAppuiBar } from '../dees-appui-appbar/index.js'; import type { DeesAppuiMainmenu } from '../dees-appui-mainmenu/dees-appui-mainmenu.js'; import type { DeesAppuiSecondarymenu } from '../dees-appui-secondarymenu/dees-appui-secondarymenu.js'; @@ -17,10 +17,8 @@ import type { DeesAppuiMaincontent } from '../dees-appui-maincontent/dees-appui- import type { DeesAppuiActivitylog } from '../dees-appui-activitylog/dees-appui-activitylog.js'; import { demoFunc } from './dees-appui-base.demo.js'; -// New module imports +// View registry for managing views import { ViewRegistry } from './view.registry.js'; -import { AppRouter } from './app.router.js'; -import { StateManager } from './state.manager.js'; // Import child components import '../dees-appui-appbar/index.js'; @@ -29,10 +27,30 @@ import '../dees-appui-secondarymenu/dees-appui-secondarymenu.js'; import '../dees-appui-maincontent/dees-appui-maincontent.js'; import '../dees-appui-activitylog/dees-appui-activitylog.js'; +declare global { + interface HTMLElementTagNameMap { + 'dees-appui-base': DeesAppuiBase; + } +} + @customElement('dees-appui-base') export class DeesAppuiBase extends DeesElement { public static demo = demoFunc; + // ========================================== + // REACTIVE OBSERVABLES (RxJS Subjects) + // ========================================== + + /** Observable stream of view lifecycle events */ + public viewLifecycle$ = new domtools.plugins.smartrx.rxjs.Subject(); + + /** Observable stream of view change events */ + public viewChanged$ = new domtools.plugins.smartrx.rxjs.Subject(); + + // ========================================== + // INTERNAL PROPERTIES (Properties for child components) + // ========================================== + // Properties for appbar @property({ type: Array }) accessor appbarMenuItems: interfaces.IAppBarMenuItem[] = []; @@ -46,17 +64,11 @@ export class DeesAppuiBase extends DeesElement { @property({ type: Boolean }) accessor appbarShowWindowControls: boolean = true; - @property({ type: Object }) - accessor appbarUser: { - name: string; - email?: string; - avatar?: string; - status?: 'online' | 'offline' | 'busy' | 'away'; - } | undefined = undefined; + accessor appbarUser: interfaces.IAppUser | undefined = undefined; @property({ type: Array }) - accessor appbarProfileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = []; + accessor appbarProfileMenuItems: interfaces.IAppBarMenuItem[] = []; @property({ type: Boolean }) accessor appbarShowSearch: boolean = false; @@ -82,7 +94,7 @@ export class DeesAppuiBase extends DeesElement { // Properties for secondarymenu @property({ type: String }) - accessor secondarymenuHeading: string = 'Menu'; + accessor secondarymenuHeading: string = ''; @property({ type: Array }) accessor secondarymenuGroups: interfaces.ISecondaryMenuGroup[] = []; @@ -90,10 +102,6 @@ export class DeesAppuiBase extends DeesElement { @property({ type: Object }) accessor secondarymenuSelectedItem: interfaces.ISecondaryMenuItem | undefined = undefined; - /** Legacy support for flat options (backward compatibility) */ - @property({ type: Array }) - accessor secondarymenuOptions: (interfaces.ISelectionOption | { divider: true })[] = []; - // Collapse states @property({ type: Boolean }) accessor mainmenuCollapsed: boolean = false; @@ -105,6 +113,9 @@ export class DeesAppuiBase extends DeesElement { @property({ type: Array }) accessor maincontentTabs: interfaces.ITab[] = []; + @property({ type: Object }) + accessor maincontentSelectedTab: interfaces.ITab | undefined = undefined; + // References to child components @state() accessor appbar: DeesAppuiBar | undefined = undefined; @@ -119,20 +130,16 @@ export class DeesAppuiBase extends DeesElement { accessor maincontent: DeesAppuiMaincontent | undefined = undefined; @state() - accessor activitylog: DeesAppuiActivitylog | undefined = undefined; + accessor activitylogElement: DeesAppuiActivitylog | undefined = undefined; - // NEW: Unified config property - @property({ type: Object }) - accessor config: interfaces.IAppConfig | undefined = undefined; - - // NEW: Current view state + // Current view state @state() accessor currentView: interfaces.IViewDefinition | undefined = undefined; - // NEW: Internal services (not reactive, managed internally) + // Internal services private viewRegistry: ViewRegistry = new ViewRegistry(); - private router: AppRouter | null = null; - private stateManager: StateManager | null = null; + private routerCleanup: (() => void) | null = null; + private searchCallback: ((query: string) => void) | null = null; public static styles = [ cssManager.defaultStyles, @@ -153,7 +160,7 @@ export class DeesAppuiBase extends DeesElement { grid-template-rows: 1fr; } - /* Z-index layering for proper stacking (position: relative required for z-index to work) */ + /* Z-index layering for proper stacking */ .maingrid > dees-appui-mainmenu { position: relative; z-index: 3; @@ -185,10 +192,8 @@ export class DeesAppuiBase extends DeesElement { `, ]; - // INSTANCE public render(): TemplateResult { return html` - this.handleAppbarMenuSelect(e)} @breadcrumb-navigate=${(e: CustomEvent) => this.handleAppbarBreadcrumbNavigate(e)} @search-click=${() => this.handleAppbarSearchClick()} + @search-query=${(e: CustomEvent) => this.handleAppbarSearchQuery(e)} @user-menu-open=${() => this.handleAppbarUserMenuOpen()} @profile-menu-select=${(e: CustomEvent) => this.handleAppbarProfileMenuSelect(e)} > @@ -218,7 +224,6 @@ export class DeesAppuiBase extends DeesElement { this.handleSecondarymenuItemSelect(e)} @@ -226,6 +231,8 @@ export class DeesAppuiBase extends DeesElement { > this.handleContentTabSelect(e)} >
@@ -237,138 +244,414 @@ export class DeesAppuiBase extends DeesElement { async firstUpdated() { // Get references to child components - this.appbar = this.shadowRoot.querySelector('dees-appui-appbar'); - this.mainmenu = this.shadowRoot.querySelector('dees-appui-mainmenu'); - this.secondarymenu = this.shadowRoot.querySelector('dees-appui-secondarymenu'); - this.maincontent = this.shadowRoot.querySelector('dees-appui-maincontent'); - this.activitylog = this.shadowRoot.querySelector('dees-appui-activitylog'); + this.appbar = this.shadowRoot!.querySelector('dees-appui-appbar') as DeesAppuiBar; + this.mainmenu = this.shadowRoot!.querySelector('dees-appui-mainmenu') as DeesAppuiMainmenu; + this.secondarymenu = this.shadowRoot!.querySelector('dees-appui-secondarymenu') as DeesAppuiSecondarymenu; + this.maincontent = this.shadowRoot!.querySelector('dees-appui-maincontent') as DeesAppuiMaincontent; + this.activitylogElement = this.shadowRoot!.querySelector('dees-appui-activitylog') as DeesAppuiActivitylog; - // Initialize from config if provided - if (this.config) { - this.applyConfig(this.config); - - // Restore state if enabled - if (this.config.statePersistence?.enabled) { - this.loadState(); - } - - // Initialize router after state restore - this.router?.init(); - } + // Set appui reference in view registry for lifecycle context + this.viewRegistry.setAppuiRef(this as unknown as interfaces.TDeesAppuiBase); } async disconnectedCallback() { await super.disconnectedCallback(); - this.router?.destroy(); - } - - // Event handlers for appbar - private handleAppbarMenuSelect(e: CustomEvent) { - this.dispatchEvent(new CustomEvent('appbar-menu-select', { - detail: e.detail, - bubbles: true, - composed: true - })); - } - - private handleAppbarBreadcrumbNavigate(e: CustomEvent) { - this.dispatchEvent(new CustomEvent('appbar-breadcrumb-navigate', { - detail: e.detail, - bubbles: true, - composed: true - })); - } - - private handleAppbarSearchClick() { - this.dispatchEvent(new CustomEvent('appbar-search-click', { - bubbles: true, - composed: true - })); - } - - private handleAppbarUserMenuOpen() { - this.dispatchEvent(new CustomEvent('appbar-user-menu-open', { - bubbles: true, - composed: true - })); - } - - private handleAppbarProfileMenuSelect(e: CustomEvent) { - this.dispatchEvent(new CustomEvent('appbar-profile-menu-select', { - detail: e.detail, - bubbles: true, - composed: true - })); - } - - // Event handlers for mainmenu - private handleMainmenuTabSelect(e: CustomEvent) { - this.mainmenuSelectedTab = e.detail.tab; - // Update secondary menu heading based on main menu selection - this.secondarymenuHeading = e.detail.tab.key; - this.dispatchEvent(new CustomEvent('mainmenu-tab-select', { - detail: e.detail, - bubbles: true, - composed: true - })); - } - - // Event handlers for secondarymenu - private handleSecondarymenuItemSelect(e: CustomEvent) { - this.secondarymenuSelectedItem = e.detail.item; - this.dispatchEvent(new CustomEvent('secondarymenu-item-select', { - detail: e.detail, - bubbles: true, - composed: true - })); - } - - // Event handlers for collapse state changes - private handleMainmenuCollapseChange(e: CustomEvent) { - this.mainmenuCollapsed = e.detail.collapsed; - this.dispatchEvent(new CustomEvent('mainmenu-collapse-change', { - detail: e.detail, - bubbles: true, - composed: true - })); - } - - private handleSecondarymenuCollapseChange(e: CustomEvent) { - this.secondarymenuCollapsed = e.detail.collapsed; - this.dispatchEvent(new CustomEvent('secondarymenu-collapse-change', { - detail: e.detail, - bubbles: true, - composed: true - })); + // Clean up router listener + if (this.routerCleanup) { + this.routerCleanup(); + this.routerCleanup = null; + } + // Complete subjects + this.viewLifecycle$.complete(); + this.viewChanged$.complete(); } // ========================================== - // NEW: Public methods for unified config API + // PROGRAMMATIC API: APP BAR // ========================================== /** - * Configure the app shell with a unified config object + * Set the app bar menu items (File, Edit, View, etc.) */ - public configure(config: interfaces.IAppConfig): void { - this.config = config; - this.applyConfig(config); + public setAppBarMenus(menus: interfaces.IAppBarMenuItem[]): void { + this.appbarMenuItems = [...menus]; } + /** + * Update a single app bar menu by name + */ + public updateAppBarMenu(name: string, update: Partial): void { + this.appbarMenuItems = this.appbarMenuItems.map(menu => { + // Check if it's not a divider and has a name property + if ('name' in menu && menu.name === name) { + return { ...menu, ...update }; + } + return menu; + }); + } + + /** + * Set the breadcrumbs (string or array) + */ + public setBreadcrumbs(breadcrumbs: string | string[]): void { + if (Array.isArray(breadcrumbs)) { + this.appbarBreadcrumbs = breadcrumbs.join(this.appbarBreadcrumbSeparator); + } else { + this.appbarBreadcrumbs = breadcrumbs; + } + } + + /** + * Set the current user + */ + public setUser(user: interfaces.IAppUser | undefined): void { + this.appbarUser = user; + } + + /** + * Set the profile dropdown menu items + */ + public setProfileMenuItems(items: interfaces.IAppBarMenuItem[]): void { + this.appbarProfileMenuItems = [...items]; + } + + /** + * Set search bar visibility + */ + public setSearchVisible(visible: boolean): void { + this.appbarShowSearch = visible; + } + + /** + * Set window controls visibility + */ + public setWindowControlsVisible(visible: boolean): void { + this.appbarShowWindowControls = visible; + } + + /** + * Register a search callback + */ + public onSearch(callback: (query: string) => void): void { + this.searchCallback = callback; + } + + // ========================================== + // PROGRAMMATIC API: MAIN MENU + // ========================================== + + /** + * Set the entire main menu configuration + */ + public setMainMenu(config: interfaces.IMainMenuConfig): void { + if (config.logoIcon !== undefined) { + this.mainmenuLogoIcon = config.logoIcon; + } + if (config.logoText !== undefined) { + this.mainmenuLogoText = config.logoText; + } + if (config.groups !== undefined) { + this.mainmenuGroups = [...config.groups]; + } + if (config.bottomTabs !== undefined) { + this.mainmenuBottomTabs = [...config.bottomTabs]; + } + } + + /** + * Update a specific menu group by name + */ + public updateMainMenuGroup(groupName: string, update: Partial): void { + this.mainmenuGroups = this.mainmenuGroups.map(group => + group.name === groupName ? { ...group, ...update } : group + ); + } + + /** + * Add a menu item to a specific group + */ + public addMainMenuItem(groupName: string, tab: interfaces.ITab): void { + this.mainmenuGroups = this.mainmenuGroups.map(group => { + if (group.name === groupName) { + return { + ...group, + tabs: [...(group.tabs || []), tab], + }; + } + return group; + }); + } + + /** + * Remove a menu item from a group by key + */ + public removeMainMenuItem(groupName: string, tabKey: string): void { + this.mainmenuGroups = this.mainmenuGroups.map(group => { + if (group.name === groupName) { + return { + ...group, + tabs: (group.tabs || []).filter(t => t.key !== tabKey), + }; + } + return group; + }); + } + + /** + * Set the selected main menu item by key + */ + public setMainMenuSelection(tabKey: string): void { + for (const group of this.mainmenuGroups) { + const tab = group.tabs?.find(t => t.key === tabKey); + if (tab) { + this.mainmenuSelectedTab = tab; + return; + } + } + // Check bottom tabs + const bottomTab = this.mainmenuBottomTabs.find(t => t.key === tabKey); + if (bottomTab) { + this.mainmenuSelectedTab = bottomTab; + } + } + + /** + * Set main menu collapsed state + */ + public setMainMenuCollapsed(collapsed: boolean): void { + this.mainmenuCollapsed = collapsed; + } + + /** + * Set a badge on a main menu item + */ + public setMainMenuBadge(tabKey: string, badge: string | number): void { + this.mainmenuGroups = this.mainmenuGroups.map(group => ({ + ...group, + tabs: (group.tabs || []).map(tab => + tab.key === tabKey ? { ...tab, badge } : tab + ), + })); + // Also check bottom tabs + this.mainmenuBottomTabs = this.mainmenuBottomTabs.map(tab => + tab.key === tabKey ? { ...tab, badge } : tab + ); + } + + /** + * Clear a badge from a main menu item + */ + public clearMainMenuBadge(tabKey: string): void { + this.mainmenuGroups = this.mainmenuGroups.map(group => ({ + ...group, + tabs: (group.tabs || []).map(tab => { + if (tab.key === tabKey) { + const { badge, ...rest } = tab; + return rest; + } + return tab; + }), + })); + // Also check bottom tabs + this.mainmenuBottomTabs = this.mainmenuBottomTabs.map(tab => { + if (tab.key === tabKey) { + const { badge, ...rest } = tab; + return rest; + } + return tab; + }); + } + + // ========================================== + // PROGRAMMATIC API: SECONDARY MENU + // ========================================== + + /** + * Set the secondary menu configuration + */ + public setSecondaryMenu(config: { heading?: string; groups: interfaces.ISecondaryMenuGroup[] }): void { + if (config.heading !== undefined) { + this.secondarymenuHeading = config.heading; + } + this.secondarymenuGroups = [...config.groups]; + } + + /** + * Update a specific secondary menu group + */ + public updateSecondaryMenuGroup(groupName: string, update: Partial): void { + this.secondarymenuGroups = this.secondarymenuGroups.map(group => + group.name === groupName ? { ...group, ...update } : group + ); + } + + /** + * Add an item to a secondary menu group + */ + public addSecondaryMenuItem( + groupName: string, + item: interfaces.ISecondaryMenuGroup['items'][0] + ): void { + this.secondarymenuGroups = this.secondarymenuGroups.map(group => { + if (group.name === groupName) { + return { + ...group, + items: [...group.items, item], + }; + } + return group; + }); + } + + /** + * Set the selected secondary menu item by key + */ + public setSecondaryMenuSelection(itemKey: string): void { + for (const group of this.secondarymenuGroups) { + const item = group.items.find(i => i.key === itemKey); + if (item) { + this.secondarymenuSelectedItem = item; + return; + } + } + } + + /** + * Clear the secondary menu + */ + public clearSecondaryMenu(): void { + this.secondarymenuHeading = ''; + this.secondarymenuGroups = []; + this.secondarymenuSelectedItem = undefined; + } + + // ========================================== + // PROGRAMMATIC API: CONTENT TABS + // ========================================== + + /** + * Set the content tabs + */ + public setContentTabs(tabs: interfaces.ITab[]): void { + this.maincontentTabs = [...tabs]; + if (tabs.length > 0 && !this.maincontentSelectedTab) { + this.maincontentSelectedTab = tabs[0]; + } + } + + /** + * Add a content tab + */ + public addContentTab(tab: interfaces.ITab): void { + this.maincontentTabs = [...this.maincontentTabs, tab]; + } + + /** + * Remove a content tab by key + */ + public removeContentTab(tabKey: string): void { + this.maincontentTabs = this.maincontentTabs.filter(t => t.key !== tabKey); + if (this.maincontentSelectedTab?.key === tabKey) { + this.maincontentSelectedTab = this.maincontentTabs[0]; + } + } + + /** + * Select a content tab by key + */ + public selectContentTab(tabKey: string): void { + const tab = this.maincontentTabs.find(t => t.key === tabKey); + if (tab) { + this.maincontentSelectedTab = tab; + } + } + + /** + * Get the currently selected content tab + */ + public getSelectedContentTab(): interfaces.ITab | undefined { + return this.maincontentSelectedTab; + } + + // ========================================== + // PROGRAMMATIC API: ACTIVITY LOG + // ========================================== + + /** + * Get the activity log API + */ + public get activityLog(): interfaces.IActivityLogAPI { + if (!this.activitylogElement) { + // Return a deferred API that will work after firstUpdated + return { + add: (entry) => { + this.updateComplete.then(() => this.activitylogElement?.add(entry)); + }, + addMany: (entries) => { + this.updateComplete.then(() => this.activitylogElement?.addMany(entries)); + }, + clear: () => { + this.updateComplete.then(() => this.activitylogElement?.clear()); + }, + getEntries: () => this.activitylogElement?.getEntries() || [], + filter: (criteria) => this.activitylogElement?.filter(criteria) || [], + search: (query) => this.activitylogElement?.search(query) || [], + }; + } + return { + add: (entry) => this.activitylogElement!.add(entry), + addMany: (entries) => this.activitylogElement!.addMany(entries), + clear: () => this.activitylogElement!.clear(), + getEntries: () => this.activitylogElement!.getEntries(), + filter: (criteria) => this.activitylogElement!.filter(criteria), + search: (query) => this.activitylogElement!.search(query), + }; + } + + // ========================================== + // PROGRAMMATIC API: NAVIGATION + // ========================================== + /** * Navigate to a view by ID */ - public navigateToView(viewId: string): boolean { - if (this.router) { - return this.router.navigate(viewId); + public async navigateToView(viewId: string, params?: Record): Promise { + const view = this.viewRegistry.get(viewId); + if (!view) { + console.warn(`Cannot navigate to unknown view: ${viewId}`); + return false; } - // Fallback for non-routed mode - const view = this.viewRegistry.get(viewId); - if (view) { - this.loadView(view); - return true; + // Check if current view allows navigation + const canLeave = await this.viewRegistry.canLeaveCurrentView(); + if (canLeave !== true) { + if (typeof canLeave === 'string') { + // Show confirmation dialog + const confirmed = window.confirm(canLeave); + if (!confirmed) return false; + } else { + return false; + } + } + + // Emit loading event + this.viewLifecycle$.next({ type: 'loading', viewId }); + + try { + await this.loadView(view, params); + + // Update URL hash + const route = view.route || viewId; + const newHash = `#${route}`; + if (window.location.hash !== newHash) { + window.history.pushState({ viewId }, '', newHash); + } + + return true; + } catch (error) { + this.viewLifecycle$.next({ type: 'loadError', viewId, error }); + return false; } - return false; } /** @@ -379,72 +662,20 @@ export class DeesAppuiBase extends DeesElement { } /** - * Get UI state for serialization - */ - public getUIState(): interfaces.IAppUIState { - return { - currentViewId: this.currentView?.id, - mainMenuCollapsed: this.mainmenuCollapsed, - secondaryMenuCollapsed: this.secondarymenuCollapsed, - secondaryMenuSelectedKey: this.secondarymenuSelectedItem?.key, - collapsedGroups: [], // TODO: Get from secondarymenu if needed - timestamp: Date.now(), - }; - } - - /** - * Restore UI state from a state object - */ - public restoreUIState(state: interfaces.IAppUIState): void { - if (state.mainMenuCollapsed !== undefined) { - this.mainmenuCollapsed = state.mainMenuCollapsed; - } - if (state.secondaryMenuCollapsed !== undefined) { - this.secondarymenuCollapsed = state.secondaryMenuCollapsed; - } - if (state.currentViewId) { - this.navigateToView(state.currentViewId); - } - } - - /** - * Save current UI state - */ - public saveState(): void { - this.stateManager?.save(this.getUIState()); - } - - /** - * Load and restore saved UI state - */ - public loadState(): boolean { - const state = this.stateManager?.load(); - if (state) { - this.restoreUIState(state); - return true; - } - return false; - } - - /** - * Get access to the view registry + * Get access to the view registry (for advanced use) */ public getViewRegistry(): ViewRegistry { return this.viewRegistry; } + // ========================================== + // UNIFIED CONFIGURATION + // ========================================== + /** - * Get access to the router + * Configure the app shell with a unified config object */ - public getRouter(): AppRouter | null { - return this.router; - } - - // ========================================== - // NEW: Private helper methods - // ========================================== - - private applyConfig(config: interfaces.IAppConfig): void { + public configure(config: interfaces.IAppConfig): void { // Register views if (config.views) { this.viewRegistry.clear(); @@ -468,43 +699,82 @@ export class DeesAppuiBase extends DeesElement { this.appbarProfileMenuItems = config.appBar.profileMenuItems || []; } - // Build main menu from view references + // Build main menu from view references or direct config if (config.mainMenu) { - this.mainmenuGroups = this.buildMainMenuGroups(config); - this.mainmenuBottomTabs = this.buildBottomTabs(config); + if (config.mainMenu.sections) { + this.mainmenuGroups = this.buildMainMenuFromSections(config); + } else if (config.mainMenu.groups) { + this.mainmenuGroups = config.mainMenu.groups; + } + + if (config.mainMenu.logoIcon) { + this.mainmenuLogoIcon = config.mainMenu.logoIcon; + } + if (config.mainMenu.logoText) { + this.mainmenuLogoText = config.mainMenu.logoText; + } + if (config.mainMenu.bottomTabs) { + this.mainmenuBottomTabs = config.mainMenu.bottomTabs; + } else if (config.mainMenu.bottomItems) { + this.mainmenuBottomTabs = this.buildBottomTabsFromItems(config.mainMenu.bottomItems); + } } - // Initialize state manager - if (config.statePersistence) { - this.stateManager = new StateManager(config.statePersistence); - } - - // Initialize router - if (config.routing && config.routing.mode !== 'none') { - this.router = new AppRouter(config.routing, this.viewRegistry); - this.router.onRouteChange((viewId) => { - const view = this.viewRegistry.get(viewId); - if (view) { - this.loadView(view); - } - }); - } + // Setup domtools.router integration + this.setupRouterIntegration(config); // Bind event callbacks if (config.onViewChange) { - this.addEventListener('view-change', ((e: CustomEvent) => { - config.onViewChange!(e.detail.viewId, e.detail.view); - }) as EventListener); + this.viewChanged$.subscribe((event) => { + config.onViewChange!(event.viewId, event.view); + }); } if (config.onSearch) { - this.addEventListener('appbar-search-click', () => { - config.onSearch!(); - }); + this.searchCallback = config.onSearch; + } + + // Navigate to default view + if (config.defaultView) { + this.navigateToView(config.defaultView); } } - private buildMainMenuGroups(config: interfaces.IAppConfig): interfaces.IMenuGroup[] { + // ========================================== + // PRIVATE HELPER METHODS + // ========================================== + + private setupRouterIntegration(config: interfaces.IAppConfig): void { + // Handle hash change events + const handleHashChange = () => { + const hash = window.location.hash.slice(1); // Remove # + if (!hash) return; + + const match = this.viewRegistry.findByRoute(hash); + if (match) { + this.navigateToView(match.view.id, match.params); + } + }; + + window.addEventListener('hashchange', handleHashChange); + + // Store cleanup function + this.routerCleanup = () => { + window.removeEventListener('hashchange', handleHashChange); + }; + + // Handle initial route from hash + const currentHash = window.location.hash.slice(1); + if (currentHash) { + const match = this.viewRegistry.findByRoute(currentHash); + if (match) { + // Use setTimeout to allow component to fully initialize + setTimeout(() => this.navigateToView(match.view.id, match.params), 0); + } + } + } + + private buildMainMenuFromSections(config: interfaces.IAppConfig): interfaces.IMenuGroup[] { if (!config.mainMenu?.sections) return []; return config.mainMenu.sections.map((section) => ({ @@ -517,19 +787,18 @@ export class DeesAppuiBase extends DeesElement { return null; } return { - key: view.name, + key: view.id, iconName: view.iconName, action: () => this.navigateToView(viewId), - }; + badge: view.badge, + } as interfaces.ITab; }) .filter(Boolean) as interfaces.ITab[], })); } - private buildBottomTabs(config: interfaces.IAppConfig): interfaces.ITab[] { - if (!config.mainMenu?.bottomItems) return []; - - return config.mainMenu.bottomItems + private buildBottomTabsFromItems(items: string[]): interfaces.ITab[] { + return items .map((viewId) => { const view = this.viewRegistry.get(viewId); if (!view) { @@ -537,46 +806,172 @@ export class DeesAppuiBase extends DeesElement { return null; } return { - key: view.name, + key: view.id, iconName: view.iconName, action: () => this.navigateToView(viewId), - }; + } as interfaces.ITab; }) .filter(Boolean) as interfaces.ITab[]; } - private loadView(view: interfaces.IViewDefinition): void { + private async loadView( + view: interfaces.IViewDefinition, + params?: Record + ): Promise { const previousView = this.currentView; this.currentView = view; - // Update secondary menu + // Get view container + const viewContainer = this.maincontent?.querySelector('.view-container') + || this.shadowRoot?.querySelector('.view-container'); + + if (viewContainer) { + // Activate view with caching and lifecycle hooks + const element = await this.viewRegistry.activateView( + view.id, + viewContainer as HTMLElement, + params + ); + + if (element) { + // Emit lifecycle event + this.viewLifecycle$.next({ + type: 'activated', + viewId: view.id, + element, + params, + }); + } + } + + // Apply view-specific secondary menu if (view.secondaryMenu) { this.secondarymenuGroups = view.secondaryMenu; this.secondarymenuHeading = view.name; } - // Update content tabs + // Apply view-specific content tabs if (view.contentTabs) { this.maincontentTabs = view.contentTabs; } - // Render view content into the view container - const viewContainer = this.maincontent?.shadowRoot?.querySelector('.view-container') - || this.shadowRoot?.querySelector('.view-container'); - if (viewContainer) { - this.viewRegistry.renderView(view.id, viewContainer as HTMLElement); - } + // Update main menu selection + this.setMainMenuSelection(view.id); - // Save state if configured - this.stateManager?.update({ currentViewId: view.id }); + // Emit view change event + const changeEvent: interfaces.IViewChangeEvent = { + viewId: view.id, + view, + previousView, + params, + }; + this.viewChanged$.next(changeEvent); - // Dispatch event + // Also dispatch DOM event for backwards compatibility this.dispatchEvent( new CustomEvent('view-change', { - detail: { viewId: view.id, view, previousView }, + detail: changeEvent, bubbles: true, composed: true, }) ); } + + // ========================================== + // EVENT HANDLERS (Internal) + // ========================================== + + private handleAppbarMenuSelect(e: CustomEvent) { + this.dispatchEvent(new CustomEvent('appbar-menu-select', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleAppbarBreadcrumbNavigate(e: CustomEvent) { + this.dispatchEvent(new CustomEvent('appbar-breadcrumb-navigate', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleAppbarSearchClick() { + this.dispatchEvent(new CustomEvent('appbar-search-click', { + bubbles: true, + composed: true + })); + } + + private handleAppbarSearchQuery(e: CustomEvent) { + if (this.searchCallback) { + this.searchCallback(e.detail.query); + } + this.dispatchEvent(new CustomEvent('search-query', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleAppbarUserMenuOpen() { + this.dispatchEvent(new CustomEvent('appbar-user-menu-open', { + bubbles: true, + composed: true + })); + } + + private handleAppbarProfileMenuSelect(e: CustomEvent) { + this.dispatchEvent(new CustomEvent('appbar-profile-menu-select', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleMainmenuTabSelect(e: CustomEvent) { + this.mainmenuSelectedTab = e.detail.tab; + this.dispatchEvent(new CustomEvent('mainmenu-tab-select', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleSecondarymenuItemSelect(e: CustomEvent) { + this.secondarymenuSelectedItem = e.detail.item; + this.dispatchEvent(new CustomEvent('secondarymenu-item-select', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleMainmenuCollapseChange(e: CustomEvent) { + this.mainmenuCollapsed = e.detail.collapsed; + this.dispatchEvent(new CustomEvent('mainmenu-collapse-change', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleSecondarymenuCollapseChange(e: CustomEvent) { + this.secondarymenuCollapsed = e.detail.collapsed; + this.dispatchEvent(new CustomEvent('secondarymenu-collapse-change', { + detail: e.detail, + bubbles: true, + composed: true + })); + } + + private handleContentTabSelect(e: CustomEvent) { + this.maincontentSelectedTab = e.detail.tab; + this.dispatchEvent(new CustomEvent('content-tab-select', { + detail: e.detail, + bubbles: true, + composed: true + })); + } } diff --git a/ts_web/elements/00group-appui/dees-appui-base/index.ts b/ts_web/elements/00group-appui/dees-appui-base/index.ts index a7ed537..2f8cb9e 100644 --- a/ts_web/elements/00group-appui/dees-appui-base/index.ts +++ b/ts_web/elements/00group-appui/dees-appui-base/index.ts @@ -1,4 +1,2 @@ export * from './dees-appui-base.js'; export * from './view.registry.js'; -export * from './app.router.js'; -export * from './state.manager.js'; diff --git a/ts_web/elements/00group-appui/dees-appui-base/readme.md b/ts_web/elements/00group-appui/dees-appui-base/readme.md new file mode 100644 index 0000000..dc37c6d --- /dev/null +++ b/ts_web/elements/00group-appui/dees-appui-base/readme.md @@ -0,0 +1,560 @@ +# DeesAppuiBase + +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 { DeesAppuiBase } from '@design.estate/dees-catalog'; + +@customElement('my-app') +class MyApp extends DeesElement { + private appui: DeesAppuiBase; + + async firstUpdated() { + this.appui = this.shadowRoot.querySelector('dees-appui-base'); + + // 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``; + } +} +``` + +## 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: DeesAppuiBase; // Reference to the app shell + viewId: string; // The view ID being activated + params?: Record; // 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 + // event.error?: unknown +}); + +// View change events +appui.viewChanged$.subscribe((event) => { + // event.viewId: string + // event.view: IViewDefinition + // event.previousView?: IViewDefinition + // event.params?: Record +}); +``` + +--- + +## Complete Example + +```typescript +import { html, DeesElement, customElement } from '@design.estate/dees-element'; +import { DeesAppuiBase, IViewActivationContext } from '@design.estate/dees-catalog'; + +@customElement('my-app') +class MyApp extends DeesElement { + private appui: DeesAppuiBase; + + async firstUpdated() { + this.appui = this.shadowRoot.querySelector('dees-appui-base'); + + 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``; + } +} + +// View with lifecycle hooks +@customElement('crm-settings') +class CrmSettings extends DeesElement { + private appui: DeesAppuiBase; + + 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 diff --git a/ts_web/elements/00group-appui/dees-appui-base/state.manager.ts b/ts_web/elements/00group-appui/dees-appui-base/state.manager.ts deleted file mode 100644 index 5ed8d8a..0000000 --- a/ts_web/elements/00group-appui/dees-appui-base/state.manager.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { IStatePersistenceConfig, IAppUIState } from '../../interfaces/appconfig.js'; - -/** - * Manager for persisting and restoring UI state - */ -export class StateManager { - private config: Required; - private memoryStorage: Map = new Map(); - - constructor(config: IStatePersistenceConfig = { enabled: false }) { - this.config = { - enabled: config.enabled, - storageKey: config.storageKey || 'dees-appui-state', - storage: config.storage || 'localStorage', - persist: { - mainMenuCollapsed: true, - secondaryMenuCollapsed: true, - selectedView: true, - secondaryMenuSelection: true, - collapsedGroups: true, - ...config.persist, - }, - }; - } - - /** - * Check if state persistence is enabled - */ - public isEnabled(): boolean { - return this.config.enabled; - } - - /** - * Save current UI state - */ - public save(state: Partial): void { - if (!this.config.enabled) return; - - const existingState = this.load() || {}; - const newState: IAppUIState = { - ...existingState, - timestamp: Date.now(), - }; - - // Only save what's configured - if (this.config.persist.selectedView && state.currentViewId !== undefined) { - newState.currentViewId = state.currentViewId; - } - if (this.config.persist.mainMenuCollapsed && state.mainMenuCollapsed !== undefined) { - newState.mainMenuCollapsed = state.mainMenuCollapsed; - } - if (this.config.persist.secondaryMenuCollapsed && state.secondaryMenuCollapsed !== undefined) { - newState.secondaryMenuCollapsed = state.secondaryMenuCollapsed; - } - if (this.config.persist.secondaryMenuSelection && state.secondaryMenuSelectedKey !== undefined) { - newState.secondaryMenuSelectedKey = state.secondaryMenuSelectedKey; - } - if (this.config.persist.collapsedGroups && state.collapsedGroups !== undefined) { - newState.collapsedGroups = state.collapsedGroups; - } - - this.setItem(this.config.storageKey, JSON.stringify(newState)); - } - - /** - * Load persisted UI state - */ - public load(): IAppUIState | null { - if (!this.config.enabled) return null; - - try { - const data = this.getItem(this.config.storageKey); - if (!data) return null; - return JSON.parse(data) as IAppUIState; - } catch (e) { - console.warn('Failed to load UI state:', e); - return null; - } - } - - /** - * Clear persisted state - */ - public clear(): void { - this.removeItem(this.config.storageKey); - } - - /** - * Check if state exists - */ - public hasState(): boolean { - return this.getItem(this.config.storageKey) !== null; - } - - /** - * Get state age in milliseconds - */ - public getStateAge(): number | null { - const state = this.load(); - if (!state?.timestamp) return null; - return Date.now() - state.timestamp; - } - - /** - * Update specific state properties - */ - public update(updates: Partial): void { - const currentState = this.load() || {}; - this.save({ ...currentState, ...updates }); - } - - /** - * Get the storage key being used - */ - public getStorageKey(): string { - return this.config.storageKey; - } - - // Storage abstraction methods - - private getItem(key: string): string | null { - switch (this.config.storage) { - case 'localStorage': - try { - return localStorage.getItem(key); - } catch { - return null; - } - case 'sessionStorage': - try { - return sessionStorage.getItem(key); - } catch { - return null; - } - case 'memory': - return this.memoryStorage.get(key) || null; - default: - return null; - } - } - - private setItem(key: string, value: string): void { - switch (this.config.storage) { - case 'localStorage': - try { - localStorage.setItem(key, value); - } catch (e) { - console.warn('Failed to save to localStorage:', e); - } - break; - case 'sessionStorage': - try { - sessionStorage.setItem(key, value); - } catch (e) { - console.warn('Failed to save to sessionStorage:', e); - } - break; - case 'memory': - this.memoryStorage.set(key, value); - break; - } - } - - private removeItem(key: string): void { - switch (this.config.storage) { - case 'localStorage': - try { - localStorage.removeItem(key); - } catch { - // Ignore - } - break; - case 'sessionStorage': - try { - sessionStorage.removeItem(key); - } catch { - // Ignore - } - break; - case 'memory': - this.memoryStorage.delete(key); - break; - } - } -} diff --git a/ts_web/elements/00group-appui/dees-appui-base/view.registry.ts b/ts_web/elements/00group-appui/dees-appui-base/view.registry.ts index 6c7b627..de25222 100644 --- a/ts_web/elements/00group-appui/dees-appui-base/view.registry.ts +++ b/ts_web/elements/00group-appui/dees-appui-base/view.registry.ts @@ -1,13 +1,31 @@ import { html, render, type TemplateResult } from '@design.estate/dees-element'; -import type { IViewDefinition } from '../../interfaces/appconfig.js'; +import type { + IViewDefinition, + IViewActivationContext, + IViewLifecycle, + TDeesAppuiBase +} 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 = new Map(); private instances: Map = new Map(); private currentViewId: string | null = null; + private appui: TDeesAppuiBase | null = null; + + /** + * Set the appui reference for view activation context + */ + public setAppuiRef(appui: TDeesAppuiBase): void { + this.appui = appui; + } /** * Register a single view @@ -56,20 +74,222 @@ export class ViewRegistry { } /** - * Find view by route + * Find view by route (supports parameterized routes like 'settings/:section') */ - public findByRoute(route: string): IViewDefinition | undefined { + public findByRoute(route: string): { view: IViewDefinition; params: Record } | undefined { for (const view of this.views.values()) { const viewRoute = view.route || view.id; - if (viewRoute === route) { - return view; + const params = this.matchRoute(viewRoute, route); + if (params !== null) { + return { view, params }; } } return undefined; } /** - * Render a view's content into a container + * Match a route pattern against an actual route + * Returns params if matched, null otherwise + */ + private matchRoute(pattern: string, route: string): Record | 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 = {}; + + 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 { + 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 + ): Promise { + 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 { + 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 { + 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 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 + ): Promise { + 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); @@ -78,25 +298,22 @@ export class ViewRegistry { return null; } - // Clear container + // For legacy compatibility, clear container container.innerHTML = ''; let element: HTMLElement; + const content = view.content; - if (typeof view.content === 'string') { - // Tag name string - element = document.createElement(view.content); - } else if (typeof view.content === 'function') { - // Check if it's a class constructor or template function - if (view.content.prototype instanceof HTMLElement) { - // Element class constructor - element = new (view.content as new () => HTMLElement)(); + 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 { - // 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 = (view.content as () => TemplateResult)(); + const template = (content as () => TemplateResult)(); render(template, wrapper); element = wrapper; } @@ -126,10 +343,29 @@ export class ViewRegistry { 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; } @@ -138,7 +374,7 @@ export class ViewRegistry { * Unregister a view */ public unregister(viewId: string): boolean { - this.instances.delete(viewId); + this.clearInstance(viewId); return this.views.delete(viewId); } @@ -147,8 +383,7 @@ export class ViewRegistry { */ public clear(): void { this.views.clear(); - this.instances.clear(); - this.currentViewId = null; + this.clearInstances(); } /** diff --git a/ts_web/elements/interfaces/appconfig.ts b/ts_web/elements/interfaces/appconfig.ts index 8d12252..8d376ae 100644 --- a/ts_web/elements/interfaces/appconfig.ts +++ b/ts_web/elements/interfaces/appconfig.ts @@ -2,6 +2,39 @@ import type { TemplateResult } from '@design.estate/dees-element'; import type { IAppBarMenuItem } from './appbarmenuitem.js'; import type { ITab } from './tab.js'; import type { ISecondaryMenuGroup } from './secondarymenu.js'; +import type { IMenuGroup } from './menugroup.js'; + +// Forward declaration for circular reference +export type TDeesAppuiBase = HTMLElement & { + setAppBarMenus: (menus: IAppBarMenuItem[]) => void; + updateAppBarMenu: (name: string, update: Partial) => void; + setBreadcrumbs: (breadcrumbs: string | string[]) => void; + setUser: (user: IAppUser | undefined) => void; + setProfileMenuItems: (items: IAppBarMenuItem[]) => void; + setSearchVisible: (visible: boolean) => void; + setWindowControlsVisible: (visible: boolean) => void; + setMainMenu: (config: IMainMenuConfig) => void; + updateMainMenuGroup: (groupName: string, update: Partial) => void; + addMainMenuItem: (groupName: string, tab: ITab) => void; + removeMainMenuItem: (groupName: string, tabKey: string) => void; + setMainMenuSelection: (tabKey: string) => void; + setMainMenuCollapsed: (collapsed: boolean) => void; + setMainMenuBadge: (tabKey: string, badge: string | number) => void; + clearMainMenuBadge: (tabKey: string) => void; + setSecondaryMenu: (config: { heading?: string; groups: ISecondaryMenuGroup[] }) => void; + updateSecondaryMenuGroup: (groupName: string, update: Partial) => void; + addSecondaryMenuItem: (groupName: string, item: ISecondaryMenuGroup['items'][0]) => void; + setSecondaryMenuSelection: (itemKey: string) => void; + clearSecondaryMenu: () => void; + setContentTabs: (tabs: ITab[]) => void; + addContentTab: (tab: ITab) => void; + removeContentTab: (tabKey: string) => void; + selectContentTab: (tabKey: string) => void; + getSelectedContentTab: () => ITab | undefined; + activityLog: IActivityLogAPI; + navigateToView: (viewId: string, params?: Record) => Promise; + getCurrentView: () => IViewDefinition | undefined; +}; /** * User configuration for the app bar @@ -13,6 +46,69 @@ export interface IAppUser { status?: 'online' | 'offline' | 'busy' | 'away'; } +/** + * Activity entry for the activity log + */ +export interface IActivityEntry { + /** Unique identifier (auto-generated if not provided) */ + id?: string; + /** Timestamp (auto-set to now if not provided) */ + timestamp?: Date; + /** Activity type for icon styling */ + type: 'login' | 'logout' | 'view' | 'create' | 'update' | 'delete' | 'custom'; + /** User who performed the action */ + user: string; + /** Activity message */ + message: string; + /** Optional custom icon (overrides type-based icon) */ + iconName?: string; + /** Optional additional data */ + data?: Record; +} + +/** + * Activity log programmatic API + */ +export interface IActivityLogAPI { + /** Add a single activity entry */ + add: (entry: IActivityEntry) => void; + /** Add multiple activity entries */ + addMany: (entries: IActivityEntry[]) => void; + /** Clear all entries */ + clear: () => void; + /** Get all entries */ + getEntries: () => IActivityEntry[]; + /** Filter entries */ + filter: (criteria: { user?: string; type?: IActivityEntry['type'] }) => IActivityEntry[]; + /** Search entries by message */ + search: (query: string) => IActivityEntry[]; +} + +/** + * View activation context passed to onActivate lifecycle hook + */ +export interface IViewActivationContext { + /** Reference to the DeesAppuiBase instance */ + appui: TDeesAppuiBase; + /** The view ID being activated */ + viewId: string; + /** Route parameters if any */ + params?: Record; +} + +/** + * View lifecycle hooks interface + * Views can implement these methods to receive lifecycle notifications + */ +export interface IViewLifecycle { + /** Called when view is activated (displayed) */ + onActivate?: (context: IViewActivationContext) => void | Promise; + /** Called when view is deactivated (hidden) */ + onDeactivate?: () => void | Promise; + /** Called before navigation away - return false or message to block */ + canDeactivate?: () => boolean | string | Promise; +} + /** * View definition for the view registry */ @@ -23,17 +119,29 @@ export interface IViewDefinition { name: string; /** Optional icon */ iconName?: string; - /** The view content - can be a tag name, element class, or template function */ - content: string | (new () => HTMLElement) | (() => TemplateResult); + /** + * The view content - can be: + * - Tag name string (e.g., 'my-dashboard') + * - Element class constructor + * - Template function returning TemplateResult + * - Async function returning any of the above (for lazy loading) + */ + content: + | string + | (new () => HTMLElement) + | (() => TemplateResult) + | (() => Promise HTMLElement) | (() => TemplateResult)>); /** Secondary menu items specific to this view */ secondaryMenu?: ISecondaryMenuGroup[]; /** Content tabs specific to this view */ contentTabs?: ITab[]; - /** Optional route path (defaults to id) */ + /** Optional route path (defaults to id). Supports params like 'settings/:section' */ route?: string; /** Badge to show on menu item */ badge?: string | number; badgeVariant?: 'default' | 'success' | 'warning' | 'error'; + /** Whether to cache this view instance (default: true) */ + cache?: boolean; } /** @@ -50,10 +158,18 @@ export interface IMainMenuSection { * Main menu configuration */ export interface IMainMenuConfig { - /** Menu sections with view references */ + /** Logo icon */ + logoIcon?: string; + /** Logo text */ + logoText?: string; + /** Menu groups with tabs */ + groups?: IMenuGroup[]; + /** Menu sections with view references (alternative to groups) */ sections?: IMainMenuSection[]; - /** Bottom pinned items (view IDs) */ + /** Bottom pinned items (view IDs or tabs) */ bottomItems?: string[]; + /** Bottom tabs */ + bottomTabs?: ITab[]; } /** @@ -77,47 +193,13 @@ export interface IBrandingConfig { logoText?: string; } -/** - * Routing configuration - */ -export interface IRoutingConfig { - /** Routing mode */ - mode: 'hash' | 'history' | 'external' | 'none'; - /** Base path for history mode */ - basePath?: string; - /** Default view ID to show on startup */ - defaultView?: string; - /** Sync URL on view change */ - syncUrl?: boolean; - /** Handle 404s - show view ID or callback */ - notFound?: string | (() => void); -} - -/** - * State persistence configuration - */ -export interface IStatePersistenceConfig { - /** Enable state persistence */ - enabled: boolean; - /** Storage key prefix */ - storageKey?: string; - /** Storage type */ - storage?: 'localStorage' | 'sessionStorage' | 'memory'; - /** What to persist */ - persist?: { - mainMenuCollapsed?: boolean; - secondaryMenuCollapsed?: boolean; - selectedView?: boolean; - secondaryMenuSelection?: boolean; - collapsedGroups?: boolean; - }; -} - /** * Activity log configuration */ export interface IActivityLogConfig { - enabled?: boolean; + /** Whether activity log is visible */ + visible?: boolean; + /** Width of activity log panel */ width?: number; } @@ -137,46 +219,15 @@ export interface IAppConfig { /** Main menu structure */ mainMenu?: IMainMenuConfig; - /** Routing configuration */ - routing?: IRoutingConfig; - - /** State persistence configuration */ - statePersistence?: IStatePersistenceConfig; + /** Default view ID to show on startup */ + defaultView?: string; /** Activity log configuration */ activityLog?: IActivityLogConfig; - /** Event callbacks (optional shorthand) */ + /** Event callbacks */ onViewChange?: (viewId: string, view: IViewDefinition) => void; - onSearch?: () => void; -} - -/** - * Serialized UI state for persistence - */ -export interface IAppUIState { - /** Current view ID */ - currentViewId?: string; - /** Main menu collapsed state */ - mainMenuCollapsed?: boolean; - /** Secondary menu collapsed state */ - secondaryMenuCollapsed?: boolean; - /** Selected secondary menu item key */ - secondaryMenuSelectedKey?: string; - /** Collapsed group names in secondary menu */ - collapsedGroups?: string[]; - /** Timestamp of last save */ - timestamp?: number; -} - -/** - * Route change event detail - */ -export interface IRouteChangeEvent { - viewId: string; - previousViewId: string | null; - params?: Record; - source: 'navigation' | 'popstate' | 'initial' | 'programmatic'; + onSearch?: (query: string) => void; } /** @@ -186,4 +237,16 @@ export interface IViewChangeEvent { viewId: string; view: IViewDefinition; previousView?: IViewDefinition; + params?: Record; +} + +/** + * View lifecycle event (for rxjs Subject) + */ +export interface IViewLifecycleEvent { + type: 'activated' | 'deactivated' | 'loading' | 'loaded' | 'loadError'; + viewId: string; + element?: HTMLElement; + params?: Record; + error?: unknown; } diff --git a/ts_web/elements/interfaces/tab.ts b/ts_web/elements/interfaces/tab.ts index 29e91d5..08c43b2 100644 --- a/ts_web/elements/interfaces/tab.ts +++ b/ts_web/elements/interfaces/tab.ts @@ -2,4 +2,6 @@ export interface ITab { key: string; iconName?: string; action: () => void; + badge?: string | number; + badgeVariant?: 'default' | 'success' | 'warning' | 'error'; }