PageSourceSearch

https://www.twincitychurchofchrist.org/media/js/congregate-v2.js?v=955fc317e68d46a6a8d966f8c094aaeb

js twincitychurchofchrist.org collected 2026-10-04 21:34:12 UTC 25,042 bytes, 634 lines download raw bytes

1/* Congregate v2 — shared read-side interactions (audio player, copy-link, lightbox).
2   Module-specific scripts (charts, modals, tabs) are added per-module as they are ported. */
3(function () {
4  'use strict';
5  function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
6
7  ready(function () {
8    var PLAY = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M6 4l14 8-14 8z"/></svg>';
9    var PAUSE = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M7 5h3v14H7zM14 5h3v14h-3z"/></svg>';
10    function fmt(t) { t = Math.floor(t || 0); var m = Math.floor(t / 60), s = t % 60; return m + ':' + (s < 10 ? '0' : '') + s; }
11
12    // Custom audio player (sermons/articles detail).
13    document.querySelectorAll('.d-player').forEach(function (p) {
14      var a = p.querySelector('audio'), pp = p.querySelector('.d-pp'), bar = p.querySelector('.d-bar'),
15        fill = p.querySelector('.d-bar-fill'), cur = p.querySelector('.d-cur'), dur = p.querySelector('.d-dur');
16      if (!a || !pp) return;
17      pp.addEventListener('click', function () { a.paused ? a.play() : a.pause(); });
18      a.addEventListener('play', function () { pp.innerHTML = PAUSE; });
19      a.addEventListener('pause', function () { pp.innerHTML = PLAY; });
20      a.addEventListener('loadedmetadata', function () { if (dur) dur.textContent = fmt(a.duration); });
21      a.addEventListener('timeupdate', function () { if (fill) fill.style.width = (a.duration ? a.currentTime / a.duration * 100 : 0) + '%'; if (cur) cur.textContent = fmt(a.currentTime); });
22      if (bar) bar.addEventListener('click', function (e) { var r = bar.getBoundingClientRect(); a.currentTime = (e.clientX - r.left) / r.width * (a.duration || 0); });
23    });
24
25    // Copy-link share buttons.
26    document.querySelectorAll('[data-copy]').forEach(function (b) {
27      b.addEventListener('click', function () {
28        try { navigator.clipboard.writeText(b.getAttribute('data-copy')); b.classList.add('copied'); setTimeout(function () { b.classList.remove('copied'); }, 1200); } catch (e) {}
29      });
30    });
31
32    // Lightbox — any [data-full] element opens its image full-size (directory/
33    // inventory/files). Elements sharing a [data-lb-group] ancestor are treated
34    // as one set, so a photo album gets arrows and keyboard navigation; a lone
35    // photo behaves exactly as before.
36    var fulls = Array.prototype.slice.call(document.querySelectorAll('[data-full]'));
37
38    if (fulls.length) {
39      var CHEV = '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>';
40      var lb = document.createElement('div'); lb.className = 'lb';
41      var im = document.createElement('img');
42      var prev = document.createElement('button'); prev.type = 'button'; prev.className = 'lb-nav lb-prev'; prev.setAttribute('aria-label', 'Previous'); prev.innerHTML = CHEV;
43      var next = document.createElement('button'); next.type = 'button'; next.className = 'lb-nav lb-next'; next.setAttribute('aria-label', 'Next'); next.innerHTML = CHEV;
44      var cap = document.createElement('div'); cap.className = 'lb-cap';
45      lb.appendChild(prev); lb.appendChild(im); lb.appendChild(next); lb.appendChild(cap);
46      document.body.appendChild(lb);
47
48      var set = [], at = 0;
49
50      function groupOf(el) {
51        var g = el.closest ? el.closest('[data-lb-group]') : null;
52        return g ? Array.prototype.slice.call(g.querySelectorAll('[data-full]')) : [el];
53      }
54
55      function show(i) {
56        if (!set.length) { return; }
57        at = (i + set.length) % set.length;
58        var el = set[at];
59        im.src = el.getAttribute('data-full');
60        var text = el.getAttribute('data-caption') || '';
61        cap.textContent = text;
62        cap.style.display = text ? '' : 'none';
63        var many = set.length > 1;
64        prev.style.display = many ? '' : 'none';
65        next.style.display = many ? '' : 'none';
66      }
67
68      function close() { lb.classList.remove('open'); im.src = ''; set = []; }
69
70      lb.addEventListener('click', close);
71      prev.addEventListener('click', function (e) { e.stopPropagation(); show(at - 1); });
72      next.addEventListener('click', function (e) { e.stopPropagation(); show(at + 1); });
73
74      document.addEventListener('keydown', function (e) {
75        if (!lb.classList.contains('open')) { return; }
76        if (e.key === 'Escape') { close(); }
77        else if (e.key === 'ArrowRight') { show(at + 1); }
78        else if (e.key === 'ArrowLeft') { show(at - 1); }
79      });
80
81      fulls.forEach(function (el) {
82        el.addEventListener('click', function () {
83          if (!el.getAttribute('data-full')) { return; }
84          set = groupOf(el);
85          show(set.indexOf(el));
86          lb.classList.add('open');
87        });
88      });
89    }
90  });
91})();
92
93/*
94 * Date picker (.cong-date).
95 *
96 * Purpose-built rather than restyling the bundled jQuery datePicker: that
97 * plugin writes its own inline top/right onto its popup immediately after
98 * firing dpDisplayed, so any position set from outside is overwritten, and its
99 * markup fights the design system. This owns both, so placement and appearance
100 * are deterministic. Values are read and written as yyyy-mm-dd.
101 */
102(function () {
103  var DOW = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
104  var MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
105    'July', 'August', 'September', 'October', 'November', 'December'];
106
107  var pop = null;
108  var field = null;
109  var view = null;
110  var selects = null;
111
112  function pad(n) { return (n < 10 ? '0' : '') + n; }
113  function iso(d) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); }
114
115  function parse(value) {
116    var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec((value || '').trim());
117    if (!m) { return null; }
118    var d = new Date(+m[1], +m[2] - 1, +m[3]);
119    return isNaN(d.getTime()) ? null : d;
120  }
121
122  // Some fields are a month/day/year select trio (the zform date wrapper) rather
123  // than a text input; the trigger carries data-selects with their id prefix.
124  function selectsOf(trigger) {
125    var base = trigger && trigger.getAttribute && trigger.getAttribute('data-selects');
126    if (!base) { return null; }
127    var m = document.getElementById(base);
128    var d = document.getElementById(base + '_day');
129    var y = document.getElementById(base + '_year');
130    return (m && d && y) ? { month: m, day: d, year: y } : null;
131  }
132
133  function readValue() {
134    if (selects) {
135      var y = parseInt(selects.year.value, 10);
136      var m = parseInt(selects.month.value, 10);
137      var d = parseInt(selects.day.value, 10);
138      if (y && m && d) { return new Date(y, m - 1, d); }
139      return null;
140    }
141    return parse(field.value);
142  }
143
144  function writeValue(isoDate) {
145    var parts = isoDate.split('-');
146    if (selects) {
147      selects.month.value = String(parseInt(parts[1], 10));
148      selects.day.value = String(parseInt(parts[2], 10));
149      selects.year.value = String(parseInt(parts[0], 10));
150      [selects.month, selects.day, selects.year].forEach(function (el) {
151        el.dispatchEvent(new Event('change', { bubbles: true }));
152      });
153      return;
154    }
155    field.value = isoDate;
156    field.dispatchEvent(new Event('change', { bubbles: true }));
157  }
158
159  function close() {
160    if (pop) { pop.remove(); pop = null; field = null; selects = null; }
161  }
162
163  function place() {
164    if (!pop || !field) { return; }
165    var r = field.getBoundingClientRect();
166    var sx = window.pageXOffset || document.documentElement.scrollLeft || 0;
167    var sy = window.pageYOffset || document.documentElement.scrollTop || 0;
168    var maxLeft = sx + document.documentElement.clientWidth - pop.offsetWidth - 8;
169    pop.style.top = Math.round(r.bottom + sy + 6) + 'px';
170    pop.style.left = Math.round(Math.min(r.left + sx, Math.max(8, maxLeft))) + 'px';
171  }
172
173  function render() {
174    var selected = readValue();
175    var today = new Date();
176    var first = new Date(view.getFullYear(), view.getMonth(), 1);
177    var start = first.getDay();
178    var days = new Date(view.getFullYear(), view.getMonth() + 1, 0).getDate();
179
180    // data-step is in months, so a year is simply +/-12.
181    var html = '<div class="cdp-head">' +
182      '<button type="button" class="cdp-nav" data-step="-12" aria-label="Previous year" title="Previous year">&#171;</button>' +
183      '<button type="button" class="cdp-nav" data-step="-1" aria-label="Previous month" title="Previous month">&#8249;</button>' +
184      '<div class="cdp-title">' + MONTHS[view.getMonth()] + ' ' + view.getFullYear() + '</div>' +
185      '<button type="button" class="cdp-nav" data-step="1" aria-label="Next month" title="Next month">&#8250;</button>' +
186      '<button type="button" class="cdp-nav" data-step="12" aria-label="Next year" title="Next year">&#187;</button>' +
187      '</div><div class="cdp-grid">';
188
189    DOW.forEach(function (d) { html += '<span class="cdp-dow">' + d + '</span>'; });
190    for (var i = 0; i < start; i++) { html += '<span class="cdp-blank"></span>'; }
191
192    for (var day = 1; day <= days; day++) {
193      var d = new Date(view.getFullYear(), view.getMonth(), day);
194      var cls = 'cdp-day';
195      if (selected && iso(d) === iso(selected)) { cls += ' is-selected'; }
196      else if (iso(d) === iso(today)) { cls += ' is-today'; }
197      html += '<button type="button" class="' + cls + '" data-date="' + iso(d) + '">' + day + '</button>';
198    }
199
200    pop.innerHTML = html + '</div>';
201    place();
202  }
203
204  function open(anchor, trio) {
205    close();
206    field = anchor;
207    selects = trio || null;
208    view = readValue() || new Date();
209    view = new Date(view.getFullYear(), view.getMonth(), 1);
210
211    pop = document.createElement('div');
212    pop.className = 'cdp';
213    document.body.appendChild(pop);
214    render();
215  }
216
217  document.addEventListener('click', function (e) {
218    var trigger = e.target.closest ? e.target.closest('.cong-date, .cong-date-btn') : null;
219
220    if (trigger) {
221      e.preventDefault();
222      var trio = selectsOf(trigger);
223
224      if (trio) {
225        open(trigger, trio);
226        return;
227      }
228
229      var input = trigger.classList.contains('cong-date')
230        ? trigger
231        : trigger.parentNode.querySelector('.cong-date');
232      if (input) { open(input); }
233      return;
234    }
235
236    if (!pop) { return; }
237    if (pop.contains(e.target)) {
238      var nav = e.target.closest('.cdp-nav');
239      if (nav) {
240        view.setMonth(view.getMonth() + parseInt(nav.getAttribute('data-step'), 10));
241        render();
242        return;
243      }
244      var day = e.target.closest('.cdp-day');
245      if (day) {
246        writeValue(day.getAttribute('data-date'));
247        close();
248      }
249      return;
250    }
251
252    close();
253  });
254
255  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { close(); } });
256  window.addEventListener('resize', place);
257  window.addEventListener('scroll', place, true);
258})();
259
260/* Recent sermons carousel.
261
262   Every instance on the page is set up independently, so a church can put more
263   than one on a page without them interfering. Nothing here reads the window's
264   width: the number of cards abreast comes from the carousel's OWN width, since
265   the same block sits in a full-width row on one page and a narrow sidebar on
266   another and only the element knows which. */
267(function () {
268  'use strict';
269  function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
270
271  /* Someone who has asked the system to keep motion down does not get a row
272     that moves on its own. Read once: a page is not re-laid-out for a change
273     made while it is open. */
274  var CALM = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
275
276  function setup(root) {
277    var track = root.querySelector('.sc-track');
278    var slides = [].slice.call(root.querySelectorAll('.sc-slide'));
279    var dotsBox = root.querySelector('.sc-dots');
280    var prev = root.querySelector('.sc-prev');
281    var next = root.querySelector('.sc-next');
282    if (!track || !slides.length) return;
283
284    var maxCols = Math.max(1, Math.min(4, parseInt(root.getAttribute('data-cols'), 10) || 4));
285    var wantsAuto = root.getAttribute('data-autoplay') === '1' && !CALM;
286    var index = 0, perView = 1, width = 0, maxIndex = 0, timer = null;
287
288    /* How many fit, capped by what the church asked for. The thresholds are the
289       card widths that stay readable: below 300px a cover with a title under it
290       starts to crowd, so the row drops a column rather than shrinking on. */
291    function fits(w) {
292      if (w < 380) return 1;
293      if (w < 700) return 2;
294      if (w < 1000) return 3;
295      return 4;
296    }
297
298    function layout() {
299      var w = root.querySelector('.sc-viewport').offsetWidth;
300      if (!w) return;
301
302      perView = Math.min(maxCols, fits(w));
303      width = w / perView;
304      maxIndex = Math.max(0, slides.length - perView);
305      if (index > maxIndex) index = maxIndex;
306
307      slides.forEach(function (s) { s.style.width = width + 'px'; });
308      track.style.width = (width * slides.length) + 'px';
309
310      /* The cover's height, for the arrows to centre on. A card is taller than
311         its cover by however many lines of title, speaker and date sit under
312         it, so centring on the card puts the arrows below the picture. The
313         cover is 16:9 of the card's inner width -- the padding either side is
314         the gutter between cards. */
315      var slidePad = 16;
316      root.style.setProperty('--sc-cover-h', (((width - slidePad) * 9) / 16) + 'px');
317
318      buildDots();
319      apply(false);
320    }
321
322    function buildDots() {
323      if (!dotsBox) return;
324      dotsBox.innerHTML = '';
325      if (maxIndex < 1) return;          // nothing to page through
326      for (var i = 0; i <= maxIndex; i++) {
327        var b = document.createElement('button');
328        b.type = 'button';
329        b.className = 'sc-dot';
330        b.setAttribute('aria-label', 'Sermons ' + (i + 1) + ' of ' + (maxIndex + 1));
331        b.addEventListener('click', (function (n) {
332          return function () { go(n); restart(); };
333        })(i));
334        dotsBox.appendChild(b);
335      }
336    }
337
338    function apply(animate) {
339      track.style.transition = (animate && !CALM) ? '' : 'none';
340      track.style.transform = 'translateX(' + (-index * width) + 'px)';
341      if (!animate) {
342        // Let the browser take the jump before transitions come back.
343        void track.offsetWidth;
344        track.style.transition = '';
345      }
346      if (dotsBox) {
347        [].forEach.call(dotsBox.children, function (d, i) {
348          d.classList.toggle('is-active', i === index);
349        });
350      }
351      /* Hidden rather than disabled at the ends: a control that cannot do
352         anything is better absent than present and dead. Both stay when the
353         row wraps around, which it does whenever autoplay is on. */
354      if (prev) prev.hidden = maxIndex < 1 || (index === 0 && !wantsAuto);
355      if (next) next.hidden = maxIndex < 1 || (index === maxIndex && !wantsAuto);
356    }
357
358    function go(i) {
359      index = Math.max(0, Math.min(maxIndex, i));
360      apply(true);
361    }
362
363    function step(dir) {
364      if (maxIndex < 1) return;
365      var target = index + dir;
366      if (target > maxIndex) target = 0;          // wrap, so autoplay never stalls
367      if (target < 0) target = maxIndex;
368      go(target);
369    }
370
371    function restart() {
372      if (timer) { clearInterval(timer); timer = null; }
373      if (wantsAuto && maxIndex > 0) timer = setInterval(function () { step(1); }, 5000);
374    }
375
376    if (prev) prev.addEventListener('click', function () { step(-1); restart(); });
377    if (next) next.addEventListener('click', function () { step(1); restart(); });
378
379    // Autoplay waits while someone is reading, and stops once they tab into it.
380    root.addEventListener('mouseenter', function () { if (timer) { clearInterval(timer); timer = null; } });
381    root.addEventListener('mouseleave', restart);
382    root.addEventListener('focusin', function () { wantsAuto = false; if (timer) { clearInterval(timer); timer = null; } });
383
384    // Swipe. 40px so a slightly untidy tap is still a tap.
385    var startX = null;
386    root.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; }, { passive: true });
387    root.addEventListener('touchend', function (e) {
388      if (startX === null) return;
389      var dx = e.changedTouches[0].clientX - startX;
390      startX = null;
391      if (Math.abs(dx) > 40) { step(dx < 0 ? 1 : -1); restart(); }
392    }, { passive: true });
393
394    var resizeTimer;
395    window.addEventListener('resize', function () {
396      clearTimeout(resizeTimer);
397      resizeTimer = setTimeout(layout, 120);
398    });
399
400    layout();
401    restart();
402  }
403
404  ready(function () {
405    [].forEach.call(document.querySelectorAll('.sc'), setup);
406  });
407})();
408
409/* ── Calendar event popup ──────────────────────────────────────────────────
410 *
411 * A month cell fits one clamped line, so most event names are cut off — badl
411y
412 * so on a phone, where the native `title` tooltip that used to carry the full
413 * text never appears at all, because touch has no hover.
414 *
415 * This shows the same information as a card: on hover (and keyboard focus)
416 * where there is a pointer, on tap where there is not. It reads the chips
417 * written by views/utils/calendar/full.php (one event per chip) and the day
418 * cells written by mini.php (data-pop-day, a JSON list of that day's events).
419 */
420(function () {
421  var SEL = '[data-pop-title], [data-pop-day]';
422  var COARSE = window.matchMedia && window.matchMedia('(hover: none)').matches;
423
424  var pop = null;   // the card, created on first use
425  var arrow = null;
426  var anchor = null;
427  var timer = null;
428
429  function esc(s) {
430    return String(s == null ? '' : s)
431      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
432      .replace(/"/g, '&quot;');
433  }
434
435  /**
436   * The design tokens the card reads. It is portalled to <body> and wears no
437   * `.cong`, which is where these are declared - so they are copied across
438   * from the calendar's own panel, and a theme's palette still reaches the
439   * card. The stylesheet carries the same literals as fallbacks, so a calendar
440   * outside a `.cong` still looks right.
441   */
442  var TOKENS = ['--c-border', '--c-text', '--c-text-2', '--c-muted', '--c-accent',
443                '--c-fs-sm', '--c-fs-xs'];
444
445  function build() {
446    if (pop) { return; }
447    pop = document.createElement('div');
448    // Not `.cong`: that is the content panel, and a theme may legitimately
449    // rewrite its width and margins - which would reshape this card with it.
450    pop.className = 'cal-pop';
451    arrow = document.createElement('span');
452    arrow.className = 'cal-pop-arrow';
453    document.body.appendChild(pop);
454  }
455
456  function inheritTokens(el) {
457    var panel = el.closest && el.closest('.cong');
458    if (!panel) { return; }
459    var cs = window.getComputedStyle(panel);
460    for (var i = 0; i < TOKENS.length; i++) {
461      var v = cs.getPropertyValue(TOKENS[i]);
462      if (v) { pop.style.setProperty(TOKENS[i], v.trim()); }
463    }
464  }
465
466  /** One event's block: colour swatch, name, time, category, description. */
467  function evHtml(ev) {
468    var swatch = ev.color
469      ? '<span class="cal-pop-swatch" style="background:' + esc(ev.color) + '"></span>'
470      : '<span class="cal-pop-swatch"></span>';
471
472    var html = '<div class="cal-pop-ev"><div class="cal-pop-head">' + swatch + '<div>'
473      + '<div class="cal-pop-title">' + esc(ev.title) + '</div>';
474
475    if (ev.when) { html += '<div class="cal-pop-when">' + esc(ev.when) + '</div>'; }
476    if (ev.cat)  { html += '<div class="cal-pop-cat">' + esc(ev.cat) + '</div>'; }
477
478    html += '</div></div>';
479
480    if (ev.desc) { html += '<div class="cal-pop-desc">' + esc(ev.desc) + '</div>'; }
481
482    return html + '</div>';
483  }
484
485  /** The card's contents for an anchor: a day's list, or a single event. */
486  function contentFor(el) {
487    var day = el.getAttribute('data-pop-day');
488
489    if (day) {
490      var list;
491      try { list = JSON.parse(day); } catch (e) { return ''; }
492      var date = el.getAttribute('data-pop-date');
493      var html = date ? '<div class="cal-pop-date">' + esc(date) + '</div>' : '';
494      for (var i = 0; i < list.length; i++) {
495        html += evHtml({
496          title: list[i].title, when: list[i].when,
497          desc: list[i].desc, color: list[i].color
498        });
499      }
500      return html;
501    }
502
503    return evHtml({
504      title: el.getAttribute('data-pop-title'),
505      when:  el.getAttribute('data-pop-when'),
506      cat:   el.getAttribute('data-pop-cat'),
507      desc:  el.getAttribute('data-pop-desc'),
508      color: el.getAttribute('data-pop-color')
509    });
510  }
511
512  /**
513   * Centre the card on its anchor, below it when there is room and above it
514   * when there is not, clamped to the viewport so a cell in the last column
515   * cannot push it off screen. `position: fixed`, so these are all viewport
516   * coordinates and no scroll offset is involved.
517   */
518  function place(el) {
519    var r = el.getBoundingClientRect();
520    var w = pop.offsetWidth;
521    var h = pop.offsetHeight;
522    var gap = 10;
523    var edge = 10;
524
525    // Below when it fits there, above when it only fits there, otherwise
526    // whichever side has more room — then clamp, so a card taller than the
527    // space either way is still fully on screen rather than half off it.
528    var roomBelow = window.innerHeight - edge - (r.bottom + gap);
529    var roomAbove = r.top - gap - edge;
530    var below = h <= roomBelow || roomBelow >= roomAbove;
531
532    var top = below ? r.bottom + gap : r.top - gap - h;
533    var left = r.left + r.width / 2 - w / 2;
534
535    top = Math.max(edge, Math.min(top, window.innerHeight - h - edge));
536    left = Math.max(edge, Math.min(left, window.innerWidth - w - edge));
537
538    pop.classList.toggle('below', below);
539    pop.classList.toggle('above', !below);
540    pop.style.top = Math.round(top) + 'px';
541    pop.style.left = Math.round(left) + 'px';
542
543    var ax = r.left + r.width / 2 - left - 5;
544    arrow.style.left = Math.round(Math.max(12, Math.min(ax, w - 22))) + 'px';
545  }
546
547  function open(el, tapped) {
548    build();
549
550    var html = contentFor(el);
551    if (!html) { return; }
552
553    // A tapped card is the only way to reach the event's own page, since the
554    // tap that opened it had to replace the chip's own navigation.
555    var href = el.getAttribute('href');
556    if (tapped && href && !el.hasAttribute('data-pop-day')) {
557      html += '<a class="cal-pop-link" href="' + esc(href) + '">View details →</a>';
558    }
559
560    close();
561    pop.innerHTML = html;
562    pop.appendChild(arrow);
563    pop.classList.toggle('tap', !!tapped);
564
565    anchor = el;
566    inheritTokens(el);
567
568    place(el);
569    // Two frames: the first commits the starting opacity, the second animates.
570    // Placing again on the way through catches a height that only settles once
571    // the card's own web font has been applied.
572    requestAnimationFrame(function () {
573      if (anchor === el) { place(el); }
574      requestAnimationFrame(function () { pop.classList.add('open'); });
575    });
576  }
577
578  function close() {
579    anchor = null;
580    if (pop) { pop.classList.remove('open'); }
581  }
582
583  document.addEventListener('mouseover', function (e) {
584    if (COARSE) { return; }
585    var el = e.target.closest && e.target.closest(SEL);
586    if (!el || el === anchor) { return; }
587    clearTimeout(timer);
588    timer = setTimeout(function () { open(el, false); }, 60);
589  });
590
591  document.addEventListener('mouseout', function (e) {
592    if (COARSE) { return; }
593    var el = e.target.closest && e.target.closest(SEL);
594    if (!el) { return; }
595    clearTimeout(timer);
596    timer = setTimeout(close, 80);
597  });
598
599  // Keyboard: the chips are already links, so focus is the hover equivalent.
600  document.addEventListener('focusin', function (e) {
601    var el = e.target.closest && e.target.closest(SEL);
602    if (el) { open(el, false); }
603  });
604
605  document.addEventListener('focusout', function (e) {
606    var el = e.target.closest && e.target.closest(SEL);
607    if (el && el === anchor) { close(); }
608  });
609
610  document.addEventListener('click', function (e) {
611    if (pop && pop.contains(e.target)) { return; }
612
613    var el = e.target.closest && e.target.closest(SEL);
614
615    if (!el) { close(); return; }
616    if (!COARSE) { return; }
617
618    // No hover to show the card, so the tap has to. The link moves into the
619    // card rather than firing here — otherwise the page would change before
620    // the name it was meant to reveal could be read.
621    e.preventDefault();
622
623    if (el === anchor) { close(); return; }
624
625    open(el, true);
626  });
627
628  document.addEventListener('keydown', function (e) {
629    if (e.key === 'Escape') { close(); }
630  });
631
632  window.addEventListener('scroll', close, true);
633  window.addEventListener('resize', close);
634})();

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.