feat: Add profile dropdown component and integrate with appbar for user menu

This commit is contained in:
Juergen Kunz
2025-06-17 09:55:28 +00:00
parent cd3c7c8e63
commit a8f0e5659e
6 changed files with 482 additions and 17 deletions

View File

@ -11,11 +11,13 @@ import {
import * as domtools from '@design.estate/dees-domtools'; import * as domtools from '@design.estate/dees-domtools';
import * as interfaces from './interfaces/index.js'; import * as interfaces from './interfaces/index.js';
import * as plugins from './00plugins.js';
import { demoFunc } from './dees-appui-appbar.demo.js'; import { demoFunc } from './dees-appui-appbar.demo.js';
// Import required components // Import required components
import './dees-icon.js'; import './dees-icon.js';
import './dees-windowcontrols.js'; import './dees-windowcontrols.js';
import './dees-appui-profiledropdown.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@ -44,10 +46,14 @@ export class DeesAppuiBar extends DeesElement {
@property({ type: Object }) @property({ type: Object })
public user?: { public user?: {
name: string; name: string;
email?: string;
avatar?: string; avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away'; status?: 'online' | 'offline' | 'busy' | 'away';
}; };
@property({ type: Array })
public profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean }) @property({ type: Boolean })
public showSearch: boolean = false; public showSearch: boolean = false;
@ -64,6 +70,9 @@ export class DeesAppuiBar extends DeesElement {
@state() @state()
private focusedDropdownItem: number = -1; private focusedDropdownItem: number = -1;
@state()
private isProfileDropdownOpen: boolean = false;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
css` css`
@ -102,7 +111,7 @@ export class DeesAppuiBar extends DeesElement {
border-radius: 4px; border-radius: 4px;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
transition: all 0.2s ease; transition: all 0.2s ease;
cursor: pointer; cursor: default;
outline: none; outline: none;
display: flex; display: flex;
align-items: center; align-items: center;
@ -162,7 +171,7 @@ export class DeesAppuiBar extends DeesElement {
.dropdown-item { .dropdown-item {
padding: 8px 16px; padding: 8px 16px;
cursor: pointer; cursor: default;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@ -206,7 +215,7 @@ export class DeesAppuiBar extends DeesElement {
.breadcrumb-item { .breadcrumb-item {
color: ${cssManager.bdTheme('#00000080', '#ffffff80')}; color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
cursor: pointer; cursor: default;
transition: color 0.2s; transition: color 0.2s;
} }
@ -229,7 +238,7 @@ export class DeesAppuiBar extends DeesElement {
} }
.search-icon { .search-icon {
cursor: pointer; cursor: default;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
@ -242,7 +251,7 @@ export class DeesAppuiBar extends DeesElement {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
cursor: pointer; cursor: default;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
transition: background 0.2s; transition: background 0.2s;
@ -413,11 +422,12 @@ export class DeesAppuiBar extends DeesElement {
${this.showSearch ? html` ${this.showSearch ? html`
<dees-icon <dees-icon
class="search-icon" class="search-icon"
.iconName=${'search'} .icon=${'lucide:search'}
@click=${this.handleSearchClick} @click=${this.handleSearchClick}
></dees-icon> ></dees-icon>
` : ''} ` : ''}
${this.user ? html` ${this.user ? html`
<div style="position: relative;">
<div class="user-info" @click=${this.handleUserClick}> <div class="user-info" @click=${this.handleUserClick}>
<div class="user-avatar"> <div class="user-avatar">
${this.user.avatar ? ${this.user.avatar ?
@ -430,6 +440,14 @@ export class DeesAppuiBar extends DeesElement {
</div> </div>
<span>${this.user.name}</span> <span>${this.user.name}</span>
</div> </div>
<dees-appui-profiledropdown
.user=${this.user}
.menuItems=${this.profileMenuItems}
.isOpen=${this.isProfileDropdownOpen}
.position=${'top-right'}
@menu-select=${(e: CustomEvent) => this.handleProfileMenuSelect(e)}
></dees-appui-profiledropdown>
</div>
` : ''} ` : ''}
`; `;
} }
@ -536,12 +554,26 @@ export class DeesAppuiBar extends DeesElement {
} }
private handleUserClick() { private handleUserClick() {
this.isProfileDropdownOpen = !this.isProfileDropdownOpen;
// Also emit the event for backward compatibility
this.dispatchEvent(new CustomEvent('user-menu-open', { this.dispatchEvent(new CustomEvent('user-menu-open', {
bubbles: true, bubbles: true,
composed: 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 // Lifecycle
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
@ -564,6 +596,7 @@ export class DeesAppuiBar extends DeesElement {
// Close all dropdowns when clicking outside // Close all dropdowns when clicking outside
this.activeMenu = null; this.activeMenu = null;
this.focusedDropdownItem = -1; this.focusedDropdownItem = -1;
// Note: Profile dropdown handles its own outside clicks
} }
private handleDropdownKeydown(e: KeyboardEvent, items: interfaces.IAppBarMenuItem[], _parentId: string) { private handleDropdownKeydown(e: KeyboardEvent, items: interfaces.IAppBarMenuItem[], _parentId: string) {

View File

@ -3,6 +3,7 @@ import type { DeesAppuiBase } from './dees-appui-base.js';
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js'; import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
import type { ITab } from './interfaces/tab.js'; import type { ITab } from './interfaces/tab.js';
import type { ISelectionOption } from './interfaces/selectionoption.js'; import type { ISelectionOption } from './interfaces/selectionoption.js';
import * as plugins from './00plugins.js';
import '@design.estate/dees-wcctools/demotools'; import '@design.estate/dees-wcctools/demotools';
export const demoFunc = () => { export const demoFunc = () => {
@ -86,6 +87,17 @@ export const demoFunc = () => {
{ key: 'Metrics', iconName: 'lineChart', action: () => console.log('Metrics tab') }, { key: 'Metrics', iconName: 'lineChart', action: () => console.log('Metrics tab') },
]; ];
// Profile menu items
const profileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile settings') },
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account settings') },
{ divider: true },
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
];
return html` return html`
<dees-demowrapper> <dees-demowrapper>
<style> <style>
@ -106,8 +118,10 @@ export const demoFunc = () => {
.appbarBreadcrumbs=${'Dashboard'} .appbarBreadcrumbs=${'Dashboard'}
.appbarUser=${{ .appbarUser=${{
name: 'Jane Smith', name: 'Jane Smith',
email: 'jane.smith@example.com',
status: 'online' as 'online' | 'offline' | 'busy' | 'away' status: 'online' as 'online' | 'offline' | 'busy' | 'away'
}} }}
.appbarProfileMenuItems=${profileMenuItems}
.appbarShowWindowControls=${true} .appbarShowWindowControls=${true}
.appbarShowSearch=${true} .appbarShowSearch=${true}
.mainmenuTabs=${mainMenuTabs} .mainmenuTabs=${mainMenuTabs}
@ -117,6 +131,7 @@ export const demoFunc = () => {
@appbar-breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb:', e.detail)} @appbar-breadcrumb-navigate=${(e: CustomEvent) => console.log('Breadcrumb:', e.detail)}
@appbar-search-click=${() => console.log('Search clicked')} @appbar-search-click=${() => console.log('Search clicked')}
@appbar-user-menu-open=${() => console.log('User menu opened')} @appbar-user-menu-open=${() => console.log('User menu opened')}
@appbar-profile-menu-select=${(e: CustomEvent) => console.log('Profile menu selected:', e.detail)}
@mainmenu-tab-select=${(e: CustomEvent) => console.log('Tab selected:', e.detail)} @mainmenu-tab-select=${(e: CustomEvent) => console.log('Tab selected:', e.detail)}
@mainselector-option-select=${(e: CustomEvent) => console.log('Option selected:', e.detail)} @mainselector-option-select=${(e: CustomEvent) => console.log('Option selected:', e.detail)}
> >

View File

@ -9,6 +9,7 @@ import {
state, state,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as interfaces from './interfaces/index.js'; import * as interfaces from './interfaces/index.js';
import * as plugins from './00plugins.js';
import type { DeesAppuiBar } from './dees-appui-appbar.js'; import type { DeesAppuiBar } from './dees-appui-appbar.js';
import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js'; import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js'; import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
@ -44,10 +45,14 @@ export class DeesAppuiBase extends DeesElement {
@property({ type: Object }) @property({ type: Object })
public appbarUser?: { public appbarUser?: {
name: string; name: string;
email?: string;
avatar?: string; avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away'; status?: 'online' | 'offline' | 'busy' | 'away';
}; };
@property({ type: Array })
public appbarProfileMenuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean }) @property({ type: Boolean })
public appbarShowSearch: boolean = false; public appbarShowSearch: boolean = false;
@ -115,11 +120,13 @@ export class DeesAppuiBase extends DeesElement {
.breadcrumbSeparator=${this.appbarBreadcrumbSeparator} .breadcrumbSeparator=${this.appbarBreadcrumbSeparator}
.showWindowControls=${this.appbarShowWindowControls} .showWindowControls=${this.appbarShowWindowControls}
.user=${this.appbarUser} .user=${this.appbarUser}
.profileMenuItems=${this.appbarProfileMenuItems}
.showSearch=${this.appbarShowSearch} .showSearch=${this.appbarShowSearch}
@menu-select=${(e: CustomEvent) => this.handleAppbarMenuSelect(e)} @menu-select=${(e: CustomEvent) => this.handleAppbarMenuSelect(e)}
@breadcrumb-navigate=${(e: CustomEvent) => this.handleAppbarBreadcrumbNavigate(e)} @breadcrumb-navigate=${(e: CustomEvent) => this.handleAppbarBreadcrumbNavigate(e)}
@search-click=${() => this.handleAppbarSearchClick()} @search-click=${() => this.handleAppbarSearchClick()}
@user-menu-open=${() => this.handleAppbarUserMenuOpen()} @user-menu-open=${() => this.handleAppbarUserMenuOpen()}
@profile-menu-select=${(e: CustomEvent) => this.handleAppbarProfileMenuSelect(e)}
></dees-appui-appbar> ></dees-appui-appbar>
<div class="maingrid"> <div class="maingrid">
<dees-appui-mainmenu <dees-appui-mainmenu
@ -182,6 +189,14 @@ export class DeesAppuiBase extends DeesElement {
})); }));
} }
private handleAppbarProfileMenuSelect(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('appbar-profile-menu-select', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
// Event handlers for mainmenu // Event handlers for mainmenu
private handleMainmenuTabSelect(e: CustomEvent) { private handleMainmenuTabSelect(e: CustomEvent) {
this.mainmenuSelectedTab = e.detail.tab; this.mainmenuSelectedTab = e.detail.tab;

View File

@ -0,0 +1,401 @@
import * as plugins from './00plugins.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
state,
} from '@design.estate/dees-element';
@customElement('dees-appui-profiledropdown')
export class DeesAppuiProfileDropdown extends DeesElement {
public static demo = () => html`
<dees-appui-profiledropdown
.user=${{
name: 'John Doe',
email: 'john.doe@example.com',
avatar: 'https://randomuser.me/api/portraits/men/1.jpg',
status: 'online' as 'online'
}}
.menuItems=${[
{ name: 'Profile Settings', iconName: 'user', action: async () => console.log('Profile') },
{ name: 'Account', iconName: 'settings', action: async () => console.log('Account') },
{ divider: true },
{ name: 'Help & Support', iconName: 'helpCircle', action: async () => console.log('Help') },
{ name: 'Keyboard Shortcuts', iconName: 'keyboard', shortcut: 'Cmd+K', action: async () => console.log('Shortcuts') },
{ divider: true },
{ name: 'Sign Out', iconName: 'logOut', action: async () => console.log('Sign out') }
]}
.isOpen=${true}
></dees-appui-profiledropdown>
`;
@property({ type: Object })
public user?: {
name: string;
email?: string;
avatar?: string;
status?: 'online' | 'offline' | 'busy' | 'away';
};
@property({ type: Array })
public menuItems: (plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true })[] = [];
@property({ type: Boolean, reflect: true })
public isOpen: boolean = false;
@property({ type: String })
public position: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' = 'top-right';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: absolute;
top: 100%;
left: 0;
right: 0;
pointer-events: none;
}
.dropdown {
position: absolute;
min-width: 220px;
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)'
)};
z-index: 1000;
opacity: 0;
transform: scale(0.95) translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
overflow: hidden;
font-size: 12px;
}
:host([isopen]) .dropdown {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.backdrop {
display: none;
}
/* Position variants */
.dropdown.top-right {
top: 100%;
right: 0;
margin-top: 4px;
}
.dropdown.top-left {
top: 100%;
left: 0;
margin-top: 8px;
}
.dropdown.bottom-right {
bottom: 100%;
right: 0;
margin-bottom: 8px;
}
.dropdown.bottom-left {
bottom: 100%;
left: 0;
margin-bottom: 8px;
}
/* User section */
.user-section {
padding: 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
position: relative;
width: 36px;
height: 36px;
border-radius: 50%;
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#666', '#999')};
overflow: hidden;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-status {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
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;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#000', '#fff')};
line-height: 1.2;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 11px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Menu section */
.menu-section {
padding: 4px 0;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: default;
transition: background 0.1s;
color: ${cssManager.bdTheme('#333', '#ccc')};
font-size: 12px;
line-height: 1;
user-select: none;
}
.menu-item:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.08)')};
}
.menu-item:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
}
.menu-item dees-icon {
font-size: 14px;
opacity: 0.7;
}
.menu-item-text {
flex: 1;
}
.menu-shortcut {
font-size: 11px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-left: auto;
opacity: 0.7;
}
.menu-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
/* Backdrop for mobile */
@media (max-width: 768px) {
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 999;
opacity: 0;
transition: opacity 0.2s;
display: none;
}
:host([isopen]) .backdrop {
display: block;
opacity: 1;
pointer-events: auto;
}
.dropdown {
position: fixed;
top: 50%;
left: 50%;
right: auto;
bottom: auto;
transform: translate(-50%, -50%) scale(0.95);
margin: 0;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow-y: auto;
}
:host([isopen]) .dropdown {
transform: translate(-50%, -50%) scale(1);
}
}
`,
];
public render(): TemplateResult {
return html`
<div class="backdrop" @click=${() => this.close()}></div>
<div class="dropdown ${this.position}">
${this.user ? html`
<div class="user-section">
<div class="user-info">
<div class="user-avatar">
${this.user.avatar
? html`<img src="${this.user.avatar}" alt="${this.user.name}">`
: this.getInitials(this.user.name)
}
${this.user.status ? html`
<div class="user-status ${this.user.status}"></div>
` : ''}
</div>
<div class="user-details">
<div class="user-name">${this.user.name}</div>
${this.user.email ? html`
<div class="user-email">${this.user.email}</div>
` : ''}
</div>
</div>
</div>
` : ''}
<div class="menu-section">
${this.menuItems.map(item => this.renderMenuItem(item))}
</div>
</div>
`;
}
private renderMenuItem(item: plugins.tsclass.website.IMenuItem & { shortcut?: string } | { divider: true }): TemplateResult {
if ('divider' in item && item.divider) {
return html`<div class="menu-divider"></div>`;
}
const menuItem = item as plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string };
return html`
<div class="menu-item" @click=${() => this.handleMenuClick(menuItem)}>
${menuItem.iconName ? html`
<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>
` : ''}
<span class="menu-item-text">${menuItem.name}</span>
${menuItem.shortcut ? html`
<span class="menu-shortcut">${menuItem.shortcut}</span>
` : ''}
</div>
`;
}
private getInitials(name: string): string {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
private async handleMenuClick(item: plugins.tsclass.website.IMenuItem & { iconName?: string; shortcut?: string }) {
await item.action();
this.close();
// Emit menu-select event
this.dispatchEvent(new CustomEvent('menu-select', {
detail: { item },
bubbles: true,
composed: true
}));
}
public open() {
this.isOpen = true;
}
public close() {
this.isOpen = false;
}
public toggle() {
this.isOpen = !this.isOpen;
}
// Handle clicks outside the dropdown
async connectedCallback() {
await super.connectedCallback();
this.handleOutsideClick = this.handleOutsideClick.bind(this);
document.addEventListener('click', this.handleOutsideClick);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleOutsideClick);
}
private handleOutsideClick(event: MouseEvent) {
if (this.isOpen && !this.contains(event.target as Node)) {
// Check if the click is on the parent element (which contains the profile button)
const parentElement = this.parentElement;
if (parentElement && parentElement.contains(event.target as Node)) {
// Don't close if clicking within the parent element (e.g., on the profile button)
return;
}
this.close();
}
}
}

View File

@ -78,7 +78,7 @@ export class DeesAppuiTabs extends DeesElement {
.tab { .tab {
color: ${cssManager.bdTheme('#666', '#a0a0a0')}; color: ${cssManager.bdTheme('#666', '#a0a0a0')};
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: default;
transition: color 0.1s; transition: color 0.1s;
} }

View File

@ -4,6 +4,7 @@ export * from './dees-appui-base.js';
export * from './dees-appui-maincontent.js'; export * from './dees-appui-maincontent.js';
export * from './dees-appui-mainmenu.js'; export * from './dees-appui-mainmenu.js';
export * from './dees-appui-mainselector.js'; export * from './dees-appui-mainselector.js';
export * from './dees-appui-profiledropdown.js';
export * from './dees-appui-tabs.js'; export * from './dees-appui-tabs.js';
export * from './dees-appui-view.js'; export * from './dees-appui-view.js';
export * from './dees-badge.js'; export * from './dees-badge.js';