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