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.