1/** 2 * @file BasicDropdown.js 3 * @description A custom web component that displays a dropdown menu where it's possible to select one item. 4 * 5 * @class BasicDropdown 6 * @extends WebComponent 7 * 8 * @example 9 * <dropdown-element 10 * label = "Label" 11 * size = "small" > 12 * </dropdown-element> 13 * 14 * document.addEventListener('DOMContentLoaded', () => { 15 * const dropdown = document.querySelector("dropdown-element"); 16 * dropdown.setItems([{id: "id1", value: "test1"}, {id: "id2", value: "test2", pill:"pill2"}, {id: "id3", value: "test3"}, {id: "id4", value: "test4"}, {id: "id5", value: "test5"}, {id: "id6", value: "test6"}]); 17 * }); 18 * or 19 * document.addEventListener('DOMContentLoaded', () => { 20 * const dropdown = document.querySelector("dropdown-element"); 21 * dropdown.setItems({id1: "test1", id2: "test2", id3: "test3", id4: test4, id5: "test5", id6: "test6", id7: "test7", id8: "test8"}); 22 * }); 23 * 24 * Usage: 25 * - Please use includeWebComponent to include this component in the main php file 26 * - Create a dropdown-element tag in DOM 27 * - on DOMContentLoaded use: 28 * - setItems to populate 29 * - this.items example: 30 * { 31 * id1: "Text", 32 * id2: "Text" 33 * ... 34 * } 35 * 36 * BasicDropdown Web Component 37 * ------------------------- 38 * This is a dropdown component 39 * implemented as a native Web Component (extending HTMLElement). 40 * 41 * Features: 42 * - Label 43 * - Optional size (default medium) 44 * - Optional state (default active) 45 * - Optional expanded initial state (default collapsed) 46 * - Setting a new item as the selected option dispatches a 'change' event 47 * - Optional per-item icon (item.iconHtml), shown in the option row and mirrored in the 48 * label when that item is selected 49 * - Colors (label text, background, chevron, option text) can be overridden via CSS custom 50 * properties, so the dropdown stays visible when placed on a dark background (e.g. the map 51 * left panel) 52 * - API for: 53 * -set items 54 * -add single item 55 * -set selected item 56 * -get selected item 57 * 58 * @attribute {string} label - The label to show on top of the multi select 59 * @attribute {string} size - default "medium", can be also "small" or "large" 60 * @attribute {string} name - when a name is assigned, an hidden input is created outside of the shadowroot with the same name. Forms can get the values from there 61 * @attribute disabled - if present the dropdown will be disabled 62 * @attribute error - if present the dropdown will be highlighted in red 63 * @attribute expanded - if present the dropdown will be exanpaded as initial state 64 * 65 * @cssprop --dropdown-label-color - text color of the label/selected value, default inherit 66 * @cssprop --dropdown-background-color - background color of the label and label wrapper, default transparent 67 * @cssprop --dropdown-chevron-color - color of the expand/collapse chevron icon, default inherit 68 * @cssprop --dropdown-option-text-color - text color of items in the option list, default inherit 69 * 70 * 71 * @method setItems - add a list of items to the list. {id1: "Label text", id2: "Label Text", ...} 72 * each item may also include an "iconHtml" string to render an icon next to it 73 * @method setSelected - set selected option by id 74 * @method getSelected - get id of selected option 75 */ 76 77import { BaseDropdowns } from '../../../js/Components/Base/BaseDropdowns.js'; 78 79class BasicDropdown extends BaseDropdowns { 80 81 constructor() { 82 super(); 83 this.selected = null; 84 this.items = []; 85 86 // Create container 87 this.container = document.createElement('div'); 88 this.container.classList.add('container'); 89 const iconStyle = document.createElement('style'); 90 iconStyle.textContent = `@import url('https://unicons.iconscout.com/release/v4.0.8/css/line.css');`; 91 92 // Apply styles 93 const style = document.createElement('style'); 94 95 style.textContent = 96 this.baseStyle + ` 97 .dropdown-list { 98 /* position:fixed so the popup escapes any ancestor overflow 99 (e.g. a scrollable panel) that would otherwise clip it. The 100 non-inline path overwrites top/left/width with pixel values 101 via _positionDropdownList(); the table-inline path only sets 102 a translateY via _positionFixedDropdown() and relies on these 103 auto defaults, since the base .dropdown-list rule's top:100% 104 would otherwise resolve against the viewport (fixed element) 105 and fling the popup to the bottom of the screen. */ 106 position: fixed; 107 top: auto; 108 left: auto; 109 overflow: visible !important; 110 max-height: none !important; 111 } 112 113 .dropdown-option { 114 color: var(--dropdown-option-text-color, inherit); 115 } 116 117 .dropdown-label, 118 .dropdown-label-wrapper { 119 background-color: var(--dropdown-background-color, transparent); 120 } 121 122 .dropdown-icon { 123 color: var(--dropdown-chevron-color, inherit); 124 } 125 /* Only .true-list is scrollable */ 126 127 /* Explicit show/hide on the popup. Belt-and-suspenders with the base 128 rule because some pages inject styles that knock out the base 129 .dropdown-list { display:none } before the popup gets .expanded. */ 130 .dropdown-list:not(.expanded) { 131 display: none !important; 132 } 133 .dropdown-list.expanded { 134 display: block !important; 135 } 136 137 :host { 138 box-sizing: border-box; 139 display: block; 140 overflow: visible; 141 --background-color: #FFFFFF; 142 } 143 144 :host([full-width]) { 145 display: block; 146 width: 100%; 147 } 148 149 .container { 150 width: 100%; 151 box-sizing: border-box; 152 } 153 154 .dropdown-label { 155 width: 100%; 156 box-sizing: border-box; 157 } 158 159 #label-text { 160 line-height: normal; 161 flex: 1 1 auto; 162 min-width: 0; 163 white-space: nowrap; 164 overflow: hidden; 165 text-overflow: ellipsis; 166 color: var(--dropdown-label-color, inherit); 167 } 168 169 .true-list { 170 position: relative; 171 overflow-x: hidden; 172 overflow-y: auto; 173 max-height: 240px; 174 } 175 176 /* Firefox */ 177 @supports not selector(::-webkit-scrollbar){ 178 .true-list { 179 scrollbar-width: thin; 180 scrollbar-color: #999 transparent; 181 } 182 } 183 184 /* Chrome, Edge, Safari */ 185 .true-list::-webkit-scrollbar {
186 width: 6px; 187 } 188 .true-list::-webkit-scrollbar-track { 189 background: transparent; 190 } 191 .true-list::-webkit-scrollbar-thumb { 192 background: #999; 193 border-radius: 4px; 194 } 195 .true-list::-webkit-scrollbar-button { 196 display: none; 197 height: 0; 198 width: 0; 199 } 200 201 /* Base .dropdown-search.expanded sets display:block, which is correct here */ 202 203 .search-input-wrapper { 204 position: relative; 205 } 206 207 .search-input-wrapper > input-element { 208 display: block; 209 width: 100%; 210 } 211 212 /* Matches the standalone SearchBarComponent clear button exactly */ 213 .search-clear { 214 position: absolute; 215 right: 8px; 216 top: 50%; 217 transform: translateY(-50%); 218 background: none; 219 border: none; 220 cursor: pointer; 221 padding: 1px; 222 border-radius: 4px; 223 display: flex; 224 align-items: center; 225 justify-content: center; 226 color: #3F4242; 227 line-height: 1; 228 } 229 230 .search-clear .uil { 231 font-size: 20px; 232 } 233 234 .search-clear.hide { 235 display: none; 236 } 237 238 .search-clear:hover { 239 background-color: #EBEDED; 240 color: #222222; 241 } 242 243 .search-clear:focus-visible { 244 outline: 2px solid #0389A0; 245 outline-offset: 2px; 246 box-shadow: 0 0 0 4px rgba(3, 137, 160, 0.2); 247 border-radius: 4px; 248 } 249 .dropdown-add-new-row { 250 display: flex; 251 align-items: center; 252 gap: 6px; 253 margin: 4px 8px; 254 padding: 8px; 255 border-radius: 4px; 256 cursor: pointer; 257 color: #026E80; 258 font-weight: 500; 259 font-size: 14px; 260 user-select: none; 261 } 262 .dropdown-add-new-row:hover { 263 background-color: #EBEDED; 264 } 265 .dropdown-add-new-row i { 266 font-size: 16px; 267 } 268 `; 269 270 //Attach empty container and style to the shadowRoot 271 const root = this.shadowRoot; 272 273 root.appendChild(iconStyle); 274 root.appendChild(style); 275 root.appendChild(this.container); 276 } 277 278 static get observedAttributes() { 279 return ['disabled']; 280 } 281 282 attributeChangedCallback(name) { 283 if (name === 'disabled') { 284 this.disabled = this.hasAttribute('disabled'); 285 // Update the label element directly without a full re-render. 286 const root = this.shadowRoot; 287 const target = root?.querySelector('.dropdown-label-wrapper') ?? root?.querySelector('.dropdown-label'); 288 if (!target) return; 289 target.classList.toggle('disabled', this.disabled); 290 // Collapse if being disabled while open. 291 if (this.disabled && this.expanded) this.setExpandedState(false); 292 } 293 } 294 295 setValue(val) { 296 this.setSelected(val); 297 } 298 299 getValue() { 300 return this.getSelected(); 301 } 302 303 getSelectedValue() { 304 return this.items.filter((i) => i.id == this.selected)[0].value; 305 } 306 307 connectedCallback(){ 308 this._baseAttributes(); 309 310 //initial value for hidden input 311 if(this.hiddenInput){ 312 this.hiddenInput.value = ""; 313 } 314 315 //only for BasicDropdown leading icon 316 const hasIcon = this.querySelector(`[slot='icon']`); 317 this.hasIcon = !! hasIcon; 318 319 this.ordered = this.hasAttribute("ordered"); 320 321 // Optional "+ â¦" affordance rendered inside the dropdown panel, 322 // just under the search bar. When set, clicking the row dispatches an 323 // `add-new` event and closes the panel; the caller decides what to do. 324 this.addNewLabel = this.getAttribute('add-new-label'); 325 this._deselect = this.hasAttribute('deselect'); 326 327 this.setAttribute('tabindex', '0'); 328 this.setAttribute('role', 'combobox'); 329 330 this._handleKeyboard = this._handleKeyboard.bind(this); 331 this.addEventListener('keydown', this._handleKeyboard); 332 333 // Replace BaseDropdowns' close-on-outside-scroll behavior with 334 // reposition-on-scroll: when the user scrolls any container, the 335 // popup follows the trigger instead of disappearing. The popup 336 // is position:fixed so this only requires recomputing top/left. 337 window.removeEventListener('scroll', this._handleScroll, true); 338 this._handleScroll = () => { 339 if (this.expanded) this._positionDropdownList(); 340 }; 341 window.addEventListener('scroll', this._handleScroll, true); 342 343 this._handleResize = () => { 344 if (this.expanded) this._positionDropdownList(); 345 }; 346 window.addEventListener('resize', this._handleResize); 347 348 this._handleFocusOut = this._handleFocusOut.bind(this); 349 this.addEventListener('focusout', this._handleFocusOut); 350 this._parseSlottedOptions(); 351 352 353 this.render(); 354 } 355 356 disconnectedCallback() { 357 if (this._handleScroll) window.removeEventListener('scroll', this._handleScroll, true); 358 if (this._handleResize) window.removeEventListener('resize', this._handleResize); 359 super.disconnectedCallback?.(); 360 } 361 362 //collapse once keyboard focus leaves the dropdown entirely (e.g. tabbing past the last option) 363 _handleFocusOut() { 364 setTimeout(() => { 365 if(!this.expanded) return; 366 const focusInside = document.activeElement === this || !!this.shadowRoot.activeElement; 367 if(!focusInside) this.setExpandedState(false); 368 }, 0); 369 } 370 371 _parseSlottedOptions() { 372 const opts = this._parseCustomOptions(); 373 if (!opts.length) return; 374 375 this.items = opts.map(opt => ({id: opt.id, value: opt.label, pill: opt.pill})); 376 const preselected = opts.find(opt => opt.checked); 377 if (preselected) this.selected = preselected.id; 378 379 this.label = this.getAttribute('label') ?? 'Label'; 380 381 if (opts.length <= 10) this.searchMode = false;
382 } 383 384 //setting items and render 385 setItems(data){ 386 let formattedData = data; 387 388 if(! Array.isArray(data)) { 389 formattedData = Object.entries(data).map(([key, val]) => ({ 390 id: key, 391 value: val 392 })); 393 } 394 395 this.items = formattedData; 396 397 this.selected = null; 398 399 this.label = this.getAttribute('label') ?? 'Label'; 400 401 this.needsLayout = true; 402 403 this.render(); 404 } 405 406 //add single item 407 addItem(key, val, pillText = ""){ 408 this.items.push({id: key, value: val, pill: pillText}); 409 410 this.render(); 411 } 412 413 // Position the fixed popup so it stays anchored to the trigger. 414 _positionDropdownList() { 415 if (!this.list) return; 416 const r = this.getBoundingClientRect(); 417 if (r.width === 0 && r.height === 0) return; // not laid out yet 418 419 this.list.style.width = `${r.width}px`; 420 421 // Desired viewport position. Flip above the trigger when the panel would 422 // overflow the bottom of the viewport and there is more room above. 423 const panelH = this.list.offsetHeight; 424 const spaceBelow = window.innerHeight - r.bottom; 425 const spaceAbove = r.top; 426 // no-flip: always open below the trigger, even when space is tight. 427 const forceDown = this.hasAttribute('no-flip'); 428 const flipAbove = !forceDown && panelH > spaceBelow && spaceAbove > spaceBelow; 429 const desiredTop = flipAbove ? (r.top - panelH) : r.bottom; 430 const desiredLeft = r.left; 431 432 // The popup is position:fixed but its containing block may be a 433 // transformed/contained ancestor (e.g. a slotted bottom-sheet wrapper), 434 // not the viewport. Rather than detect that block, write the desired 435 // viewport coords, measure where the popup actually landed, and correct 436 // by the delta. This anchors it to the trigger regardless of ancestors. 437 this.list.style.top = `${desiredTop}px`; 438 this.list.style.left = `${desiredLeft}px`; 439 const got = this.list.getBoundingClientRect(); 440 const dTop = desiredTop - got.top; 441 const dLeft = desiredLeft - got.left; 442 if (dTop || dLeft) { 443 this.list.style.top = `${desiredTop + dTop}px`; 444 this.list.style.left = `${desiredLeft + dLeft}px`; 445 } 446 } 447 448 449 //set expanded state 450 setExpandedState(isExpanded){ 451 const action = isExpanded ? 'add' : 'remove'; 452 const root = this.shadowRoot; 453 454 // If opening this dropdown, close all other dropdowns 455 if (isExpanded) { 456 document.querySelectorAll('dropdown-element').forEach(dropdown => { 457 if (dropdown !== this && dropdown.expanded) { 458 dropdown.setExpandedState(false); 459 } 460 }); 461 } 462 463 root.querySelector('.dropdown-list').classList[action]('expanded'); 464 465 // Position after expanding so the panel has a measurable height and can 466 // flip above the trigger when there isn't enough room below. 467 if (isExpanded && this.inlineVariant) { 468 this._positionFixedDropdown(); 469 } else if (!isExpanded && this.inlineVariant) { 470 const list = root.querySelector('.dropdown-list'); 471 if (list) list.style.transform = ''; 472 } else if(isExpanded){ 473 this._positionDropdownList(); 474 } 475 476 477 if(this.hasIcon){ 478 root.querySelector('.dropdown-label-wrapper').classList[action]('expanded'); 479 }else { 480 root.querySelector('.dropdown-label').classList[action]('expanded'); 481 } 482 483 // Show/hide search input based on expanded state and item count 484 if(this.searchMode){ 485 const searchInput = root.querySelector('.dropdown-search'); 486 if(searchInput && this.shouldShowSearchBar()){ 487 searchInput.classList[action]('expanded'); 488 } 489 // Clear the search term on close so the next open starts fresh and 490 // shows the full, unfiltered list rather than the last query. 491 if(!isExpanded){ 492 this.clearSearch(); 493 this.searchEvent(); 494 } 495 } 496 497 root.querySelector('.dropdown-icon').innerHTML = isExpanded ? this.iconUp : this.iconDown; 498 499 this._notifyInlineToggle(isExpanded); 500 501 this.setAttribute('aria-expanded', isExpanded); 502 503 const tabValue = isExpanded ? '0' : '-1'; 504 root.querySelectorAll('.dropdown-option').forEach(o => { 505 o.setAttribute('tabindex', tabValue); 506 }); 507 508 if (!isExpanded) { 509 this._restoreLabelToSelected(); 510 } 511 512 this.expanded = isExpanded; 513 } 514 515 //Restore the trigger label to reflect the actual committed selection. 516 _restoreLabelToSelected() { 517 const labelText = this.shadowRoot.querySelector('#label-text'); 518 if (!labelText) return; 519 520 if (this.selected != null) { 521 const item = this.items.find(el => el.id == this.selected); 522 if (item) { 523 // isHtml: only set this from trusted, non-user-controlled markup - this bypasses HTML escaping 524 if (item.isHtml === true) labelText.innerHTML = item.value; 525 else labelText.textContent = this.decodeHtmlEntities(item.value); 526 return; 527 } 528 } 529 labelText.textContent = this.decodeHtmlEntities(this.label); 530 } 531 532 //Preview the trigger label as the user moves focus through options. 533 _previewLabel(text, isHtml = false) { 534 const labelText = this.shadowRoot.querySelector('#label-text'); 535 if (!labelText) return; 536 // isHtml: only set this from trusted, non-user-controlled markup - this bypasses HTML escaping 537 if (isHtml) labelText.innerHTML = text; 538 else labelText.textContent = this.decodeHtmlEntities(text); 539 } 540 541 //Move focus to the option next/prev to the one currently focused. 542 _focusOptionByDirection(direction) { 543 const root = this.shadowRoot; 544 const options = Array.from(root.querySelectorAll('.dropdown-option')) 545 .filter(o => o.style.display !== 'none'); 546 if (options.length === 0) return; 547 548 const current = root.activeElement; 549 const currentIndex = options.indexOf(current); 550 551 let nextIndex; 552 if (currentIndex === -1) { 553 const selectedIndex = options.findIndex(o => o.dataset.id == this.selected); 554 if (selectedIndex !== -1) nextIndex = selectedIndex; 555 else nextIndex = direction >= 0 ? 0 : options.length - 1; 556 } else { 557 nextIndex = (currentIndex + direction + options.length) % options.length; 558 } 559 560 options[nextIndex].focus(); 561 } 562 563 //utility method to scroll to the selected option 564 _scrollToSelected() { 565 if(this.selected == null) return; 566 const optionEl = this.shadowRoot.querySelector(`#option-${CSS.escape(this.selected)}`); 567 const container = this.shadowRoot.querySelector('.true-list'); 568 const optionTop = optionEl.offsetTop; 569 const optionBottom = optionTop + optionEl.offsetHeight; 570 571 const containerScrollTop = container.scrollTop; 572 const containerHeight = container.clientHeight; 573 574 if(optionTop < containerScrollTop) container.scrollTop = optionTop; 575 else container.scrollTop = optionBottom - containerHeight; 576 577 } 578 579 //currently visible option elements, in DOM order 580 _getOptionElements() { 581 return [...this.shadowRoot.querySelectorAll('.dropdown-option')].filter(o => o.offsetParent !== null); 582 } 583 584 //focus the selected option, or the first one when nothing is selected 585 _focusSelectedOrFirst() { 586 const options = this._getOptionElements(); 587 if(!options.length) return; 588 const index = options.findIndex(o => o.dataset.id == this.selected); 589 options[index === -1 ? 0 : index].focus(); 590 } 591 592 //move focus to the next/previous option, wrapping around the ends 593 _moveOptionFocus(direction) { 594 const options = this._getOptionElements(); 595 if(!options.length) return; 596 597 let index = options.indexOf(this.shadowRoot.activeElement); 598 if(index === -1){ 599 index = options.findIndex(o => o.dataset.id == this.selected); 600 if(index === -1) index = direction > 0 ? -1 : 0; 601 } 602 603 let next = index + direction; 604 if(next < 0) next = options.length - 1; 605 if(next >= options.length) next = 0; 606 607 options[next].focus(); 608 } 609 610 //click enter and arrows down and up 611 _handleKeyboard(event) { 612 const root = this.shadowRoot; 613 const focusedOption = root.activeElement; 614 615 if (focusedOption?.classList?.contains('search-clear')) return; 616 617 const isOptionFocused = focusedOption && focusedOption.classList && focusedOption.classList.contains('dropdown-option'); 618 619 // Focus inside the search bar lands on the <input-element> host, not on 620 // .dropdown-search itself, so match the ancestor rather than the class. 621 const isSearchFocused = focusedOption?.closest?.('.dropdown-search'); 622 623 switch(event.key){ 624 case 'Enter': 625 case ' ': 626 // While typing in the search box, let the key reach the input 627 // (Space must insert a space, not toggle/close the dropdown). 628 if (isSearchFocused) break; 629 event.preventDefault(); 630 if (isOptionFocused) { 631 this.setSelected(focusedOption.dataset.id); 632 this.focus(); 633 } else if (this.expanded) { 634 this.setExpandedState(false); 635 } else { 636 this.setExpandedState(true); 637 } 638 break; 639 case 'Spacebar': 640 if (isSearchFocused) break; 641 event.preventDefault(); 642 if (isOptionFocused) { 643 this.setSelected(focusedOption.dataset.id); 644 this.focus(); 645 } else if (this.expanded) { 646 this.setExpandedState(false); 647 } else { 648 this.setExpandedState(true); 649 } 650 break; 651 case 'ArrowDown': 652 event.preventDefault(); 653 if(!this.expanded){ 654 this.setExpandedState(true); 655 this._focusSelectedOrFirst(); 656 } else { 657 this._moveOptionFocus(1); 658 } 659 break; 660 case 'ArrowUp': 661 event.preventDefault(); 662 if(!this.expanded){ 663 this.setExpandedState(true); 664 this._focusSelectedOrFirst(); 665 } else { 666 this._moveOptionFocus(-1); 667 } 668 break; 669 case 'Tab': 670 case 'Shift': 671 case 'Control': 672 case 'Alt': 673 case 'Meta': 674 break; 675 case 'Escape': 676 if (this.expanded) { 677 event.preventDefault(); 678 this.setExpandedState(false); 679 this.focus(); 680 } 681 break; 682 } 683 } 684 685 //public method to get the selected item 686 getSelected() { 687 return this.selected; 688 } 689 690 //public method to refresh dropdown from DOM (rebuilds items from sibling hidden input
690s or select options) 691 refresh() { 692 // Build an array (not a plain object) so DOM order is preserved. 693 // Plain objects with integer-string keys get reordered numerically by Object.entries, 694 // which silently overrides any author-specified ordering (e.g. EO map_order). 695 const items = []; 696 697 const selectId = this.id; 698 const select = document.getElementById(selectId); 699 700 if (select) { 701 const options = select.querySelectorAll('option:not([hidden])'); 702 options.forEach(option => { 703 const value = option.value; 704 const text = option.textContent; 705 if (value !== '' && value !== '0') { 706 items.push({ id: value, value: text }); 707 } 708 }); 709 } 710 711 if (items.length > 0) { 712 this.setItems(items); 713 } 714 715 return this; 716 } 717 718 //public method to set the selected item, collapsing the menu and adding style for the option. 719 setSelected(id){ 720 const prev = this.selected; 721 this.selected = id; 722 const root = this.shadowRoot; 723 const escapedId = CSS.escape(id); 724 const option = root.querySelector(`#option-${escapedId}`); 725 726 if(option){ 727 this.selected = id; 728 const selectedItem = this.items.find(el => el.id == id); 729 const labelText = root.querySelector("#label-text"); 730 // isHtml: only set this from trusted, non-user-controlled markup - this bypasses HTML escaping 731 if (selectedItem?.isHtml === true) labelText.innerHTML = selectedItem.value; 732 else labelText.textContent = this.decodeHtmlEntities(selectedItem?.value); 733 734 const labelIconEl = root.querySelector('#label-icon'); 735 if (labelIconEl) { 736 if (selectedItem?.iconHtml) { 737 labelIconEl.innerHTML = selectedItem.iconHtml; 738 labelIconEl.style.display = 'inline-flex'; 739 } else { 740 labelIconEl.innerHTML = ''; 741 labelIconEl.style.display = 'none'; 742 } 743 } 744 root.querySelector('.dropdown-label').classList.remove('error'); 745 746 root.querySelectorAll('.dropdown-option').forEach((o) => { 747 o.classList.remove('selected'); 748 o.setAttribute('aria-selected', 'false'); 749 }); 750 751 option.classList.add('selected'); 752 option.setAttribute('aria-selected', 'true'); 753 this.setExpandedState(false); 754 //if there is the hidden input, its value gets synced 755 if(this.hiddenInput){ 756 this.hiddenInput.value = id; 757 } 758 759 const closeBtn = root.querySelector('.close-button'); 760 if(this._deselect && closeBtn) closeBtn.classList.remove('hide'); 761 762 if (String(prev) !== String(id)) { 763 this.dispatchEvent(new CustomEvent('change', { detail: { prev: prev !== null ? String(prev) : null }, bubbles: true, composed: true })); 764 } 765 } 766 } 767 768 deselect(){ 769 this.selected = null; 770 const root = this.shadowRoot; 771 772 root.querySelector("#label-text").textContent = this.decodeHtmlEntities(this.label); 773 root.querySelector('.dropdown-label').classList.remove('error'); 774 775 root.querySelectorAll('.dropdown-option').forEach((o) => { 776 o.classList.remove('selected'); 777 o.setAttribute('aria-selected', 'false'); 778 }); 779 780 //if there is the hidden input, its value gets synced 781 if(this.hiddenInput){ 782 this.hiddenInput.value = ""; 783 } 784 785 const closeBtn = root.querySelector('.close-button'); 786 if(this._deselect && closeBtn) closeBtn.classList.add('hide'); 787 788 this.setExpandedState(false); 789 790 //dispatching change event on deselecting the item 791 this.dispatchEvent(new CustomEvent('change'), { 792 bubbles: true, 793 composed: true 794 }) 795 } 796 797 //setter 798 set value(v){ 799 this.setSelected(v); 800 } 801 802 //getter 803 get value(){
804 return this.getSelected(); 805 } 806 807 //render 808 render() { 809 //Attach empty container and style to the shadowRoot 810 const root = this.shadowRoot; 811 812 this.container.innerHTML = ''; 813 814 //Creates the label, using the custom label text and attaching the counters 815 const labelField = document.createElement('div'); 816 817 labelField.classList.add("dropdown-label"); 818 819 const labelIcon = document.createElement('span'); 820 labelIcon.id = 'label-icon'; 821 labelIcon.style.cssText = 'display:none; align-items:center; margin-right:6px; flex-shrink:0;'; 822 823 const span = document.createElement('span'); 824 span.id = 'label-text'; 825 const _selectedItem = this.selected != null ? this.items.find(el => el.id == this.selected) : null; 826 span.textContent = this.decodeHtmlEntities(_selectedItem ? _selectedItem.value : this.label); 827 828 labelField.appendChild(labelIcon); 829 labelField.appendChild(span); 830 831 const dropdown = document.createElement('div'); 832 dropdown.classList.add('dropdown-icon'); 833 834 dropdown.innerHTML = this.expanded ? this.iconUp : this.iconDown; 835 836 if(this._deselect){ 837 const dropdownWrapper = document.createElement('div'); 838 dropdownWrapper.classList.add('dropdown-wrapper'); 839 const closeButton = document.createElement('div'); 840 closeButton.classList.add('close-button'); 841 closeButton.classList.toggle('hide', this.selected == null); 842 closeButton.innerHTML = this.closeIcon; 843 844 dropdownWrapper.appendChild(closeButton); 845 dropdownWrapper.appendChild(dropdown); 846 labelField.appendChild(dropdownWrapper); 847 848 closeButton.addEventListener('click', (e) => { 849 e.stopPropagation(); 850 this.deselect(); 851 }); 852 }else { 853 labelField.appendChild(dropdown); 854 } 855 856 857 // leading icon 858 if(this.hasIcon){ 859 const labelWrapper = document.createElement('div'); 860 labelWrapper.classList.add('dropdown-label-wrapper'); 861 862 const iconWrapper = document.createElement('div'); 863 iconWrapper.classList.add('icon-slot'); 864 865 const slot = document.createElement('slot'); 866 slot.name = 'icon'; 867 iconWrapper.appendChild(slot); 868 labelWrapper.appendChild(iconWrapper); 869 870 //disable state 871 if(this.disabled) labelWrapper.classList.add('disabled'); 872 873 //error state 874 if(this.error) labelWrapper.classList.add('error'); 875 876 labelWrapper.appendChild(labelField); 877 this.container.appendChild(labelWrapper); 878 } else { 879 //disable state 880 if(this.disabled) labelField.classList.add('disabled'); 881 882 //error state 883 if(this.error) labelField.classList.add('error'); 884 885 this.container.appendChild(labelField); 886 } 887 888 // Always use the same structure: dropdown-list > (optional search bar) + true-list 889 const list = document.createElement("div"); 890 list.classList.add("dropdown-list"); 891 list.setAttribute('role', 'listbox'); 892 893 894 if(this.ordered){ 895 this.items.sort((a,b) => a.value.localeCompare(b.value)); 896 } 897 // Always add search bar if searchMode and shouldShowSearchBar() are true 898 if(this.searchMode && this.shouldShowSearchBar()){ 899 const searchWrapper = document.createElement('div'); 900 searchWrapper.classList.add('dropdown-search'); 901 if(this.expanded) searchWrapper.classList.add('expanded'); 902 903 const searchInput = document.createElement('input-element'); 904 searchInput.setAttribute('type', 'text'); 905 searchInput.setAttribute('placeholder', 'Search..'); 906 searchInput.setAttribute('name', 'search-input'); 907 // Suppress the browser's saved-entry popup, which renders over the options. 908 searchInput.setAttribute('autocomplete', 'off'); 909 910 // Trailing "X" to clear the search; hidden until the field has text. 911 const clearBtn = document.createElement('button'); 912 clearBtn.classList.add('search-clear', 'hide'); 913 clearBtn.setAttribute('type', 'button'); 914 clearBtn.setAttribute('aria-label', 'Clear search'); 915 clearBtn.innerHTML = `<i class='uil uil-times'></i>`; 916 917 const focusSearchInput = () => { 918 searchInput.shadowRoot?.querySelector('.input-field')?.focus(); 919 }; 920 921 clearBtn.addEventListener('click', (e) => { 922 e.stopPropagation(); 923 this.clearSearch(); 924 clearBtn.classList.add('hide'); 925 this.searchEvent(); 926 focusSearchInput(); 927 }); 928 929 clearBtn.addEventListener('keydown', (e) => { 930 if (e.key === 'Enter' || e.key === ' ') { 931 e.preventDefault(); 932 e.stopPropagation(); 933 this.clearSearch(); 934 clearBtn.classList.add('hide'); 935 this.searchEvent(); 936 focusSearchInput(); 937 } 938 }); 939 940 const inputWrapper = document.createElement('div'); 941 inputWrapper.classList.add('search-input-wrapper'); 942 inputWrapper.appendChild(searchInput); 943 inputWrapper.appendChild(clearBtn); 944
945 searchWrapper.appendChild(inputWrapper); 946 list.appendChild(searchWrapper); 947 } 948 949 // Optional "+..." affordance rendered under the search bar. 950 if(this.addNewLabel){ 951 const addNew = document.createElement('div'); 952 addNew.classList.add('dropdown-add-new-row'); 953 addNew.innerHTML = `<i class="uil uil-plus"></i><span>${this.decodeHtmlEntities(this.addNewLabel)}</span>`; 954 addNew.addEventListener('click', (e) => { 955 e.stopPropagation(); 956 this.setExpandedState(false); 957 this.dispatchEvent(new CustomEvent('add-new', { bubbles: true, composed: true })); 958 }); 959 list.appendChild(addNew); 960 } 961 962 // Always add a scrollable true-list for items 963 const trueList = document.createElement('div'); 964 trueList.classList.add('true-list'); 965 trueList.setAttribute('role', 'listbox'); 966 967 let opt; 968 969 this.items.forEach((item) => { 970 let key = item.id; 971 let value = item.value; 972 let pill = ""; 973 974 if("pill" in item) { 975 pill = item.pill; 976 } 977 978 // isHtml: only set this from trusted, non-user-controlled markup - this bypasses HTML escaping 979 const isHtml = item.isHtml === true; 980 981 opt = document.createElement("div"); 982 opt.classList.add("dropdown-option"); 983 984 if(pill == "") { 985 if (item.iconHtml) { 986 const iconSpan = document.createElement('span'); 987 iconSpan.style.cssText = 'display:inline-flex; align-items:center; margin-right:6px; flex-shrink:0;'; 988 iconSpan.innerHTML = item.iconHtml; 989 opt.appendChild(iconSpan); 990 opt.style.display = 'flex'; 991 opt.style.alignItems = 'center'; 992 } 993 const textSpan = document.createElement('span'); 994 if(isHtml) textSpan.innerHTML = value; 995 else textSpan.textContent = this.decodeHtmlEntities(value); 996 opt.appendChild(textSpan); 997 } else { 998 const innerLabel = document.createElement('span'); 999 innerLabel.classList.add('dropdown-option-with-pill-text'); 1000 if(isHtml) innerLabel.innerHTML = value; 1001 else innerLabel.textContent = this.decodeHtmlEntities(value); 1002 const pillElement = document.createElement('pill-element'); 1003 pillElement.setAttribute('gray', ''); 1004 pillElement.textContent = this.decodeHtmlEntities(pill); 1005 opt.appendChild(innerLabel); 1006 opt.appendChild(pillElement); 1007 opt.classList.add("dropdown-option-with-pill"); 1008 } 1009 1010 opt.dataset.id = key; 1011 opt.id = `option-${key}`; 1012 opt.setAttribute('role', 'option'); 1013 opt.setAttribute('tabindex', this.expanded ? '0' : '-1'); 1014 opt.setAttribute('aria-selected', key == this.selected ? 'true' : 'false'); 1015 1016 if(key == this.selected) opt.classList.add("selected"); 1017 1018 trueList.appendChild(opt); 1019 1020 //set click event for each element 1021 opt.addEventListener('click', (e) => { 1022 this.setSelected(key); 1023 }); 1024 1025 //preview the trigger label as keyboard focus moves through items 1026 opt.addEventListener('focus', () => { 1027 this._previewLabel(value, isHtml); 1028 }); 1029 1030 opt.addEventListener('keydown', (e) => { 1031 if(e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar'){ 1032 e.preventDefault(); 1033 e.stopPropagation(); 1034 this.setSelected(key); 1035 this.focus(); 1036 } 1037 }); 1038 }); 1039 1040 //empty state 1041 if(this.items.length == 0) trueList.innerHTML = this.emptyStateElement('No selection available.'); 1042 1043 list.appendChild(trueList); 1044 this.container.appendChild(list); 1045 1046 //initial expanded state 1047 if(this.expanded){ 1048 if(this.hasIcon){ 1049 labelWrapper.classList.add('expanded'); 1050 }else { 1051 labelField.classList.add('expanded'); 1052 } 1053 list.classList.add('expanded'); 1054 } 1055 1056 this.list = this.overlayContainer = root.querySelector('.dropdown-list'); 1057 1058 // If the dropdown was rendered already expanded (e.g. expanded attr), 1059 // anchor the popup to the trigger now that this.list is bound. 1060 if (this.expanded) this._positionDropdownList(); 1061 1062 // size variants, small, medium (default), large 1063 switch(this.size){ 1064 case 'small': 1065 labelField.style.height = "32px"; 1066 break; 1067 case 'medium': 1068 labelField.style.height = "40px"; 1069 labelField.style.boxSizing = "border-box"; 1070 break; 1071 case 'large': 1072 labelField.style.height = "48px"; 1073 break; 1074 } 1075 1076 this._ro = new ResizeObserver(() => { 1077 this._calculateMaxHeight(); 1078 }); 1079 1080 this._ro.observe(this.overlayContainer); 1081 1082 //clicking the label toggles the state 1083 root.querySelector('.dropdown-label').addEventListener('click', (e) => { 1084 if(this.disabled) return; 1085 1086 this.setExpandedState(!this.expanded); 1087 }); 1088 1089 //Search event listener 1090 if(this.searchMode){ 1091 const searchInput = root.querySelector('input-element[name="search-input"]'); 1092 if(searchInput){ 1093 searchInput.addEventListener('input', () => { 1094 this.searchEvent(); 1095 }); 1096 } 1097 } 1098 } 1099 1100 //Search event implementation - filters items based on search term 1101 searchEvent(){ 1102 if(!this.searchMode) return; 1103
1104 const term = this.getSearchBarValue().toLowerCase(); 1105 const root = this.shadowRoot; 1106 1107 // Show the clear "X" only while the field has text 1108 const clearBtn = root.querySelector('.search-clear'); 1109 if(clearBtn) clearBtn.classList.toggle('hide', term.length === 0); 1110 1111 // Filter dropdown options based on search term 1112 const options = root.querySelectorAll('.dropdown-option'); 1113 options.forEach(option => { 1114 const text = option.textContent.toLowerCase(); 1115 if(text.includes(term)){ 1116 option.style.display = ''; 1117 } else { 1118 option.style.display = 'none'; 1119 } 1120 }); 1121 1122 } 1123 1124 //empty state internal function 1125 emptyStateElement(text){ 1126 const emptyState = document.createElement("span"); 1127 emptyState.classList.add("empty-state-disclaimer"); 1128 emptyState.textContent = text; 1129 1130 return emptyState.outerHTML; 1131 } 1132} 1133 1134// Define the custom element 1135if(!customElements.get("dropdown-element")){ 1136 customElements.define("dropdown-element", BasicDropdown); 1137}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.