PageSourceSearch

https://www.citiboces.org/js/stack_items.js?v=20260928201145

js citiboces.org collected 2026-09-29 14:00:05 UTC 116,800 bytes, 2,684 lines download raw bytes

1// https://medium.com/talk-like/detecting-if-an-element-is-in-the-viewport-jquery-a6a4405a3ea2
2
3if (typeof $.fn.isInViewport === "undefined") {
4    $.fn.isInViewport = function() {
5        var elementTop = $(this).offset().top;
6        var elementBottom = elementTop + $(this).outerHeight();
7        var viewportTop = $(window).scrollTop();
8        var viewportBottom = viewportTop + $(window).height();
9        return elementBottom > viewportTop && elementTop < viewportBottom;
10    };
11}
12
13var updateCarouselAriaLabels = function() {
14    let items = $(this).find('.item');
15    let totalSlides = items.length;
16    if (totalSlides < 2) {
17        return;
18    }
19
20    let activeSlide = 1;
21    items.each(function(index) {
22        if ($(this).hasClass('active')) {
23            activeSlide = index + 1;
24        }
25    });
26
27    let nextSlide = activeSlide + 1;
28    if (nextSlide > totalSlides) {
29        nextSlide = 1;
30    }
31    let prevSlide = activeSlide - 1;
32    if (prevSlide < 1) {
33        prevSlide = totalSlides;
34    }
35
36    let buildAriaLabel = function(index, totalSlides) {
37        return `View slide ${index} of ${totalSlides}`;
38    }
39
40    let nextAriaLabel = buildAriaLabel(nextSlide, totalSlides);
41    let prevAriaLabel = buildAriaLabel(prevSlide, totalSlides);
42    $(this).find('.js-carousel-control[data-direction="next"]').attr('aria-label', nextAriaLabel);
43    $(this).find('.js-carousel-control[data-direction="prev"]').attr('aria-label', prevAriaLabel);
44}
45
46// slick's built-in adaptiveHeight measures the slide at currentSlide, which in
47// centerMode (with infinite cloning) is not the visually centered slide, so the
48// list can be sized to a shorter slide and clip longer descriptions. Size the
49// list to the actually-centered slide so text is never cropped (WCAG 1.4.10
50// reflow at 400% zoom) and short slides don't leave whitespace.
51
52// Set the list height to whichever slide is currently centered, with no
53// animation. Used on load and resize/zoom where animating would look janky.
54var syncCarouselListHeight = function(component) {
55    let $centered = component.find('.slick-slide.slick-center');
56    if (!$centered.length) {
57        $centered = component.find('.slick-slide.slick-current');
58    }
59    if ($centered.length) {
60        // .stop(true, true) jumps any in-progress beforeChange animation to its
61        // end value before snapping, so the height lands cleanly. setPosition
62        // also fires after every slide change, so stop-in-place would otherwise
63        // cut the animation short by a few frames and produce a subtle snap.
64        component.find('.slick-list').stop(true, true).css('height', $centered.outerHeight(true));
65    }
66}
67
68// Animate the list height toward the incoming slide so the height eases in sync
69// with the horizontal slide instead of snapping after it. The slide that becomes
70// centered shares its logical data-slick-index with its clone, so measuring the
71// real slide for that index gives the correct target height even when slick is
72// scrolling through a clone.
73var animateCarouselListHeight = function(component, nextSlide, duration) {
74    let $target = component.find('.slick-slide[data-slick-index="' + nextSlide + '"]').first();
75    if ($target.length) {
76        component.find('.slick-list').stop(true, false).animate({ height: $target.outerHeight(true) }, duration);
77    }
78}
79
80$(document).on('click', '.stack_news .ss-post-feed-card', function(event) {
81    let primaryLink = $(this).find('.ss-post-feed-link').get(0);
82    let clickedInteractiveElement = $(event.target).closest(
83        'a, button, input, select, textarea, label, summary, [contenteditable], '
84        + '[tabindex]:not([tabindex="-1"]), [role="button"], [role="link"]',
85        this
86    ).length;
87    let modifiedClick = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
88    let nonPrimaryClick = event.button !== undefined && event.button !== 0;
89    let clickedImage = $(event.target).closest('img').length;
90    // Carousel peek slides switch their content off with pointer-events, but the
91    // card wrapper stays live. Without this check the delegated handler would
92    // make slides clickable that CSS deliberately keeps inert. pointer-events
93    // inherits, so reading it off the link covers every display style at once.
94    let linkNotInteractive = primaryLink
95        && window.getComputedStyle(primaryLink).pointerEvents === 'none';
96    let selection = window.getSelection();
97    let selectionTouchesCard = selection && (
98        selection.anchorNode === this ||
99        selection.focusNode === this ||
100        (selection.anchorNode && $.contains(this, selection.anchorNode)) ||
101        (selection.focusNode && $.contains(this, selection.focusNode))
102    );
103    let selectedCardText = selection && !selection.isCollapsed && selectionTouchesCard;
104
105    if (
106        !primaryLink ||
107        linkNotInteractive ||
108        clickedInteractiveElement ||
109        modifiedClick ||
110        nonPrimaryClick ||
111        (selectedCardText && !clickedImage)
112    ) {
113        return;
114    }
115
116    primaryLink.click();
117});
118
119$(function() {
120    const ANNOUNCER_DEBOUNCE_MS = 750;
121    // Minimum gap between clearing a live region and writing the new message.
122    // NVDA and JAWS need at least ~50ms to register the "clear" mutation before
123    // the re-population, otherwise the two writes are coalesced and the count
124    // may not be re-announced when it hasn't changed.
125    const ANNOUNCER_FLUSH_MS = 50;
126
127    /* -- stack_events_slider JS -- */
128    $('.js-carousel-control').on('click keypress', function(ev) {
129        let el = $(ev.target);
130        if (!el.hasClass('js-carousel-control')) {
131            el = el.parents('.js-carousel-control');
132        }
133        let carouselEl = $(el).parents('.carousel');
134        $(carouselEl).carousel(el.attr('data-direction'));
135    });
136    $('.calendar-events-slider-carousel-container').each(updateCarouselAriaLabels);
137    $('.calendar-events-slider-carousel-container').on('slid.bs.carousel', function() {
138        updateCarouselAriaLabels.call(this);
139        let announcement = $(this).find('.carousel-inner .item.active').attr('data-ss-slide-announcement') || '';
140        $(this).find('.js-carousel-slide-announcer').text(announcement);
141    });
142
143    /* -- stack_spotlight JS -- */
144    $('.js-carousel-control-spotlight').on('click', function() {
145        let containerEl = $(this).parents('.spotlight-container');
146        let carouselEl = containerEl.children('.carousel');
147        let direction = $(this).attr('data-direction');
148        if (direction === 'pause' || direction === 'cycle') {
149            let showPause = direction === 'cycle';
150            containerEl.find('.js-carousel-pause').toggle(showPause);
151            containerEl.find('.js-carousel-cycle').toggle(!showPause);
152            containerEl.find(showPause ? '.js-carousel-pause' : '.js-carousel-cycle').focus();
153        }
154        // WCAG 4.1.3: only announce slide changes the user explicitly requested,
155        // so auto-rotation, pause, and cycle don't barrage the screen reader.
156        if (direction === 'next' || direction === 'prev') {
157            carouselEl.data('ss-announce-slide', true);
158        }
159        $(carouselEl).carousel(direction);
160    });
161
162    $('.spotlight-carousel-container').on('slid.bs.carousel', function() {
163        let carouselEl = $(this);
164        if (!carouselEl.data('ss-announce-slide')) {
165            return;
166        }
167        carouselEl.data('ss-announce-slide', false);
168        // Announcement is composed server-side as
169        // "Slide N of M. Title. Paragraph Text. alt text" with empty parts skipped.
170        let announcement = carouselEl.find('.carousel-inner .item.active')
171            .attr('data-ss-slide-announcement') || '';
172        carouselEl.closest('.spotlight-container')
173            .find('.js-spotlight-slide-announcer')
174            .text(announcement);
175    });
176
177    var normalizeSpotlightSlides = function() {
178        $('.spotlight-caption-fixed').each(function() {
179            if ($(this).find('.carousel .spotlight-slide').css('flex-direction') === 'row') {
180                $(this).find('.carousel .spotlight-slide').css('height', 'initial');
181                return;
182            }
183            let maxHeight = 0;
184            $(this).find('.normalize-slides').css('display', 'block');
185            $(this).find('.normalize-slides .spotlight-slide').each(function() {
186                let height = $(this).height();
187                if (height > maxHeight) {
188                    maxHeight = height;
189                }
190            });
191            $(this).find('.carousel .spotlight-slide').css('height', maxHeight);
192            $(this).find('.normalize-slides').css('display', 'none');
193        })
194    };
195
196    var pauseSpotlightOutOfView = function() {
197        let spotlightContainer = $(this);
198        if (!spotlightContainer.length) {
199            return;
200        }
201        if (!$.fn.carousel || !$.fn.isInViewport) {
202            return;
203        }
204
205        let carouselEl = spotlightContainer.children('.carousel');
206        let cycleButton = spotlightContainer.find('.js-carousel-cycle');
207        if (!carouselEl || !cycleButton || cycleButton.is(':visible')) {
208            return;
209        }
210        if ($.fn.isInViewport && $(this).isInViewport()) {
211            if (spotlightContainer.attr('data-suspend') == 'true') {
212                carouselEl.carousel('cycle');
213                spotlightContainer.attr('data-suspend', 'false');
214            }
215        } else {
216            if (spotlightContainer.attr('data-suspend') != 'true') {
217                carouselEl.carousel('pause');
218                spotlightContainer.attr('data-suspend', 'true');
219            }
220        }
221    }
222
223    /* Stack Scrollover Image */
224    var setupScrolloverImage = function() {
225        $(this).find('.image-rollover-content').each(function() {
226            let revealEl = $(this).find('.image-rollover-reveal');
227
228            if ($(this).closest('.scrollover-highlight').length) {
229                if ($(this).hasClass('title-only')) {
230                    $(this).css('--ss-reveal-offset', '0px');
231                    return;
232                }
233                let gap = parseFloat($(this).css('row-gap')) || 0;
234                let revealHeight = revealEl.outerHeight() || 0;
235                let titleOffset = revealHeight ? Math.ceil(revealHeight + gap) : 0;
236                $(this).css('--ss-reveal-offset', `${titleOffset}px`);
237                return;
238            }
239
240            let containerOffset = $(this).offset();
241            let revealOffset = revealEl.offset();
242            let containerHeight = $(this).outerHeight();
243            let translateHeight = Math.abs(Math.ceil(revealOffset.top) - Math.ceil(containerOffset.top));
244            let translateY = containerHeight - translateHeight;
245            $(this).css('transform', `translateY(${translateY}px)`);
246            $(this).css('visibility', 'visible');
247        });
248    };
249
250    $(document).on(
251        'click',
252        '.scrollover-image-stack-item-container .image-rollover-container',
253        function(event) {
254            let link = $(this).find('.ss-scrollover-link').get(0);
255            let clickedLink = $(event.target).closest('.ss-scrollover-link').length;
256            let modifiedClick = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
257            let nonPrimaryClick = event.button !== undefined && event.button !== 0;
258
259            if (!link || clickedLink || modifiedClick || nonPrimaryClick) {
260                return;
261            }
262
263            link.click();
264        }
265    );
266
267    // A bit of a hack for live-editing and hiding/showing the container. We can't
268    // calculate height values when hidden so we add an event to set the values when
269    // they're "shown"
270    $('.scrollover-image-stack-item-container .visButton').on('click', function() {
271        let container = $(this).parents('.scrollover-image-stack-item-container');
272        // There's a animation to grow the container height and we have to calulate
273        // values after it's finished running.
274        //
275        // Adding it to the event chain is too much work for an admin-only edge case
276        // so we'll just delay the calculation to be after the animation is finished.
277        setTimeout(function() {
278            container.each(setupScrolloverImage);
279        }, 1000);
280    })
281
282    const googleTranslateDiv = $('#GoogleTranslateDropdown');
283    const setGoogleTranslateSpot = function() {
284        if (!googleTranslateDiv.length) {
285            return;
286        }
287        let isHeader3 = $('.ss-body-site-header-three').length;
288        if (isHeader3) {
289            let mainNavTop = $('.ss-site-header-main-nav-top');
290            let mainNavTopTranslate = mainNavTop.find('.js-site-header-contains-translate');
291            let mobileTranslate = $('.ss-site-header-main-nav').find('.js-site-header-contains-translate');
292            let desktopVisible = mainNavTop.css('display') !== 'none';
293            if (desktopVisible) {
294                if (!mainNavTopTranslate.find('.googleTranslateDropdown').length) {
295                    googleTranslateDiv.appendTo(mainNavTopTranslate.find('.googleDropdownContainerTarget'));
296                }
297            } else {
298                if (!mobileTranslate.find('.googleTranslateDropdown').length) {
299                    googleTranslateDiv.appendTo(mobileTranslate.find('.googleDropdownContainerTarget'));
300                }
301            }
302        } else {
303            let navMainLinks = $('.ss-site-header-main-nav').find('.js-site-header-contains-translate');
304            let heroMainLinks = $('.ss-site-header-main-container').find('.js-site-header-contains-translate');
305            let desktopVisible = navMainLinks.css('display') == 'none';
306            if (desktopVisible) {
307                if (!heroMainLinks.find('.googleTranslateDropdown').length) {
308                    googleTranslateDiv.appendTo(heroMainLinks.find('.googleDropdownContainerTarget'));
309                }
310            } else {
311                if (!navMainLinks.find('.googleTranslateDropdown').length) {
312                    googleTranslateDiv.appendTo(navMainLinks.find('.googleDropdownContainerTarget'));
313                }
314            }
315        }
316
317        googleTranslateDiv.css('display', 'block');
318    }
319
320    /////
321    // Site Header - Main navigation resize logic
322    //
323    // On some headers we have the page naivgation in the "main" container and it might overlap
324    // with the content on the other side. To handle this we "move" the page navigation below the
325    // main header when there would be overlap.
326    const containedNav = $('.ss-site-header-main-nav-contained');
327    const fullWidthNav = $('.ss-site-header-main-nav-full-width');
328    const heroContainer = $('.ss-site-header-hero-container');
329    const mainContainer = $('.ss-site-header-main-container');
330    const mainContainerFirstSiteLink = mainContainer.find('.ss-site-header-page-links').first();
331    const mainContainerSiteLinks = mainContainer.find('.ss-site-header-main-links-container');
332    const mainContainerPadding = 64;
333    const mainLinksPageLinksSpacing = 10;
334    const containedNavHeight = containedNav.height();
335    const mainLinksContainerHeight = mainContainerSiteLinks.height();
336    const mainContainerFullWidthMinHeight = (containedNavHeight + mainLinksContainerHeight + mainContainerPadding + mainLinksPageLinksSpacing)+'px';
337
338
339    /////
340
341    /////
342    // Site Header - Transparent header spacing
343    //
344    // For transparent headers, the page navigation is absolutely positioned and overlays the content.
345    // We need to push down the content below the header by the actual height of the navigation,
346    // which changes dynamically based on viewport width as the navigation links wrap.
347    const updateTransparentHeaderSpacing = function() {
348        // Only apply spacing for site header versions 1 and 2 with transparent header and top-aligned nav
349        const isTopAlignedHeader = $('body').hasClass('ss-body-site-header-one') || $('body').hasClass('ss-body-site-header-two');
350        const hasTransparentHeader = $('body').hasClass('ss-transparent-header');
351        const isTopAlignedNav = $('.ss-site-header-main-nav-top-aligned').length > 0;
352
353        if (!isTopAlignedHeader || !hasTransparentHeader || !isTopAlignedNav) {
354            return;
355        }
356
357        // Use matchMedia to match the exact CSS media query breakpoint (including scrollbar width)
358        const isDesktop = window.matchMedia('(min-width: 992px)').matches;
359        if (isDesktop) {
360            const navHeight = $('.ss-site-header-page-links').outerHeight();
361            if (navHeight && navHeight > 0) {
362                $('.stack_sort_area').css('margin-top', navHeight + 'px');
363            }
364        } else {
365            $('.stack_sort_area').css('margin-top', '');
366        }
367    };
368    /////
369
370    /////
371    // Site Header - Pulled out popular links resize logic
372    //
373    // On some headers we allow the user to pull out a few "popular links" to show outside of the modal.
374    // There is the possibility that the links will overlap with content on the other side of the screen.
375    // When this happens we want to move the popular links back into the modal until there isn't an overlap.
376    class HeaderMainNavManager {
377        constructor() {
378            this.siteHeaderMainNavTop = $('.ss-site-header-main-nav-top');
379            this.topNavPopularLinks = this.siteHeaderMainNavTop.find('.js-header-custom-link');
380            this.leftNavArea = $('.js-site-header-main-links-container-left');
381            this.rightNavArea = $('.js-site-header-main-links-container-right');
382            this.popularLinksModalLink = this.rightNavArea.find('li.popular-links');
383            this.popularLinksModal = $('#ss-popular-links-modal');
384            this.popularLinksModalEntries = this.popularLinksModal.find('.js-header-custom-link');
385            this.totalPopularLinksInModal = this.popularLinksModal.find('.ss-popular-links-list li').length
386        }
387
388        // Check to see if there is too little space between the left and right areas in the header top nav
389        mainNavTopCollision() {
390            let leftNavAreaRightEdge = this.leftNavArea.offset().left + this.leftNavArea.width();
391            let rightNavAreaLeftEdge = this.rightNavArea.offset().left;
392            let leftRightSpacing = rightNavAreaLeftEdge - leftNavAreaRightEdge;
393            return leftRightSpacing < 20;
394        }
395
396        // The main top nav is hidden in most header options and on mobile views
397        mainTopHidden() {
398            return this.siteHeaderMainNavTop.length == 0 || !this.siteHeaderMainNavTop.is(':visible');
399        }
400
401        checkHeaderMainNavTop() {
402            if (this.mainTopHidden() || !this.topNavPopularLinks.length) {
403                return;
404            }
405
406            if (this.mainNavTopCollision()) {
407                // Go from the end of the list and hide links until we have enough space
408                let finalLinkIndex = this.topNavPopularLinks.length - 1;
409                while (this.mainNavTopCollision() && finalLinkIndex >= 0) {
410                    let link = $(this.topNavPopularLinks[finalLinkIndex--]);
411                    link.addClass('ss-hidden');
412                }
413            } else {
414                // Go from the start of the list and show links
415                let firstLinkIndex = 0;
416                while (!this.mainNavTopCollision() && firstLinkIndex < this.topNavPopularLinks.length) {
417                    let link = $(this.topNavPopularLinks[firstLinkIndex]);
418                    link.removeClass('ss-hidden');
419                    // If we just revealed a link and it causes a collision, hide it and break
420                    if (this.mainNavTopCollision()) {
421                        link.addClass('ss-hidden');
422                        break;
423                    }
424                    firstLinkIndex++;
425                }
426            }
427
428            // We hide the element when the page loads so the user doesn't see a flash of content before it's hidden
429            // We need to remove the loading class so the user can see the content after we've hidden the conflicting elements.
430            this.rightNavArea.removeClass('ss-site-header-loading');
431
432            // Now that we figured out how many nav links are visible, we need to update the modal to show any hidden links
433            this.updateModalLinks();
434        }
435
436        updateModalLinks() {
437            // Figure out how many links should be visible in the modal based off of the number hidden in the top nav
438            let hiddenLinks = this.rightNavArea.find('.js-header-custom-link.ss-hidden');
439            let numHiddenInModal = this.topNavPopularLinks.length - hiddenLinks.length;
440
441            // We'll reset the hidden links then hide up to the number hidden in the top nav
442            this.popularLinksModalEntries.removeClass('ss-desktop-hidden');
443            for (let i = 0; i < numHiddenInModal; i++) {
444                let modalLink = $(this.popularLinksModalEntries[i]);
445                modalLink.addClass('ss-desktop-hidden');
446            }
447
448            // We might have hidden all the entries in the modal, so we need to hide or show the link to open the modal
449            let hiddenLinksInModal = this.popularLinksModal.find('.js-header-custom-link.ss-desktop-hidden');
450            let hidePopularLinkButton = hiddenLinksInModal.length === this.totalPopularLinksInModal;
451            this.popularLinksModalLink.toggleClass('ss-desktop-hidden', hidePopularLinkButton);
452        }
453    }
454    /////
455
456    //Global Parallax Function
457    $(window).on("load", function() {
458      if($(".rellax").length) {
459        var rellax = new Rellax('.rellax');
460      }
461    });
462
463    const headerMainNavManager = new HeaderMainNavManager();
464    $(window).on('load resize', function() {
465        normalizeSpotlightSlides();
466        $('.scrollover-image-container').each(setupScrolloverImage);
467        setGoogleTranslateSpot();
468        headerMainNavManager.checkHeaderMainNavTop();
469        updateTransparentHeaderSpacing();
470    });
471
472    $(window).on('resize scroll', function() {
473        $('.spotlight-caption-responsive').each(pauseSpotlightOutOfView);
474    });
475
476    /* -- stack_video JS -- */
477    // Keep in sync with FeaturedVideoTemplate::BLOB_PLAYBACK_MAX_BYTES
478    const FEATURED_VIDEO_BLOB_MAX_BYTES = 40 * 1024 * 1024;
479
480    const getFeaturedVideoStackContainer = function(fromEl) {
481        return $(fromEl).closest('.stack-item-content-featured-video');
482    };
483
484    const getFeaturedVideoInnerContainer = function(stackContainerEl) {
485        return stackContainerEl.find('.featured-video-container').first();
486    };
487
488    const getFeaturedVideoEls = function(stackContainerEl) {
489        return stackContainerEl.find('video');
490    };
491
492    const setFeaturedVideoUserPaused = function(stackContainerEl, paused) {
493        stackContainerEl.attr('data-fv-user-paused', paused ? 'true' : 'false');
494    };
495
496    const isFeaturedVideoUserPaused = function(stackContainerEl) {
497        return stackContainerEl.attr('data-fv-user-paused') === 'true';
498    };
499
500    const setFeaturedVideoPlaybackUi = function(stackContainerEl, isPlaying) {
501        let playBtn = stackContainerEl.find('.js-featured-video-nav-control[data-action="play"]');
502        let pauseBtn = stackContainerEl.find('.js-featured-video-nav-control[data-action="pause"]');
503        if (isPlaying) {
504            playBtn.hide();
505            pauseBtn.show();
506        } else {
507            playBtn.show();
508            pauseBtn.hide();
509        }
510    };
511
512    const pauseFeaturedVideos = function(stackContainerEl) {
513        getFeaturedVideoEls(stackContainerEl).each(function() {
514            try {
515                this.pause();
516            } catch (err) {
517                // Ignore pause errors from browsers rejecting the call.
518            }
519        });
520        stackContainerEl.attr('data-fv-system-suspended', 'true');
521        setFeaturedVideoPlaybackUi(stackContainerEl, false);
522    };
523
524    const resumeFeaturedVideos = function(stackContainerEl) {
525        if (isFeaturedVideoUserPaused(stackContainerEl)) {
526            return;
527        }
528        if (document.visibilityState === 'hidden') {
529            return;
530        }
531        if (stackContainerEl.attr('data-fv-in-view') === 'false') {
532            return;
533        }
534        getFeaturedVideoEls(stackContainerEl).each(function() {
535            let playPromise = this.play();
536            if (playPromise && typeof playPromise.catch === 'function') {
537                playPromise.catch(function() {});
538            }
539        });
540        stackContainerEl.attr('data-fv-system-suspended', 'false');
541        setFeaturedVideoPlaybackUi(stackContainerEl, true);
542    };
543
544    const applyFeaturedVideoVisibilityState = function(stackContainerEl, isIntersecting) {
545        stackContainerEl.attr('data-fv-in-view', isIntersecting ? 'true' : 'false');
546        if (!isIntersecting || document.visibilityState === 'hidden') {
547            pauseFeaturedVideos(stackContainerEl);
548            return;
549        }
550        resumeFeaturedVideos(stackContainerEl);
551    };
552
553    const resolveFeaturedVideoUrl = function(videoEl) {
554        if (!videoEl) {
555            return '';
556        }
557        let sourceEl = videoEl.querySelector('source');
558        return videoEl.currentSrc || (sourceEl && sourceEl.src) || videoEl.src || '';
559    };
560
561    const applyBlobUrlToFeaturedVideos = function(stackContainerEl, objectUrl) {
562        getFeaturedVideoEls(stackContainerEl).each(function() {
563            let videoEl = this;
564            let trackEls = Array.prototype.slice.call(videoEl.querySelectorAll('track'));
565            Array.prototype.slice.call(videoEl.querySelectorAll('source')).forEach(function(sourceEl) {
566                sourceEl.remove();
567            });
568            videoEl.removeAttribute('src');
569            videoEl.src = objectUrl;
570            trackEls.forEach(function(trackEl) {
571                videoEl.appendChild(trackEl);
572            });
573            videoEl.load();
574        });
575        getFeaturedVideoInnerContainer(stackContainerEl).attr('data-fv-blob-url', objectUrl);
576    };
577
578    const tryLoadFeaturedVideoAsBlob = function(stackContainerEl) {
579        let innerContainerEl = getFeaturedVideoInnerContainer(stackContainerEl);
580        if (innerContainerEl.attr('data-fv-prefer-blob') !== 'true') {
581            return Promise.resolve(false);
582        }
583        if (!window.fetch) {
584            return Promise.resolve(false);
585        }
586
587        let knownBytes = parseInt(innerContainerEl.attr('data-video-bytes') || '0', 10);
588        if (!knownBytes || knownBytes > FEATURED_VIDEO_BLOB_MAX_BYTES) {
589            return Promise.resolve(false);
590        }
591
592        let displayVideo = stackContainerEl.find('video.featured-video-display')[0];
593        let videoUrl = resolveFeaturedVideoUrl(displayVideo);
594        if (!videoUrl || videoUrl.indexOf('blob:') === 0) {
595            return Promise.resolve(false);
596        }
597
598        // Cancel any early progressive download before fetching the full blob once.
599        getFeaturedVideoEls(stackContainerEl).each(function() {
600            try {
601                this.pause();
602            } catch (err) {}
603            Array.prototype.slice.call(this.querySelectorAll('source')).forEach(function(sourceEl) {
604                sourceEl.remove();
605            });
606            this.removeAttribute('src');
607            this.load();
608        });
609
610        return fetch(videoUrl, {mode: 'cors', credentials: 'omit'})
611            .then(function(response) {
612                if (!response.ok) {
613                    throw new Error('Featured video blob fetch failed');
614                }
615                return response.blob();
616            })
617            .then(function(blob) {
618                if (!blob || blob.size > FEATURED_VIDEO_BLOB_MAX_BYTES) {
619                    throw new Error('Featured video exceeds blob playback threshold');
620                }
621                let objectUrl = URL.createObjectURL(blob);
622                applyBlobUrlToFeaturedVideos(stackContainerEl, objectUrl);
623                return true;
624            })
625            .catch(function() {
626                // Restore progressive source playback if blob loading fails.
627                getFeaturedVideoEls(stackContainerEl).each(function() {
628                    let sourceEl = document.createElement('source');
629                    sourceEl.src = videoUrl;
630                    this.appendChild(sourceEl);
631                    this.load();
632                });
633                return false;
634            });
635    };
636
637    const initFeaturedVideoBandwidthGuards = function() {
638        $('.stack-item-content-featured-video').each(function() {
639            let stackContainerEl = $(this);
640            let displayVideo = stackContainerEl.find('video.featured-video-display')[0];
641            if (!displayVideo) {
642                return;
643            }
644
645            let innerContainerEl = getFeaturedVideoInnerContainer(stackContainerEl);
646            let shouldAutoplay = innerContainerEl.attr('data-fv-prefer-blob') === 'true'
647                ? innerContainerEl.attr('data-fv-should-autoplay') === 'true'
648                : !!displayVideo.autoplay;
649
650            setFeaturedVideoUserPaused(stackContainerEl, !shouldAutoplay);
651            stackContainerEl.attr('data-fv-in-view', 'true');
652
653            let startPlaybackIfAllowed = function() {
654                if (shouldAutoplay) {
655                    resumeFeaturedVideos(stackContainerEl);
656                } else {
657                    setFeaturedVideoPlaybackUi(stackContainerEl, false);
658                }
659            };
660
661            tryLoadFeaturedVideoAsBlob(stackContainerEl).then(function() {
662                startPlaybackIfAllowed();
663            });
664
665            if ('IntersectionObserver' in window) {
666                let observer = new IntersectionObserver(function(entries) {
667                    entries.forEach(function(entry) {
668                        applyFeaturedVideoVisibilityState($(entry.target), entry.isIntersecting);
669                    });
670                }, {threshold: 0.15, rootMargin: '0px'});
671                observer.observe(this);
672            }
673        });
674
675        document.addEventListener('visibilitychange', function() {
676            $('.stack-item-content-featured-video').each(function() {
677                let stackContainerEl = $(this);
678                if (document.visibilityState === 'hidden') {
679                    pauseFeaturedVideos(stackContainerEl);
680                    return;
681                }
682                if (stackContainerEl.attr('data-fv-in-view') !== 'false') {
683                    resumeFeaturedVideos(stackContainerEl);
684                }
685            });
686        });
687    };
688
689    $('.js-featured-video-sound-control').on('click keydown', function(ev) {
690        if (!ssGlobal.A11yInteraction(ev)) { return; }
691        ev.preventDefault();
692        let featuredVideoContainerEl = $(this).parents('.stack-item-content-featured-video');
693        let muteVideo = $(this).attr('data-mute-video') == 'true';
694        featuredVideoContainerEl.find('.js-featured-video-sound-control').toggle();
695        featuredVideoContainerEl.find('.js-featured-video-sound-control:visible').trigger('focus');
696        featuredVideoContainerEl.find('video.featured-video-display').prop('muted', muteVideo);
697    });
698
699    $('.js-featured-video-nav-control').on('click keydown', function(ev) {
700        if (!ssGlobal.A11yInteraction(ev)) { return; }
701        ev.preventDefault();
702        let featuredVideoContainerEl = $(this).parents('.stack-item-content-featured-video');
703        let action = $(this).attr('data-action');
704        setFeaturedVideoUserPaused(featuredVideoContainerEl, action === 'pause');
705        featuredVideoContainerEl.find('.js-featured-video-nav-control').toggle();
706        featuredVideoContainerEl.find('.js-featured-video-nav-control:visible').trigger('focus');
707        featuredVideoContainerEl.find('video').trigger(action);
708        featuredVideoContainerEl.attr('data-fv-system-suspended', 'false');
709    });
710
711    const setFeaturedVideoCaptionButtonState = function(captionButtonEl, captionsEnabled) {
712        captionButtonEl.toggleClass('is-on', captionsEnabled);
713        captionButtonEl.toggleClass('is-off', !captionsEnabled);
714        captionButtonEl.attr('aria-pressed', captionsEnabled ? 'true' : 'false');
715        let iconEl = captionButtonEl.find('.js-featured-video-cc-icon');
716        if (iconEl.length) {
717            iconEl.toggleClass('fa-solid', captionsEnabled);
718            iconEl.toggleClass('fa-regular', !captionsEnabled);
719        }
720    };
721
722    const setFeaturedVideoTrackMode = function(videoEl, captionsEnabled) {
723        if (!videoEl || !videoEl.textTracks || videoEl.textTracks.length < 1) {
724            return false;
725        }
726        try {
727            let track = videoEl.textTracks[0];
728            track.mode = captionsEnabled ? 'showing' : 'disabled';
729            return true;
730        } catch (err) {
731            return false;
732        }
733    };
734
735    const applyFeaturedVideoCaptionState = function(featuredVideoEl, captionButtonEl) {
736        let captionsEnabled = captionButtonEl.attr('aria-pressed') === 'true';
737        let modeWasSet = setFeaturedVideoTrackMode(featuredVideoEl, captionsEnabled);
738        if (!modeWasSet) {
739            return false;
740        }
741        setFeaturedVideoCaptionButtonState(captionButtonEl, captionsEnabled);
742        return true;
743    };
744
745    const initializeFeaturedVideoCaptions = function() {
746        $('.stack-item-content-featured-video').each(function() {
747            let featuredVideoContainerEl = $(this);
748            let captionButtonEl = featuredVideoContainerEl.find('.js-featured-video-caption-control');
749            if (!captionButtonEl.length) {
750                return;
751            }
752            let featuredVideoEl = featuredVideoContainerEl.find('video.featured-video-display')[0];
753            if (!featuredVideoEl) {
754                return;
755            }
756
757            // Try immediately; if tracks are not ready yet, retry after metadata loads.
758            if (applyFeaturedVideoCaptionState(featuredVideoEl, captionButtonEl)) {
759                return;
760            }
761
762            let initOnMetadata = function() {
763                if (!applyFeaturedVideoCaptionState(featuredVideoEl, captionButtonEl)) {
764                    captionButtonEl.hide();
765                }
766            };
767            if (featuredVideoEl.readyState >= 1) {
768                initOnMetadata();
769                return;
770            }
771            featuredVideoEl.addEventListener('loadedmetadata', initOnMetadata, {once: true});
772        });
773    };
774
775    $('.js-featured-video-caption-control').on('click keydown', function(ev) {
776        if (!ssGlobal.A11yInteraction(ev)) { return; }
777        ev.preventDefault();
778        let captionButtonEl = $(this);
779        let featuredVideoContainerEl = captionButtonEl.parents('.stack-item-content-featured-video');
780        let featuredVideoEl = featuredVideoContainerEl.find('video.featured-video-display')[0];
781        let captionsEnabled = captionButtonEl.attr('aria-pressed') !== 'true';
782        let modeWasSet = setFeaturedVideoTrackMode(featuredVideoEl, captionsEnabled);
783        if (!modeWasSet) {
784            return;
785        }
786        setFeaturedVideoCaptionButtonState(captionButtonEl, captionsEnabled);
787    });
788
789    initializeFeaturedVideoCaptions();
790    initFeaturedVideoBandwidthGuards();
791
792    // If the video is in a transparent header, we need to hide the caption until the video can start playing to keep it from overlapping the header content.
793    const transparentHeaderVideoCaption = $(".ss-hp.ss-transparent-header .stack_sort_area > section.stack-item-featured-video.break-out:first-of-type .featured-video-display");
794    $(transparentHeaderVideoCaption).on('canplay', function() {
795        $(this).closest(".stack-item-content-container").addClass("ss-video-loaded");
796    });
797
798    // @TODO: Validate if we still need this after the new calendar implementation.
799    var loadCalendar = function(divID, options) {
800        let calApiID = options.CalApiID;
801        let calendarDiv = $(`#${divID}`);
802        /* To Do: Attach Str Color? */
803        let attachmentImg = options.tooltipClass == 'qtip-tipsy' ?
804         '//cdn.smartsites.parentsquare.com/library/common/glyphiconPaperclipWhite.png' :
805         '//cdn.smartsites.parentsquare.com/library/common/glyphiconPaperclip.png';
806        let buildToolTip = function(data) {
807            let timeHTML = '';
808            if (!data.all_day) {
809                let startTime = data.start ? data.start.format('h:mm a') : '';
810                let endTime = data.end ? data.end.format('h:mm a') : '';
811                if (startTime || endTime) {
812                    let timeStr = startTime ? startTime : endTime;
813                    if (startTime && endTime) {
814                        timeStr = `${startTime} - ${endTime}`;
815                    }
816                    timeStr += ` (${options.timeZoneAbbr})`;
817                    timeHTML = /*html*/`<div>${timeStr}</div>`;
818                }
819            }
820            let dateHTML = '';
821            if (data.start) {
822                let startDate = data.start.format('MMM D');
823                let endDate = data.end ? data.end.format('MMM D') : startDate;
824                if (startDate != endDate) {
825                    // All Day Events for 1 day have different start and end days.
826                    // We don't want both dates showing up on the tooltip b/c 
826it's confusing.
827                    if (!(data.all_day && data.end.diff(data.start, 'days') == 1)) {
828                        dateHTML = /*html*/`<div>${startDate} - ${endDate} (${options.timeZoneAbbr})</div>`;
829                    }
830                }
831            }
832            let attachmentHTML = '';
833            if (data.attachments) {
834                attachmentHTML = /*html*/`
835                    <div class="events-slider-calendar-event-tool-tip-icon-row">
836                        <img src="${attachmentImg}">
837                        <div>Attachment(s)</div>
838                    </div>`;
839            }
840            let sourceHTML = '';
841            let hasRemoteSource = data.feed_source === 'google' || data.feed_source === 'ics' || (Number.isInteger(data.id) && data.calendar_id != calApiID);
842            if (data.calendar_name && hasRemoteSource) {
843                let sourceName = data.calendar_name.replace('Calendar: ', '').replace(/\\(.)/mg, "$1");
844                if (data.feed_source == 'google') {
845                    sourceHTML = /*html*/`
846                        <div class="events-slider-calendar-event-tool-tip-icon-row">
847                            <img src="//cdn.smartsites.parentsquare.com/library/common/gcalIcon.png">
848                            <div>${sourceName}</div>
849                        </div>`;
850                } else {
851                    sourceHTML = /*html*/`
852                        <div>Source: ${sourceName}</div>`;
853                }
854            }
855            let srOnlyDateHTML = '';
856            if (data.start) {
857                srOnlyDateHTML = `<span class="sr-only">${data.start.format('dddd, MMMM Do YYYY')}</span>`;
858            }
859            let dayTooltip = /*html*/`
860                <div class="events-slider-calendar-event-tool-tip">
861                    ${srOnlyDateHTML}
862                    <div class="events-slider-calendar-event-tool-tip-icon-row">
863                        <div class="events-slider-calendar-event-tool-tip-bubble" style="background-color: ${options.eventBGColor};"></div>
864                        <div>${data.title}</div>
865                    </div>
866                    ${timeHTML}
867                    ${dateHTML}
868                    ${sourceHTML}
869                    ${attachmentHTML}
870                </div>
871            `;
872            return dayTooltip;
873        }
874        let calEventSources = [
875            {
876                url: `/sndreq/calendar_feed.php?calendar_id=${calApiID}`,
877            },
878            {
879                url: `/sndreq/ics_feed.php?calendar_id=${calApiID}`,
880            },
881            {
882                url: `/sndreq/google_feed.php?calendar_id=${calApiID}`,
883            }
884        ];
885        if (options.addCampusFeed) {
886            calEventSources.push({url: `/sndreq/campus_calendar_feed.php?calendar_id=${calApiID}`});
887        }
888        if (options.addDistrictFeed) {
889            calEventSources.push({url: `/sndreq/district_calendar_feed.php?calendar_id=${calApiID}`});
890        }
891        let calendarConfig = {
892            editable: false,
893            height: 'auto',
894            minTime: '00:00:00',
895            maxTime: '24:00:00',
896            timezone: options.userTimeZone,
897            header: {
898                left: 'prevYear,prev',
899                center: 'title',
900                right: 'next,nextYear'
901            },
902            views: {
903                month: {
904                    eventLimit: 99
905                }
906            },
907            googleCalendarApiKey: 'AIzaSyDTTgBWki5P141BVMItARiZykc21-7HXxo',
908            eventSources: calEventSources,
909            eventRender: function(event, element, view) {
910                let event_date = event.end_date ? event.end_date : event.start_date
911                // We want each event to be focusable. To be focusable, it needs a valid href.
912                let eventUrl = ['google', 'ics'].includes(event.feed_source) ?
913                    event.event_url :
914                    `/event_view?event_id=${event.id}&calIDref=${event.calendar_id}&eventDate=${event_date}`;
915                let eventEl = $(element);
916                eventEl.attr('href', eventUrl);
917                if (typeof $.fn.qtip === "undefined") {
918                    if (window.console && window.console.error) {
919                        window.console.error('Error: qTip jQuery plugin is not loaded!');
920                    }
921                    return;
922                }
923                eventEl.qtip({
924                    content: buildToolTip(event),
925                    position: {
926                        my: 'top left',
927                        at: 'bottom left',
928                        viewport: $(window),
929                        adjust:{
930                            method: 'flipinvert',
931                            x: 0,
932                            y: 3
933                        }
934                    },
935                    style: {
936                        classes: `${options.tooltipClass} events-slider-calendar-event-qtip`,
937                        tip: {
938                            width:12, height:6, mimic:'center'
939                        }
940                    },
941                    show: {
942                        ready: false
943                    },
944                    hide: {
945                        fixed: true
946                    }
947                });
948                eventEl.on('focus mouseover', function() {
949                    $(this).qtip('show');
950                });
951                eventEl.on('focusout mouseleave', function() {
952                    $(this).qtip('hide');
953                });
954            },
955            dayRender: function(date, cell) {
956                //add class and date data so that after all events are rendered
957                //can loop through and render ONE colored circle if an event Occurred
958                var currentDate = date.unix();
959                cell.addClass(`cellDay${calApiID}`);
960                cell.attr('data-unix', currentDate);
961            },
962            eventAfterAllRender: function(view) {
963                calendarDiv.LoadingOverlay('hide');
964            }
965        }
966        calendarDiv.LoadingOverlay('show');
967        calendarDiv.fullCalendar(calendarConfig);
968    };
969
970    // @TODO: this can go away once we migrate FULL calendar component
971    $('.js-stack-item-events-calendar-container').each(function () {
972        console.warn('[DEPRECATED] js-stack-item-events-calendar-container is deprecated and will be removed in a future version.');
973        let calendarData = JSON.parse(window[$(this).attr('data-var-name')]);
974        loadCalendar($(this).attr('id'), calendarData);
975    });
976
977    $(".ss-schools-heading .ss-button").on("click", function() {
978        let container = $(this).closest(".modal");
979
980        if(!$(this).hasClass("ss-active")) {
981            let listToControl = $(this).attr("aria-controls");
982
983            $(".ss-schools-group").slideUp();
984            $(".ss-schools-heading .ss-button").removeClass("ss-active").attr("aria-expanded", "false");
985
986            $(this).addClass("ss-active").attr("aria-expanded", "true");
987            $("#"+listToControl).slideDown(function() {
988                if (container.length) {
989                    ssLightbox.GetFocusableElements(container[0]);
990                    ssLightbox.NavigateFocusLoop(container[0]);
991                }
992            });
993        } else {
994            let listToControl = $(this).attr("aria-controls");
995            $("#"+listToControl).slideUp(function() {
996                if (container.length) {
997                    ssLightbox.GetFocusableElements(container[0]);
998                    ssLightbox.NavigateFocusLoop(container[0]);
999                }
1000            });
1001            $(this).removeClass("ss-active").attr("aria-expanded", "false");
1002        }
1003    });
1004
1005    $("#ss-header-mobile-menu .ss-mobile-menu-disclosure-button").on("click", function() {
1006        let $button = $(this);
1007        let container = $button.closest(".modal");
1008
1009        if (!$button.hasClass("ss-active")) {
1010            let listToControl = $button.attr("aria-controls");
1011            let parents = $button.parents('ul');
1012            let parent = $(parents[0]);
1013
1014            parent.find(".ss-mobile-menu-group").slideUp();
1015            parent.find(".ss-mobile-menu-disclosure-button").removeClass("ss-active").attr("aria-expanded", "false");
1016
1017            $button.addClass("ss-active").attr("aria-expanded", "true");
1018            $("#"+listToControl).slideDown(function() {
1019                // Update focus loop after animation completes and content is visible
1020                if (container.length) {
1021                    ssLightbox.GetFocusableElements(container[0]);
1022                    ssLightbox.NavigateFocusLoop(container[0]);
1023                }
1024            });
1025        } else {
1026            let listToControl = $button.attr("aria-controls");
1027            $("#"+listToControl).slideUp(function() {
1028                // Update focus loop after animation completes
1029                if (container.length) {
1030                    ssLightbox.GetFocusableElements(container[0]);
1031                    ssLightbox.NavigateFocusLoop(container[0]);
1032                }
1033            });
1034            $button.removeClass("ss-active").attr("aria-expanded", "false");
1035        }
1036    });
1037
1038    // for zoom effect on linked images
1039    $(".ss-columns img, .ss-accordion-tabs img, .ss-linked-image-hover-zoom img").each(function() {
1040        let image = $(this);
1041        let imageLink = image.closest("a");
1042
1043        if (imageLink.length) {
1044            let imageStyles = image.attr("style");
1045            $(imageLink).attr("style", imageStyles).addClass("ss-image-link");
1046            image.removeAttr("style");
1047        }
1048    })
1049
1050    ssLightbox.Init();
1051    ssAccordionTabs.Init();
1052
1053    /* Post Feed Carousel */
1054    const postFeedSliders = $('.news-feed-balanced-box-container, .news-feed-floating-box-container, .news-feed-psq-container');
1055    const carouselSliders = $('.news-feed-carousel-container');
1056
1057    enquire.register("screen and (max-width: 768px)", function() {
1058        postFeedSliders.each(function() {
1059            let postSlider = $(this);
1060            if (!postSlider.hasClass('slick-initialized')) {
1061                if (typeof window.slickSliderA11y === 'function') {
1062                    window.slickSliderA11y(postSlider, postSlider.parent());
1063                }
1064                postSlider.slick({
1065                    infinite: true,
1066                    slidesToShow: 1,
1067                    adaptiveHeight: true,
1068                    centerMode: postSlider.hasClass('news-feed-balanced-box-container') || postSlider.hasClass('news-feed-psq-container'),
1069                    centerPadding: '150px',
1070                    prevArrow: postSlider.next().find('.slick-prev'),
1071                    nextArrow: postSlider.next().find('.slick-next'),
1072                    speed: 1000,
1073                    responsive: [
1074                        {
1075                            breakpoint: 640,
1076                            settings: {
1077                                centerPadding: '75px'
1078                            }
1079                        },
1080                        {
1081                            breakpoint: 480,
1082                            settings: {
1083                                centerPadding: '20px'
1084                            }
1085                        }
1086                    ]
1087                });
1088            }
1089        });
1090
1091        carouselSliders.each(function() {
1092          let carouselSlider = $(this);
1093          if(carouselSlider.parent().find(".ss-slick-control-wrapper .ss-slick-play-pause-wrapper").length < 1) {
1094            carouselSlider.parent().find(".ss-slick-play-pause-wrapper").prependTo(carouselSlider.parent().find(".ss-slick-control-wrapper"));
1095          }
1096        });
1097    });
1098
1099    enquire.register("screen and (min-width: 769px)", function() {
1100        postFeedSliders.each(function() {
1101            let postSlider = $(this);
1102            if (postSlider.hasClass("slick-initialized")) {
1103                postSlider.slick("unslick");
1104            }
1105        });
1106
1107        carouselSliders.each(function() {
1108          let carouselSlider = $(this);
1109          if(carouselSlider.parent().find(".ss-slick-control-wrapper .ss-slick-play-pause-wrapper").length === 1) {
1110            carouselSlider.parent().find(".ss-slick-play-pause-wrapper").insertBefore(carouselSlider);
1111          }
1112        });
1113    });
1114
1115    $('.news-feed-carousel-container').each(function() {
1116      let component = $(this);
1117      let slide = component.find('.article-container');
1118      let numSlides = slide.length;
1119
1120      if(numSlides <= 3) {
1121        slide.each(function() {
1122          let clonedSlide = $(this).clone();
1123          component.append(clonedSlide);
1124        })
1125      }
1126
1127      let autoPlay = component.closest('.ss-component-content').attr('data-auto-rotate') === '1';
1128      if (typeof window.slickSliderA11y === 'function') {
1129          window.slickSliderA11y(component, component.parent(), { autoRotates: autoPlay });
1130      }
1131      initCarouselNavAndHoverText(component, autoPlay);
1132      component.on('init setPosition', function() {
1133          syncCarouselListHeight(component);
1134      });
1135      component.on('beforeChange', function(event, slick, currentSlide, nextSlide) {
1136          animateCarouselListHeight(component, nextSlide, slick.options.speed);
1137      });
1138      component.slick({
1139          infinite: true,
1140          slidesToShow: 3,
1141          slidesToScroll: 1,
1142          adaptiveHeight: false,
1143          centerMode: true,
1144          centerPadding: '0px',
1145          autoplay: autoPlay,
1146          autoplaySpeed: 5000,
1147          prevArrow: component.next().find('.slick-prev'),
1148          nextArrow: component.next().find('.slick-next'),
1149          speed: 1500,
1150          responsive: [
1151              {
1152                  breakpoint: 768,
1153                  settings: {
1154                      slidesToShow: 1,
1155                      centerPadding: '150px',
1156                      prevArrow: component.next().find('.slick-prev-mobile-carou
1156sel'),
1157                      nextArrow: component.next().find('.slick-next-mobile-carousel')
1158                  }
1159              },
1160              {
1161                  breakpoint: 640,
1162                  settings: {
1163                      slidesToShow: 1,
1164                      centerPadding: '75px',
1165                      prevArrow: component.next().find('.slick-prev-mobile-carousel'),
1166                      nextArrow: component.next().find('.slick-next-mobile-carousel')
1167                  }
1168              },
1169              {
1170                  breakpoint: 480,
1171                  settings: {
1172                      slidesToShow: 1,
1173                      centerPadding: '50px',
1174                      prevArrow: component.next().find('.slick-prev-mobile-carousel'),
1175                      nextArrow: component.next().find('.slick-next-mobile-carousel')
1176                  }
1177              }
1178          ]
1179      });
1180
1181      //KEEP THE SCALING TRANSITION SMOOTH WHEN THE SLIDER IS JUMPING FROM LAST SLIDE TO FIRST SLIDE
1182      component.on('beforeChange', function(event, slick, currentSlide, nextSlide){
1183        let slidesLength = slick.$slides.length - 1,
1184            isCurrentFirstOrLast = currentSlide === 0 || currentSlide === slidesLength,
1185            isNextFirstOrLast = nextSlide === 0 || nextSlide === slidesLength;
1186
1187        if (isCurrentFirstOrLast && isNextFirstOrLast){
1188          let nextClone = $(event.currentTarget).find('.slick-cloned.slick-active');
1189          setTimeout(function(){
1190            nextClone.addClass('slick-current slick-center');
1191          }, 100)
1192        }
1193      });
1194
1195      //SLICK SLIDER WILL AUTOMATICALLY START TO ROTATE WHEN BREAKPOINTS CHANGE, SO WE NEED TO CHECK TO SEE IF
1196      //THE USER HAD ALREADY PAUSED IT BEFORE THE BREAKPOINT CHANGE, AND IF SO, REPAUSE IT.
1197      $(component).on('breakpoint', function(event, slick, breakpoint) {
1198        if($(this).closest('.ss-component-content').find('.ss-slick-play-pause-wrapper').hasClass('paused')) {
1199          component.slick("slickPause");
1200        }
1201      });
1202  });
1203
1204    $('.news-feed-compact-container').each(function() {
1205        let component = $(this);
1206        if (typeof window.slickSliderA11y === 'function') {
1207            window.slickSliderA11y(component, component.parent());
1208        }
1209        component.slick({
1210            infinite: true,
1211            slidesToShow: 4,
1212            slidesToScroll: 4,
1213            adaptiveHeight: true,
1214            centerMode: false,
1215            centerPadding: '150px',
1216            prevArrow: component.next().find('.slick-prev'),
1217            nextArrow: component.next().find('.slick-next'),
1218            speed: 1000,
1219            responsive: [
1220                {
1221                    breakpoint: 768,
1222                    settings: {
1223                        slidesToShow: 1,
1224                        slidesToScroll: 1,
1225                        centerMode: true,
1226                        centerPadding: '150px'
1227                    }
1228                },
1229                {
1230                    breakpoint: 640,
1231                    settings: {
1232                        slidesToShow: 1,
1233                        slidesToScroll: 1,
1234                        centerMode: true,
1235                        centerPadding: '75px'
1236                    }
1237                },
1238                {
1239                    breakpoint: 480,
1240                    settings: {
1241                        slidesToShow: 1,
1242                        slidesToScroll: 1,
1243                        centerMode: true,
1244                        centerPadding: '20px'
1245                    }
1246                }
1247            ]
1248        });
1249    });
1250
1251    $('.news-feed-three-two').each(function() {
1252        let component = $(this);
1253        let $inner = component.find('.ss-carousel-inner');
1254        if (typeof window.slickSliderA11y === 'function') {
1255            window.slickSliderA11y($inner, component);
1256        }
1257        $inner.slick({
1258            prevArrow: component.find('.slick-prev'),
1259            nextArrow: component.find('.slick-next'),
1260        });
1261    });
1262
1263    /////
1264    // Document Component
1265
1266    // Updates an aria-live region with the current search result count for a
1267    // document component. Debounced so the screen reader finishes echoing the
1268    // typed character before the result count is announced.
1269    function setSearchAnnouncer(announcer, numVisible, searchVal) {
1270        if (!announcer.length) {
1271            if (window.ssDebug) {
1272                console.warn('setSearchAnnouncer: announcer node not found — screen reader announcements will not work.');
1273            }
1274            return;
1275        }
1276
1277        // Cancel any pending announcement from a previous keystroke so we only
1278        // ever announce the result of the user's final keystroke in a burst.
1279        const pending = announcer.data('announceTimeout');
1280        if (pending) {
1281            clearTimeout(pending);
1282        }
1283
1284        // ANNOUNCER_DEBOUNCE_MS outer debounce: the search runs on `keyup`, and
1285        // screen readers echo each typed character. Updating the live region
1286        // while the user is still typing either interrupts the character echo
1287        // or gets dropped by the SR's "polite" queue. Waiting until the user
1288        // pauses guarantees the count is announced AFTER the typed character is
1289        // read, not on top of it.
1290        const timeoutID = setTimeout(function() {
1291            if (searchVal === '') {
1292                announcer.text('');
1293                return;
1294            }
1295
1296            const message = numVisible > 0
1297                ? `${numVisible} document${numVisible === 1 ? '' : 's'} found.`
1298                : 'No matching records found.';
1299
1300            // Clear first, then re-set after ANNOUNCER_FLUSH_MS. Keeping the
1301            // node in the DOM is critical — detaching it de-registers it as an
1302            // active live region in NVDA/JAWS. The two-step update coaxes SRs
1303            // into re-announcing even when the message text is identical to the
1304            // previous announcement (e.g. same count after refinement).
1305            announcer.text('');
1306            setTimeout(function() {
1307                announcer.text(message);
1308            }, ANNOUNCER_FLUSH_MS);
1309        }, ANNOUNCER_DEBOUNCE_MS);
1310        announcer.data('announceTimeout', timeoutID);
1311    }
1312
1313    const DOCUMENT_COLUMN_COUNT = 3;
1314
1315    // Assign each visible item an explicit grid column so the vertical layout
1316    // stays balanced top-to-bottom as items are filtered: each column gets
1317    // floor(n / cols) items and the first (n % cols) columns get one extra. This
1318    // mirrors the PHP/legacy split (e.g. 4 docs => 2/1/1, 7 docs => 3/2/2). The
1319    // DOM order is never changed, so list semantics and reading order hold.
1320    function assignVerticalDocumentColumns(items, columns) {
1321        const total = items.length;
1322        const base = Math.floor(total / columns);
1323        const remainder = total % columns;
1324        let index = 0;
1325        for (let column = 1; column <= columns; column++) {
1326            const size = base + (column <= remainder ? 1 : 0);
1327            for (let i = 0; i < size; i++) {
1328                items[index++].style.gridColumn = column;
1329            }
1330        }
1331    }
1332
1333    $('.js-document-component-directory-search').on('change keyup', function() {
1334        const searchInput = $(this);
1335        const documentComponent = searchInput.parents('.ss-component');
1336        const links = documentComponent.find('.ss-document-container .ss-document-item');
1337        const searchVal = searchInput.val().toLowerCase();
1338        if (searchVal == searchInput.data('lastSearched')) {
1339            return;
1340        }
1341        searchInput.data('lastSearched', searchVal);
1342        let numVisible = 0;
1343
1344        // Hide or show items based on search match. The list is a single semantic
1345        // <ul> laid out in columns via CSS, so hidden items reflow automatically
1346        // and the original document order is preserved.
1347        const visibleItems = [];
1348        links.each(function() {
1349            const linkText = $(this).text().toLowerCase();
1350            const searchMatch = linkText.indexOf(searchVal) !== -1;
1351            $(this).toggle(searchMatch);
1352            if (searchMatch) {
1353                numVisible++;
1354                visibleItems.push(this);
1355            }
1356        });
1357
1358        // Vertical reading uses explicit per-item grid columns, so rebalance the
1359        // visible items across the columns to match the legacy split. Horizontal
1360        // reading is row-major and reflows on its own — no reassignment needed.
1361        if (searchInput.attr('data-displayDirection') === 'vertical') {
1362            assignVerticalDocumentColumns(visibleItems, DOCUMENT_COLUMN_COUNT);
1363        }
1364
1365        documentComponent.find('.js-no-records-found').toggle(numVisible == 0);
1366        setSearchAnnouncer(documentComponent.find('.ss-document-search-announcer'), numVisible, searchVal);
1367    });
1368
1369    $('.js-document-component-list-search').on('change keyup', function() {
1370        const documentComponent = $(this).parents('.ss-component');
1371        const links = documentComponent.find('.ss-document-item');
1372        const searchInput = $(this);
1373        const searchVal = searchInput.val().toLowerCase();
1374        const announcer = documentComponent.find('.ss-document-search-announcer');
1375
1376        if (searchVal == searchInput.data('lastSearched')) {
1377            return;
1378        }
1379        let numVisible = 0;
1380        searchInput.data('lastSearched', searchVal);
1381
1382        // Hide or show links based on search match
1383        links.each(function() {
1384            const linkText = $(this).attr('data-searchText').toLowerCase();
1385            const searchMatch = linkText.indexOf(searchVal) !== -1;
1386            $(this).toggle(searchMatch);
1387            if (searchMatch) {
1388                numVisible++;
1389            }
1390        });
1391
1392        documentComponent.find('.js-no-records-found').toggle(numVisible == 0);
1393        setSearchAnnouncer(announcer, numVisible, searchVal);
1394    });
1395});
1396
1397function initCarouselNavAndHoverText(component, autoPlay) {
1398    component.on('init', function() {
1399        // Move prev/next buttons outside the slider container
1400        component.next().find(".slick-prev").insertBefore(component);
1401        component.next().find(".slick-next").insertAfter(component);
1402
1403        if (autoPlay) {
1404            component.parent().find(".ss-slick-play-pause-wrapper").addClass("playing");
1405            enquire.register("screen and (min-width: 769px)", function() {
1406                component.parent().find(".ss-slick-play-pause-wrapper").insertBefore(component);
1407            });
1408
1409            component.parent().find(".ss-slick-pause").on("click", function() {
1410                component.slick("slickPause");
1411                $(this).parent().removeClass("playing").addClass("paused");
1412                $(this).siblings(".ss-slick-play").focus();
1413            });
1414            component.parent().find(".ss-slick-play").on("click", function() {
1415                component.slick("slickPlay");
1416                $(this).parent().removeClass("paused").addClass("playing");
1417                $(this).siblings(".ss-slick-pause").focus();
1418            });
1419        }
1420
1421        // Show post text on hover. The text container holds the article's only
1422        // link, so it must never carry aria-hidden: that would drop the link out
1423        // of the accessibility tree for browse-mode screen readers, which never
1424        // fire focus events. Slick already marks inactive and cloned slides
1425        // aria-hidden, so off-screen articles stay hidden from assistive tech.
1426        // Keyboard reveal is handled by :focus-within in CSS rather than a class,
1427        // so pointer and keyboard state can no longer overwrite each other.
1428        component.find(".ss-news-feed-row-content").hover(function() {
1429            $(this).addClass("hover");
1430        }, function() {
1431            $(this).removeClass("hover");
1432        });
1433    });
1434}
1435
1436const ssGlobal = {
1437    "A11yKeys": { 'tab': 'Tab', 'enter': 'Enter', 'space': 'Space', 'left': 'ArrowLeft', 'up': 'ArrowUp', 'right': 'ArrowRight', 'down': 'ArrowDown' },
1438    "A11yInteraction": function(e) {
1439        if(e.type == "click") {
1440            return true;
1441        } else if(e.type == 'keydown' && (e.key == this.A11yKeys.space || e.key == this.A11yKeys.enter)) {
1442            return true;
1443        } else {
1444            return false;
1445        }
1446    }
1447}
1448
1449/* Accordion Tabs JS */
1450const ssAccordionTabs = {
1451    "Init": function() {
1452        this.AccordionInteractions();
1453        this.TabsInteractions();
1454    },
1455
1456    "AccordionInteractions": function() {
1457        $(".ss-accordion-btn").on("click keydown", function(e) {
1458            if(ssGlobal.A11yInteraction(e)) {
1459                e.preventDefault();
1460
1461                let accordionPanel = $(this).attr("aria-controls");
1462
1463                if($(this).hasClass("open")) {
1464                    $("#"+accordionPanel).slideUp();
1465                    $(this).attr("aria-expanded", "false")
1466                    .removeClass("open");
1467                } else {
1468                    $("#"+accordionPanel).slideDown();
1469                    $(this).attr("aria-expanded", "true")
1470                    .addClass("open");
1471                }
1472            }
1473        });
1474
1475        $(".ss-all-or-nothing-toggle").on("click keydown", function(e) {
1476            if(ssGlobal.A11yInteraction(e)) {
1477                e.preventDefault();
1478
1479                let accordionID = $(this).attr("data-stack");
1480
1481                if($(this).hasClass("expanded")) {
1482                    $("#item"+accordionID).find(".ss-accordion-btn").attr("aria-expanded", "false").removeClass("open");
1483                    $("#item"+accordionID).find(".ss-accordion-panel").slideUp();
1484                    $(this).attr("aria-label", "Expand All Accordion Rows").removeClass("expanded");
1485                } else {
1486                    $("#item"+accordionID).find(".ss-accordion-btn").attr("aria-expanded", "true").addClass("open");
1487                    $("#item"+accordionID).find(".ss-accordion-panel").slideDown();
1488                    $(this).attr("aria-label", "Collapse All Accordion Rows").addClass("expanded");
1489                }
1490            }
1491        });
1492    },
1493
1494    "TabsInteractions": function() {
1495        $(".ss-tab").on("click keydown", function(e) {
1496            if(ssGlobal.A11yInteraction(e)) {
1497                e.preventDefault();
1498
1499                let tabsID = $(this).closest(".ss-tabs").attr("data-stack");
1500                let tabPanel = $(this).attr("aria-controls");
1501
1502                if(!$(this).hasClass("active")) {
1503                    $("#stack_content_"+tabsID+" .ss-tab").attr({
1504                        "aria-selected": "false",
1505                        "tabindex": "-1"
1506
1507                    }).removeClass("active");
1508                    $("#stack_content_"+tabsID+" .ss-tab-panel").removeClass("active");
1509                    $("#"+tabPanel).addClass("active");
1510                    $(this).attr({
1511                        "aria-selected": "true",
1512                        "tabindex": "0"
1513                    }).addClass("active");
1514                }
1515            }
1516
1517            switch(e.key) {
1518                case ssGlobal.A11yKeys.right:
1519                case ssGlobal.A11yKeys.down:
1520                    e.preventDefault();
1521
1522                    if($(this).is(":last-child")) {
1523                        $(this).closest(".ss-tabs-list").find(".ss-tab:first-child").trigger('focus');
1524                    } else {
1525                        $(this).next().trigger('focus');
1526                    }
1527                    break;
1528                case ssGlobal.A11yKeys.left:
1529                case ssGlobal.A11yKeys.up:
1530                    e.preventDefault();
1531
1532                    if($(this).is(":first-child")) {
1533                        $(this).closest(".ss-tabs-list").find(".ss-tab:last-child").trigger('focus');
1534                    } else {
1535                        $(this).prev().trigger('focus');
1536                    }
1537                    break;
1538            }
1539        });
1540    },
1541
1542    "CheckColors": function(divID, tabColor, colorScheme) {
1543      const contrastCheck = new ColorContrast();
1544      const TabColorCheck = contrastCheck.calculateContrast({0: "Tab Text Color", 1: "#000000"}, {"Tab Background Color": tabColor});
1545
1546      //ONLY CHANGE TEXT COLOR IF TAB BACKGROUND COLOR IN SITE DESIGN DOES NOT PASS CONTRAST WITH DARK TEXT
1547      if(TabColorCheck.includes("warn")){
1548          let tempColor       = "";
1549          let txtColorCheck   = contrastCheck.calculateContrast({0: "Tab Text Color", 1: "#FFFFFF"}
1549, {"Tab Background Color": tabColor});
1550          let a11yCSS         = '';
1551
1552          if(!txtColorCheck.includes("warn")){
1553              tempColor = "#FFFFFF";
1554          }
1555
1556          switch(colorScheme) {
1557            case "0":
1558              a11yCSS = `<style type="text/css">
1559                #${divID} .ss-accordion-btn:hover,
1560                #${divID} .ss-accordion-btn:focus,
1561                #${divID} .ss-accordion-btn.open {
1562                    color: ${tempColor};
1563                }
1564              </style>`;
1565              break;
1566            case "1":
1567              a11yCSS = `<style type="text/css">
1568                #${divID} .ss-accordion-btn:not(.open) {
1569                    color: ${tempColor};
1570                }
1571              </style>`;
1572              break;
1573          }
1574
1575          $("#"+divID).append(a11yCSS);
1576      }
1577    }
1578}
1579
1580/* Lightbox JS */
1581const ssLightbox = {
1582    "FocusLoopElements": [],
1583    "FocusableElements": "a, button, input, select, textarea, [tabindex], [contenteditable]",
1584    "Init": function() {
1585        this.SharedModalReset();
1586        this.BlockSiblings();
1587        this.CreateFocusLoop();
1588        this.PreventBodyScrollMobile();
1589    },
1590
1591    "SharedModalReset": function() {
1592        $(document).on("hidden.bs.modal", ".ss-lightbox-modal", function() {
1593            if(!$(this).closest(".ss-site-header").length) {
1594                $(this).find(".ss-lightbox-modal-content-inner").empty();
1595            }
1596
1597            ssLightbox.TeardownArrayTrap(this);
1598
1599            const tabTracker = $(this).data('ss-tab-tracker');
1600            if (tabTracker) {
1601                document.removeEventListener('keydown', tabTracker, true);
1602                $(this).removeData('ss-tab-tracker');
1603            }
1604
1605            $(this).find('.ss-focus-sentinel').remove();
1606
1607            // Reset custom flags set during init so subsequent opens re-initialize correctly
1608            $(this).removeData('ss-shown-polyfill');
1609            $(this).removeData('ss-focus-loop-init');
1610            $(this).removeAttr('data-ss-focus-init');
1611
1612            // Handle aria-label cleanup
1613            const modalId = this.id;
1614            const staticAriaLabelModals = ['ss-schools-modal', 'ss-popular-links-modal', 'ss-header-mobile-menu'];
1615
1616            // For shared modal and other dynamic modals, clean up aria-label
1617            if (staticAriaLabelModals.indexOf(modalId) === -1) {
1618                const originalAriaLabel = $(this).data('ss-original-aria-label');
1619                if (originalAriaLabel) {
1620                    $(this).attr('aria-label', originalAriaLabel);
1621                } else {
1622                    $(this).removeAttr('aria-label');
1623                }
1624                $(this).removeData('ss-original-aria-label');
1625            }
1626
1627            // Restore focus to the trigger element if one was stored
1628            const trigger = $(this).data('ss-trigger');
1629            if (trigger) {
1630                if (document.body.contains(trigger)) {
1631                    trigger.focus();
1632                }
1633                $(this).removeData('ss-trigger');
1634            }
1635        });
1636    },
1637
1638    // Marks body siblings as inert + aria-hidden while any ss-lightbox-modal is open,
1639    // satisfying WCAG 1.3.1 Item #69 (iOS Safari + VoiceOver swipe-escape).
1640    // The data-ss-inert-owner marker stores a JSON snapshot of pre-existing
1641    // inert / aria-hidden state so cleanup restores exactly what was there before
1642    // (e.g. a closed BS3 modal rendered as body > .modal.fade[aria-hidden="true"]
1643    // must retain its aria-hidden after the lightbox closes).
1644    // Browser support floor: inert is native in iOS Safari ≥ 15.5, Chrome ≥ 102,
1645    // Firefox ≥ 112. Older browsers receive aria-hidden only (VoiceOver trapped; keyboard
1646    // tab into background DOM is not prevented — acceptable per the SC).
1647    "BlockSiblings": function() {
1648        // Exclude our own modal containers; the !el.contains(modal) guard is the real
1649        // load-bearing check — :not(.ss-lightbox-modal) makes the intent explicit.
1650        const SIBLINGS_SELECTOR = 'body > *:not(.ss-lightbox-modal):not(.modal-backdrop):not(script):not(style)';
1651
1652        $(document).on('shown.bs.modal', '.ss-lightbox-modal', function () {
1653            const modal = this;
1654            document.querySelectorAll(SIBLINGS_SELECTOR).forEach(function(el) {
1655                if (el.contains(modal)) return;
1656                // Idempotency: a nested ss-lightbox-modal opening while another is already
1657                // open must not overwrite the snapshot captured by the first open.
1658                if (el.hasAttribute('data-ss-inert-owner')) return;
1659                el.dataset.ssInertOwner = JSON.stringify({
1660                    hadInert: el.hasAttribute('inert'),
1661                    prevAriaHidden: el.getAttribute('aria-hidden'),
1662                });
1663                el.setAttribute('inert', '');
1664                el.setAttribute('aria-hidden', 'true');
1665            });
1666        });
1667
1668        $(document).on('hidden.bs.modal', '.ss-lightbox-modal', function () {
1669            // .in is removed before hidden.bs.modal fires, so this count correctly
1670            // excludes the just-closed modal and reflects any remaining open modals.
1671            if ($('.ss-lightbox-modal.in:visible').length === 0) {
1672                document.querySelectorAll('[data-ss-inert-owner]').forEach(function(el) {
1673                    let prev = {};
1674                    try {
1675                        prev = JSON.parse(el.dataset.ssInertOwner || '{}');
1676                    } catch (e) {
1677                        prev = {};
1678                    }
1679                    if (!prev.hadInert) {
1680                        el.removeAttribute('inert');
1681                    }
1682                    if (prev.prevAriaHidden === null || prev.prevAriaHidden === undefined) {
1683                        el.removeAttribute('aria-hidden');
1684                    } else {
1685                        el.setAttribute('aria-hidden', prev.prevAriaHidden);
1686                    }
1687                    delete el.dataset.ssInertOwner;
1688                });
1689            }
1690        });
1691    },
1692
1693    "CreateFocusLoop": function() {
1694        const onModalVisible = function(e) {
1695            const modalTrigger = e.relatedTarget || document.activeElement;
1696            const container = e.target;
1697
1698            // Prevent multiple initializations - check if already initialized for this modal open
1699            const initKey = 'ss-focus-loop-init';
1700            if ($(container).data(initKey)) {
1701                return;
1702            }
1703            $(container).data(initKey, true);
1704
1705            const contentWrap = $(container).find('.ss-lightbox-modal-content-wrap');
1706
1707            // Wait until the modal content is fully visible before initializing focusables
1708            let attemptsRemaining = 30; // ~1.5s max wait
1709            const tryInit = function() {
1710                const isOpen = $(container).hasClass('in') || $(container).is(':visible');
1711                const hasSize = contentWrap.length && contentWrap[0].offsetWidth > 0 && contentWrap[0].offsetHeight > 0;
1712                const hasIframes = $(container).find('iframe').length > 0;
1713
1714                if (isOpen && hasSize) {
1715                    if ($(container).find('.ss-close-modal')[0]) {
1716                        ssLightbox.FocusCloseButton(container);
1717                    } else {
1718                        container.setAttribute('tabindex', '-1');
1719                        container.focus();
1720                    }
1721
1722                    if (hasIframes) {
1723                        // Two-sentinel boundary pattern. Tab is NOT intercepted inside
1724                        // the modal — browser handles native Tab through the iframe form.
1725                        // Post-sentinel wraps Tab-out-of-iframe → close button.
1726                        // Pre-sentinel wraps Shift+Tab-off-close → last element in iframe.
1727                        ssLightbox.SetupBoundarySentinels(container);
1728                    } else {
1729                        // Existing array-based trap. MutationObserver below catches any
1730                        // late content injection and refreshes the captured array.
1731                        ssLightbox.RefreshFocusLoop(container);
1732
1733                        const inner = container.querySelector('.ss-lightbox-modal-content-inner');
1734                        if (inner && window.MutationObserver && !$(container).data('ss-focus-observer')) {
1735                            let pending = false;
1736                            const observer = new MutationObserver(function () {
1737                                if (pending) return;
1738                                pending = true;
1739                                requestAnimationFrame(function () {
1740                                    pending = false;
1741                                    if ($(container).find('iframe').length > 0) {
1742                                        // Iframe arrived after initial tryInit — switch to sentinel pattern.
1743                                        observer.disconnect();
1744                                        $(container).removeData('ss-focus-observer');
1745                                        ssLightbox.SetupBoundarySentinels(container);
1746                                    } else {
1747                                        ssLightbox.RefreshFocusLoop(container);
1748                                    }
1749                                });
1750                            });
1751                            observer.observe(inner, { childList: true, subtree: true });
1752                            $(container).data('ss-focus-observer', observer);
1753                        }
1754                    }
1755
1756                    ssLightbox.CloseWhenClickAway(modalTrigger);
1757                    return;
1758                }
1759
1760                if (attemptsRemaining-- > 0) {
1761                    setTimeout(tryInit, 50);
1762                }
1763            };
1764            tryInit();
1765        };
1766
1767        $(document).on('shown.bs.modal', '.ss-lightbox-modal', function (e) {
1768            onModalVisible(e);
1769        });
1770
1771        // Fallback for environments where 'shown' might not fire reliably
1772        $(document).on('show.bs.modal', '.ss-lightbox-modal', function (e) {
1773            setTimeout(function() { onModalVisible(e); }, 0);
1774        });
1775
1776        // Ultimate fallback: bind to the mobile menu open trigger
1777        $(document).on('click', '[data-toggle="modal"][data-target="#ss-header-mobile-menu"]', function (e) {
1778            ssLightbox.SetupModalTrigger(this, $('#ss-header-mobile-menu'));
1779            setTimeout(function() {
1780                const container = document.getElementById('ss-header-mobile-menu');
1781                if (container) {
1782                    onModalVisible({ target: container, relatedTarget: e.currentTarget });
1783                }
1784            }, 0);
1785        });
1786
1787        // Bind to schools and popular links modal open triggers
1788        ssLightbox.BindStaticModalTrigger('#ss-schools-modal');
1789        ssLightbox.BindStaticModalTrigger('#ss-popular-links-modal');
1790
1791        // Treat inner content transition end as the moment the modal is "shown"
1792        $(document).on('transitionend webkitTransitionEnd oTransitionEnd', '#ss-header-mobile-menu .ss-lightbox-modal-content-wrap', function () {
1793            const container = $(this).closest('.ss-lightbox-modal')[0];
1794            if (container) {
1795                onModalVisible({ target: container, relatedTarget: document.activeElement });
1796            }
1797        });
1798
1799        // Polyfill 'shown.bs.modal' for mobile menu: fire after inner transition ends
1800        $(document).on('show.bs.modal', '#ss-header-mobile-menu', function (e) {
1801            const modal = this;
1802            const dataKey = 'ss-shown-polyfill';
1803            const fireShown = function() {
1804                if ($(modal).data(dataKey)) return;
1805                $(modal).data(dataKey, true);
1806                $(modal).trigger($.Event('shown.bs.modal', { relatedTarget: e.relatedTarget }));
1807            };
1808            const contentWrap = modal.querySelector('.ss-lightbox-modal-content-wrap');
1809            if (contentWrap) {
1810                const onEnd = function() {
1811                    contentWrap.removeEventListener('transitionend', onEnd);
1812                    contentWrap.removeEventListener('webkitTransitionEnd', onEnd);
1813                    contentWrap.removeEventListener('oTransitionEnd', onEnd);
1814                    fireShown();
1815                };
1816                contentWrap.addEventListener('transitionend', onEnd);
1817                contentWrap.addEventListener('webkitTransitionEnd', onEnd);
1818                contentWrap.addEventListener('oTransitionEnd', onEnd);
1819                setTimeout(fireShown, 600); // safety timeout
1820            } else {
1821                setTimeout(fireShown, 0);
1822            }
1823        });
1824
1825        // Additional targeted bindings for robustness and legacy event names
1826        $('#ss-header-mobile-menu').on('shown.bs.modal shown', function(e) {
1827            onModalVisible(e);
1828        });
1829        $('#ss-header-mobile-menu').on('show.bs.modal show', function(e) {
1830            onModalVisible(e);
1831        });
1832
1833        // MutationObserver fallback: detect visibility/class changes on the mobile menu
1834        const mobileMenuEl = document.getElementById('ss-header-mobile-menu');
1835        if (mobileMenuEl && window.MutationObserver) {
1836            const markInitialized = () => { mobileMenuEl.setAttribute('data-ss-focus-init', '1'); };
1837            const isInitialized = () => mobileMenuEl.getAttribute('data-ss-focus-init') === '1';
1838            const maybeInit = () => {
1839                const isVisible = $(mobileMenuEl).is(':visible') || $(mobileMenuEl).hasClass('in');
1840                if (isVisible && !isInitialized()) {
1841                    onModalVisible({ target: mobileMenuEl, relatedTarget: document.activeElement });
1842                    markInitialized();
1843                }
1844            };
1845            const observer = new MutationObserver(function() { maybeInit(); });
1846            observer.observe(mobileMenuEl, { attributes: true, attributeFilter: ['class', 'style'] });
1847            // Initial check in case it is already open
1848            setTimeout(maybeInit, 0);
1849        }
1850    },
1851
1852    "NavigateFocusLoop": function(container) {
1853        $(ssLightbox.FocusLoopElements).off("keydown.ssLightbox");
1854
1855        $(ssLightbox.FocusLoopElements).on("keydown.ssLightbox", function (e) {
1856            if (e.key !== "Tab") return;
1857            e.preventDefault();
1858            ssLightbox.FocusNext(e.shiftKey ? "backward" : "forward");
1859        });
1860    },
1861
1862    "FocusNext": function (direction) {
1863        let nextFocused = 0;
1864
1865        for (let i = 0; i < ssLightbox.FocusLoopElements.length; i++) {
1866            if (document.activeElement === ssLightbox.FocusLoopElements[i]) {
1867                nextFocused = i;
1868            }
1869        }
1870
1871        if (direction === "forward") {
1872            if (nextFocused >= ssLightbox.FocusLoopElements.length - 1) {
1873                $(ssLightbox.FocusLoopElements[0]).trigger("focus");
1874            } else {
1875                $(ssLightbox.FocusLoopElements[nextFocused + 1]).trigger("focus");
1876            }
1877        }
1878
1879        if (direction === "backward") {
1880            if (nextFocused === 0) {
1881                $(ssLightbox.FocusLoopElements[ssLightbox.FocusLoopElements.length - 1]).trigger("focus");
1882            } else {
1883                $(ssLightbox.FocusLoopElements[nextFocused - 1]).trigger("focus");
1884            }
1885        }
1886    },
1887
1888    "GetFocusableElements": function(container) {
1889        let focusableElements = $(container).find(ssLightbox.FocusableElements)
1890        .filter(':visible')
1891        .filter(function() {
1892            return !$(this).attr('disabled');
1893        })
1894        .filter(function() {
1895            return $(this).attr('tabindex') !== '-1';
1896        });
1897
1898        ssLightbox.FocusLoopElements = focusableElements.toArray();
1899    },
1900
1901    "RefreshFocusLoop": function(container) {
1902        $(ssLightbox.FocusLoopElements).off("keydown.ssLightbox");
1903        ssLightbox.GetFocusableElements(container);
1904        ssLightbox.NavigateFocusLoop(container);
1905    },
1906
1907    // Walks the nested iframe chain and returns the innermost accessible document.
1908    // Returns null if no iframe is found or if a cross-origin boundary is hit.
1909    "GetInnermostIframeDoc": function(container) {
1910        try {
1911            let current = $(container).find('iframe')[0];
1912            let doc = null;
1913            while (current && current.contentDocument) {
1914                doc = current.contentDocument;
1915                const nested = doc.querySelector('iframe');
1916                if (nested && nested.contentDocument) {
1917                    current = nested;
1918                } else {
1919                    break;
1920                }
1921            }
1922            return doc;
1923        } catch (e) {
1924            return null;
1925        }
1926    },
1927
1928    // Returns an array of elements from `doc` that are reachable via Tab:
1929    // tabIndex >= 0, not disabled, and have visible dimensions.
1930    "GetTabbableElements": function(doc) {
1931        return Array.from(doc.querySelectorAll(ssLightbox.FocusableElements)).filter(function (el) {
1932            return el.tabIndex >= 0 && !el.disabled && el.offsetWidth > 0 && el.offsetHeight > 0;
1933        });
1934    },
1935
1936    // Tears down the array-based focus trap: disconnects any MutationObserver,
1937    // unbinds Tab-intercepting keydown handlers, and clears FocusLoopElements.
1938    // Call when switching to the sentinel pattern or when the modal closes.
1939    "TeardownArrayTrap": function(container) {
1940        const observer = $(container).data('ss-focus-observer');
1941        if (observer) {
1942            observer.disconnect();
1943            $(container).removeData('ss-focus-observer');
1944        }
1945        $(ssLightbox.FocusLoopElements).off("keydown.ssLightbox");
1946        ssLightbox.FocusLoopElements = [];
1947    },
1948
1949    "FocusCloseButton": function(container) {
1950        const cb = $(container).find('.ss-close-modal')[0];
1951        if (cb) cb.focus();
1952    },
1953
1954    // Attaches a keydown Escape listener to the innermost same-origin iframe
1955    // document so the modal can be closed while focus is trapped inside the iframe.
1956    // Uses load events to handle the nested async loading chain:
1957    //   outer iframe (emailform.php) → inner iframe (responsive_msg_ppl.php)
1958    // Cleanup is implicit: the iframe elements are removed from the DOM when
1959    // SharedModalReset empties .ss-lightbox-modal-content-inner, making the
1960    // contentDocument objects stale.
1961    "AttachIframeEscapeListener": function(container) {
1962        const triggerClose = function () {
1963            $(container).find('.ss-close-modal').trigger('click');
1964        };
1965
1966        const attachToDoc = function (doc) {
1967            try {
1968                $(doc).on('keydown.ssLightbox-esc', function (e) {
1969                    if (e.key === 'Escape' || e.key === 'Esc') triggerClose();
1970                });
1971            } catch (e) { /* cross-origin — skip */ }
1972        };
1973
1974        const attachToInnerWhenReady = function (outerDoc) {
1975            try {
1976                const innerIframe = outerDoc.querySelector('iframe');
1977                if (!innerIframe) {
1978                    attachToDoc(outerDoc);
1979                    return;
1980                }
1981                if (innerIframe.contentDocument && innerIframe.contentDocument.readyState === 'complete') {
1982                    attachToDoc(innerIframe.contentDocument);
1983                } else {
1984                    $(innerIframe).one('load.ssLightbox-esc', function () {
1985                        if (innerIframe.contentDocument) attachToDoc(innerIframe.contentDocument);
1986                    });
1987                }
1988            } catch (e) { /* cross-origin — skip */ }
1989        };
1990
1991        const outerIframe = $(container).find('iframe')[0];
1992        if (!outerIframe) return;
1993
1994        try {
1995            const outerDoc = outerIframe.contentDocument;
1996            // about:blank is the initial state before the real src loads — treat as not yet ready
1997            const outerReallyLoaded = outerDoc &&
1998                outerDoc.readyState === 'complete' &&
1999                outerDoc.URL !== 'about:blank';
2000            if (outerReallyLoaded) {
2001                attachToInnerWhenReady(outerDoc);
2002            } else {
2003                $(outerIframe).one('load.ssLightbox-esc', function () {
2004                    if (outerIframe.contentDocument) attachToInnerWhenReady(outerIframe.contentDocument);
2005                });
2006            }
2007        } catch (e) { /* cross-origin — skip */ }
2008    },
2009
2010    "SetupBoundarySentinels": function(container) {
2011        if ($(container).find('.ss-focus-sentinel').length) return;
2012
2013        // Clean up any array-based trap that ran before the iframe arrived
2014        // (race condition: shown.bs.modal can fire before iframe injection).
2015        ssLightbox.TeardownArrayTrap(container);
2016
2017        const $contentWrap = $(container).find('.ss-lightbox-modal-content').first();
2018        if (!$contentWrap.length) return;
2019
2020        // Track Tab direction so the pre-sentinel knows which way to wrap.
2021        let tabDirection = 'forward';
2022        const trackTab = function (e) {
2023            if (e.key === 'Tab') tabDirection = e.shiftKey ? 'backward' : 'forward';
2024        };
2025        document.addEventListener('keydown', trackTab, true);
2026        $(container).data('ss-tab-tracker', trackTab);
2027
2028        const sentinelCSS = {
2029            position: 'absolute', width: '1px', height: '1px',
2030            overflow: 'hidden', clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)'
2031        };
2032
2033        const focusLastInIframe = function () {
2034            const doc = ssLightbox.GetInnermostIframeDoc(container);
2035            if (doc) {
2036                const tabbable = ssLightbox.GetTabbableElements(doc);
2037                if (tabbable.length) {
2038                    tabbable[tabbable.length - 1].focus();
2039                    return;
2040                }
2041            }
2042            ssLightbox.FocusCloseButton(container);
2043        };
2044
2045        // Pre-sentinel: Shift+Tab from close → last tabbable element in iframe.
2046        // Fallback to close if Tab somehow lands here going forward.
2047        const $pre = $('<div class="ss-focus-sentinel ss-focus-sentinel-pre" tabindex="0"></div>')
2048            .css(sentinelCSS)
2049            .on('focus.ssLightbox', function () {
2050                if (tabDirection === 'backward') { focusLastInIframe(); } else { ssLightbox.FocusCloseButton(container); }
2051            });
2052        $contentWrap.prepend($pre);
2053
2054        // Post-sentinel: Tab out of iframe → close button.
2055        const $post = $('<div class="ss-focus-sentinel ss-focus-sentinel-post" tabindex="0"></div>')
2056            .css(sentinelCSS)
2057            .on('focus.ssLightbox', function () { ssLightbox.FocusCloseButton(container); });
2058        $contentWrap.append($post);
2059
2060        ssLightbox.AttachIframeEscapeListener(container);
2061    },
2062
2063    "CloseWhenClickAway": function(modalTrigger) {
2064        $('.ss-lightbox-modal').on('click', function(ev) {
2065            let targetEl = $(ev.target);
2066            if ($(targetEl).hasClass('ss-lightbox-modal-content-wrap')) {
2067                $(targetEl).find('.ss-close-modal').trigger('click');
2068                $(modalTrigger).trigger("focus");
2069
2070                ssLightbox.FocusLoopElements = [];
2071            }
2072            return;
2073        })
2074    },
2075
2076    // These triggers open modal dialogs that can only be dismissed from inside
2077    // the dialog, so they carry no expanded state — only the reference needed
2078    // to restore focus on close.
2079    "SetupModalTrigger": function(triggerEl, modal, ariaLabel) {
2080        modal.data('ss-trigger', triggerEl);
2081
2082        if (ariaLabel === undefined) {
2083            return;
2084        }
2085
2086        if (!modal.data('ss-original-aria-label')) {
2087            modal.data('ss-original-aria-label', modal.attr('aria-label') || '');
2088        }
2089        modal.attr('aria-label', ariaLabel);
2090    },
2091
2092    "BindStaticModalTrigger": function(modalSelector) {
2093        $(document).on('click', '[data-toggle="modal"][data-target="' + modalSelector + '"]', function () {
2094            $(modalSelector).data('ss-trigger', this);
2095        });
2096    },
2097
2098    "PreventBodyScrollMobile": function() {
2099        const mobileMenu = $("#ss-header-mobile-menu");
2100        $(document).on("show.bs.modal", mobileMenu,function () {
2101            $('body').addClass('ss-no-scroll')
2102        });
2103
2104        $(document).on("hide.bs.modal", mobileMenu, function () {
2105            $('body').removeClass('ss-no-scroll')
2106        });
2107    }
2108}
2109
2110/* Google Translate Callback */
2111function googleTranslateElementInit() {
2112    new google.translate.TranslateElement(
2113        {pageLanguage: 'en'},
2114        'google_translate_element'
2115    );
2116    $(".goog-te-combo").attr('id','GoogleTranslate');
2117}
2118
2119/* on-demand-directory classic view */
2120$(document).on('click', '.js-carousel-control-directory', function (ev) { // prev/next buttons
2121    const el = $(this);
2122    if (!el.hasClass('disabled')) {
2123        const carousel_el  = $(el).parents('.directory-carousel-container').children('.carousel');
2124        const carousel_id  = $(this).data('target');
2125        const current_page = $(this).data('page');
2126        $(carousel_el).carousel(el.attr('data-direction'));
2127        updatePaginationBtns(carousel_id, current_page);
2128    }
2129});
2130
2131$(document).on('click', '.slide-item', function() { // pagination buttons
2132    const carousel_id = $(this).data('target');
2133    const current_page = parseInt($(this).data('slide-to'));
2134    updatePaginationBtns(carousel_id, current_page);
2135});
2136
2137$(document).on('keydown', '.js-carousel-control-directory', function (ev) {
2138    if (ev.key === 'Enter' || ev.key === ' ') {
2139        ev.preventDefault();
2140        $(this).trigger('click');
2141    }
2142});
2143
2144$(document).on('keydown', '.slide-item', function (ev) {
2145    if (ev.key === 'Enter' || ev.key === ' ') {
2146        ev.preventDefault();
2147        $(this).trigger('click');
2148    }
2149});
2150
2151function updatePaginationBtns(carousel_id, current_page = 0) {
2152    const total_slides     = ($(`${carousel_id} > div.carousel-inner > div.item`).length - 1);
2153    let max_buttons        = 5
2154    let ellipsis_offset    = 0;
2155    let pagination_buttons = '';
2156
2157    if (total_slides > 0) { // only show pagination if there are more than 1 slide
2158        pagination_buttons += buildNavBtn(carousel_id, current_page, total_slides, 'prev');
2159
2160        if (current_page >= (max_buttons - 1) && current_page !== 0) {
2161            ellipsis_offset++;
2162            pagination_buttons += buildFirstAndLastPageBtns(current_page, carousel_id, 0);
2163        }
2164
2165        if (total_slides > max_buttons && ellipsis_offset > 0) {
2166            ellipsis_offset++;
2167        }
2168
2169        max_buttons      = current_page >= total_slides - 4 ? 5 : max_buttons - ellipsis_offset;
2170        const end_page   = Math.min(current_page + max_buttons - 1, total_slides);
2171        const start_page = Math.max(total_slides - current_page >= max_buttons ? current_page : total_slides - (max_buttons - 1), 0);
2172
2173        for (let i = start_page; i <= end_page; i++) {
2174            pagination_buttons += buildSlideItemBtn(current_page, carousel_id, i);
2175        }
2176
2177        if (total_slides > max_buttons && total_slides !== end_page) {
2178            pagination_buttons += buildFirstAndLastPageBtns(current_page, carousel_id, total_slides);
2179        }
2180        pagination_buttons += buildNavBtn(carousel_id, current_page, total_slides, 'next');
2181
2182        $(carousel_id).parent('.directory-carousel-container').find('.directory-nav-options').html(pagination_buttons);
2183    }
2184}
2185
2186function buildNavBtn(carousel_id, current_page, total_slides, direction) {
2187    const increment  = direction === 'next' ? 1 : -1;
2188    const disabled   = direction === 'next' ? current_page === total_slides : current_page === 0;
2189    const label      = direction === 'next' ? 'Next' : 'Previous';
2190    const tabindex   = disabled ? 'tabindex="-1"' : 'tabindex="0"';
2191    return `
2192    <li class="js-carousel-control-directory ${disabled ? 'disabled' : ''}" role="button" ${tabindex} aria-label="${label} page" aria-disabled="${disabled}" data-direction="${direction}" data-page="${(current_page + increment)}" data-target="${carousel_id}">
2193        <span class="slide-link" title="" aria-hidden="true">
2194            ${label}
2195        </span>
2196    </li>`;
2197}
2198
2199function buildSlideItemBtn(current_page, carousel_id, index) {
2200    const is_active = index === current_page;
2201    return `
2202    <li class="slide-item ${is_active ? 'active' : ''}" role="button" tabindex="0" aria-label="Page ${index + 1}"${is_active ? ' aria-current="page"' : ''} data-target="${carousel_id}" data-slide-to="${index}">
2203        <span class="slide-link" aria-hidden="true">${(index + 1)}</span>
2204    </li>`;
2205}
2206
2207function buildFirstAndLastPageBtns(current_page, carousel_id, index) {
2208    const slide_nav    = buildSlideItemBtn(current_page, carousel_id, index);
2209    const ellipsis_btn = buildEllipsisBtn();
2210
2211    if (index === 0) {
2212        return `
2213        ${slide_nav}
2214        ${ellipsis_btn}`;
2215    }
2216
2217    return `
2218    ${ellipsis_btn}
2219    ${slide_nav}`;
2220}
2221
2222function buildEllipsisBtn() {
2223    return `
2224    <li class="paginate_button disabled" id="directory_ellipsis">
2225        <span>...</span>
2226    </li>`;
2227}
2228
2229/* on-demand-directory */
2230$(document).on('click', '.sndPPLmsg', function () {
2231    const trigger = $(this);
2232    const modal = $('#ss-components-shared-modal');
2233
2234    ssLightbox.SetupModalTrigger(this, modal, 'Send A Message');
2235
2236    modal.find('.ss-lightbox-modal-content-inner').html(`
2237        <div class="row">
2238            <div style="margin:0px;" class="col-md-12">
2239                <center><h2>Send A Message</h2></center>
2240                <iframe title="Send a Message Dialog Container" scrolling="no" src="/sndreq/emailform.php?person=${trigger.data("person")}&type=${trigger.data("sendtype")}&email=${trigger.data("hiddenemail")}" frameBorder="0" style="width:100%; height:650px !important;"></iframe>
2241            </div>
2242        </div>
2243    `);
2244
2245    // shown.bs.modal fires synchronously (no CSS transitions) before this
2246    // delegated click handler runs, so tryInit already ran with hasIframes=false
2247    // and bound Tab-intercepting keydown handlers. Call SetupBoundarySentinels
2248    // directly here — modal is open, iframe is in DOM, safe to call immediately.
2249    ssLightbox.SetupBoundarySentinels(modal[0]);
2250});
2251
2252/* icon matrix */
2253$(document).on('click', '.ss-lightbox-link', function () {
2254  const lightBoxHTML = $(this).next().html();
2255  const lightBoxLabel = $(this).find('.ss-icon-title').text();
2256  const modal = $('#ss-components-shared-modal');
2257
2258  ssLightbox.SetupModalTrigger(this, modal, lightBoxLabel);
2259  modal.find('.ss-lightbox-modal-content-inner').html(lightBoxHTML);
2260});
2261
2262/* ckEditor contact form link */
2263/* Prevent the legacy modal from auto-opening via Bootstrap's data-toggle mechanism
2264   when the React staff message widget is handling the click instead. The data-toggle
2265   attribute on .sndContactFormLink causes Bootstrap to open #ckeditor-contactForm-modal
2266   automatically (Bootstrap registers before this handler), resulting in a blank modal
2267   appearing behind the React dialog. Canceling show.bs.modal at click time is safe
2268   because window.openStaffMessageWidget is always set before user interaction. */
2269$(document).ready(function () {
2270    $('#ckeditor-contactForm-modal').on('show.bs.modal', function (e) {
2271        if (typeof window.openStaffMessageWidget === 'function') {
2272            e.preventDefault();
2273        }
2274    });
2275});
2276
2277$(document).on('click', '.sndContactFormLink', function (e) {
2278    const modal = $('#ckeditor-contactForm-modal');
2279    if (typeof window.openStaffMessageWidget === 'function') {
2280        e.preventDefault();
2281        modal.modal('hide');
2282        const trigger = $(this);
2283        window.openStaffMessageWidget({
2284            staffName: trigger.text().trim(),
2285            recipientAddress: trigger.data('email'),
2286            recaptchaSiteKey: window.SS_RECAPTCHA_SITE_KEY || '',
2287            emailVerificationRequired: window.SS_STAFF_MESSAGE_EMAIL_VERIFICATION_REQUIRED !== false,
2288            primaryColor: window.SS_STAFF_MESSAGE_PRIMARY_COLOR || '',
2289            linkColor: window.SS_STAFF_MESSAGE_LINK_COLOR || '',
2290        });
2291        return;
2292    }
2293
2294    const trigger = $(this);
2295    const person = trigger.data("getvar") ?? '';
2296    const type = trigger.data("sendtype");
2297    const email = trigger.data("email");
2298    const request = 'ckEditor';
2299    const emailSrc = `/sndreq/emailform.php?person=${person}&type=${type}&email=${email}&request=${request}`;
2300
2301    ssLightbox.SetupModalTrigger(this, modal, 'Send A Message');
2302
2303    modal.find('.ss-lightbox-modal-content-inner').html(/*html*/`
2304        <div class="row">
2305            <div style="margin:0px;" class="col-sm-12">
2306                <center><h2>Send A Message</h2></center>
2307                <iframe title="Send a Message Dialog Container" scrolling="no" style="width:100%;height:650px;" src="${emailSrc}" frameBorder="0"></iframe>
2308            </div>
2309        </div>
2310    `);
2311});
2312
2313$(document).on("click", ".staff-about-me", function () {
2314    // Read with attr() rather than data(): the staff name and bio are
2315    // free text, and data() would coerce numeric- or JSON-looking values
2316    // away from the string we need. Both are author-supplied, so they are
2317    // inserted with text()/attr() and never as markup.
2318    const about_me = $(this).attr("data-about-me") || '';
2319    const current_name = $(this).attr("data-name") || '';
2320    const current_photo = $(this).attr("data-photo-url") || '';
2321    const modal = $('#ss-components-shared-modal');
2322
2323    ssLightbox.SetupModalTrigger(this, modal, current_name);
2324
2325    const $content = modal.find('.ss-lightbox-modal-content-inner').empty();
2326
2327    if (current_photo) {
2328        $content.append(
2329            $('<div class="staff-image modal-image"></div>').append(
2330                $('<img class="center-block staff-about-me-img">')
2331                    .attr({ src: current_photo, alt: current_name })
2332            )
2333        );
2334    }
2335
2336    $content.append($('<h2></h2>').text(current_name));
2337    $content.append($('<p></p>').text(about_me));
2338});
2339
2340function setStaffDirectoryStatus(item_id, message) {
2341    const $status = $(`#staff_directory_status_${item_id}`);
2342    if (!$status.length) {
2343        return;
2344    }
2345    // Clearing first ensures repeated identical messages (e.g. two empty searches
2346    // in a row) are re-announced by screen readers, since polite live regions
2347    // only announce when the text content actually changes.
2348    $status.text('');
2349    if (message) {
2350        // Defer the new value so the empty-string mutation is observed first.
2351        setTimeout(function () {
2352            $status.text(message);
2353        }, 100);
2354    }
2355}
2356
2357function buildStaffDirectoryResultMessage(html, display_style) {
2358    const $parsed = $('<div>').append($.parseHTML(html));
2359    let count = 0;
2360
2361    if (display_style === 1) {
2362        count = $parsed.find('.directory-table-container tbody > tr').filter(function () {
2363            // DataTables empty state row uses a single cell with class
2364            // dataTables_empty; exclude it from the result count.
2365            return $(this).find('td.dataTables_empty').length === 0;
2366        }).length;
2367    } else {
2368        count = $parsed.find('.staff-item').length;
2369    }
2370
2371    if (count === 0) {
2372        return 'No staff found.';
2373    }
2374    return count === 1 ? '1 result available.' : `${count} results available.`;
2375}
2376
2377function getOnDemandDirectoryContent(item_id, is_search = false, use_mongo = false, revision_time = null) {
2378    try {
2379        return new Promise(function (resolve, reject) {
2380            const target_element = $(`#item${item_id} .ss-component-content`);
2381            const search_term = $(`#staff_directory_search_${item_id}`).val();
2382
2383            target_element.LoadingOverlay('show');
2384            setStaffDirectoryStatus(item_id, is_search ? 'Loading search results.' : 'Loading staff directory.');
2385            $.ajax({
2386                type: 'POST',
2387                url: "/includes/ajax/load_stack_staff_directory.php",
2388                data: {
2389                    item_id,
2390                    search_term,
2391                    use_mongo,
2392                    revision_time,
2393                },
2394                success: function (response) {
2395                    const response_obj = JSON.parse(response);
2396                    const html = response_obj.data.html;
2397                    if (is_search) { //on search
2398                        target_element.find(".directory-carousel-container").replaceWith(html);
2399                    } else { //on load
2400                        target_element.append(html);
2401                    }
2402
2403                    if (response_obj.data.display_style === 0) { // classic view code
2404                        // normalize heights of directory classic view slide items based on maximum height
2405                        // to prevent content from being cut off at the bottom
2406                        let maxSlideHeight = 0;
2407                        $(`#staffCarousel_${item_id} > div.carousel-inner > div.item`).each(function calculateSlideHeight() {
2408                            const slide_height = $(this).height();
2409                            if (slide_height > maxSlideHeight) {
2410                                maxSlideHeight = slide_height;
2411                            }
2412                        });
2413                        $(`#staffCarousel_${item_id} > div.carousel-inner > div.item`).css("height", maxSlideHeight);
2414
2415                        updatePaginationBtns(`#staffCarousel_${item_id}`);
2416                    }
2417
2418                    target_element.LoadingOverlay('hide');
2419                    setStaffDirectoryStatus(
2420                        item_id,
2421                        buildStaffDirectoryResultMessage(html, response_obj.data.display_style)
2422                    );
2423                    resolve(response);
2424                },
2425                error: function (error) {
2426                    setStaffDirectoryStatus(item_id, 'Unable to load staff directory.');
2427                    target_element.LoadingOverlay('hide');
2428                    reject(error);
2429                },
2430            });
2431        });
2432    } catch (error) {
2433        console.error('An error occurred:', error);
2434    }
2435}
2436
2437/**
2438 * Make the DataTables Responsive disclosure controls in the Staff Directory
2439 * compact view announce as accordion-style buttons with a meaningful
2440 * accessible name, an accurate expanded/collapsed state, and an
2441 * `aria-controls` relationship to the detail row they reveal — mirroring the
2442 * WAI-ARIA Authoring Practices accordion pattern so every screen reader
2443 * (NVDA, JAWS, VoiceOver on iOS and macOS, TalkBack, Narrator) can navigate
2444 * between trigger and panel. The Responsive plugin renders the control as a
2445 * `<td>` and only injects its panel `<tr class="child">` on expand, so we
2446 * own both the trigger markup and the panel id wiring here.
2447 *
2448 * @param {string|number} stack_item_id Stack item id used in the table id.
2449 */
2450function enhanceDirectoryTableA11y(stack_item_id) {
2451    const tableSelector = `.directory_table_${stack_item_id}`;
2452    const $table = $(tableSelector);
2453    if (!$table.length || !$.fn.dataTable.isDataTable(tableSelector)) {
2454        return;
2455    }
2456
2457    const dt = $table.DataTable();
2458
2459    // DataTables Responsive 2.1.1 (the version we ship) does not add a
2460    // `.dtr-control` class — it targets the disclosure cell as
2461    // `tbody > tr > td:first-child`. We exclude `tr.child` (the inserted
2462    // detail row) to avoid promoting the wrong cell to a button.
2463    const controlCellSelector = 'tbody > tr:not(.child) > td:first-child';
2464
2465    // Stable per-row id pair (button id ↔ panel id) for the accordion
2466    // relationship. Persisted on the parent <tr> via data-* attributes so
2467    // they survive DataTables re-draws (sort, paginate) that re-use the same
2468    // <tr> elements but re-paint their contents.
2469    let rowIdCounter = 0;
2470    const ensureRowIds = function ($row) {
2471        let buttonId = $row.attr('data-dtr-button-id');
2472        let panelId  = $row.attr('data-dtr-panel-id');
2473        if (!buttonId || !panelId) {
2474            rowIdCounter += 1;
2475            buttonId = `dtr-toggle-${stack_item_id}-${rowIdCounter}`;
2476            panelId  = `dtr-panel-${stack_item_id}-${rowIdCounter}`;
2477            $row.attr('data-dtr-button-id', buttonId);
2478            $row.attr('data-dtr-panel-id', panelId);
2479        }
2480        return { buttonId, panelId };
2481    };
2482
2483    // Inject or remove a real <button> in each first-child cell depending on
2484    // whether the table is currently in the responsive collapsed state.
2485    // DataTables' click handler is delegated on tbody, so the button's click
2486    // naturally bubbles up to trigger expand/collapse.
2487    const syncToggleButtons = function () {
2488        const isCollapsed = $table.hasClass('collapsed');
2489
2490        $table.find(controlCellSelector).each(function () {
2491            const $cell = $(this);
2492            const $row  = $cell.closest('tr');
2493            const staffName = $row.attr('data-staff-name') || '';
2494            const expanded  = $row.hasClass('parent');
2495            const label     = staffName ? `More details about ${staffName}` : 'More details';
2496
2497            if (!isCollapsed) {
2498                $cell.find('.dtr-toggle-btn').remove();
2499                return;
2500            }
2501
2502            const { buttonId, panelId } = ensureRowIds($row);
2503
2504            let $btn = $cell.find('.dtr-toggle-btn');
2505            if (!$btn.length) {
2506                // type="button" is required: the page wraps directory tables in
2507                // a <form>, and a <button> without an explicit type defaults to
2508                // type="submit" per the HTML spec — meaning Enter would submit
2509                // the form instead of firing a click.
2510                $btn = $('<button>', { type: 'button', class: 'dtr-toggle-btn' });
2511
2512                // DataTables Responsive 2.1.1 binds `keyup.dtr` on tbody that
2513                // calls $(td).click() when Enter is pressed inside a control
2514                // cell. The button's own UA activation already fires a click
2515                // on Enter, so DT's keyup-driven click would double-toggle the
2516                // row. Stop the keyup at the button so it never reaches tbody.
2517                $btn[0].addEventListener('keyup', function (ke) {
2518                    if (ke.key === 'Enter') {
2519                        ke.stopPropagation();
2520                    }
2521                });
2522
2523                $cell.prepend($btn);
2524            }
2525
2526            // The DataTables Responsive plugin sets tabindex="0" on the td
2527            // itself. Remove it now that the button owns focus for this cell.
2528            $cell.removeAttr('tabindex');
2529
2530            $btn.attr({
2531                id:              buttonId,
2532                'aria-controls': panelId,
2533                'aria-label':    label,
2534                'aria-expanded': expanded ? 'true' : 'false'
2535            });
2536        });
2537    };
2538
2539    // Inject a visually-hidden live region so screen readers announce the
2540    // active sort whenever a column header sort button is activated.
2541    const liveRegionId = `directory-sort-live-${stack_item_id}`;
2542    const $container = $table.closest('.directory-table-container');
2543    if (!$container.length) {
2544        return;
2545    }
2546    if (!document.getElementById(liveRegionId)) {
2547        $('<div>', {
2548            id:            liveRegionId,
2549            role:          'status',
2550            'aria-live':   'polite',
2551            'aria-atomic': 'true',
2552            'class':       'sr-only'
2553        }).prependTo($container);
2554    }
2555    const $liveEl = $container.find('#' + liveRegionId);
2556
2557    dt.on('order.dt', function () {
2558        const order = dt.order();
2559        if (!order || !order.length) {
2560            return;
2561        }
2562        const colIdx    = order[0][0];
2563        const direction = order[0][1] === 'asc' ? 'ascending' : 'descending';
2564        const colHeader = dt.column(colIdx).header();
2565        const colName   = colHeader ? $(colHeader).text().trim() : 'column ' + (colIdx + 1);
2566        $liveEl.text('Table sorted by ' + colName + ', ' + direction);
2567    });
2568
2569    // Label the DataTables pagination as a navigation landmark, give each
2570    // numeric page link a "Page N" accessible name (DataTables 1.10 ships
2571    // the page number as bare text, so screen readers otherwise announce
2572    // just "link 1" / "link 2"), and stamp aria-current="page" on the
2573    // active page link so the current page is announced (WCAG 2.2 SC 4.1.2).
2574    // DataTables fires draw.dt after every page change, so we re-sync on
2575    // each draw to move aria-current to the newly active link and re-label
2576    // any newly-rendered page links.
2577    const $dtWrapper = $(dt.table().container());
2578    const syncPaginationA11y = function () {
2579        const $paginate = $dtWrapper.find('.dataTables_paginate').first();
2580        const $pagination = $paginate.find('ul.pagination').first();
2581        if (!$pagination.length) {
2582            return;
2583        }
2584        if (!$paginate.attr('role')) {
2585            $paginate.attr({ role: 'navigation', 'aria-label': 'Pagination' });
2586        }
2587        // Label numeric page buttons "Page N". Skip previous/next (DataTables
2588        // already sets their text to "Previous"/"Next") and the ellipsis
2589        // span (non-numeric link text, e.g. "…").
2590        $pagination.find('li.paginate_button:not(.previous):not(.next) > a').each(function () {
2591            const $link = $(this);
2592            const text = $link.text().trim();
2593            if (/^\d+$/.test(text)) {
2594                $link.attr('aria-label', 'Page ' + text);
2595            }
2596        });
2597        $pagination.find('a[aria-current]').removeAttr('aria-current');
2598        $pagination.find('li.paginate_button.active > a').attr('aria-current', 'page');
2599    };
2600
2601    syncToggleButtons();
2602    syncPaginationA11y();
2603    // Re-run on the next tick so any pending responsive layout work (browsers
2604    // can finalize column widths after synchronous DataTables init returns)
2605    // has settled before we decide whether buttons are needed.
2606    setTimeout(syncToggleButtons, 0);
2607
2608    dt.on('draw.dt', function () {
2609        syncToggleButtons();
2610        syncPaginationA11y();
2611    });
2612    dt.on('responsive-resize.dt', syncToggleButtons);
2613    dt.on('responsive-display.dt', function (e, datatable, row, showHide) {
2614        const $row = $(row.node());
2615        $row.find('> td:first-child .dtr-toggle-btn')
2616            .attr('aria-expanded', showHide ? 'true' : 'false');
2617
2618        // The detail panel only exists in the DOM while expanded — DataTables
2619        // inserts `<tr class="child">` immediately after the parent on show
2620        // and removes it on hide. Stamp the matching panel id (and a back-
2621        // reference to the trigger) each time it appears so aria-controls on
2622        // the button always resolves to a real element.
2623        if (showHide) {
2624            const buttonId = $row.attr('data-dtr-button-id');
2625            const panelId  = $row.attr('data-dtr-panel-id');
2626            if (panelId) {
2627                $row.next('tr.child').attr({
2628                    id:                panelId,
2629                    'aria-labelledby': buttonId || null
2630                });
2631            }
2632        }
2633    });
2634}
2635
2636let timeout = null; // used to prevent ajax call from firing too often
2637$(document).on('keyup', '.staff_directory_search', function (e) {
2638    if (timeout) {
2639        clearTimeout(timeout);
2640    }
2641
2642    timeout = setTimeout(() => { // only run ajax call once in .75 seconds
2643        const item_id = $(this).data('id');
2644        getOnDemandDirectoryContent(item_id, true)
2645    }, 750);
2646});
2647
2648//ENSURE A11Y COMPLIANCE FOR OPTIONAL HEADINGS ON COMPONENTS THAT DO NOT HAVE COLORS SET FOR THEM YET
2649function a11yOptHeading(divID, bgColor){
2650    const contrastCheck = new ColorContrast();
2651    const siteDesignColorCheck = contrastCheck.calculateContrast({0: "Heading Text Color", 1: siteDesignOptionsHeadingColor}
2651, {"Background Color": bgColor});
2652
2653    //ONLY CHANGE TEXT COLOR IF HEADING COLOR IN SITE DESIGN DOES NOT PASS CONTRAST WITH BACKGROUND COLOR
2654    if(siteDesignColorCheck.includes("warn")){
2655        let tempColor       = "#000000";
2656        let txtColorCheck   = contrastCheck.calculateContrast({0: "Heading Text Color", 1: "#FFFFFF"}, {"Background Color": bgColor});
2657
2658        if(!txtColorCheck.includes("warn")){
2659            tempColor = "#FFFFFF";
2660        }
2661
2662        let a11yHeadingCSS = `<style type="text/css">
2663            #${divID} .ss-component-header h2 {
2664                color: ${tempColor};
2665            }
2666            #${divID} .ss-column-heading {
2667                color: ${tempColor};
2668            }
2669            #${divID} .ss-component-header h2::before,
2670            #${divID} .ss-component-header h2::after {
2671                border-color: ${tempColor};
2672            }
2673            </style>`;
2674
2675        $(`#${divID}`).append(a11yHeadingCSS);
2676    }
2677}
2678
2679// Bridge for MobileMenuAccessibility.ts: allows the TypeScript module to
2680// refresh the ssLightbox focus loop after a mobile submenu closes.
2681window.ssMobileMenuUpdateFocusLoop = function(container) {
2682    ssLightbox.GetFocusableElements(container);
2683    ssLightbox.NavigateFocusLoop(container);
2684};

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.