Files

22 KiB

DeesAppui

A comprehensive application shell component providing a complete UI framework with navigation, menus, activity logging, and view management. 🚀

Quick Start

import { html, DeesElement, customElement } from '@design.estate/dees-element';
import { DeesAppui } from '@design.estate/dees-catalog';

@customElement('my-app')
class MyApp extends DeesElement {
  private appui: DeesAppui;

  async firstUpdated() {
    this.appui = this.shadowRoot.querySelector('dees-appui');

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

Architecture Overview

The DeesAppui shell consists of several interconnected components:

┌─────────────────────────────────────────────────────────────────────┐
│  AppBar (dees-appui-appbar)                                         │
│  ├── Menus (File, Edit, View...)                                    │
│  ├── Breadcrumbs                                                    │
│  ├── User Profile + Dropdown                                        │
│  └── Activity Log Toggle                                            │
├─────────────┬───────────────────────────────────┬───────────────────┤
│ Main Menu   │  Content Area                     │  Activity Log     │
│ (collapsed/ │  ├── Content Tabs                 │  (slide panel)    │
│  expanded)  │  │   (closable, from tables/lists)│                   │
│             │  └── View Container               │                   │
│ ┌─────────┐ │      └── Active View              │                   │
│ │ 🏠 Home │ ├─────────────────────────────────┐ │                   │
│ │ 📁 Files│ │ Secondary Menu                  │ │                   │
│ │ ⚙ Settings ├── Collapsible Groups            │ │                   │
│ │         │ │   ├── Item 1                    │ │                   │
│ └─────────┘ │   ├── Item 2 (with badge)       │ │                   │
│             │   └── Item 3                    │ │                   │
└─────────────┴─────────────────────────────────┴───────────────────────┘

Configuration API

configure(config: IAppConfig)

Configure the entire application shell with a single configuration object.

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

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.

// 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.

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

// Visibility control
appui.setMainMenuCollapsed(true);     // Collapse to icon-only sidebar
appui.setMainMenuVisible(false);       // Hide completely

// Badges
appui.setMainMenuBadge('inbox', 12);
appui.clearMainMenuBadge('inbox');

Secondary Menu API 📋

The secondary menu is a contextual sidebar that appears next to the main content area. It supports collapsible groups with icons and badges, making it perfect for:

  • Settings pages (grouped settings categories)
  • File browsers (folder trees)
  • Project navigation (grouped by category)
  • Documentation (chapters/sections)

Collapsible Groups

Groups can be collapsed/expanded by clicking the group header. The state is visually indicated with an icon rotation.

// Set secondary menu with collapsible groups
appui.setSecondaryMenu({
  heading: 'Settings',
  groups: [
    {
      name: 'Account',
      iconName: 'lucide:user',        // Group icon
      collapsed: false,                // Initial state (default: false)
      items: [
        { key: 'profile', iconName: 'lucide:user', action: () => showProfile() },
        { key: 'security', iconName: 'lucide:shield', badge: '!', badgeVariant: 'warning', action: () => showSecurity() },
        { key: 'billing', iconName: 'lucide:credit-card', action: () => showBilling() }
      ]
    },
    {
      name: 'Preferences',
      iconName: 'lucide:settings',
      collapsed: true,                 // Start collapsed
      items: [
        { key: 'notifications', iconName: 'lucide:bell', action: () => {} },
        { key: 'appearance', iconName: 'lucide:palette', action: () => {} },
        { key: 'language', iconName: 'lucide:globe', action: () => {} }
      ]
    }
  ]
});

Secondary Menu Item Properties

interface ISecondaryMenuItem {
  key: string;                    // Unique identifier
  iconName?: string;              // Icon (e.g., 'lucide:user')
  action: () => void;             // Click handler
  badge?: string | number;        // Badge text/count
  badgeVariant?: 'default' | 'success' | 'warning' | 'error';
}

interface ISecondaryMenuGroup {
  name: string;                   // Group name (shown in header)
  iconName?: string;              // Group icon
  collapsed?: boolean;            // Initial collapsed state
  items: ISecondaryMenuItem[];    // Items in this group
}

Updating Secondary Menu

// Update a specific group
appui.updateSecondaryMenuGroup('Account', {
  items: [...newItems]
});

// Add item to a group
appui.addSecondaryMenuItem('Account', {
  key: 'api-keys',
  iconName: 'lucide:key',
  action: () => showApiKeys()
});

// Selection (highlights the item)
appui.setSecondaryMenuSelection('profile');

// Visibility control
appui.setSecondaryMenuCollapsed(true);   // Collapse panel
appui.setSecondaryMenuVisible(false);     // Hide completely

// Clear
appui.clearSecondaryMenu();

View-Specific Secondary Menus

Each view can define its own secondary menu that appears when the view is activated:

// In view definition
{
  id: 'settings',
  name: 'Settings',
  content: 'my-settings-view',
  secondaryMenu: [
    {
      name: 'General',
      items: [
        { key: 'account', iconName: 'lucide:user', action: () => {} },
        { key: 'security', iconName: 'lucide:shield', action: () => {} }
      ]
    }
  ]
}

// Or set dynamically in view's onActivate hook
onActivate(context: IViewActivationContext) {
  context.appui.setSecondaryMenu({
    heading: 'Project Files',
    groups: [...]
  });
}

Content Tabs API 📑

Content tabs appear above the main view content. They're designed for opening multiple items from tables, lists, or other data sources—similar to browser tabs or IDE editor tabs.

Common Use Cases

  • Table row details - Click a row to open it as a tab
  • Document editing - Open multiple documents
  • Entity inspection - View customer, order, product details
  • Multi-file editing - Edit multiple configuration files

Closable Tabs

Tabs can be closable, allowing users to open items, work with them, and close when done:

// Set initial tabs
appui.setContentTabs([
  { key: 'overview', iconName: 'lucide:home', action: () => showOverview() },
  { key: 'activity', iconName: 'lucide:activity', action: () => showActivity() }
]);

// Add a closable tab when user clicks a table row
table.addEventListener('row-click', (e) => {
  const item = e.detail.item;

  appui.addContentTab({
    key: `item-${item.id}`,
    label: item.name,                      // Display label
    iconName: 'lucide:file',
    closable: true,                        // Allow closing
    action: () => showItemDetails(item)
  });

  // Select the new tab
  appui.selectContentTab(`item-${item.id}`);
});

// Handle tab close
appui.addEventListener('tab-close', (e) => {
  const tabKey = e.detail.key;
  // Cleanup resources if needed
  console.log(`Tab ${tabKey} closed`);
});

Tab Management

// Add/remove tabs
appui.addContentTab({
  key: 'debug',
  iconName: 'lucide:bug',
  closable: true,
  action: () => {}
});
appui.removeContentTab('debug');

// Select tab
appui.selectContentTab('preview');

// Get current tab
const current = appui.getSelectedContentTab();

// Visibility control
appui.setContentTabsVisible(false);        // Hide tab bar

// Auto-hide when only one tab
appui.setContentTabsAutoHide(true, 1);     // Hide when ≤ 1 tab

Opening Items from Tables/Lists

A common pattern is opening table rows as closable tabs:

@customElement('my-customers-view')
class MyCustomersView extends DeesElement {
  private appui: DeesAppui;

  onActivate(context: IViewActivationContext) {
    this.appui = context.appui;

    // Set base tabs
    this.appui.setContentTabs([
      { key: 'list', label: 'All Customers', iconName: 'lucide:users', action: () => this.showList() }
    ]);
  }

  render() {
    return html`
      <dees-table
        .data=${this.customers}
        @row-dblclick=${this.openCustomerTab}
      ></dees-table>
    `;
  }

  openCustomerTab(e: CustomEvent) {
    const customer = e.detail.item;
    const tabKey = `customer-${customer.id}`;

    // Check if tab already exists
    const existingTab = this.appui.getSelectedContentTab();
    if (existingTab?.key === tabKey) {
      return; // Already viewing this customer
    }

    // Add new closable tab
    this.appui.addContentTab({
      key: tabKey,
      label: customer.name,
      iconName: 'lucide:user',
      closable: true,
      action: () => this.showCustomerDetails(customer)
    });

    this.appui.selectContentTab(tabKey);
  }

  showCustomerDetails(customer: Customer) {
    // Render customer details
    this.currentView = html`<customer-details .customer=${customer}></customer-details>`;
  }

  showList() {
    this.currentView = html`<dees-table ...></dees-table>`;
  }
}

Activity Log API 📊

The activity log is a slide-out panel on the right side showing user actions and system events.

Activity Log Toggle

The appbar includes a toggle button with a badge showing the entry count:

// Control visibility
appui.setActivityLogVisible(true);     // Show panel
appui.toggleActivityLog();             // Toggle state
const isVisible = appui.getActivityLogVisible();

// The toggle button automatically shows entry count
// Add entries and the badge updates automatically

Adding Entries

// 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 entries (e.g., from backend)
appui.activityLog.addMany([...entries]);

// Clear all entries
appui.activityLog.clear();

// Query entries
const entries = appui.activityLog.getEntries();
const filtered = appui.activityLog.filter({ user: 'John', type: 'create' });
const searched = appui.activityLog.search('invoice');

Activity Entry Types

Each type has a default icon that can be overridden:

Type Default Icon Use Case
login lucide:log-in User sign-in
logout lucide:log-out User sign-out
view lucide:eye Page/item viewed
create lucide:plus New item created
update lucide:pencil Item modified
delete lucide:trash Item deleted
custom lucide:activity Custom events

Navigation API

Navigate between views programmatically.

// 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.

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

interface IViewActivationContext {
  appui: DeesAppui;           // Reference to the app shell
  viewId: string;                  // The view ID being activated
  params?: Record<string, string>; // Route parameters
}

Routing

Routes are automatically registered from view definitions using domtools.router.

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.

// 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.

{
  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.

// View lifecycle events
appui.viewLifecycle$.subscribe((event) => {
  // event.type: 'loading' | 'activated' | 'deactivated' | 'loaded' | 'loadError'
  // event.viewId: string
  // event.element?: HTMLElement
  // event.params?: Record<string, string>
  // event.error?: unknown
});

// View change events
appui.viewChanged$.subscribe((event) => {
  // event.viewId: string
  // event.view: IViewDefinition
  // event.previousView?: IViewDefinition
  // event.params?: Record<string, string>
});

Complete Example

import { html, DeesElement, customElement } from '@design.estate/dees-element';
import { DeesAppui, IViewActivationContext } from '@design.estate/dees-catalog';

@customElement('my-app')
class MyApp extends DeesElement {
  private appui: DeesAppui;

  async firstUpdated() {
    this.appui = this.shadowRoot.querySelector('dees-appui');

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

// View with lifecycle hooks
@customElement('crm-settings')
class CrmSettings extends DeesElement {
  private appui: DeesAppui;

  onActivate(context: IViewActivationContext) {
    this.appui = context.appui;

    // Set secondary menu for settings
    this.appui.setSecondaryMenu({
      heading: 'Settings',
      groups: [
        {
          name: 'Account',
          iconName: 'lucide:user',
          items: [
            { key: 'profile', iconName: 'lucide:user', action: () => this.showSection('profile') },
            { key: 'security', iconName: 'lucide:shield', action: () => this.showSection('security') }
          ]
        },
        {
          name: 'Preferences',
          iconName: 'lucide:settings',
          collapsed: true,
          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
  • ISecondaryMenuItem - Secondary menu item
  • IMenuItem - Tab/menu item definition