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