466 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {
 | |
|   customElement,
 | |
|   type TemplateResult,
 | |
|   property,
 | |
|   state,
 | |
|   html,
 | |
|   css,
 | |
|   cssManager,
 | |
| } from '@design.estate/dees-element';
 | |
| import * as domtools from '@design.estate/dees-domtools';
 | |
| import { demoFunc } from './dees-input-dropdown.demo.js';
 | |
| import { DeesInputBase } from './dees-input-base.js';
 | |
| import { cssGeistFontFamily } from './00fonts.js';
 | |
| 
 | |
| declare global {
 | |
|   interface HTMLElementTagNameMap {
 | |
|     'dees-input-dropdown': DeesInputDropdown;
 | |
|   }
 | |
| }
 | |
| 
 | |
| @customElement('dees-input-dropdown')
 | |
| export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
 | |
|   public static demo = demoFunc;
 | |
| 
 | |
|   // INSTANCE
 | |
| 
 | |
|   @property()
 | |
|   public options: { option: string; key: string; payload?: any }[] = [];
 | |
| 
 | |
|   @property()
 | |
|   public selectedOption: { option: string; key: string; payload?: any } = null;
 | |
| 
 | |
|   // Add value property for form compatibility
 | |
|   public get value() {
 | |
|     return this.selectedOption;
 | |
|   }
 | |
| 
 | |
|   public set value(val: { option: string; key: string; payload?: any }) {
 | |
|     this.selectedOption = val;
 | |
|   }
 | |
| 
 | |
|   @property({
 | |
|     type: Boolean,
 | |
|   })
 | |
|   public enableSearch: boolean = true;
 | |
| 
 | |
|   @state()
 | |
|   public opensToTop: boolean = false;
 | |
| 
 | |
|   @state()
 | |
|   private filteredOptions: { option: string; key: string; payload?: any }[] = [];
 | |
| 
 | |
|   @state()
 | |
|   private highlightedIndex: number = 0;
 | |
| 
 | |
|   @state()
 | |
|   public isOpened = false;
 | |
| 
 | |
|   @state()
 | |
|   private searchValue: string = '';
 | |
| 
 | |
|   public static styles = [
 | |
|     ...DeesInputBase.baseStyles,
 | |
|     cssManager.defaultStyles,
 | |
|     css`
 | |
|       * {
 | |
|         box-sizing: border-box;
 | |
|       }
 | |
| 
 | |
|       :host {
 | |
|         font-family: ${cssGeistFontFamily};
 | |
|         position: relative;
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
 | |
|       }
 | |
| 
 | |
|       .maincontainer {
 | |
|         display: block;
 | |
|         position: relative;
 | |
|       }
 | |
| 
 | |
|       .selectedBox {
 | |
|         user-select: none;
 | |
|         position: relative;
 | |
|         width: 100%;
 | |
|         height: 40px;
 | |
|         line-height: 38px;
 | |
|         padding: 0 40px 0 12px;
 | |
|         background: transparent;
 | |
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         border-radius: 6px;
 | |
|         transition: all 0.15s ease;
 | |
|         font-size: 14px;
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
 | |
|         cursor: pointer;
 | |
|         overflow: hidden;
 | |
|         text-overflow: ellipsis;
 | |
|         white-space: nowrap;
 | |
|       }
 | |
| 
 | |
|       .selectedBox:hover:not(.disabled) {
 | |
|         border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
 | |
|       }
 | |
| 
 | |
|       .selectedBox:focus-visible {
 | |
|         outline: none;
 | |
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
 | |
|         box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
 | |
|       }
 | |
| 
 | |
|       .selectedBox.disabled {
 | |
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
 | |
|         border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
 | |
|         cursor: not-allowed;
 | |
|         opacity: 0.5;
 | |
|       }
 | |
| 
 | |
|       /* Dropdown arrow */
 | |
|       .selectedBox::after {
 | |
|         content: '';
 | |
|         position: absolute;
 | |
|         right: 12px;
 | |
|         top: 50%;
 | |
|         transform: translateY(-50%);
 | |
|         width: 0;
 | |
|         height: 0;
 | |
|         border-left: 4px solid transparent;
 | |
|         border-right: 4px solid transparent;
 | |
|         border-top: 4px solid ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
 | |
|         transition: transform 0.15s ease;
 | |
|       }
 | |
| 
 | |
|       .selectedBox.open::after {
 | |
|         transform: translateY(-50%) rotate(180deg);
 | |
|       }
 | |
| 
 | |
|       .selectionBox {
 | |
|         will-change: transform, opacity;
 | |
|         pointer-events: none;
 | |
|         transition: all 0.15s ease;
 | |
|         opacity: 0;
 | |
|         transform: translateY(-8px) scale(0.98);
 | |
|         background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
 | |
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
 | |
|         min-height: 40px;
 | |
|         max-height: 300px;
 | |
|         overflow: hidden;
 | |
|         border-radius: 6px;
 | |
|         position: absolute;
 | |
|         user-select: none;
 | |
|         margin-top: 4px;
 | |
|         z-index: 50;
 | |
|         left: 0;
 | |
|         right: 0;
 | |
|       }
 | |
|       
 | |
|       .selectionBox.top {
 | |
|         bottom: calc(100% + 4px);
 | |
|         top: auto;
 | |
|         margin-top: 0;
 | |
|         margin-bottom: 4px;
 | |
|         transform: translateY(8px) scale(0.98);
 | |
|       }
 | |
|       
 | |
|       .selectionBox.bottom {
 | |
|         top: 100%;
 | |
|       }
 | |
| 
 | |
|       .selectionBox.show {
 | |
|         pointer-events: all;
 | |
|         transform: translateY(0) scale(1);
 | |
|         opacity: 1;
 | |
|       }
 | |
| 
 | |
|       /* Options container */
 | |
|       .options-container {
 | |
|         max-height: 250px;
 | |
|         overflow-y: auto;
 | |
|         padding: 4px;
 | |
|       }
 | |
| 
 | |
|       /* Options */
 | |
|       .option {
 | |
|         transition: all 0.15s ease;
 | |
|         line-height: 32px;
 | |
|         padding: 0 8px;
 | |
|         border-radius: 4px;
 | |
|         margin: 2px 0;
 | |
|         cursor: pointer;
 | |
|         font-size: 14px;
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
 | |
|       }
 | |
| 
 | |
|       .option.highlighted {
 | |
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
 | |
|       }
 | |
| 
 | |
|       .option:hover {
 | |
|         background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
 | |
|       }
 | |
| 
 | |
|       /* No options message */
 | |
|       .no-options {
 | |
|         padding: 8px;
 | |
|         text-align: center;
 | |
|         font-size: 14px;
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
 | |
|         font-style: italic;
 | |
|       }
 | |
| 
 | |
|       /* Search */
 | |
|       .search {
 | |
|         padding: 4px;
 | |
|         border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         margin-bottom: 4px;
 | |
|       }
 | |
|       
 | |
|       .search.bottom {
 | |
|         border-bottom: none;
 | |
|         border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         margin-bottom: 0;
 | |
|         margin-top: 4px;
 | |
|       }
 | |
|       
 | |
|       .search input {
 | |
|         display: block;
 | |
|         width: 100%;
 | |
|         height: 32px;
 | |
|         padding: 0 8px;
 | |
|         background: transparent;
 | |
|         border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         border-radius: 4px;
 | |
|         color: inherit;
 | |
|         font-size: 14px;
 | |
|         font-family: inherit;
 | |
|         outline: none;
 | |
|         transition: border-color 0.15s ease;
 | |
|       }
 | |
| 
 | |
|       .search input::placeholder {
 | |
|         color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
 | |
|       }
 | |
| 
 | |
|       .search input:focus {
 | |
|         border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
 | |
|       }
 | |
| 
 | |
|       /* Scrollbar styling */
 | |
|       .options-container::-webkit-scrollbar {
 | |
|         width: 8px;
 | |
|       }
 | |
| 
 | |
|       .options-container::-webkit-scrollbar-track {
 | |
|         background: transparent;
 | |
|       }
 | |
| 
 | |
|       .options-container::-webkit-scrollbar-thumb {
 | |
|         background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
 | |
|         border-radius: 4px;
 | |
|       }
 | |
| 
 | |
|       .options-container::-webkit-scrollbar-thumb:hover {
 | |
|         background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
 | |
|       }
 | |
|     `,
 | |
|   ];
 | |
| 
 | |
|   public render(): TemplateResult {
 | |
|     return html`
 | |
|       <div class="input-wrapper">
 | |
|         <dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
 | |
|         <div class="maincontainer">
 | |
|           <div
 | |
|             class="selectedBox ${this.isOpened ? 'open' : ''} ${this.disabled ? 'disabled' : ''}"
 | |
|             @click="${() => !this.disabled && this.toggleSelectionBox()}"
 | |
|             tabindex="${this.disabled ? '-1' : '0'}"
 | |
|             @keydown="${this.handleSelectedBoxKeydown}"
 | |
|           >
 | |
|             ${this.selectedOption?.option || 'Select an option'}
 | |
|           </div>
 | |
|           <div class="selectionBox ${this.isOpened ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}">
 | |
|             ${this.enableSearch
 | |
|               ? html`
 | |
|                   <div class="search">
 | |
|                     <input 
 | |
|                       type="text" 
 | |
|                       placeholder="Search options..." 
 | |
|                       .value="${this.searchValue}"
 | |
|                       @input="${this.handleSearch}"
 | |
|                       @click="${(e: Event) => e.stopPropagation()}"
 | |
|                       @keydown="${this.handleSearchKeydown}"
 | |
|                     />
 | |
|                   </div>
 | |
|                 `
 | |
|               : null}
 | |
|             <div class="options-container">
 | |
|               ${this.filteredOptions.length === 0
 | |
|                 ? html`<div class="no-options">No options found</div>`
 | |
|                 : this.filteredOptions.map((option, index) => {
 | |
|                     const isHighlighted = this.highlightedIndex === index;
 | |
|                     return html`
 | |
|                       <div
 | |
|                         class="option ${isHighlighted ? 'highlighted' : ''}"
 | |
|                         @click="${() => this.updateSelection(option)}"
 | |
|                         @mouseenter="${() => this.highlightedIndex = index}"
 | |
|                       >
 | |
|                         ${option.option}
 | |
|                       </div>
 | |
|                     `;
 | |
|                   })
 | |
|               }
 | |
|             </div>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   async connectedCallback() {
 | |
|     super.connectedCallback();
 | |
|     this.handleClickOutside = this.handleClickOutside.bind(this);
 | |
|   }
 | |
| 
 | |
|   firstUpdated() {
 | |
|     this.selectedOption = this.selectedOption || null;
 | |
|     this.filteredOptions = this.options;
 | |
|   }
 | |
| 
 | |
|   updated(changedProperties: Map<string, any>) {
 | |
|     super.updated(changedProperties);
 | |
|     
 | |
|     if (changedProperties.has('options')) {
 | |
|       this.filteredOptions = this.options;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   public async updateSelection(selectedOption: { option: string; key: string; payload?: any }) {
 | |
|     this.selectedOption = selectedOption;
 | |
|     this.isOpened = false;
 | |
|     this.searchValue = '';
 | |
|     this.filteredOptions = this.options;
 | |
|     this.highlightedIndex = 0;
 | |
| 
 | |
|     this.dispatchEvent(
 | |
|       new CustomEvent('selectedOption', {
 | |
|         detail: selectedOption,
 | |
|         bubbles: true,
 | |
|       })
 | |
|     );
 | |
|     
 | |
|     this.changeSubject.next(this);
 | |
|   }
 | |
| 
 | |
|   private handleClickOutside = (event: MouseEvent) => {
 | |
|     const path = event.composedPath();
 | |
|     if (!path.includes(this)) {
 | |
|       this.isOpened = false;
 | |
|       this.searchValue = '';
 | |
|       this.filteredOptions = this.options;
 | |
|       document.removeEventListener('click', this.handleClickOutside);
 | |
|     }
 | |
|   };
 | |
|   
 | |
|   public async toggleSelectionBox() {
 | |
|     this.isOpened = !this.isOpened;
 | |
|     
 | |
|     if (this.isOpened) {
 | |
|       // Check available space and set position
 | |
|       const selectedBox = this.shadowRoot.querySelector('.selectedBox') as HTMLElement;
 | |
|       const rect = selectedBox.getBoundingClientRect();
 | |
|       const spaceBelow = window.innerHeight - rect.bottom;
 | |
|       const spaceAbove = rect.top;
 | |
|       
 | |
|       // Determine if we should open upwards
 | |
|       this.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
 | |
|       
 | |
|       // Focus search input if present
 | |
|       await this.updateComplete;
 | |
|       const searchInput = this.shadowRoot.querySelector('.search input') as HTMLInputElement;
 | |
|       if (searchInput) {
 | |
|         searchInput.focus();
 | |
|       }
 | |
|       
 | |
|       // Add click outside listener
 | |
|       setTimeout(() => {
 | |
|         document.addEventListener('click', this.handleClickOutside);
 | |
|       }, 0);
 | |
|     } else {
 | |
|       // Cleanup
 | |
|       this.searchValue = '';
 | |
|       this.filteredOptions = this.options;
 | |
|       document.removeEventListener('click', this.handleClickOutside);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private handleSearch(event: Event): void {
 | |
|     const searchTerm = (event.target as HTMLInputElement).value;
 | |
|     this.searchValue = searchTerm;
 | |
|     const searchLower = searchTerm.toLowerCase();
 | |
|     this.filteredOptions = this.options.filter((option) =>
 | |
|       option.option.toLowerCase().includes(searchLower)
 | |
|     );
 | |
|     this.highlightedIndex = 0;
 | |
|   }
 | |
| 
 | |
|   private handleKeyDown(event: KeyboardEvent): void {
 | |
|     const key = event.key;
 | |
|     const maxIndex = this.filteredOptions.length - 1;
 | |
| 
 | |
|     if (key === 'ArrowDown') {
 | |
|       event.preventDefault();
 | |
|       this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
 | |
|     } else if (key === 'ArrowUp') {
 | |
|       event.preventDefault();
 | |
|       this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
 | |
|     } else if (key === 'Enter') {
 | |
|       event.preventDefault();
 | |
|       if (this.filteredOptions[this.highlightedIndex]) {
 | |
|         this.updateSelection(this.filteredOptions[this.highlightedIndex]);
 | |
|       }
 | |
|     } else if (key === 'Escape') {
 | |
|       event.preventDefault();
 | |
|       this.isOpened = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private handleSearchKeydown(event: KeyboardEvent): void {
 | |
|     if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
 | |
|       this.handleKeyDown(event);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private handleSelectedBoxKeydown(event: KeyboardEvent) {
 | |
|     if (this.disabled) return;
 | |
|     
 | |
|     if (event.key === 'Enter' || event.key === ' ') {
 | |
|       event.preventDefault();
 | |
|       this.toggleSelectionBox();
 | |
|     } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
 | |
|       event.preventDefault();
 | |
|       if (!this.isOpened) {
 | |
|         this.toggleSelectionBox();
 | |
|       }
 | |
|     } else if (event.key === 'Escape') {
 | |
|       event.preventDefault();
 | |
|       if (this.isOpened) {
 | |
|         this.isOpened = false;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   public getValue(): { option: string; key: string; payload?: any } {
 | |
|     return this.selectedOption;
 | |
|   }
 | |
| 
 | |
|   public setValue(value: { option: string; key: string; payload?: any }): void {
 | |
|     this.selectedOption = value;
 | |
|   }
 | |
|   
 | |
|   async disconnectedCallback() {
 | |
|     await super.disconnectedCallback();
 | |
|     document.removeEventListener('click', this.handleClickOutside);
 | |
|   }
 | |
| } |