import { DeesElement, type TemplateResult, customElement, property, state, html, css, cssManager, } 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 { demoFunc } from './dees-appui-appbar.demo.js'; // Import required components import './dees-icon.js'; import './dees-windowcontrols.js'; import './dees-appui-profiledropdown.js'; declare global { interface HTMLElementTagNameMap { 'dees-appui-appbar': DeesAppuiBar; } } @customElement('dees-appui-appbar') export class DeesAppuiBar extends DeesElement { public static demo = demoFunc; // INSTANCE PROPERTIES @property({ type: Array }) public menuItems: interfaces.IAppBarMenuItem[] = []; @property({ type: String }) public breadcrumbs: string = ''; @property({ type: String }) public breadcrumbSeparator: string = ' > '; @property({ type: Boolean }) public showWindowControls: boolean = true; @property({ type: Object }) public user?: { name: string; email?: string; avatar?: string; status?: 'online' | 'offline' | 'busy' | 'away'; }; @property({ type: Array }) public profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = []; @property({ type: Boolean }) public showSearch: boolean = false; // STATE @state() private activeMenu: string | null = null; @state() private openDropdowns: Set = new Set(); @state() private focusedItem: string | null = null; @state() private focusedDropdownItem: number = -1; @state() private isProfileDropdownOpen: boolean = false; public static styles = [ cssManager.defaultStyles, css` :host { /* CSS Variables for theming */ --appbar-height: 40px; --appbar-font-size: 12px; display: block; position: relative; width: 100%; height: var(--appbar-height); border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; background: ${cssManager.bdTheme('#ffffff', '#000000')}; color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; font-size: var(--appbar-font-size); display: grid; grid-template-columns: ${cssManager.cssGridColumns(3, 20)}; -webkit-app-region: drag; user-select: none; } .menus { display: flex; align-items: center; gap: 4px; padding: 0 8px; cursor: default; } .menuItem { position: relative; line-height: 24px; padding: 0px 12px; margin: 8px 0px; border-radius: 4px; -webkit-app-region: no-drag; transition: all 0.2s ease; cursor: default; outline: none; display: flex; align-items: center; gap: 4px; } /* Optional: Style for menu items with icons (not typically used for top-level items) */ .menuItem dees-icon { font-size: 14px; opacity: 0.8; } .menuItem:hover { background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; color: ${cssManager.bdTheme('#000000', '#ffffff')}; } .menuItem.active { background: ${cssManager.bdTheme('#00000020', '#ffffff30')}; color: ${cssManager.bdTheme('#000000', '#ffffff')}; } .menuItem[disabled] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } .menuItem:focus-visible { box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')}; } /* Dropdown styles */ .dropdown { position: absolute; top: 100%; left: 0; min-width: 200px; background: ${cssManager.bdTheme('#ffffff', '#000000')}; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')}; border-radius: 4px; box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')}; margin-top: 4px; z-index: 1000; opacity: 0; transform: translateY(-10px); transition: opacity 0.2s, transform 0.2s; pointer-events: none; } .dropdown.open { opacity: 1; transform: translateY(0); pointer-events: auto; } .dropdown-item { padding: 8px 16px; cursor: default; display: flex; align-items: center; gap: 8px; transition: background 0.1s; } .dropdown-item:hover, .dropdown-item.focused { background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; } .dropdown-divider { height: 1px; background: ${cssManager.bdTheme('#e0e0e0', '#202020')}; margin: 4px 0; } .dropdown-item[disabled] { opacity: 0.5; cursor: not-allowed; pointer-events: none; } .dropdown-item .shortcut { margin-left: auto; opacity: 0.6; font-size: 11px; } /* Breadcrumbs */ .breadcrumbs { display: flex; align-items: center; justify-content: center; height: 100%; padding: 0 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .breadcrumb-item { color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; cursor: default; transition: color 0.2s; } .breadcrumb-item:hover { color: ${cssManager.bdTheme('#000000', '#ffffff')}; } .breadcrumb-separator { margin: 0 8px; opacity: 0.5; } /* Account section */ .account { display: flex; align-items: center; justify-content: flex-end; padding: 0 16px; gap: 12px; } .search-icon { cursor: default; opacity: 0.7; transition: opacity 0.2s; } .search-icon:hover { opacity: 1; } .user-info { display: flex; align-items: center; gap: 8px; cursor: default; padding: 4px 8px; border-radius: 4px; transition: background 0.2s; } .user-info:hover { background: ${cssManager.bdTheme('#00000010', '#ffffff20')}; } .user-avatar { position: relative; width: 24px; height: 24px; border-radius: 50%; background: ${cssManager.bdTheme('#00000020', '#ffffff30')}; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; } .user-avatar img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; } .user-status { position: absolute; bottom: -2px; right: -2px; width: 8px; height: 8px; border-radius: 50%; border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')}; } .user-status.online { background: #4caf50; } .user-status.offline { background: #757575; } .user-status.busy { background: #f44336; } .user-status.away { background: #ff9800; } `, ]; // INSTANCE public render(): TemplateResult { return html`
${this.renderAccountSection()}
`; } private renderMenuItems(): TemplateResult { return html` ${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))} `; } private renderMenuItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult { if ('divider' in item && item.divider) { return html``; } const menuItem = item as interfaces.IAppBarMenuItemRegular; const isActive = this.activeMenu === itemId; const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0; return html` `; } private renderDropdown(items: interfaces.IAppBarMenuItem[], parentId: string, isOpen: boolean): TemplateResult { return html` `; } private renderDropdownItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult { if ('divider' in item && item.divider) { return html``; } const menuItem = item as interfaces.IAppBarMenuItemRegular; const itemIndex = parseInt(itemId.split('-').pop() || '0'); const isFocused = this.focusedDropdownItem === itemIndex; return html` `; } private renderBreadcrumbs(): TemplateResult { if (!this.breadcrumbs) { return html``; } const parts = this.breadcrumbs.split(this.breadcrumbSeparator); return html` ${parts.map((part, index) => html` ${index > 0 ? html`${this.breadcrumbSeparator}` : ''} this.handleBreadcrumbClick(part, index)} > ${part} `)} `; } private renderAccountSection(): TemplateResult { return html` ${this.showSearch ? html` ` : ''} ${this.user ? html`
this.handleProfileMenuSelect(e)} >
` : ''} `; } // Event handlers private handleMenuClick(item: interfaces.IAppBarMenuItemRegular, itemId: string) { if (item.disabled) return; if (item.submenu && item.submenu.length > 0) { // Toggle dropdown if (this.activeMenu === itemId) { this.activeMenu = null; } else { this.activeMenu = itemId; } } else { // Execute action this.activeMenu = null; if (item.action) { item.action(); } this.dispatchEvent(new CustomEvent('menu-select', { detail: { item }, bubbles: true, composed: true })); } } private handleDropdownItemClick(item: interfaces.IAppBarMenuItemRegular) { if (item.disabled) return; this.activeMenu = null; if (item.action) { item.action(); } this.dispatchEvent(new CustomEvent('menu-select', { detail: { item }, bubbles: true, composed: true })); } private handleMenuKeydown(e: KeyboardEvent, item: interfaces.IAppBarMenuItemRegular, itemId: string) { switch (e.key) { case 'Enter': case ' ': e.preventDefault(); this.handleMenuClick(item, itemId); break; case 'ArrowDown': if (item.submenu && this.activeMenu === itemId) { e.preventDefault(); // Focus first non-disabled item in dropdown this.focusedDropdownItem = 0; const firstValidItem = this.findNextValidItem(item.submenu, -1, 1); if (firstValidItem !== -1) { this.focusedDropdownItem = firstValidItem; // Focus the dropdown element setTimeout(() => { const dropdown = this.renderRoot.querySelector('.dropdown.open'); if (dropdown) { (dropdown as HTMLElement).focus(); } }, 0); } } break; case 'Escape': this.activeMenu = null; this.focusedDropdownItem = -1; break; case 'Tab': // Let default tab navigation work but close dropdown if (this.activeMenu === itemId) { this.activeMenu = null; this.focusedDropdownItem = -1; } break; case 'ArrowRight': e.preventDefault(); this.focusNextMenuItem(itemId, 1); break; case 'ArrowLeft': e.preventDefault(); this.focusNextMenuItem(itemId, -1); break; } } private handleBreadcrumbClick(breadcrumb: string, index: number) { this.dispatchEvent(new CustomEvent('breadcrumb-navigate', { detail: { breadcrumb, index }, bubbles: true, composed: true })); } private handleSearchClick() { this.dispatchEvent(new CustomEvent('search-click', { bubbles: true, composed: true })); } private handleUserClick() { this.isProfileDropdownOpen = !this.isProfileDropdownOpen; // Also emit the event for backward compatibility this.dispatchEvent(new CustomEvent('user-menu-open', { bubbles: true, composed: true })); } private handleProfileMenuSelect(e: CustomEvent) { this.isProfileDropdownOpen = false; // Re-emit the event this.dispatchEvent(new CustomEvent('profile-menu-select', { detail: e.detail, bubbles: true, composed: true })); } // Lifecycle async connectedCallback() { await super.connectedCallback(); // Add global click listener to close dropdowns this.addEventListener('click', this.handleGlobalClick); document.addEventListener('click', this.handleDocumentClick); } async disconnectedCallback() { await super.disconnectedCallback(); document.removeEventListener('click', this.handleDocumentClick); } private handleGlobalClick = (e: Event) => { // Prevent closing when clicking inside e.stopPropagation(); } private handleDocumentClick = () => { // Close all dropdowns when clicking outside this.activeMenu = null; this.focusedDropdownItem = -1; // Note: Profile dropdown handles its own outside clicks } private handleDropdownKeydown(e: KeyboardEvent, items: interfaces.IAppBarMenuItem[], _parentId: string) { const validItems = items.filter(item => !('divider' in item && item.divider)); switch (e.key) { case 'ArrowDown': e.preventDefault(); const nextIndex = this.findNextValidItem(items, this.focusedDropdownItem, 1); if (nextIndex !== -1) { this.focusedDropdownItem = nextIndex; } break; case 'ArrowUp': e.preventDefault(); const prevIndex = this.findNextValidItem(items, this.focusedDropdownItem, -1); if (prevIndex !== -1) { this.focusedDropdownItem = prevIndex; } break; case 'Enter': e.preventDefault(); if (this.focusedDropdownItem !== -1) { const focusedItem = validItems[this.focusedDropdownItem]; if (focusedItem && 'action' in focusedItem && !focusedItem.disabled) { this.handleDropdownItemClick(focusedItem as interfaces.IAppBarMenuItemRegular); } } break; case 'Home': e.preventDefault(); const firstIndex = this.findNextValidItem(items, -1, 1); if (firstIndex !== -1) { this.focusedDropdownItem = firstIndex; } break; case 'End': e.preventDefault(); const lastIndex = this.findNextValidItem(items, items.length, -1); if (lastIndex !== -1) { this.focusedDropdownItem = lastIndex; } break; case 'Escape': e.preventDefault(); this.activeMenu = null; this.focusedDropdownItem = -1; // Return focus to menu item const menuItem = this.renderRoot.querySelector(`.menuItem.active`); if (menuItem) { (menuItem as HTMLElement).focus(); } break; } } private findNextValidItem(items: interfaces.IAppBarMenuItem[], currentIndex: number, direction: number): number { let index = currentIndex + direction; while (index >= 0 && index < items.length) { const item = items[index]; // Skip dividers and disabled items if (!('divider' in item && item.divider) && !('disabled' in item && item.disabled)) { return index; } index += direction; } return -1; } private focusNextMenuItem(currentItemId: string, direction: number) { const menuItems = Array.from(this.renderRoot.querySelectorAll('.menuItem')); const currentIndex = menuItems.findIndex(item => item.getAttribute('data-item-id') === currentItemId); if (currentIndex === -1) return; let nextIndex = currentIndex + direction; // Wrap around if (nextIndex < 0) { nextIndex = menuItems.length - 1; } else if (nextIndex >= menuItems.length) { nextIndex = 0; } // Find next non-disabled item let attempts = 0; while (attempts < menuItems.length) { const nextItem = menuItems[nextIndex] as HTMLElement; if (!nextItem.hasAttribute('disabled')) { nextItem.focus(); // Close current dropdown if open if (this.activeMenu) { this.activeMenu = null; this.focusedDropdownItem = -1; } break; } nextIndex = (nextIndex + direction + menuItems.length) % menuItems.length; attempts++; } } }