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.