1((Drupal) => { 2 'use strict'; 3 4 /** 5 * Registry for search adapters. 6 * Kept for backward compatibility - conditions page uses API-based search. 7 * Care-services uses client-side DOM filtering (no adapter needed). 8 */ 9 Drupal.complexListingAdapters = Drupal.complexListingAdapters || {}; 10 11 /** 12 * Complex Listing organism behavior. 13 * 14 * Manages: 15 * - Sticky header pinned state (IntersectionObserver) 16 * - Jump-to dialog trigger wiring 17 * - Back-to-top navigation (context-dependent) 18 * - Search via client-side DOM filtering (like Flask prod) 19 * - Dynamic count updates with aria-live 20 */ 21 Drupal.behaviors.mtComplexListing = { 22 attach(context) { 23 once('mtComplexListing', '[data-complex-listing]', context).forEach((section) => { 24 const adapterName = section.dataset.searchAdapter; 25 const analyticsPrefix = section.dataset.analyticsPrefix || ''; 26 27 // DOM references 28 const browseGroups = section.querySelector('[data-browse-groups]'); 29 const resultCount = section.querySelector('[data-result-count]'); 30 const loader = section.querySelector('[data-loader]'); 31 const noResults = section.querySelector('[data-no-results]'); 32 const queryEcho = section.querySelector('[data-query-echo]'); 33 const dialog = section.querySelector('[data-jump-to-dialog]'); 34 const searchArea = section.querySelector('[data-search-area]'); 35 const clearResultsLink = section.querySelector('[data-clear-results]'); 36 37 const resultsRelated = section.querySelector('[data-results-related]'); 38 const relatedHeadingHTML = resultsRelated ? resultsRelated.innerHTML : ''; 39 40 const originalCountText = resultCount ? resultCount.textContent : ''; 41 let isSearchMode = false; 42 let searchSeq = 0; 43 44 const prefersReducedMotion = () => 45 window.matchMedia('(prefers-reduced-motion: reduce)').matches; 46 47 // ----------------------------------------------------------- 48 // 1. Sticky Header Pinned State + fixed-element geometry 49 // ----------------------------------------------------------- 50 // The fixed elements above the listing are the site nav and the Drupal 51 // admin toolbar. Both are read through the shared Drupal.scrollUtils 52 // helpers (getNavOffset() / getToolbarOffset(), also used by 53 // jump-to.js and the back-to-top handler below), so there is a single 54 // source of truth for each measurement. The toolbar's height only 55 // changes on resize/breakpoint, so it is cached here (the costly 56 // layout read) and refreshed on resize â never on scroll. 57 const NAV_HIDDEN_CLASS = 'nyuuninav--header--hidden'; 58 const navEl = 59 document.querySelector('#nyuuninav--header') || document.querySelector('.v4-header'); 60 61 let cachedToolbarHeight = 0; 62 63 // Cache the admin toolbar height (bar + optional expanded 64 // administration tray). Reads layout (offsetHeight), so it runs only 65 // on attach and resize â never on scroll. 66 const measureFixedElements = () => { 67 cachedToolbarHeight = Drupal.scrollUtils.getToolbarOffset(); 68 }; 69 measureFixedElements(); 70 71 // Full space below the fixed chrome with the nav counted as visible. 72 // Drives the IntersectionObserver rootMargin and the back-to-top landing 73 // (the nav is forced visible there via nyuPrepareManagedScroll, so its 74 // height must be reserved even if it is hidden at click time). 75 const getReservedOffset = () => 76 cachedToolbarHeight + Drupal.scrollUtils.getNavOffset(); 77 78 // Live offset: drops the nav height while it is slid off-screen so the 79 // sticky headers ride up in lockstep with the nav. 80 const getCurrentOffset = () => 81 cachedToolbarHeight + 82 (navEl?.classList.contains(NAV_HIDDEN_CLASS) ? 0 : Drupal.scrollUtils.getNavOffset()); 83 84 // Publish the live offset as a single custom property on the section. 85 // It inherits to every [data-pinnable-header] descendant (browse and 86 // dynamically-rendered related groups alike), so the sticky `top` 87 // clears the fixed chrome without touching each header individually. 88 const applyStickyOffset = () => { 89 const offset = getCurrentOffset(); 90 // The -1px puts a pinned header 1px past its threshold so the 91 // IntersectionObserver can detect intersectionRatio < 1 (threshold 1). 92 section.style.setProperty(
93 '--listing-sticky-offset', 94 offset > 0 ? `${offset - 1}px` : '-1px' 95 ); 96 }; 97 98 // Observer lives in outer scope so dynamically-rendered letter groups 99 // (search-related results) can be observed after insertion. 100 let stickyObserver = null; 101 let observerOffset = null; 102 103 const createStickyObserver = (offset) => 104 new IntersectionObserver( 105 (entries) => { 106 entries.forEach((entry) => { 107 entry.target.classList.toggle('is-pinned', entry.intersectionRatio < 1); 108 }); 109 }, 110 { threshold: [1], rootMargin: `-${offset + 1}px 0px 0px 0px` } 111 ); 112 113 const observePinnable = (root) => { 114 if (!stickyObserver || !root) { 115 return; 116 } 117 root.querySelectorAll('[data-pinnable-header]').forEach((h) => stickyObserver.observe(h)); 118 }; 119 120 // (Re)build the observer for the current reserved offset and re-observe 121 // existing headers. rootMargin is immutable, so the pinned-gradient 122 // threshold can only move by rebuilding â but we do that solely when the 123 // reserved offset actually changes (a resize, or the nav crossing its 124 // relative<->fixed threshold), never on the frequent show/hide toggles. 125 // That per-toggle teardown was what made the sticky tracking feel janky. 126 const rebuildObserver = () => { 127 if (!('IntersectionObserver' in window)) { 128 return; 129 } 130 observerOffset = getReservedOffset(); 131 stickyObserver?.disconnect(); 132 stickyObserver = createStickyObserver(observerOffset); 133 observePinnable(section); 134 const related = section.querySelector('[data-results-related]'); 135 if (related && !related.hasAttribute('hidden')) { 136 observePinnable(related); 137 } 138 }; 139 140 rebuildObserver(); 141 applyStickyOffset(); 142 143 // Re-measure the cached heights and refresh everything that depends on 144 // them. Used whenever the fixed chrome changes size off the scroll path: 145 // a viewport resize, or the Drupal toolbar tray expanding/collapsing. 146 const refreshGeometry = () => { 147 measureFixedElements(); 148 applyStickyOffset(); 149 rebuildObserver(); 150 }; 151 152 // On resize the nav/toolbar height can change at breakpoints. 153 let resizeTimer = null; 154 window.addEventListener('resize', () => { 155 clearTimeout(resizeTimer); 156 resizeTimer = setTimeout(refreshGeometry, 150); 157 }, { passive: true }); 158 159 // The nav slides in/out (and pins/unpins) during scroll, toggling its 160 // classes. Update the offset var synchronously so the headers track the 161 // nav in step â no debounce. Rebuild the observer only when the reserved 162 // offset actually changed (the nav crossed relative<->fixed), not on the 163 // frequent show/hide toggles. In Always-Show-Nav mode the nav never 164 // hides, so the var holds steady and only the one-time pin can rebuild. 165 if (navEl) { 166 new MutationObserver(() => { 167 applyStickyOffset(); 168 if (getReservedOffset() !== observerOffset) { 169 rebuildObserver(); 170 } 171 }).observe(navEl, { attributes: true, attributeFilter: ['class'] }); 172 } 173 174 // The Drupal admin toolbar can expand or collapse its tray without 175 // firing a resize â it toggles body classes such as `toolbar-tray-open`, 176 // which changes the fixed-chrome height. Re-measure when the body class 177 // set changes so the sticky offset and back-to-top landing stay correct 178 // without a page refresh. Only wired when a toolbar is present, and 179 // coalesced to one refresh per frame so unrelated body-class churn does 180 // not thrash the layout reads. 181 if (document.getElementById('toolbar-bar')) { 182 let geometryPending = false; 183 new MutationObserver(() => { 184 if (geometryPending) { 185 return; 186 } 187 geometryPending = true; 188 requestAnimationFrame(() => { 189 geometryPending = false;
190 refreshGeometry(); 191 }); 192 }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); 193 } 194 195 // ----------------------------------------------------------- 196 // 2. Jump-to Dialog Trigger Wiring 197 // ----------------------------------------------------------- 198 // Scope the dialog per click: triggers inside [data-results-related] 199 // show related letters and scroll to related groups; all other 200 // triggers (browse and matched/filtered browse) show browse letters 201 // and scroll to browse groups. Matches prod behavior. 202 const collectLetters = (root) => { 203 const set = new Set(); 204 root?.querySelectorAll('[data-listing-group]').forEach((group) => { 205 // Skip groups hidden by DOM-filter search so matched mode only 206 // lists letters that actually have visible items. 207 if (group.style.display === 'none') return; 208 const letter = group.dataset.letter; 209 if (letter) set.add(letter); 210 }); 211 return Array.from(set).sort(); 212 }; 213 214 section.addEventListener('click', (e) => { 215 const trigger = e.target.closest('[data-jump-to-trigger]'); 216 if (!trigger || !dialog?.mtJumpTo) return; 217 const inRelated = !!trigger.closest('[data-results-related]'); 218 const scope = inRelated ? 'related' : 'browse'; 219 const rootEl = inRelated 220 ? section.querySelector('[data-results-related]') 221 : browseGroups; 222 dialog.mtJumpTo.open(trigger, { 223 scope: scope, 224 letters: collectLetters(rootEl), 225 }); 226 }); 227 228 // ----------------------------------------------------------- 229 // 3. Back-to-Top Navigation 230 // ----------------------------------------------------------- 231 // Scrolls the top of the listing paragraph wrapper to sit immediately 232 // below the fixed chrome (Drupal toolbar + sticky nav) and stay fully 233 // visible. Lands focus on the listing section so keyboard/screen-reader 234 // users hear the listing's accessible name after the jump (instead of 235 // focus staying on the back-to-top link, which has just scrolled away). 236 section.addEventListener('click', (e) => { 237 const backToTop = e.target.closest('[data-back-to-top]'); 238 if (!backToTop) return; 239 240 e.preventDefault(); 241 242 // Reserve the full toolbar + nav height: nyuPrepareManagedScroll below 243 // forces the nav visible at the landing position, so it would overlay 244 // the wrapper top unless we clear its full height here. 245 const offset = getReservedOffset(); 246 // Land the paragraph wrapper's top edge at the content-area top, 247 // falling back to the section if the wrapper isn't present. 248 const target = section.closest('.mt-complex-listing__paragraph-wrapper') || section; 249 250 // +4px buffer: provides space for the nav drop-shadow to render cleanly; 251 // without it, the shadow blends into the container, creating a mixed visual effect. 252 const scrollTarget = target.getBoundingClientRect().top + window.scrollY - offset + 4; 253 254 // Pre-seed stickyNav's lastKnownScrollPosition so the scroll event 255 // fired by window.scrollTo is always interpreted as "scrolling up", 256 // keeping the nav visible after landing â regardless of whether the 257 // user is above or below the section when they click Back To Top. 258 navEl?.nyuPrepareManagedScroll?.(scrollTarget); 259 260 window.scrollTo({ top: scrollTarget, behavior: 'instant' }); 261 // Use preventScroll so .focus() doesn't fight our scrollTo above. 262 section.focus({ preventScroll: true }); 263 }); 264 265 // ----------------------------------------------------------- 266 // 4. Search Integration (Client-side DOM filtering) 267 // ----------------------------------------------------------- 268 // Instead of hiding browse groups and rendering new HTML, 269 // we show/hide existing DOM items. This preserves the full 270 // template markup (links, icons, sticky headers, etc.) 271 // identical between browse and search modes. 272 // Matches the Flask prod care-services search pattern. 273 if (!adapterName) return; 274 275 /** 276 * Show each "Jump to (A-Z)" trigger only when its own dialog would 277 * offer more than one letter. The dialog's letter set is scoped per 278 * trigger (browse/matched vs related), so visibility is decided per 279 * scope on distinct visible data-letter values - not group sections, 280 * which double-count a letter that appears in both matched and 281 * related results (#2150). With <= 1 letter in a scope there is 282 * nothing to jump to, so that scope's triggers are suppressed via a 283 * section-level modifier class. Applies to the adapter (Conditions) 284 * and DOM-filter (Care & Services) search paths alike. 285 */ 286 const updateJumpToVisibility = () => { 287 const lettersIn = (root) => { 288 const letters = new Set(); 289 root?.querySelectorAll('[data-listing-group]').forEach((group) => { 290 // Skip groups hidden by a filter and groups inside a hidden 291 // results container (e.g. the related section before it renders). 292 if (group.style.display === 'none') return; 293 if (group.closest('[hidden]')) return; 294 if (group.dataset.letter) letters.add(group.dataset.letter); 295 }); 296 return letters; 297 }; 298 section.classList.toggle( 299 'mt-complex-listing--single-group-browse', 300 lettersIn(browseGroups).size <= 1 301 ); 302 section.classList.toggle( 303 'mt-complex-listing--single-group-related', 304 lettersIn(resultsRelated).size <= 1 305 ); 306 }; 307 308 /** 309 * Filter existing DOM items by query string. 310 * Shows/hides items and letter groups based on text match. 311 * Returns count of visible items. 312 */ 313 const filterItems = (query) => { 314 if (!browseGroups) return 0; 315 316 const normalized = query.toLowerCase().replace(/[^a-z0-9]/g, ''); 317 let visibleCount = 0; 318 319 browseGroups.querySelectorAll('[data-listing-group]').forEach((group) => { 320 const items = group.querySelectorAll('.mt-complex-listing-group__item'); 321 let groupHasVisible = false;
322 323 items.forEach((item) => { 324 const text = (item.textContent || '').toLowerCase().replace(/[^a-z0-9]/g, ''); 325 if (text.includes(normalized)) { 326 item.style.display = ''; 327 groupHasVisible = true; 328 visibleCount++; 329 } else { 330 item.style.display = 'none'; 331 } 332 }); 333 334 group.style.display = groupHasVisible ? '' : 'none'; 335 }); 336 337 return visibleCount; 338 }; 339 340 /** 341 * Reset all items to visible (clear filter). 342 */ 343 const resetFilter = () => { 344 if (!browseGroups) return; 345 346 browseGroups.querySelectorAll('[data-listing-group]').forEach((group) => { 347 group.style.display = ''; 348 group.querySelectorAll('.mt-complex-listing-group__item').forEach((item) => { 349 item.style.display = ''; 350 }); 351 }); 352 }; 353 354 /** 355 * Filter DOM items by exact title match (for adapter results). 356 * The adapter decides what's "matched" - we just show/hide. 357 */ 358 const filterItemsByTitles = (titles) => { 359 if (!browseGroups) return 0; 360 361 const titleSet = new Set( 362 titles.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, '')) 363 ); 364 let visibleCount = 0; 365 366 browseGroups.querySelectorAll('[data-listing-group]').forEach((group) => { 367 const items = group.querySelectorAll('.mt-complex-listing-group__item'); 368 let groupHasVisible = false; 369 370 items.forEach((item) => { 371 const primary = item.querySelector('.mt-complex-listing-item__primary'); 372 const itemTitle = (primary ? primary.textContent : item.textContent || '') 373 .trim().toLowerCase().replace(/[^a-z0-9]/g, ''); 374 if (titleSet.has(itemTitle)) { 375 item.style.display = ''; 376 groupHasVisible = true; 377 visibleCount++; 378 } else { 379 item.style.display = 'none'; 380 } 381 }); 382 383 group.style.display = groupHasVisible ? '' : 'none'; 384 }); 385 386 return visibleCount; 387 }; 388 389 /** 390 * Render a single related result item as HTML. 391 */ 392 const renderRelatedItem = (item) => { 393 const safe = Drupal.checkPlain; 394 const prefix = analyticsPrefix; 395 let html = '<li class="mt-complex-listing-group__item mt-complex-listing-item">'; 396 397 // 1. Title (plain text, label-03 - matches paragraph template) 398 html += '<div class="mt-complex-listing-item__primary">'; 399 html += '<span class="mt-text mt-text--label-03">' + safe(item.title) + '</span>'; 400 html += '</div>'; 401 402 // 2. Find a doctor & schedule (with arrow icon) 403 const doctorUrl = item.find_doctor_url || (item.sub_heading && item.sub_heading.url) || ''; 404 if (doctorUrl) { 405 html += '<a href="' + safe(doctorUrl) + '"'; 406 html += ' class="mt-complex-listing-item__doctor-link"'; 407 html += ' aria-label="' + safe(Drupal.t('Find a doctor & schedule for @title', { '@title': item.title })) + '"'; 408 if (prefix) html += ' data-linkname="' + prefix + ':search_result:related:condition:to_fad_search"'; 409 html += '>'; 410 html += '<span class="mt-complex-listing-item__link-text">' + Drupal.t('Find a doctor & schedule') + '</span>'; 411 html += ' <span class="mt-icon" aria-hidden="true">arrow_forward</span>'; 412 html += '</a>'; 413 } 414 415 // 3. "Our approach to:" + comma-separated article links (matches browse). 416 const articles = item.articles || item.list_items || []; 417 if (articles.length > 0) { 418 html += '<div class="mt-complex-listing-item__articles">'; 419 html += '<span class="mt-complex-listing-item__articles-label">' + Drupal.t('Our approach to:') + '</span> '; 420 articles.forEach(function (article, i) { 421 const articleUrl = article.url || '/conditions/' + safe(article.slug || ''); 422 html += '<a href="' + safe(articleUrl) + '"'; 423 html += ' class="mt-complex-listing-item__name-link"';
424 if (prefix) html += ' data-linkname="' + prefix + ':search_result:related:condition:article"'; 425 html += '>' + safe(article.title) + '</a>'; 426 if (i < articles.length - 1) html += ', '; 427 }); 428 html += '</div>'; 429 } 430 431 html += '</li>'; 432 return html; 433 }; 434 435 /** 436 * Render one letter group's sticky header - mirrors 437 * complex-listing-group.twig (and the nyulh_base_v4:button SDC output) 438 * so CSS sticky + jump-to + back-to-top work and LOOK identical to 439 * the browse groups. 440 */ 441 const renderGroupHeader = (letter) => { 442 const prefix = analyticsPrefix; 443 const linkname = prefix ? ' data-linkname="' + prefix + ':letter_browse:letter:back_to_top"' : ''; 444 const btn = (label) => 445 '<button data-component-id="nyulh_base_v4:button"' + 446 ' class="mt-button mt-button--secondary mt-button__type--internal mt-button__icon--trailing">' + 447 '<span class="mt-button__content">' + 448 '<span class="mt-button__label">' + label + '</span>' + 449 '<span class="mt-button__icon">' + 450 '<span class="mt-icon" aria-hidden="true">more_vert</span>' + 451 '</span>' + 452 '</span>' + 453 '</button>'; 454 return ( 455 '<div class="mt-complex-listing-group__header" data-pinnable-header>' + 456 '<div class="mt-complex-listing-group__header-start">' + 457 '<h2 class="mt-text mt-text--headline-emphasis-03 mt-complex-listing-group__letter">' + Drupal.checkPlain(letter) + '</h2>' + 458 '<span class="mt-complex-listing-group__jump-to-desktop" data-jump-to-trigger>' + 459 btn(Drupal.t('Jump to (A-Z)')) + 460 '</span>' + 461 '<span class="mt-complex-listing-group__jump-to-mobile" data-jump-to-trigger>' + 462 btn(Drupal.t('Jump to (A-Z)')) + 463 '</span>' + 464 '</div>' + 465 '<div class="mt-complex-listing-group__header-end">' + 466 '<button type="button" class="mt-complex-listing-group__back-to-top" aria-label="' + Drupal.t('Back to top of list') + '" data-back-to-top' + linkname + '>' + 467 '<span class="mt-complex-listing-group__back-to-top-label">' + Drupal.t('Back to top') + '</span>' + 468 '<span class="mt-icon" aria-hidden="true">arrow_upward</span>' + 469 '</button>' + 470 '</div>' + 471 '</div>' 472 ); 473 }; 474 475 /** 476 * Group related items by first-letter category, sort A-Z, and render 477 * as a series of letter-group sections mirroring complex-listing-group.twig. 478 */ 479 const renderRelatedGroups = (related) => { 480 const byLetter = {}; 481 related.forEach((item) => { 482 const title = (item.title || ''); 483 const letter = (item.category || (title ? title.charAt(0).toUpperCase() : '#')); 484 if (!byLetter[letter]) byLetter[letter] = []; 485 byLetter[letter].push(item); 486 }); 487 const letters = Object.keys(byLetter).sort(); 488 489 return letters.map((letter) => { 490 const safeLetter = Drupal.checkPlain(letter); 491 const id = 'complex-listing-related-letter-' + safeLetter; 492 const label = Drupal.t('Related items starting with @letter', { '@letter': safeLetter }); 493 const items = byLetter[letter].map(renderRelatedItem).join(''); 494 return ( 495 '<section class="mt-complex-listing-group" id="' + id + '"' + 496 ' data-listing-group data-letter="' + safeLetter + '"' + 497 ' aria-label="' + label + '">' + 498 renderGroupHeader(letter) + 499 '<ul class="mt-complex-listing-group__list" role="list">' + items + '</ul>' + 500 '</section>' 501 ); 502 }).join(''); 503 }; 504 505 /** 506 * Show adapter-based search results (matched + related). 507 */ 508 const showAdapterResults = (query) => { 509 const seq = ++searchSeq; 510 isSearchMode = true; 511 loader?.removeAttribute('hidden'); 512 noResults?.setAttribute('hidden', ''); 513 resultsRelated?.setAttribute('hidden', ''); 514 515 const adapter = Drupal.complexListingAdapters[adapterName]; 516 adapter(query).then(({ matched, related }) => { 517 if (seq !== searchSeq) return; // stale result 518 519 loader?.setAttribute('hidden', ''); 520 clearResultsLink?.removeAttribute('hidden'); 521 522 // Filter browse DOM to show only matched titles 523 const matchedCount = filterItemsByTitles(matched.map((m) => m.title)); 524
525 // Render related results as letter-grouped sections mirroring 526 // the browse groups (sticky headers, per-letter jump-to, back-to-top). 527 let relatedCount = 0; 528 const relatedLetters = []; 529 if (related.length > 0 && resultsRelated) { 530 relatedCount = related.length; 531 resultsRelated.innerHTML = relatedHeadingHTML + renderRelatedGroups(related); 532 resultsRelated.removeAttribute('hidden'); 533 // Pin the new related headers. The --listing-sticky-offset var 534 // is set on the section and inherits to them, so there is no 535 // per-header (or per-container) write to do here. 536 observePinnable(resultsRelated); 537 resultsRelated.querySelectorAll('[data-listing-group]').forEach((g) => { 538 if (g.dataset.letter) relatedLetters.push(g.dataset.letter); 539 }); 540 } 541 542 const total = matchedCount + relatedCount; 543 544 if (total === 0) { 545 if (noResults) { 546 noResults.removeAttribute('hidden'); 547 if (queryEcho) queryEcho.textContent = query; 548 } 549 if (resultCount) { 550 resultCount.textContent = Drupal.t('0 results for "@query"', { '@query': query }); 551 } 552 } else if (resultCount) { 553 resultCount.textContent = total === 1 554 ? Drupal.t('1 result for "@query"', { '@query': query }) 555 : Drupal.t('@count results for "@query"', { '@count': total.toLocaleString(), '@query': query }); 556 } 557 558 // No pre-rebuild of the dialog's letter set - the jump-to click 559 // handler above now computes letters per click from the trigger's 560 // scope (browse/matched vs related), matching prod behavior. 561 562 updateJumpToVisibility(); 563 Drupal.announce?.(resultCount ? resultCount.textContent : ''); 564 }).catch((err) => { 565 if (seq !== searchSeq) return; 566 console.error('Search adapter error:', err); 567 loader?.setAttribute('hidden', ''); 568 if (noResults) { 569 noResults.removeAttribute('hidden'); 570 if (queryEcho) queryEcho.textContent = query; 571 } 572 }); 573 }; 574 575 /** 576 * Apply search filter and update UI. 577 */ 578 const showSearchResults = (query) => { 579 isSearchMode = true; 580 loader?.setAttribute('hidden', ''); 581 clearResultsLink?.removeAttribute('hidden'); 582 noResults?.setAttribute('hidden', ''); 583 584 const count = filterItems(query); 585 586 if (count === 0) { 587 if (noResults) { 588 noResults.removeAttribute('hidden'); 589 if (queryEcho) queryEcho.textContent = query; 590 } 591 if (resultCount) { 592 resultCount.textContent = Drupal.t('0 results for "@query"', { '@query': query }); 593 } 594 } else { 595 if (resultCount) { 596 resultCount.textContent = count === 1 597 ? Drupal.t('1 result for "@query"', { '@query': query }) 598 : Drupal.t('@count results for "@query"', { '@count': count.toLocaleString(), '@query': query }); 599 } 600 601 // Update jump-to dialog with only visible letters 602 if (dialog?.mtJumpTo) { 603 const visibleLetters = []; 604 browseGroups.querySelectorAll('[data-listing-group]').forEach((group) => { 605 if (group.style.display !== 'none') { 606 visibleLetters.push(group.dataset.letter); 607 } 608 }); 609 dialog.mtJumpTo.rebuildLetters(visibleLetters); 610 } 611 } 612 613 updateJumpToVisibility(); 614 Drupal.announce?.(resultCount ? resultCount.textContent : ''); 615 }; 616 617 /** 618 * Restore browse view - show everything, reset count. 619 */ 620 const restoreBrowseView = () => { 621 isSearchMode = false; 622 623 resetFilter(); 624 noResults?.setAttribute('hidden', ''); 625 loader?.setAttribute('hidden', ''); 626 clearResultsLink?.setAttribute('hidden', ''); 627 628 // Clean up related results 629 if (resultsRelated) { 630 resultsRelated.setAttribute('hidden', ''); 631 resultsRelated.innerHTML = relatedHeadingHTML; 632 } 633 634 if (resultCount) { 635 resultCount.textContent = originalCountText; 636 } 637 638 // Restore jump-to dialog with all letters 639 if (dialog?.mtJumpTo) { 640 const allLetters = []; 641 browseGroups?.querySelectorAll('[data-listing-group]').forEach((group) => { 642 allLetters.push(group.dataset.letter); 643 }); 644 dialog.mtJumpTo.rebuildLetters(allLetters); 645 } 646 647 updateJumpToVisibility(); 648 Drupal.announce?.(Drupal.t('Showing all results')); 649 }; 650 651 // Listen for search submit 652 section.addEventListener('inline-search:submit', (e) => { 653 window.NYULH?.trackWithOverride( 654 'link_click', 655 { components: Drupal.getComplexListingComponentAnalytics() }, 656 { 657 link_name: window.analytics_json.experience_section + ':' + window.analytics_json.page_type + ':component:search', 658 input_query: e.detail.query?.toLowerCase(), 659 search_input_type: 'text_entry' 660 } 661 ); 662 663 const adapter = Drupal.complexListingAdapters[adapterName]; 664 if (typeof adapter === 'function') { 665 showAdapterResults(e.detail.query); 666 } else { 667 showSearchResults(e.detail.query); 668 } 669 }); 670 671 // Listen for search clear (from inline-search X button) 672 section.addEventListener('inline-search:clear', (e) => { 673 window.NYULH?.trackWithOverride( 674 'link_click', 675 { components: Drupal.getComplexListingComponentAnalytics() }, 676 { 677 link_name: window.analytics_json.experience_section + ':' + window.analytics_json.page_type + ':component:search:clear', 678 input_query: e.detail.query?.toLowerCase(), 679 search_input_type: 'text_entry' 680 } 681 ); 682 restoreBrowseView(); 683 }); 684 685 // Listen for "Clear search results" button click. Sends focus 686 // back to the search input so keyboard users land on a stable, 687 // visible target instead of <body> (which previously caused the 688 // browser to scroll to page top to bring focus into view). 689 clearResultsLink?.addEventListener('click', (e) => { 690 e.preventDefault(); 691 const searchInput = section.querySelector('.mt-inline-search__input'); 692 window.NYULH?.trackWithOverride( 693 'link_click', 694 { components: Drupal.getComplexListingComponentAnalytics() }, 695 { 696 link_name: window.analytics_json.experience_section + ':' + window.analytics_json.page_type + ':component:search:clear_search_results', 697 input_query: searchInput ? searchInput.value.toLowerCase() : '', 698 search_input_type: 'text_entry' 699 } 700 ); 701 if (searchInput) searchInput.value = ''; 702 const inlineClearBtn = section.querySelector('.mt-inline-search__clear'); 703 inlineClearBtn?.setAttribute('hidden', ''); 704 restoreBrowseView(); 705 searchInput?.focus(); 706 }); 707 708 // Set the initial trigger state for the default browse view - covers a 709 // directory that renders with only a single letter group. 710 updateJumpToVisibility(); 711 }); 712 }, 713 }; 714})(Drupal);
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.