PageSourceSearch

https://www.eventeny.com/js/Components/Base/BaseDropdowns.js

js eventeny.com collected 2026-09-24 08:56:54 UTC 18,107 bytes, 504 lines download raw bytes

1/**
2 * @file BaseDropdowns.js
3 * @description Base dropdowns component. Common methods and attributes shared between BasicDropdown and MultiSelect
4 *
5 * @class BaseDropdowns
6 * @extends WebComponent
7 *
8 */
9
10import { WebComponent } from '../../../js/Components/Base/WebComponent.js';
11
12export class BaseDropdowns extends WebComponent {
13
14    constructor() {
15        super();
16        this.loadBaseCSS();
17        this.loadJQuery();
18        this.loadBaseJS();
19        this.loadIcons();
20
21        this.iconDown = `<i class='uil uil-angle-down'></i>`;
22        this.iconUp = `<i class='uil uil-angle-up'></i>`;
23        this.closeIcon = `<i class='uil uil-multiply'></i>`;
24
25        this.items = {};
26
27        this.baseStyle = `
28            .container {
29                font-family: 'Plus Jakarta Sans';
30                position:relative;
31            }
32
33            .dropdown-label,
34            .dropdown-label-wrapper {
35                align-items: center;
36                font-weight: 400;
37                font-size: 16px;
38                height: 40px;
39                cursor: pointer;
40                border: 1px solid #9DA6A5;
41                border-radius: 4px;
42                width: unset;
43            }
44            
45            .dropdown-label-wrapper {
46                display: grid;
47                grid-template-columns: auto 1fr;
48            }
49
50            .dropdown-label {
51                display: flex;
52                justify-content: space-between;
53                padding: 0 12px;
54                background: var(--background-color, #FFFFFF);
55            }
56
57            .dropdown-label-wrapper .dropdown-label {
58                border: none;
59            }
60
61            .dropdown-label:hover,
62            .dropdown-label-wrapper:hover {
63                border: 1px solid #3F4242;
64            }
65
66            .dropdown-label-wrapper .dropdown-label:hover {
67                border: none;
68            }
69
70            .dropdown-label.disabled,
71            .dropdown-label-wrapper.disabled {
72                border: 1px solid #D8DBDB;
73                background-color: #D8DBDB;
74                color: #9DA6A5;
75            }
76
77            .dropdown-label.error,
78            .dropdown-label-wrapper.error,
79            :host([error]) .dropdown-label,
80            :host([error]) .dropdown-label-wrapper {
81                border: 1px solid #D63952;
82            }
83
84            .dropdown-label.expanded,
85            .dropdown-label-wrapper.expanded,
86            :host(:focus) .dropdown-label,
87            :host(:focus) .dropdown-label-wrapper {
88                border: 1px solid #0389A0;
89            }
90
91            /* Visible focus ring for keyboard users on the dropdown trigger.
92               The soft halo keeps the ring obvious against bordered inputs.
93               Focus lands on the host (tabindex=0), so the ring is hung off
94               :host(:focus-visible) and projected onto the inner label. */
95            :host(:focus-visible) {
96                outline: none;
97                border-radius: 4px;
98            }
99            :host(:focus-visible) .dropdown-label,
100            :host(:focus-visible) .dropdown-label-wrapper,
101            .dropdown-label:focus-visible,
102            .dropdown-label-wrapper:focus-visible {
103                outline: 2px solid #0389A0;
104                outline-offset: 2px;
105                box-shadow: 0 0 0 4px rgba(3, 137, 160, 0.2);
106            }
107
108            /* Visible focus ring for keyboard users on individual options.
109               Combines the outline with a tinted background so the currently
110               focused option stands out even when several rows look similar. */
111            .dropdown-option:focus-visible {
112                outline: 2px solid #0389A0;
113                outline-offset: -2px;
114                background-color: #E6F6F5;
115            }
116
117            .dropdown-wrapper {
118                display: flex;
119                gap: 12px;
120                align-items: center;
121            }
122
123            .close-button {
124                display:block;
125                background: none;
126                border: none;
127                cursor: pointer;
128                font-size: 20px;
129                padding: 0;
130            }
131
132            .close-button.hide {
133                display: none;
134            }
135
136            .icon-slot {
137                display: flex;
138                font-size: 16px;
139                width: 40px;
140                background: #F3F4F6;
141                align-items: center;
142                justify-content: center;
143                height: 100%;
144                border: none;
145                border-right: 1px solid #9DA6A5;
146                border-top-left-radius: 4px;
147                border-bottom-left-radius: 4px;
148            }
149
150            .dropdown-list {
151               position: absolute;
152               top: 100%;
153               left: 0;
154               width:100%;
155
156               display:none;
157               visibility: hidden;
158               transform: scaleY(0.95);
159               pointer-events: none;
160
161               border: 1px solid #D1D5DB;
162               box-shadow: 0px 4px 16px 0px rgba(34, 34, 34, 0.2);
163               border-radius: 4px;
164               background: #FFF;
165               z-index: 999;
166               padding: 4px 0;
167            }
168
169            .dropdown-list.expanded {
170                display:block;
171                visibility: visible;
172                transform: scaleY(1);
173                pointer-events: auto;
174            }
175
176            .dropdown-option {
177                padding: 8px;
178                margin: 4px 8px;
179                border-radius: 4px;
180                cursor:pointer;
181                white-space: normal;
182                overflow-wrap: anywhere;
183                word-break: break-word;
184            }
185                
186            .dropdown-option-with-pill {
187                display:grid;
188                grid-template-columns: 1fr auto;
189                gap: 8px;
190                align-items:center;
191            }
192
193            .dropdown-option-with-pill-text {
194                overflow-wrap: break-word;
195                min-width: 0;
196            }
197
198            .empty-state-disclaimer {
199                justify-self: center;
200                display: block;
201                font-size:14px;
202                padding: 20px;
203            }
204
205            .dropdown-icon {
206                font-size: 24px;
207                width: 24px;
208                position: relative;
209                cursor: pointer;
210            }
211
212            .dropdown-option:hover {
213                background-color: #EBEDED;
214            }
215
216            .dropdown-option.selected {
217                background-color: #E6F6F5;
218            }
219
220            .dropdown-option:focus {
221                outline: none;
222                background-color: var(--gray-100);
223                box-shadow: inset 0 0 0 2px var(--border-focused);
224            }
225
226            .dropdown-search {
227                display: none;
228                padding: 0.5rem;
229            }
230
231            .dropdown-search.expanded {
232                display: block;
233            }
234
235            :host([table-inline]) {
236                display: block;
237                width: fit-content;
238                min-width: var(--inline-min-width, 80px);
239            }
240
241            :host([table-inline]) .dropdown-label,
242            :host([table-inline]) .dropdown-label:hover,
243            :host([table-inline]) .dropdown-label.expanded,
244            :host([table-inline]) .dropdown-label.expanded:hover {
245                border: none;
246            }
247
248            :host([table-inline]) .dropdown-label {
249                height: unset;
250                min-height: 24px;
251                background: transparent;
252                padding: 2px 6px;
253                font-size: 14px;
254                gap: 4px;
255            }
256
257            :host([table-inline]) .dropdown-label:hover {
258                background: rgba(0, 0, 0, 0.04);
259                border-radius: 4px;
260            }
261
262            :host([table-inline]) .dropdown-option {
263                padding: 4px 6px;
264                margin: 2px 4px;
265            }
266
267            :host([table-inline]) .dropdown-wrapper {
268                gap: 6px;
269            }
270
271            :host([table-inline]) .dropdown-icon {
272                font-size: 18px;
273                width: 18px;
274            }
275
276            :host([no-clear]) .close-button {
277                display: none !important;
278            }
279
280            :host([table-inline]) .dropdown-list {
281                position: fixed;
282                width: auto;
283            }
284        `;
285    }
286
287    _baseAttributes(){
288        //Get attributes
289        this.searchMode = this.hasAttribute('search');
290        this.explicitSearch = this.hasAttribute('search');
291        this.label = this.getAttribute('label') ?? 'Label';
292        this.disabled = this.hasAttribute('disabled');
293        this.error = this.hasAttribute('error');
294        this.expanded = this.hasAttribute('expanded');
295        this.needsLayout = true;
296        this.size = this.hasAttribute('size') ? this.getAttribute('size') : 'medium';
297
298        this._handleClickOutside = this._handleClickOutside.bind(this);
299        document.addEventListener('click', this._handleClickOutside);
300
301        this._isWheelScrolling = false;
302        this._wheelScrollTimer = null;
303        this._handleWheel = () => {
304            this._isWheelScrolling = true;
305            clearTimeout(this._wheelScrollTimer);
306            this._wheelScrollTimer = setTimeout(() => { this._isWheelScrolling = false; }, 200);
307        };
308        window.addEventListener('wheel', this._handleWheel, { passive: true, capture: true });
309
310        this._justExpanded = false;
311        this._justExpandedTimer = null;
312
313        this._handleScroll = (e) => {
314            if (!this.expanded) return;
315            // Ignore the scroll we trigger ourselves to bring the trigger into view
316            // on open; otherwise it would immediately close the just-opened dropdown.
317            if (this._suppressScrollClose) return;
318            if (e.target === this || this.contains(e.target)) return;
319            // When inside a modal, the list is rendered in the modal body's flow, so
320            // scrolling the modal to reveal it must not dismiss it. Test the composed
321            // path (rather than the retargeted target) so this holds even when the
322            // modal is itself nested inside another component's shadow root.
323            const modal = this.closest('modal-element');
324            if (modal && e.composedPath().includes(modal)) return;
325            // Don't close when the user is wheel-scrolling, or when the dropdown just opened
326            // and the browser is auto-scrolling the parent to accommodate the new layout.
327            if ((this._isWheelScrolling || this._justExpanded) && e.target !== document && e.target.contains && e.target.contains(this)) return;
328            this.setExpandedState(false);
329        };
330        if (!this.hasAttribute('portal')) {
331            window.addEventListener('scroll', this._handleScroll, true);
332        }
333
334        this.inlineVariant = this.hasAttribute('table-inline');
335        if (this.inlineVariant) this._setupTableInlineScrollHandler();
336
337        let name = this.getAttribute('name');
338
339        this.hiddenInput = null;
340
341        //if the element has a name, an hidden input is created containing the value. This can be used for FormData
342        if(name){
343            this.hiddenInput = document.createElement('input');
344            this.hiddenInput.type = 'hidden';
345            this.hiddenInput.name = name;
346            this.after(this.hiddenInput);
347        }
348    }
349
350    disconnectedCallback(){
351        document.removeEventListener('click', this._handleClickOutside);
352        window.removeEventListener('scroll', this._handleScroll, true);
353        window.removeEventListener('wheel', this._handleWheel, { capture: true });
354        clearTimeout(this._wheelScrollTimer);
355        clearTimeout(this._justExpandedTimer);
356        if(this._ro) this._ro.disconnect();
357    }
358
359    //click outside the shadow dom will close the dropdown menu
360    _handleClickOutside(event) {
361        // Nothing to close if it's already closed. This listener is registered on
362        // `document` per instance, so on a page with many dropdowns/multi-selects
363        // (e.g. every row of a table) every single click anywhere re-runs this for
364        // every instance -- without this guard, each already-closed one still pays
365        // for composedPath() + two querySelector() calls and a full setExpandedState(false)
366        // (which, for tagVariant instances, rebuilds the tag chips via updateTags()),
367        // even though nothing was open and nothing changes.
368        if (!this.expanded) return;
369
370        const path = event.composedPath();
371        const root = this.shadowRoot;
372
373        if(
374            !path.includes(root.querySelector('.dropdown-label')) &&
375            !path.includes(root.querySelector('.dropdown-list'))
376            ){
377            this.setExpandedState(false);
378        }
379    }
380
381    //this method sets a max height for the dropdown list under the condition the elment is visible and there are more than 7 elements
382    _calculateMaxHeight() {
383        if(this.needsLayout && this.expanded && this.overlayContainer && this.offsetHeight > 0 && this.overlayContainer.offsetHeight > 0){
384            this.needsLayout = false;
385
386            let offsetId;
387            if(Array.isArray(this.items)) {
388                offsetId = this.items.length >= 7 ? this.items[6].id : null;
389            } else {
390                let keys = Object.keys(this.items);
391                
392                if(this.entries){
393                    keys = this.entries.map(([key])=> key);
394                }
395
396                offsetId = keys.length >= 7 ? keys[6] : null; 
397            }
398            
399            //if list has more than 7 element I need to fix the height and add overflowY
400            if(offsetId){
401                const item = this.shadowRoot.querySelector(`#option-${CSS.escape(offsetId)}`);
402                let maxHeight = item.offsetTop + item.offsetHeight;
403                this.list.style.maxHeight = `${maxHeight}px`;
404                this.list.style.overflowY = 'auto';
405            }
406        }
407    }
408
409    //safely renders html entities and returns the values that can be used as textContent
410    decodeHtmlEntities(str) {
411        const txt = document.createElement('textarea');
412        txt.innerHTML = str;
413        return txt.value;
414    }
415  
416    //Search functionality - gets the value from the search bar
417    getSearchBarValue(){
418        const search = this.shadowRoot.querySelector('input-element[name="search-input"]');
419        return search ? search.getValue() : '';
420    }
421
422    //Search functionality - clears the search input
423    clearSearch() {
424        const search = this.shadowRoot.querySelector('input-element[name="search-input"]');
425        if (search) {
426            search.reset();
427        }
428    }
429
430    //Search functionality - determines if search bar should be shown
431    // default: 10+ items; opt-in `search-always` attribute forces it regardless
432    // (useful for tiny lists where the user still wants to type-to-filter)
433    shouldShowSearchBar() {
434        if (this.hasAttribute('search-always')) return true;
435        return this.getItemsNumber() >= 10;
436    }
437
438    _parseCustomOptions() {
439        const options = this.querySelectorAll('custom-option');
440        if (!options.length) return [];
441
442        return Array.from(options).map(el => {
443            el.hidden = true;
444            const id = el.getAttribute('id') || el.getAttribute('value');
445            if (!id) return null;
446            return {
447                id,
448                label: el.textContent.trim(),
449                pill: el.getAttribute('pill') || '',
450                checked: el.hasAttribute('checked') || el.hasAttribute('selected')
451            };
452        }
452).filter(Boolean);
453    }
454
455    _setupTableInlineScrollHandler() {
456        window.removeEventListener('scroll', this._handleScroll, true);
457        this._handleScroll = (e) => {
458            if (!this.expanded) return;
459            const container = this.closest('.list-table-container');
460            if (container && e.target === container) {
461                const list = this.shadowRoot.querySelector('.dropdown-list');
462                if (list) list.style.transform = `translateX(${-container.scrollLeft}px) translateY(${-container.scrollTop}px)`;
463            } else {
464                if (e.target === this || this.contains(e.target)) return;
465                this.setExpandedState(false);
466            }
467        };
468        window.addEventListener('scroll', this._handleScroll, true);
469    }
470
471    _positionFixedDropdown() {
472        const list = this.shadowRoot.querySelector('.dropdown-list');
473        if (!list) return;
474        const container = this.closest('.list-table-container');
475        if (container) list.style.transform = `translateX(${-container.scrollLeft}px) translateY(${-container.scrollTop}px)`;
476        list.style.minWidth = `${Math.max(this.getBoundingClientRect().width, 200)}px`;
477    }
478
479    // A table-inline popup lives inside the table's tbody, which isolates its own
480    // stacking context (see ListTable.js) so a table overlay (e.g. the custom
481    // horizontal scrollbar) with an explicit z-index always paints above it, no
482    // matter how high this popup's own z-index goes. Raising the popup's z-index
483    // cannot fix that, so this notifies an ancestor list-table to yield its own
484    // overlays instead while the popup is open.
485    _notifyInlineToggle(isExpanded) {
486        if (!this.inlineVariant) return;
487        this.dispatchEvent(new CustomEvent('inline-dropdown-toggle', {
488            bubbles: true,
489            composed: true,
490            detail: { expanded: isExpanded }
491        }));
492    }
493
494    //Search functionality - gets the total number of items
495    getItemsNumber() {
496        return Object.keys(this.items).length;
497    }
498
499    //Search functionality - override this in child classes to implement search logic
500    searchEvent() {
501        // To be implemented by child classes
502        console.warn('searchEvent() should be implemented by child class');
503    }
504}

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.