PageSourceSearch

https://www.eventeny.com/js/Components/BasicDropdown.js?v=1790189618

js eventeny.com collected 2026-09-24 08:54:40 UTC 41,889 bytes, 1,137 lines download raw bytes

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.