import { DeesElement, type TemplateResult, property, customElement, html, css, cssManager, state, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import * as interfaces from '../../interfaces/index.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'; import type { DeesAppuiMaincontent } from '../dees-appui-maincontent/dees-appui-maincontent.js'; import type { DeesAppuiActivitylog } from '../dees-appui-activitylog/dees-appui-activitylog.js'; import { demoFunc } from './dees-appui-base.demo.js'; // View registry for managing views import { ViewRegistry } from './view.registry.js'; // Import child components import '../dees-appui-appbar/index.js'; import '../dees-appui-mainmenu/dees-appui-mainmenu.js'; 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[] = []; @property({ type: String }) accessor appbarBreadcrumbs: string = ''; @property({ type: String }) accessor appbarBreadcrumbSeparator: string = ' > '; @property({ type: Boolean }) accessor appbarShowWindowControls: boolean = true; @property({ type: Object }) accessor appbarUser: interfaces.IAppUser | undefined = undefined; @property({ type: Array }) accessor appbarProfileMenuItems: interfaces.IAppBarMenuItem[] = []; @property({ type: Boolean }) accessor appbarShowSearch: boolean = false; // Properties for mainmenu @property({ type: String }) accessor mainmenuLogoIcon: string = ''; @property({ type: String }) accessor mainmenuLogoText: string = ''; @property({ type: Array }) accessor mainmenuGroups: interfaces.IMenuGroup[] = []; @property({ type: Array }) accessor mainmenuBottomTabs: interfaces.ITab[] = []; @property({ type: Array }) accessor mainmenuTabs: interfaces.ITab[] = []; @property({ type: Object }) accessor mainmenuSelectedTab: interfaces.ITab | undefined = undefined; // Properties for secondarymenu @property({ type: String }) accessor secondarymenuHeading: string = ''; @property({ type: Array }) accessor secondarymenuGroups: interfaces.ISecondaryMenuGroup[] = []; @property({ type: Object }) accessor secondarymenuSelectedItem: interfaces.ISecondaryMenuItem | undefined = undefined; // Collapse states @property({ type: Boolean }) accessor mainmenuCollapsed: boolean = false; @property({ type: Boolean }) accessor secondarymenuCollapsed: boolean = false; // Properties for maincontent @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; @state() accessor mainmenu: DeesAppuiMainmenu | undefined = undefined; @state() accessor secondarymenu: DeesAppuiSecondarymenu | undefined = undefined; @state() accessor maincontent: DeesAppuiMaincontent | undefined = undefined; @state() accessor activitylogElement: DeesAppuiActivitylog | undefined = undefined; // Current view state @state() accessor currentView: interfaces.IViewDefinition | undefined = undefined; // Internal services private viewRegistry: ViewRegistry = new ViewRegistry(); private routerCleanup: (() => void) | null = null; private searchCallback: ((query: string) => void) | null = null; public static styles = [ cssManager.defaultStyles, css` :host { position: absolute; height: 100%; width: 100%; background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')}; } .maingrid { position: absolute; top: 40px; height: calc(100% - 40px); width: 100%; display: grid; grid-template-columns: auto auto 1fr 240px; grid-template-rows: 1fr; } /* Z-index layering for proper stacking */ .maingrid > dees-appui-mainmenu { position: relative; z-index: 3; } .maingrid > dees-appui-secondarymenu { position: relative; z-index: 2; } .maingrid > dees-appui-maincontent { position: relative; z-index: 1; } .maingrid > dees-appui-activitylog { position: relative; z-index: 1; } /* View container for dynamically loaded views */ .view-container { display: contents; } .view-container:empty { display: none; } `, ]; 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)} >
this.handleMainmenuTabSelect(e)} @collapse-change=${(e: CustomEvent) => this.handleMainmenuCollapseChange(e)} > this.handleSecondarymenuItemSelect(e)} @collapse-change=${(e: CustomEvent) => this.handleSecondarymenuCollapseChange(e)} > this.handleContentTabSelect(e)} >
`; } async firstUpdated() { // Get references to child components 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; // Set appui reference in view registry for lifecycle context this.viewRegistry.setAppuiRef(this as unknown as interfaces.TDeesAppuiBase); } async disconnectedCallback() { await super.disconnectedCallback(); // Clean up router listener if (this.routerCleanup) { this.routerCleanup(); this.routerCleanup = null; } // Complete subjects this.viewLifecycle$.complete(); this.viewChanged$.complete(); } // ========================================== // PROGRAMMATIC API: APP BAR // ========================================== /** * Set the app bar menu items (File, Edit, View, etc.) */ 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 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; } // 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; } } /** * Get the current view */ public getCurrentView(): interfaces.IViewDefinition | undefined { return this.currentView; } /** * Get access to the view registry (for advanced use) */ public getViewRegistry(): ViewRegistry { return this.viewRegistry; } // ========================================== // UNIFIED CONFIGURATION // ========================================== /** * Configure the app shell with a unified config object */ public configure(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 or direct config if (config.mainMenu) { 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); } } // Setup domtools.router integration this.setupRouterIntegration(config); // Bind event callbacks if (config.onViewChange) { this.viewChanged$.subscribe((event) => { config.onViewChange!(event.viewId, event.view); }); } if (config.onSearch) { this.searchCallback = config.onSearch; } // Navigate to default view if (config.defaultView) { this.navigateToView(config.defaultView); } } // ========================================== // 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) => ({ 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.id, iconName: view.iconName, action: () => this.navigateToView(viewId), badge: view.badge, } as interfaces.ITab; }) .filter(Boolean) as interfaces.ITab[], })); } private buildBottomTabsFromItems(items: string[]): interfaces.ITab[] { return items .map((viewId) => { const view = this.viewRegistry.get(viewId); if (!view) { console.warn(`View "${viewId}" not found in registry`); return null; } return { key: view.id, iconName: view.iconName, action: () => this.navigateToView(viewId), } as interfaces.ITab; }) .filter(Boolean) as interfaces.ITab[]; } private async loadView( view: interfaces.IViewDefinition, params?: Record ): Promise { const previousView = this.currentView; this.currentView = view; // 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; } // Apply view-specific content tabs if (view.contentTabs) { this.maincontentTabs = view.contentTabs; } // Update main menu selection this.setMainMenuSelection(view.id); // Emit view change event const changeEvent: interfaces.IViewChangeEvent = { viewId: view.id, view, previousView, params, }; this.viewChanged$.next(changeEvent); // Also dispatch DOM event for backwards compatibility this.dispatchEvent( new CustomEvent('view-change', { 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 })); } }