From 13fa654c0ff8b530347cd07c8164f14705ba8962 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 9 Dec 2025 08:26:24 +0000 Subject: [PATCH] feat(dees-appui-base): Add unified App UI API to dees-appui-base with ViewRegistry, AppRouter and StateManager --- changelog.md | 11 + readme.hints.md | 122 +++++++- ts_web/00_commitinfo_data.ts | 2 +- .../dees-appui-base/app.router.ts | 271 +++++++++++++++++ .../dees-appui-base/dees-appui-base.ts | 284 ++++++++++++++++++ .../00group-appui/dees-appui-base/index.ts | 3 + .../dees-appui-base/state.manager.ts | 185 ++++++++++++ .../dees-appui-base/view.registry.ts | 167 ++++++++++ ts_web/elements/interfaces/appconfig.ts | 189 ++++++++++++ ts_web/elements/interfaces/index.ts | 1 + 10 files changed, 1233 insertions(+), 2 deletions(-) create mode 100644 ts_web/elements/00group-appui/dees-appui-base/app.router.ts create mode 100644 ts_web/elements/00group-appui/dees-appui-base/state.manager.ts create mode 100644 ts_web/elements/00group-appui/dees-appui-base/view.registry.ts create mode 100644 ts_web/elements/interfaces/appconfig.ts diff --git a/changelog.md b/changelog.md index 62755f7..febd96a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-12-09 - 3.3.0 - feat(dees-appui-base) +Add unified App UI API to dees-appui-base with ViewRegistry, AppRouter and StateManager + +- Introduce ViewRegistry for declarative view registration and rendering (supports tag names, element classes and template functions). +- Add AppRouter with hash/history/external/none modes, URL synchronization, navigate/back/forward and onRouteChange listener support. +- Add StateManager to persist UI state (localStorage, sessionStorage or in-memory) with save/load/update/clear APIs. +- Extend interfaces (interfaces/appconfig.ts) with IAppConfig, IViewDefinition, IRoutingConfig, IStatePersistenceConfig and IAppUIState. +- Expose new public DeesAppuiBase methods: configure, navigateToView, getCurrentView, getUIState, restoreUIState, saveState, loadState, getViewRegistry, getRouter. +- Maintain backward compatibility with existing property-based API and slot usage. +- Export new modules (view.registry, app.router, state.manager) from dees-appui-base index and update element exports. + ## 2025-12-08 - 3.2.0 - feat(dees-simple-appdash,dees-simple-login,dees-terminal) Revamp UI: dashboard & login styling, standardize icons to Lucide, and add terminal background/config diff --git a/readme.hints.md b/readme.hints.md index 01ce966..97520cd 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -680,4 +680,124 @@ According to Lit's documentation (https://lit.dev/docs/components/decorators/#de - All unit tests passing - Manual testing of key components verified - No regressions detected -- Focus management and interactions working correctly \ No newline at end of file +- Focus management and interactions working correctly + +## Enhanced AppUI API (2025-12-08) + +The `dees-appui-base` component has been enhanced with a unified configuration API for building real-world applications. + +### New Modules: + +1. **ViewRegistry** (`view.registry.ts`) + - Manages view definitions and their lifecycle + - Supports tag names, element classes, and template functions as view content + - Methods: register, get, renderView, findByRoute + +2. **AppRouter** (`app.router.ts`) + - Built-in routing with hash or history mode + - External router support for framework integration + - Methods: navigate, back, forward, onRouteChange + +3. **StateManager** (`state.manager.ts`) + - Persists UI state (collapsed menus, selections, current view) + - Supports localStorage, sessionStorage, or memory storage + - Methods: save, load, update, clear + +### New Interfaces (in `interfaces/appconfig.ts`): + +```typescript +interface IAppConfig { + branding?: { logoIcon?: string; logoText?: string }; + appBar?: IAppBarConfig; + views: IViewDefinition[]; + mainMenu?: IMainMenuConfig; + routing?: IRoutingConfig; + statePersistence?: IStatePersistenceConfig; + onViewChange?: (viewId: string, view: IViewDefinition) => void; +} + +interface IViewDefinition { + id: string; + name: string; + iconName?: string; + content: string | (new () => HTMLElement) | (() => TemplateResult); + secondaryMenu?: ISecondaryMenuGroup[]; + contentTabs?: ITab[]; + route?: string; +} + +interface IRoutingConfig { + mode: 'hash' | 'history' | 'external' | 'none'; + basePath?: string; + defaultView?: string; + syncUrl?: boolean; +} +``` + +### New Public Methods on DeesAppuiBase: + +```typescript +// Configure with unified config +configure(config: IAppConfig): void + +// Navigation +navigateToView(viewId: string): boolean +getCurrentView(): IViewDefinition | undefined + +// State management +getUIState(): IAppUIState +restoreUIState(state: IAppUIState): void +saveState(): void +loadState(): boolean + +// Access internals +getViewRegistry(): ViewRegistry +getRouter(): AppRouter | null +``` + +### Usage Example (New Unified Config API): + +```typescript +import type { IAppConfig } from '@design.estate/dees-catalog'; + +const config: IAppConfig = { + 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: [{ views: ['dashboard'] }], + bottomItems: ['settings'], + }, + routing: { mode: 'hash', defaultView: 'dashboard' }, + statePersistence: { enabled: true, storage: 'localStorage' }, +}; + +html``; +``` + +### Backward Compatibility: + +The existing property-based API still works: + +```typescript +html` + +
...
+
+`; +``` + +### Key Features: + +- **Declarative View Registry**: Map menu items to view components +- **Built-in Routing**: Hash or history mode with URL synchronization +- **External Router Support**: Integrate with Angular Router or other frameworks +- **State Persistence**: Save/restore collapsed menus, selections, and current view +- **View-specific Menus**: Each view can define its own secondary menu and tabs +- **Full Backward Compatibility**: Existing code continues to work \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 6a4db6b..82ee55e 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.2.0', + version: '3.3.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-base/app.router.ts b/ts_web/elements/00group-appui/dees-appui-base/app.router.ts new file mode 100644 index 0000000..35137dc --- /dev/null +++ b/ts_web/elements/00group-appui/dees-appui-base/app.router.ts @@ -0,0 +1,271 @@ +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.ts b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts index a2a4d33..2076510 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 @@ -17,6 +17,11 @@ 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 +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'; import '../dees-appui-mainmenu/dees-appui-mainmenu.js'; @@ -116,6 +121,19 @@ export class DeesAppuiBase extends DeesElement { @state() accessor activitylog: DeesAppuiActivitylog | undefined = undefined; + // NEW: Unified config property + @property({ type: Object }) + accessor config: interfaces.IAppConfig | undefined = undefined; + + // NEW: Current view state + @state() + accessor currentView: interfaces.IViewDefinition | undefined = undefined; + + // NEW: Internal services (not reactive, managed internally) + private viewRegistry: ViewRegistry = new ViewRegistry(); + private router: AppRouter | null = null; + private stateManager: StateManager | null = null; + public static styles = [ cssManager.defaultStyles, css` @@ -155,6 +173,15 @@ export class DeesAppuiBase extends DeesElement { position: relative; z-index: 1; } + + /* View container for dynamically loaded views */ + .view-container { + display: contents; + } + + .view-container:empty { + display: none; + } `, ]; @@ -200,6 +227,7 @@ export class DeesAppuiBase extends DeesElement { +
@@ -214,6 +242,24 @@ export class DeesAppuiBase extends DeesElement { this.secondarymenu = this.shadowRoot.querySelector('dees-appui-secondarymenu'); this.maincontent = this.shadowRoot.querySelector('dees-appui-maincontent'); this.activitylog = this.shadowRoot.querySelector('dees-appui-activitylog'); + + // 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(); + } + } + + async disconnectedCallback() { + await super.disconnectedCallback(); + this.router?.destroy(); } // Event handlers for appbar @@ -295,4 +341,242 @@ export class DeesAppuiBase extends DeesElement { composed: true })); } + + // ========================================== + // NEW: Public methods for unified config API + // ========================================== + + /** + * Configure the app shell with a unified config object + */ + public configure(config: interfaces.IAppConfig): void { + this.config = config; + this.applyConfig(config); + } + + /** + * Navigate to a view by ID + */ + public navigateToView(viewId: string): boolean { + if (this.router) { + return this.router.navigate(viewId); + } + + // Fallback for non-routed mode + const view = this.viewRegistry.get(viewId); + if (view) { + this.loadView(view); + return true; + } + return false; + } + + /** + * Get the current view + */ + public getCurrentView(): interfaces.IViewDefinition | undefined { + return this.currentView; + } + + /** + * 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 + */ + public getViewRegistry(): ViewRegistry { + return this.viewRegistry; + } + + /** + * Get access to the router + */ + public getRouter(): AppRouter | null { + return this.router; + } + + // ========================================== + // NEW: Private helper methods + // ========================================== + + private applyConfig(config: interfaces.IAppConfig): void { + // Register views + if (config.views) { + this.viewRegistry.clear(); + this.viewRegistry.registerAll(config.views); + } + + // Apply branding + if (config.branding) { + this.mainmenuLogoIcon = config.branding.logoIcon || ''; + this.mainmenuLogoText = config.branding.logoText || ''; + } + + // Apply app bar config + if (config.appBar) { + this.appbarMenuItems = config.appBar.menuItems || []; + this.appbarBreadcrumbs = config.appBar.breadcrumbs || ''; + this.appbarBreadcrumbSeparator = config.appBar.breadcrumbSeparator || ' > '; + this.appbarShowWindowControls = config.appBar.showWindowControls ?? true; + this.appbarShowSearch = config.appBar.showSearch ?? false; + this.appbarUser = config.appBar.user; + this.appbarProfileMenuItems = config.appBar.profileMenuItems || []; + } + + // Build main menu from view references + if (config.mainMenu) { + this.mainmenuGroups = this.buildMainMenuGroups(config); + this.mainmenuBottomTabs = this.buildBottomTabs(config); + } + + // 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); + } + }); + } + + // Bind event callbacks + if (config.onViewChange) { + this.addEventListener('view-change', ((e: CustomEvent) => { + config.onViewChange!(e.detail.viewId, e.detail.view); + }) as EventListener); + } + + if (config.onSearch) { + this.addEventListener('appbar-search-click', () => { + config.onSearch!(); + }); + } + } + + private buildMainMenuGroups(config: interfaces.IAppConfig): interfaces.IMenuGroup[] { + if (!config.mainMenu?.sections) return []; + + return config.mainMenu.sections.map((section) => ({ + name: section.name, + tabs: section.views + .map((viewId) => { + const view = this.viewRegistry.get(viewId); + if (!view) { + console.warn(`View "${viewId}" not found in registry`); + return null; + } + return { + key: view.name, + iconName: view.iconName, + action: () => this.navigateToView(viewId), + }; + }) + .filter(Boolean) as interfaces.ITab[], + })); + } + + private buildBottomTabs(config: interfaces.IAppConfig): interfaces.ITab[] { + if (!config.mainMenu?.bottomItems) return []; + + return config.mainMenu.bottomItems + .map((viewId) => { + const view = this.viewRegistry.get(viewId); + if (!view) { + console.warn(`View "${viewId}" not found in registry`); + return null; + } + return { + key: view.name, + iconName: view.iconName, + action: () => this.navigateToView(viewId), + }; + }) + .filter(Boolean) as interfaces.ITab[]; + } + + private loadView(view: interfaces.IViewDefinition): void { + const previousView = this.currentView; + this.currentView = view; + + // Update secondary menu + if (view.secondaryMenu) { + this.secondarymenuGroups = view.secondaryMenu; + this.secondarymenuHeading = view.name; + } + + // Update 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); + } + + // Save state if configured + this.stateManager?.update({ currentViewId: view.id }); + + // Dispatch event + this.dispatchEvent( + new CustomEvent('view-change', { + detail: { viewId: view.id, view, previousView }, + 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 5298b3a..a7ed537 100644 --- a/ts_web/elements/00group-appui/dees-appui-base/index.ts +++ b/ts_web/elements/00group-appui/dees-appui-base/index.ts @@ -1 +1,4 @@ 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/state.manager.ts b/ts_web/elements/00group-appui/dees-appui-base/state.manager.ts new file mode 100644 index 0000000..5ed8d8a --- /dev/null +++ b/ts_web/elements/00group-appui/dees-appui-base/state.manager.ts @@ -0,0 +1,185 @@ +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 new file mode 100644 index 0000000..6c7b627 --- /dev/null +++ b/ts_web/elements/00group-appui/dees-appui-base/view.registry.ts @@ -0,0 +1,167 @@ +import { html, render, type TemplateResult } from '@design.estate/dees-element'; +import type { IViewDefinition } from '../../interfaces/appconfig.js'; + +/** + * Registry for managing views and their lifecycle + */ +export class ViewRegistry { + private views: Map = new Map(); + private instances: Map = new Map(); + private currentViewId: string | null = null; + + /** + * Register a single view + */ + public register(view: IViewDefinition): void { + if (this.views.has(view.id)) { + console.warn(`View with id "${view.id}" already registered. Overwriting.`); + } + this.views.set(view.id, view); + } + + /** + * Register multiple views + */ + public registerAll(views: IViewDefinition[]): void { + views.forEach((view) => this.register(view)); + } + + /** + * Get a view definition by ID + */ + public get(viewId: string): IViewDefinition | undefined { + return this.views.get(viewId); + } + + /** + * Get all registered view IDs + */ + public getViewIds(): string[] { + return Array.from(this.views.keys()); + } + + /** + * Get all views + */ + public getAll(): IViewDefinition[] { + return Array.from(this.views.values()); + } + + /** + * Get route for a view + */ + public getRoute(viewId: string): string { + const view = this.views.get(viewId); + return view?.route || view?.id || ''; + } + + /** + * Find view by route + */ + public findByRoute(route: string): IViewDefinition | undefined { + for (const view of this.views.values()) { + const viewRoute = view.route || view.id; + if (viewRoute === route) { + return view; + } + } + return undefined; + } + + /** + * Render a view's content into a container + */ + public renderView(viewId: string, container: HTMLElement): HTMLElement | null { + const view = this.views.get(viewId); + if (!view) { + console.error(`View "${viewId}" not found in registry`); + return null; + } + + // Clear container + container.innerHTML = ''; + + let element: HTMLElement; + + 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)(); + } 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)(); + render(template, wrapper); + element = wrapper; + } + } else { + console.error(`Invalid content type for view "${viewId}"`); + return null; + } + + container.appendChild(element); + this.instances.set(viewId, element); + this.currentViewId = viewId; + + return element; + } + + /** + * Get currently active view ID + */ + public getCurrentViewId(): string | null { + return this.currentViewId; + } + + /** + * Get cached instance of a view + */ + public getInstance(viewId: string): HTMLElement | undefined { + return this.instances.get(viewId); + } + + /** + * Clear all instances + */ + public clearInstances(): void { + this.instances.clear(); + this.currentViewId = null; + } + + /** + * Unregister a view + */ + public unregister(viewId: string): boolean { + this.instances.delete(viewId); + return this.views.delete(viewId); + } + + /** + * Clear the registry + */ + public clear(): void { + this.views.clear(); + this.instances.clear(); + this.currentViewId = null; + } + + /** + * Check if a view is registered + */ + public has(viewId: string): boolean { + return this.views.has(viewId); + } + + /** + * Get the number of registered views + */ + public get size(): number { + return this.views.size; + } +} diff --git a/ts_web/elements/interfaces/appconfig.ts b/ts_web/elements/interfaces/appconfig.ts new file mode 100644 index 0000000..8d12252 --- /dev/null +++ b/ts_web/elements/interfaces/appconfig.ts @@ -0,0 +1,189 @@ +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'; + +/** + * User configuration for the app bar + */ +export interface IAppUser { + name: string; + email?: string; + avatar?: string; + status?: 'online' | 'offline' | 'busy' | 'away'; +} + +/** + * View definition for the view registry + */ +export interface IViewDefinition { + /** Unique identifier for routing */ + id: string; + /** Display name */ + name: string; + /** Optional icon */ + iconName?: string; + /** The view content - can be a tag name, element class, or template function */ + content: string | (new () => 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) */ + route?: string; + /** Badge to show on menu item */ + badge?: string | number; + badgeVariant?: 'default' | 'success' | 'warning' | 'error'; +} + +/** + * Main menu section with view references + */ +export interface IMainMenuSection { + /** Section name (optional for ungrouped) */ + name?: string; + /** Views in this section (by ID reference) */ + views: string[]; +} + +/** + * Main menu configuration + */ +export interface IMainMenuConfig { + /** Menu sections with view references */ + sections?: IMainMenuSection[]; + /** Bottom pinned items (view IDs) */ + bottomItems?: string[]; +} + +/** + * App bar configuration + */ +export interface IAppBarConfig { + menuItems?: IAppBarMenuItem[]; + breadcrumbs?: string; + breadcrumbSeparator?: string; + showWindowControls?: boolean; + showSearch?: boolean; + user?: IAppUser; + profileMenuItems?: IAppBarMenuItem[]; +} + +/** + * Branding configuration + */ +export interface IBrandingConfig { + logoIcon?: string; + 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; + width?: number; +} + +/** + * Main unified configuration interface for dees-appui-base + */ +export interface IAppConfig { + /** Application branding */ + branding?: IBrandingConfig; + + /** App bar configuration */ + appBar?: IAppBarConfig; + + /** View definitions (the registry) */ + views: IViewDefinition[]; + + /** Main menu structure */ + mainMenu?: IMainMenuConfig; + + /** Routing configuration */ + routing?: IRoutingConfig; + + /** State persistence configuration */ + statePersistence?: IStatePersistenceConfig; + + /** Activity log configuration */ + activityLog?: IActivityLogConfig; + + /** Event callbacks (optional shorthand) */ + 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'; +} + +/** + * View change event detail + */ +export interface IViewChangeEvent { + viewId: string; + view: IViewDefinition; + previousView?: IViewDefinition; +} diff --git a/ts_web/elements/interfaces/index.ts b/ts_web/elements/interfaces/index.ts index 009fd4c..1536930 100644 --- a/ts_web/elements/interfaces/index.ts +++ b/ts_web/elements/interfaces/index.ts @@ -3,3 +3,4 @@ export * from './selectionoption.js'; export * from './appbarmenuitem.js'; export * from './menugroup.js'; export * from './secondarymenu.js'; +export * from './appconfig.js';