- Implemented DeesInputFileupload component with file upload functionality, including drag-and-drop support, file previews, and clear all option. - Developed DeesInputRichtext component featuring a rich text editor with a formatting toolbar, link management, and word count display. - Created demo for DeesInputRichtext showcasing various use cases including basic editing, placeholder text, different heights, and disabled state. - Added styles for both components to ensure a consistent and user-friendly interface. - Introduced types for toolbar buttons in the rich text editor for better type safety and maintainability.
		
			
				
	
	
		
			461 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			461 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {
 | |
|   DeesElement,
 | |
|   type TemplateResult,
 | |
|   customElement,
 | |
|   property,
 | |
|   state,
 | |
|   html,
 | |
| } 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 './demo.js';
 | |
| import { appuiAppbarStyles } from './styles.js';
 | |
| import { renderAppuiAppbar } from './template.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<string> = new Set();
 | |
| 
 | |
|   @state()
 | |
|   private focusedItem: string | null = null;
 | |
| 
 | |
|   @state()
 | |
|   private focusedDropdownItem: number = -1;
 | |
| 
 | |
|   @state()
 | |
|   private isProfileDropdownOpen: boolean = false;
 | |
| 
 | |
|   public static styles = appuiAppbarStyles;
 | |
| 
 | |
|   // INSTANCE
 | |
|   public render(): TemplateResult {
 | |
|     return renderAppuiAppbar(this);
 | |
|   }
 | |
| 
 | |
| 
 | |
| 
 | |
|   public 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`<div class="dropdown-divider"></div>`;
 | |
|     }
 | |
| 
 | |
|     const menuItem = item as interfaces.IAppBarMenuItemRegular;
 | |
|     const isActive = this.activeMenu === itemId;
 | |
|     const hasSubmenu = menuItem.submenu && menuItem.submenu.length > 0;
 | |
| 
 | |
|     return html`
 | |
|       <div
 | |
|         class="menuItem ${isActive ? 'active' : ''}"
 | |
|         ?disabled=${menuItem.disabled}
 | |
|         tabindex="${menuItem.disabled ? -1 : 0}"
 | |
|         data-item-id="${itemId}"
 | |
|         @click=${() => this.handleMenuClick(menuItem, itemId)}
 | |
|         @keydown=${(e: KeyboardEvent) => this.handleMenuKeydown(e, menuItem, itemId)}
 | |
|         role="menuitem"
 | |
|         aria-haspopup="${hasSubmenu}"
 | |
|         aria-expanded="${isActive}"
 | |
|       >
 | |
|         ${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
 | |
|         ${menuItem.name}
 | |
|         ${hasSubmenu ? this.renderDropdown(menuItem.submenu, itemId, isActive) : ''}
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   private renderDropdown(items: interfaces.IAppBarMenuItem[], parentId: string, isOpen: boolean): TemplateResult {
 | |
|     return html`
 | |
|       <div 
 | |
|         class="dropdown ${isOpen ? 'open' : ''}" 
 | |
|         @click=${(e: Event) => e.stopPropagation()}
 | |
|         @keydown=${(e: KeyboardEvent) => this.handleDropdownKeydown(e, items, parentId)}
 | |
|         tabindex="${isOpen ? 0 : -1}"
 | |
|         role="menu"
 | |
|       >
 | |
|         ${items.map((item, index) => this.renderDropdownItem(item, `${parentId}-${index}`))}
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   private renderDropdownItem(item: interfaces.IAppBarMenuItem, itemId: string): TemplateResult {
 | |
|     if ('divider' in item && item.divider) {
 | |
|       return html`<div class="dropdown-divider"></div>`;
 | |
|     }
 | |
| 
 | |
|     const menuItem = item as interfaces.IAppBarMenuItemRegular;
 | |
|     const itemIndex = parseInt(itemId.split('-').pop() || '0');
 | |
|     const isFocused = this.focusedDropdownItem === itemIndex;
 | |
|     
 | |
|     return html`
 | |
|       <div
 | |
|         class="dropdown-item ${isFocused ? 'focused' : ''}"
 | |
|         ?disabled=${menuItem.disabled}
 | |
|         @click=${() => this.handleDropdownItemClick(menuItem)}
 | |
|         @mouseenter=${() => this.focusedDropdownItem = itemIndex}
 | |
|         role="menuitem"
 | |
|         tabindex="${menuItem.disabled ? -1 : 0}"
 | |
|       >
 | |
|         ${menuItem.iconName ? html`<dees-icon .icon="${`lucide:${menuItem.iconName}`}"></dees-icon>` : ''}
 | |
|         <span>${menuItem.name}</span>
 | |
|         ${menuItem.shortcut ? html`<span class="shortcut">${menuItem.shortcut}</span>` : ''}
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   public renderBreadcrumbs(): TemplateResult {
 | |
|     if (!this.breadcrumbs) {
 | |
|       return html``;
 | |
|     }
 | |
| 
 | |
|     const parts = this.breadcrumbs.split(this.breadcrumbSeparator);
 | |
|     return html`
 | |
|       ${parts.map((part, index) => html`
 | |
|         ${index > 0 ? html`<span class="breadcrumb-separator">${this.breadcrumbSeparator}</span>` : ''}
 | |
|         <span 
 | |
|           class="breadcrumb-item" 
 | |
|           @click=${() => this.handleBreadcrumbClick(part, index)}
 | |
|         >
 | |
|           ${part}
 | |
|         </span>
 | |
|       `)}
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   public renderAccountSection(): TemplateResult {
 | |
|     return html`
 | |
|       ${this.showSearch ? html`
 | |
|         <dees-icon 
 | |
|           class="search-icon" 
 | |
|           .icon=${'lucide:search'}
 | |
|           @click=${this.handleSearchClick}
 | |
|         ></dees-icon>
 | |
|       ` : ''}
 | |
|       ${this.user ? html`
 | |
|         <div style="position: relative;">
 | |
|           <div class="user-info" @click=${this.handleUserClick}>
 | |
|             <div class="user-avatar">
 | |
|               ${this.user.avatar ? 
 | |
|                 html`<img src="${this.user.avatar}" alt="${this.user.name}">` : 
 | |
|                 html`${this.user.name.charAt(0).toUpperCase()}`
 | |
|               }
 | |
|               ${this.user.status ? html`
 | |
|                 <div class="user-status ${this.user.status}"></div>
 | |
|               ` : ''}
 | |
|             </div>
 | |
|             <span>${this.user.name}</span>
 | |
|           </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>
 | |
|       ` : ''}
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   // 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++;
 | |
|     }
 | |
|   }
 | |
| }
 |