PageSourceSearch

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

js eventeny.com collected 2026-10-01 10:28:14 UTC 27,806 bytes, 730 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    /**
15     * Shortest scroller band, in px, worth confining an open panel to. A container shorter
16     * than this cannot hold a usable panel, and squeezing one into it is the very thing fixed
17     * positioning exists to avoid -- so the panel escapes to the viewport instead.
18     */
19    static MIN_BOUNDED_BAND = 240;
20
21    /**
22     * Least room, in px, worth showing a panel in -- roughly a search bar and a couple of
23     * rows. Below this the panel is hidden until scrolling frees space up again, rather than
24     * squashed to a sliver or slid over its own trigger to find room elsewhere. The same
25     * threshold decides which side a panel opens towards, so open and scroll agree on what
26     * counts as usable.
27     */
28    static MIN_PANEL_ROOM = 160;
29
30    constructor() {
31        super();
32        this.loadBaseCSS();
33        this.loadJQuery();
34        this.loadBaseJS();
35        this.loadIcons();
36
37        this.iconDown = `<i class='uil uil-angle-down'></i>`;
38        this.iconUp = `<i class='uil uil-angle-up'></i>`;
39        this.closeIcon = `<i class='uil uil-multiply'></i>`;
40
41        this.items = {};
42
43        this.baseStyle = `
44            .container {
45                font-family: 'Plus Jakarta Sans';
46                position:relative;
47            }
48
49            .dropdown-label,
50            .dropdown-label-wrapper {
51                align-items: center;
52                font-weight: 400;
53                font-size: 16px;
54                height: 40px;
55                cursor: pointer;
56                border: 1px solid #9DA6A5;
57                border-radius: 4px;
58                width: unset;
59            }
60            
61            .dropdown-label-wrapper {
62                display: grid;
63                grid-template-columns: auto 1fr;
64            }
65
66            .dropdown-label {
67                display: flex;
68                justify-content: space-between;
69                padding: 0 12px;
70                background: var(--background-color, #FFFFFF);
71            }
72
73            .dropdown-label-wrapper .dropdown-label {
74                border: none;
75            }
76
77            .dropdown-label:hover,
78            .dropdown-label-wrapper:hover {
79                border: 1px solid #3F4242;
80            }
81
82            .dropdown-label-wrapper .dropdown-label:hover {
83                border: none;
84            }
85
86            .dropdown-label.disabled,
87            .dropdown-label-wrapper.disabled {
88                border: 1px solid #D8DBDB;
89                background-color: #D8DBDB;
90                color: #9DA6A5;
91            }
92
93            .dropdown-label.error,
94            .dropdown-label-wrapper.error,
95            :host([error]) .dropdown-label,
96            :host([error]) .dropdown-label-wrapper {
97                border: 1px solid #D63952;
98            }
99
100            .dropdown-label.expanded,
101            .dropdown-label-wrapper.expanded,
102            :host(:focus) .dropdown-label,
103            :host(:focus) .dropdown-label-wrapper {
104                border: 1px solid #0389A0;
105            }
106
107            /* Visible focus ring for keyboard users on the dropdown trigger.
108               The soft halo keeps the ring obvious against bordered inputs.
109               Focus lands on the host (tabindex=0), so the ring is hung off
110               :host(:focus-visible) and projected onto the inner label. */
111            :host(:focus-visible) {
112                outline: none;
113                border-radius: 4px;
114            }
115            :host(:focus-visible) .dropdown-label,
116            :host(:focus-visible) .dropdown-label-wrapper,
117            .dropdown-label:focus-visible,
118            .dropdown-label-wrapper:focus-visible {
119                outline: 2px solid #0389A0;
120                outline-offset: 2px;
121                box-shadow: 0 0 0 4px rgba(3, 137, 160, 0.2);
122            }
123
124            /* Visible focus ring for keyboard users on individual options.
125               Combines the outline with a tinted background so the currently
126               focused option stands out even when several rows look similar. */
127            .dropdown-option:focus-visible {
128                outline: 2px solid #0389A0;
129                outline-offset: -2px;
130                background-color: #E6F6F5;
131            }
132
133            .dropdown-wrapper {
134                display: flex;
135                gap: 12px;
136                align-items: center;
137            }
138
139            .close-button {
140                display:block;
141                background: none;
142                border: none;
143                cursor: pointer;
144                font-size: 20px;
145                padding: 0;
146            }
147
148            .close-button.hide {
149                display: none;
150            }
151
152            .icon-slot {
153                display: flex;
154                font-size: 16px;
155                width: 40px;
156                background: #F3F4F6;
157                align-items: center;
158                justify-content: center;
159                height: 100%;
160                border: none;
161                border-right: 1px solid #9DA6A5;
162                border-top-left-radius: 4px;
163                border-bottom-left-radius: 4px;
164            }
165
166            .dropdown-list {
167               position: absolute;
168               top: 100%;
169               left: 0;
170               width:100%;
171
172               display:none;
173               visibility: hidden;
174               transform: scaleY(0.95);
175               pointer-events: none;
176
177               border: 1px solid #D1D5DB;
178               box-shadow: 0px 4px 16px 0px rgba(34, 34, 34, 0.2);
179               border-radius: 4px;
180               background: #FFF;
181               z-index: 999;
182               padding: 4px 0;
183            }
184
185            .dropdown-list.expanded {
186                display:block;
187                visibility: visible;
188                transform: scaleY(1);
189                pointer-events: auto;
190            }
191
192            .dropdown-option {
193                padding: 8px;
194                margin: 4px 8px;
195                border-radius: 4px;
196                cursor:pointer;
197                white-space: normal;
198                overflow-wrap: anywhere;
199                word-break: break-word;
200            }
201                
202            .dropdown-option-with-pill {
203                display:grid;
204                grid-template-columns: 1fr auto;
205                gap: 8px;
206                align-items:center;
207            }
208
209            .dropdown-option-with-pill-text {
210                overflow-wrap: break-word;
211                min-width: 0;
212            }
213
214            .empty-state-disclaimer {
215                justify-self: center;
216                display: block;
217                font-size:14px;
218                padding: 20px;
219            }
220
221            .dropdown-icon {
222                font-size: 24px;
223                width: 24px;
224                position: relative;
225                cursor: pointer;
226            }
227
228            .dropdown-option:hover {
229                background-color: #EBEDED;
230            }
231
232            .dropdown-option.selected {
233                background-color: #E6F6F5;
234            }
235
236            .dropdown-option:focus {
237                outline: none;
238                background-color: var(--gray-100);
239                box-shadow: inset 0 0 0 2px var(--border-focused);
240            }
241
242            .dropdown-search {
243                display: none;
244                padding: 0.5rem;
245            }
246
247            .dropdown-search.expanded {
248                display: block;
249            }
250
251            :host([table-inline]) {
252                display: block;
253                width: fit-content;
254                min-width: var(--inline-min-width, 80px);
255            }
256
257            :host([table-inline]) .dropdown-label,
258            :host([table-inline]) .dropdown-label:hover,
259            :host([table-inline]) .dropdown-label.expanded,
260            :host([table-inline]) .dropdown-label.expanded:hover {
261                border: none;
262            }
263
264            :host([table-inline]) .dropdown-label {
265                height: unset;
266                min-height: 24px;
267                background: transparent;
268                padding: 2px 6px;
269                font-size: 14px;
270                gap: 4px;
271            }
272
273            :host([table-inline]) .dropdown-label:hover {
274                background: rgba(0, 0, 0, 0.04);
275                border-radius: 4px;
276            }
277
278            :host([table-inline]) .dropdown-option {
279                padding: 4px 6px;
280                margin: 2px 4px;
281            }
282
283            :host([table-inline]) .dropdown-wrapper {
284                gap: 6px;
285            }
286
287            :host([table-inline]) .dropdown-icon {
288                font-size: 18px;
289                width: 18px;
290            }
291
292            :host([no-clear]) .close-button {
293                display: none !important;
294            }
295
296            :host([table-inline]) .dropdown-list {
297                position: fixed;
298                width: auto;
299            }
300        `;
301    }
302
303    _baseAttributes(){
304        //Get attributes
305        this.searchMode = this.hasAttribute('search');
306        this.explicitSearch = this.hasAttribute('search');
307        this.label = this.getAttribute('label') ?? 'Label';
308        this.disabled = this.hasAttribute('disabled');
309        this.error = this.hasAttribute('error');
310        this.expanded = this.hasAttribute('expanded');
311        this.needsLayout = true;
312        this.size = this.hasAttribute('size') ? this.getAttribute('size') : 'medium';
313
314        this._handleClickOutside = this._handleClickOutside.bind(this);
315        document.addEventListener('click', this._handleClickOutside);
316
317        this._isWheelScrolling = false;
318        this._wheelScrollTimer = null;
319        this._handleWheel = () => {
320            this._isWheelScrolling = true;
321            clearTimeout(this._wheelScrollTimer);
322            this._wheelScrollTimer = setTimeout(() => { this._isWheelScrolling = false; }, 200);
323        };
324        window.addEventListener('wheel', this._handleWheel, { passive: true, capture: true });
325
326        this._justExpanded = false;
327        this._justExpandedTimer = null;
328
329        this._handleScroll = (e) => {
330            if (!this.expanded) return;
331            // Ignore the scroll we trigger ourselves to bring the trigger into view
332            // on open; otherwise it would immediately close the just-opened dropdown.
333            if (this._suppressScrollClose) return;
334            if (e.target === this || this.contains(e.target)) return;
335            // When inside a modal, the list is rendered in the modal body's flow, so
336            // scrolling the modal to reveal it must not dismiss it. Test the composed
337            // path (rather than the retargeted target) so this holds even when the
338            // modal is itself nested inside another component's shadow root.
339            const modal = this.closest('modal-element');
340            if (modal && e.composedPath().includes(modal)) return;
341            // Don't close when the user is wheel-scrolling, or when the dropdown just opened
342            // and the browser is auto-scrolling the parent to accommodate the new layout.
343            if ((this._isWheelScrolling || this._justExpanded) && e.target !== document && e.target.contains && e.target.contains(this)) return;
344            this.setExpandedState(false);
345        };
346        if (!this.hasAttribute('portal')) {
347            window.addEventListener('scroll', this._handleScroll, true);
348        }
349
350        this.inlineVariant = this.hasAttribute('table-inline');
351        if (this.inlineVariant) this._setupTableInlineScrollHandler();
352
353        let name = this.getAttribute('name');
354
355        this.hiddenInput = null;
356
357        //if the element has a name, an hidden input is created containing the value. This can be used for FormData
358        if(name){
359            this.hiddenInput = document.createElement('input');
360            this.hiddenInput.type = 'hidden';
361            this.hiddenInput.name = name;
362            this.after(this.hiddenInput);
363        }
364    }
365
366    disconnectedCallback(){
367        document.removeEventListener('click', this._handleClickOutside);
368        window.removeEventListener('scroll', this._handleScroll, true);
369        window.removeEventListener('wheel', this._handleWheel, { capture: true });
370        this._unbindAncestorScroll();
371        clearTimeout(this._wheelScrollTimer);
372        clearTimeout(this._justExpandedTimer);
373        if(this._ro) this._ro.disconnect();
374    }
375
376    //click outside the shadow dom will close the dropdown menu
377    _handleClickOutside(event) {
378        // Nothing to close if it's already closed. This listener is registered on
379        // `document` per instance, so on a page with many dropdowns/multi-selects
380        // (e.g. every row of a table) every single click anywhere re-runs this for
381        // every instance -- without this guard, each already-closed one still pays
382        // for composedPath() + two querySelector() calls and a full setExpandedState(false)
383        // (which, for tagVariant instances, rebuilds the tag chips via updateTags()),
384        // even though nothing was open and nothing changes.
385        if (!this.expanded) return;
386
387        const path = event.composedPath();
388        const root = this.shadowRoot;
389
390        if(
391            !path.includes(root.querySelector('.dropdown-label')) &&
392            !path.includes(root.querySelector('.dropdown-list'))
393            ){
394            this.setExpandedState(false);
395        }
396    }
397
398    //this method sets a max height for the dropdown list under the condition the elment is visible and there are more than 7 elements
399    _calculateMaxHeight() {
400        if(this.needsLayout && this.expanded && this.overlayContainer && this.offsetHeight > 0 && this.overlayContainer.offsetHeight > 0){
401            this.needsLayout = false;
402
403            let offsetId;
404            if(Array.isArray(this.items)) {
405                offsetId = this.items.length >= 7 ? this.items[6].id : null;
406            } else {
407                let keys = Object.keys(this.items);
408                
409                if(this.entries){
410                    keys = this.entries.map(([key])=> key);
411                }
412
413                offsetId = keys.length >= 7 ? keys[6] : null; 
414            }
415            
416            //if list has more than 7 element I need to fix the height and add overflowY
417            if(offsetId){
418                const item = this.shadowRoot.querySelector(`#option-${CSS.escape(offsetId)}`);
419                let maxHeight = item.offsetTop + item.offsetHeight;
420                this.list.style.maxHeight = `${maxHeight}px`;
421                this.list.style.overflowY = 'auto';
422            }
423        }
424    }
425
426    //safely renders html entities and returns the values that can be used as textContent
427    decodeHtmlEntities(str) {
428        const txt = document.createElement('textarea');
429        txt.innerHTML = str;
430        return txt.value;
431    }
432  
433    //Search functionality - gets the value from the search bar
434    getSearchBarValue(){
435        const search = this.shadowRoot.querySelector('input-element[name="search-input"]');
436        if (!search) return '';
437        // <input-element> may not be upgraded yet if BasicInput.js loads after this component
438        if (typeof search.getValue === 'function') return search.getValue();
439        return search.shadowRoot?.querySelector('.input-field')?.value ?? search.getAttribute('value') ?? '';
440    }
441
442    //Search functionality - clears the search input
443    clearSearch() {
444        const search = this.shadowRoot.querySelector('input-element[name="search-input"]');
445        if (!search) return;
446        // <input-element> may not be upgraded yet if BasicInput.js loads after this component
447        if (typeof search.reset === 'function') {
448            search.reset();
449            return;
450        }
451        const field = search.shadowRoot?.querySelector('.input-field');
452        if (field) field.value = '';
453        search.removeAttribute('value');
454    }
455
456    /**
457     * Ancestors up to <body>, walking the flattened tree so the chain crosses shadow
458     * boundaries. This component is often slotted into a modal whose scroll container lives in
459     * the modal's shadow DOM, which a plain parentElement walk never reaches.
460     * @return {Element[]}
461     */
462    _ancestorChain() {
463        const chain = [];
464        let node = this;
465        for (let i = 0; i < 30 && node; i++) {
466            const slot = node.assignedSlot;
467            let parent;
468            if (slot) {
469                parent = slot.parentElement;
470            } else {
471                parent = node.parentElement;
472                if (!parent) {
473                    const r = node.getRootNode();
474                    parent = (r instanceof ShadowRoot) ? r.host : null;
475                }
476            }
477            node = parent;
478            if (!node || node === document.body || node === document.documentElement) break;
479            if (node.nodeType === 1) chain.push(node);
480        }
481        return chain;
482    }
483
484    /**
485     * Ancestors that live inside a shadow root. `scroll` is not a composed event, so their
486     * scrolling never reaches a window listener and an open panel has to subscribe to them
487     * directly; light-DOM ancestors are left out because the window capture listener sees those.
488     *
489     * Deliberately not filtered by overflow: a modal body turns scrollable only once its
490     * content is tall, which a ResizeObserver can flip while the panel is already open, and a
491     * scroll event only fires on an element that actually scrolls.
492     * @return {Element[]}
493     */
494    _shadowScrollAncestors() {
495        return this._ancestorChain().filter(el => el.getRootNode() instanceof ShadowRoot);
496    }
497
498    /**
499     * Nearest ancestor that clips by scrolling. The panel escapes that clipping with
500     * position:fixed, so this is also the box it has to be kept inside.
501     * @return {?Element}
502     */
503    _scrollClipAncestor() {
504        return this._ancestorChain().find(el => {
505            const style = window.getComputedStyle(el);
506            return /(auto|scroll)/.test(style.overflowY + style.overflow);
507        }) ?? null;
508    }
509
510    /**
511     * Vertical band the open panel must stay within: the scroller it escaped, intersected with
512     * the viewport, so a panel following its trigger cannot ride out past a modal's edge.
513     * Stay inside the container when the container is roomy enough to hold a panel; escape to
514     * the viewport when it is not.
515     * @param  {?Element} scroller
516     * @return {{top: number, bottom: number}}
517     */
518    _panelBounds(scroller) {
519        const margin = 8;
520        const viewport = { top: margin, bottom: window.innerHeight - margin };
521        if (!scroller) return viewport;
522
523        const r = scroller.getBoundingClientRect();
524        const top = Math.max(viewport.top, r.top);
525        const bottom = Math.min(viewport.bottom, r.bottom);
526        return (bottom - top) < BaseDropdowns.MIN_BOUNDED_BAND ? viewport : { top, bottom };
527    }
528
529    /**
530     * Whether the trigger has scrolled out of the scroller's visible band. A fixed panel has
531     * nowhere sensible to sit once that happens, so callers hide it rather than leave it
532     * stranded against the edge.
533     * @param  {DOMRect}  rect     The trigger's box.
534     * @param  {?Element} scroller
535     * @return {boolean}
536     */
537    _anchorOutOfView(rect, scroller) {
538        if (!scroller) return false;
539        const r = scroller.getBoundingClientRect();
540        return rect.bottom <= r.top || rect.top >= r.bottom;
541    }
542
543    /**
544     * Room the band leaves below the trigger. Panels are capped to this and hidden when it
545     * runs out; they are never placed above the trigger, which reads as the panel appearing
546     * on top of the fields the user was looking at.
547     * @param  {DOMRect}                       rect The trigger's box.
548     * @param  {{top: number, bottom: number}} bounds
549     * @param  {number}                        [gap]
550     * @return {number}
551     */
552    _panelRoomBelow(rect, bounds, gap = 0) {
553        return bounds.bottom - rect.bottom - gap;
554    }
555
556    /**
557     * Scrolls the trigger up within its scroller until there is room for the panel beneath it.
558     * Opening strictly downwards needs this: without it, a trigger low in a modal would get a
559     * sliver of a panel, or none, where flipping over the trigger used to take up the slack.
560     * @param  {?Element} scroller
561     * @param  {DOMRect}  rect   The trigger's box.
562     * @param  {number}   needed Height the panel wants below the trigger.
563     * @return {void}
564     */
565    _scrollRoomBelow(scroller, rect, needed) {
566        if (!scroller) return;
567        const short = needed - this._panelRoomBelow(rect, this._panelBounds(scroller));
568        if (short <= 0) return;
569        const max = scroller.scrollHeight - scroller.clientHeight;
570        scroller.scrollTop = Math.min(scroller.scrollTop + short, max);
571    }
572
573    /**
574     * Records the option area's height while the panel is uncapped, so _fitPanelInner() can
575     * size against it later without clearing the cap to re-measure -- which w
575ould reset how
576     * far the user had scrolled through the options.
577     * @param  {HTMLElement} list
578     * @param  {?HTMLElement} inner
579     * @return {void}
580     */
581    _recordPanelMetrics(list, inner) {
582        if (!list || !inner) {
583            this._panelNaturalInner = null;
584            return;
585        }
586        this._panelNaturalInner = inner.offsetHeight;
587        this._panelOverhead = list.offsetHeight - inner.offsetHeight; // search bar + paddings
588    }
589
590    /**
591     * Sizes the option area to the room available, never past the height it had on open.
592     * Driven only by that remembered height and the room, both independent of the panel's
593     * current size, so it grows back as scrolling frees room up instead of ratcheting smaller
594     * -- and cannot oscillate, since neither input depends on the result.
595     * @param  {?HTMLElement} inner
596     * @param  {number}       room
597     * @return {void}
598     */
599    _fitPanelInner(inner, room) {
600        if (!inner || this._panelNaturalInner == null) return;
601        const fitted = Math.min(this._panelNaturalInner, Math.max(room - this._panelOverhead, 40));
602        const next = `${Math.round(fitted)}px`;
603        if (inner.style.maxHeight !== next) inner.style.maxHeight = next;
604        inner.style.overflowY = 'auto';
605    }
606
607    /**
608     * Hides or reveals the panel without collapsing it, so scrolling the trigger back into
609     * view brings it back with its search term and checkboxes intact.
610     * @param  {HTMLElement} list
611     * @param  {boolean}     hidden
612     * @return {void}
613     */
614    _setPanelHidden(list, hidden) {
615        if (!list) return;
616        list.style.visibility = hidden ? 'hidden' : '';
617        list.style.pointerEvents = hidden ? 'none' : '';
618    }
619
620    /**
621     * Takes the panel off screen because its trigger scrolled out of view. Collapses outright
622     * when focus is inside it: focus must not be left sitting on a hidden element, and pulling
623     * it back to the off-screen trigger would scroll the container against the user.
624     * @param  {HTMLElement} list
625     * @return {void}
626     */
627    _hidePanelOffAnchor(list) {
628        if (list && list.contains(this.shadowRoot.activeElement)) {
629            this.setExpandedState(false);
630            return;
631        }
632        this._setPanelHidden(list, true);
633    }
634
635    /**
636     * Subscribes the panel's scroll handler to those ancestors. Resolved per open, since the
637     * modal that hosts the panel is built and torn down around it.
638     * @param  {Function} handler
639     * @return {void}
640     */
641    _bindAncestorScroll(handler) {
642        this._unbindAncestorScroll();
643        if (typeof handler !== 'function') return;
644        this._ancestorScrollHandler = handler;
645        this._ancestorScrollNodes = this._shadowScrollAncestors();
646        this._ancestorScrollNodes.forEach(n => n.addEventListener('scroll', handler, { passive: true }));
647    }
648
649    /** @return {void} */
650    _unbindAncestorScroll() {
651        if (!this._ancestorScrollNodes) return;
652        this._ancestorScrollNodes.forEach(n => n.removeEventListener('scroll', this._ancestorScrollHandler));
653        this._ancestorScrollNodes = null;
654    }
655
656    //Search functionality - determines if search bar should be shown
657    // default: 10+ items; opt-in `search-always` attribute forces it regardless
658    // (useful for tiny lists where the user still wants to type-to-filter)
659    shouldShowSearchBar() {
660        if (this.hasAttribute('search-always')) return true;
661        return this.getItemsNumber() >= 10;
662    }
663
664    _parseCustomOptions() {
665        const options = this.querySelectorAll('custom-option');
666        if (!options.length) return [];
667
668        return Array.from(options).map(el => {
669            el.hidden = true;
670            const id = el.getAttribute('id') || el.getAttribute('value');
671            if (!id) return null;
672            return {
673                id,
674                label: el.textContent.trim(),
675                pill: el.getAttribute('pill') || '',
676                checked: el.hasAttribute('checked') || el.hasAttribute('selected')
677            };
678        }
678).filter(Boolean);
679    }
680
681    _setupTableInlineScrollHandler() {
682        window.removeEventListener('scroll', this._handleScroll, true);
683        this._handleScroll = (e) => {
684            if (!this.expanded) return;
685            const container = this.closest('.list-table-container');
686            if (container && e.target === container) {
687                const list = this.shadowRoot.querySelector('.dropdown-list');
688                if (list) list.style.transform = `translateX(${-container.scrollLeft}px) translateY(${-container.scrollTop}px)`;
689            } else {
690                if (e.target === this || this.contains(e.target)) return;
691                this.setExpandedState(false);
692            }
693        };
694        window.addEventListener('scroll', this._handleScroll, true);
695    }
696
697    _positionFixedDropdown() {
698        const list = this.shadowRoot.querySelector('.dropdown-list');
699        if (!list) return;
700        const container = this.closest('.list-table-container');
701        if (container) list.style.transform = `translateX(${-container.scrollLeft}px) translateY(${-container.scrollTop}px)`;
702        list.style.minWidth = `${Math.max(this.getBoundingClientRect().width, 200)}px`;
703    }
704
705    // A table-inline popup lives inside the table's tbody, which isolates its own
706    // stacking context (see ListTable.js) so a table overlay (e.g. the custom
707    // horizontal scrollbar) with an explicit z-index always paints above it, no
708    // matter how high this popup's own z-index goes. Raising the popup's z-index
709    // cannot fix that, so this notifies an ancestor list-table to yield its own
710    // overlays instead while the popup is open.
711    _notifyInlineToggle(isExpanded) {
712        if (!this.inlineVariant) return;
713        this.dispatchEvent(new CustomEvent('inline-dropdown-toggle', {
714            bubbles: true,
715            composed: true,
716            detail: { expanded: isExpanded }
717        }));
718    }
719
720    //Search functionality - gets the total number of items
721    getItemsNumber() {
722        return Object.keys(this.items).length;
723    }
724
725    //Search functionality - override this in child classes to implement search logic
726    searchEvent() {
727        // To be implemented by child classes
728        console.warn('searchEvent() should be implemented by child class');
729    }
730}

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.