PageSourceSearch

https://nyulangone.org/themes/custom/nyulh_base_v4/components/orga…/complex-listing/complex-listing.js?tkemnk

js nyulangone.org collected 2026-09-24 08:32:36 UTC 32,267 bytes, 714 lines download raw bytes

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.