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 primaryColor: window.SS_STAFF_MESSAGE_PRIMARY_COLOR || '', 2288 linkColor: window.SS_STAFF_MESSAGE_LINK_COLOR || '', 2289 }); 2290 return; 2291 } 2292 2293 const trigger = $(this); 2294 const person = trigger.data("getvar") ?? ''; 2295 const type = trigger.data("sendtype"); 2296 const email = trigger.data("email"); 2297 const request = 'ckEditor'; 2298 const emailSrc = `/sndreq/emailform.php?person=${person}&type=${type}&email=${email}&request=${request}`; 2299 2300 ssLightbox.SetupModalTrigger(this, modal, 'Send A Message'); 2301 2302 modal.find('.ss-lightbox-modal-content-inner').html(/*html*/` 2303 <div class="row"> 2304 <div style="margin:0px;" class="col-sm-12"> 2305 <center><h2>Send A Message</h2></center> 2306 <iframe title="Send a Message Dialog Container" scrolling="no" style="width:100%;height:650px;" src="${emailSrc}" frameBorder="0"></iframe> 2307 </div> 2308 </div> 2309 `); 2310}); 2311 2312$(document).on("click", ".staff-about-me", function () { 2313 // Read with attr() rather than data(): the staff name and bio are 2314 // free text, and data() would coerce numeric- or JSON-looking values 2315 // away from the string we need. Both are author-supplied, so they are 2316 // inserted with text()/attr() and never as markup. 2317 const about_me = $(this).attr("data-about-me") || ''; 2318 const current_name = $(this).attr("data-name") || ''; 2319 const current_photo = $(this).attr("data-photo-url") || ''; 2320 const modal = $('#ss-components-shared-modal'); 2321 2322 ssLightbox.SetupModalTrigger(this, modal, current_name); 2323 2324 const $content = modal.find('.ss-lightbox-modal-content-inner').empty(); 2325 2326 if (current_photo) { 2327 $content.append( 2328 $('<div class="staff-image modal-image"></div>').append( 2329 $('<img class="center-block staff-about-me-img">') 2330 .attr({ src: current_photo, alt: current_name }) 2331 ) 2332 ); 2333 } 2334 2335 $content.append($('<h2></h2>').text(current_name)); 2336 $content.append($('<p></p>').text(about_me)); 2337}); 2338 2339function setStaffDirectoryStatus(item_id, message) { 2340 const $status = $(`#staff_directory_status_${item_id}`); 2341 if (!$status.length) { 2342 return; 2343 } 2344 // Clearing first ensures repeated identical messages (e.g. two empty searches 2345 // in a row) are re-announced by screen readers, since polite live regions 2346 // only announce when the text content actually changes. 2347 $status.text(''); 2348 if (message) { 2349 // Defer the new value so the empty-string mutation is observed first. 2350 setTimeout(function () { 2351 $status.text(message); 2352 }, 100); 2353 } 2354} 2355 2356function buildStaffDirectoryResultMessage(html, display_style) { 2357 const $parsed = $('<div>').append($.parseHTML(html)); 2358 let count = 0; 2359 2360 if (display_style === 1) { 2361 count = $parsed.find('.directory-table-container tbody > tr').filter(function () { 2362 // DataTables empty state row uses a single cell with class 2363 // dataTables_empty; exclude it from the result count. 2364 return $(this).find('td.dataTables_empty').length === 0; 2365 }).length; 2366 } else { 2367 count = $parsed.find('.staff-item').length; 2368 } 2369 2370 if (count === 0) { 2371 return 'No staff found.'; 2372 } 2373 return count === 1 ? '1 result available.' : `${count} results available.`; 2374} 2375 2376function getOnDemandDirectoryContent(item_id, is_search = false, use_mongo = false, revision_time = null) { 2377 try { 2378 return new Promise(function (resolve, reject) { 2379 const target_element = $(`#item${item_id} .ss-component-content`); 2380 const search_term = $(`#staff_directory_search_${item_id}`).val(); 2381 2382 target_element.LoadingOverlay('show'); 2383 setStaffDirectoryStatus(item_id, is_search ? 'Loading search results.' : 'Loading staff directory.'); 2384 $.ajax({ 2385 type: 'POST', 2386 url: "/includes/ajax/load_stack_staff_directory.php", 2387 data: { 2388 item_id, 2389 search_term, 2390 use_mongo, 2391 revision_time, 2392 }, 2393 success: function (response) { 2394 const response_obj = JSON.parse(response); 2395 const html = response_obj.data.html; 2396 if (is_search) { //on search 2397 target_element.find(".directory-carousel-container").replaceWith(html); 2398 } else { //on load 2399 target_element.append(html); 2400 } 2401 2402 if (response_obj.data.display_style === 0) { // classic view code 2403 // normalize heights of directory classic view slide items based on maximum height 2404 // to prevent content from being cut off at the bottom 2405 let maxSlideHeight = 0; 2406 $(`#staffCarousel_${item_id} > div.carousel-inner > div.item`).each(function calculateSlideHeight() { 2407 const slide_height = $(this).height(); 2408 if (slide_height > maxSlideHeight) { 2409 maxSlideHeight = slide_height; 2410 } 2411 }); 2412 $(`#staffCarousel_${item_id} > div.carousel-inner > div.item`).css("height", maxSlideHeight); 2413 2414 updatePaginationBtns(`#staffCarousel_${item_id}`); 2415 } 2416 2417 target_element.LoadingOverlay('hide'); 2418 setStaffDirectoryStatus( 2419 item_id, 2420 buildStaffDirectoryResultMessage(html, response_obj.data.display_style) 2421 ); 2422 resolve(response); 2423 }, 2424 error: function (error) { 2425 setStaffDirectoryStatus(item_id, 'Unable to load staff directory.'); 2426 target_element.LoadingOverlay('hide'); 2427 reject(error); 2428 }, 2429 }); 2430 }); 2431 } catch (error) { 2432 console.error('An error occurred:', error); 2433 } 2434} 2435 2436/** 2437 * Make the DataTables Responsive disclosure controls in the Staff Directory 2438 * compact view announce as accordion-style buttons with a meaningful
2439 * accessible name, an accurate expanded/collapsed state, and an 2440 * `aria-controls` relationship to the detail row they reveal â mirroring the 2441 * WAI-ARIA Authoring Practices accordion pattern so every screen reader 2442 * (NVDA, JAWS, VoiceOver on iOS and macOS, TalkBack, Narrator) can navigate 2443 * between trigger and panel. The Responsive plugin renders the control as a 2444 * `<td>` and only injects its panel `<tr class="child">` on expand, so we 2445 * own both the trigger markup and the panel id wiring here. 2446 * 2447 * @param {string|number} stack_item_id Stack item id used in the table id. 2448 */ 2449function enhanceDirectoryTableA11y(stack_item_id) { 2450 const tableSelector = `.directory_table_${stack_item_id}`; 2451 const $table = $(tableSelector); 2452 if (!$table.length || !$.fn.dataTable.isDataTable(tableSelector)) { 2453 return; 2454 } 2455 2456 const dt = $table.DataTable(); 2457 2458 // DataTables Responsive 2.1.1 (the version we ship) does not add a 2459 // `.dtr-control` class â it targets the disclosure cell as 2460 // `tbody > tr > td:first-child`. We exclude `tr.child` (the inserted 2461 // detail row) to avoid promoting the wrong cell to a button. 2462 const controlCellSelector = 'tbody > tr:not(.child) > td:first-child'; 2463 2464 // Stable per-row id pair (button id â panel id) for the accordion 2465 // relationship. Persisted on the parent <tr> via data-* attributes so 2466 // they survive DataTables re-draws (sort, paginate) that re-use the same 2467 // <tr> elements but re-paint their contents. 2468 let rowIdCounter = 0; 2469 const ensureRowIds = function ($row) { 2470 let buttonId = $row.attr('data-dtr-button-id'); 2471 let panelId = $row.attr('data-dtr-panel-id'); 2472 if (!buttonId || !panelId) { 2473 rowIdCounter += 1; 2474 buttonId = `dtr-toggle-${stack_item_id}-${rowIdCounter}`; 2475 panelId = `dtr-panel-${stack_item_id}-${rowIdCounter}`; 2476 $row.attr('data-dtr-button-id', buttonId); 2477 $row.attr('data-dtr-panel-id', panelId); 2478 } 2479 return { buttonId, panelId }; 2480 }; 2481 2482 // Inject or remove a real <button> in each first-child cell depending on 2483 // whether the table is currently in the responsive collapsed state. 2484 // DataTables' click handler is delegated on tbody, so the button's click 2485 // naturally bubbles up to trigger expand/collapse. 2486 const syncToggleButtons = function () { 2487 const isCollapsed = $table.hasClass('collapsed'); 2488 2489 $table.find(controlCellSelector).each(function () { 2490 const $cell = $(this); 2491 const $row = $cell.closest('tr'); 2492 const staffName = $row.attr('data-staff-name') || ''; 2493 const expanded = $row.hasClass('parent'); 2494 const label = staffName ? `More details about ${staffName}` : 'More details'; 2495 2496 if (!isCollapsed) { 2497 $cell.find('.dtr-toggle-btn').remove(); 2498 return; 2499 } 2500 2501 const { buttonId, panelId } = ensureRowIds($row); 2502 2503 let $btn = $cell.find('.dtr-toggle-btn'); 2504 if (!$btn.length) { 2505 // type="button" is required: the page wraps directory tables in 2506 // a <form>, and a <button> without an explicit type defaults to 2507 // type="submit" per the HTML spec â meaning Enter would submit 2508 // the form instead of firing a click. 2509 $btn = $('<button>', { type: 'button', class: 'dtr-toggle-btn' }); 2510 2511 // DataTables Responsive 2.1.1 binds `keyup.dtr` on tbody that 2512 // calls $(td).click() when Enter is pressed inside a control 2513 // cell. The button's own UA activation already fires a click 2514 // on Enter, so DT's keyup-driven click would double-toggle the 2515 // row. Stop the keyup at the button so it never reaches tbody. 2516 $btn[0].addEventListener('keyup', function (ke) { 2517 if (ke.key === 'Enter') { 2518 ke.stopPropagation(); 2519 } 2520 }); 2521 2522 $cell.prepend($btn); 2523 } 2524 2525 // The DataTables Responsive plugin sets tabindex="0" on the td 2526 // itself. Remove it now that the button owns focus for this cell. 2527 $cell.removeAttr('tabindex'); 2528 2529 $btn.attr({ 2530 id: buttonId, 2531 'aria-controls': panelId, 2532 'aria-label': label, 2533 'aria-expanded': expanded ? 'true' : 'false' 2534 }); 2535 }); 2536 }; 2537 2538 // Inject a visually-hidden live region so screen readers announce the 2539 // active sort whenever a column header sort button is activated. 2540 const liveRegionId = `directory-sort-live-${stack_item_id}`; 2541 const $container = $table.closest('.directory-table-container'); 2542 if (!$container.length) { 2543 return; 2544 } 2545 if (!document.getElementById(liveRegionId)) { 2546 $('<div>', { 2547 id: liveRegionId, 2548 role: 'status', 2549 'aria-live': 'polite', 2550 'aria-atomic': 'true', 2551 'class': 'sr-only' 2552 }).prependTo($container); 2553 } 2554 const $liveEl = $container.find('#' + liveRegionId); 2555 2556 dt.on('order.dt', function () { 2557 const order = dt.order(); 2558 if (!order || !order.length) { 2559 return; 2560 } 2561 const colIdx = order[0][0]; 2562 const direction = order[0][1] === 'asc' ? 'ascending' : 'descending'; 2563 const colHeader = dt.column(colIdx).header(); 2564 const colName = colHeader ? $(colHeader).text().trim() : 'column ' + (colIdx + 1); 2565 $liveEl.text('Table sorted by ' + colName + ', ' + direction); 2566 }); 2567 2568 // Label the DataTables pagination as a navigation landmark, give each 2569 // numeric page link a "Page N" accessible name (DataTables 1.10 ships 2570 // the page number as bare text, so screen readers otherwise announce 2571 // just "link 1" / "link 2"), and stamp aria-current="page" on the 2572 // active page link so the current page is announced (WCAG 2.2 SC 4.1.2). 2573 // DataTables fires draw.dt after every page change, so we re-sync on 2574 // each draw to move aria-current to the newly active link and re-label 2575 // any newly-rendered page links. 2576 const $dtWrapper = $(dt.table().container()); 2577 const syncPaginationA11y = function () { 2578 const $paginate = $dtWrapper.find('.dataTables_paginate').first(); 2579 const $pagination = $paginate.find('ul.pagination').first(); 2580 if (!$pagination.length) { 2581 return; 2582 } 2583 if (!$paginate.attr('role')) { 2584 $paginate.attr({ role: 'navigation', 'aria-label': 'Pagination' }); 2585 } 2586 // Label numeric page buttons "Page N". Skip previous/next (DataTables 2587 // already sets their text to "Previous"/"Next") and the ellipsis 2588 // span (non-numeric link text, e.g. "â¦"). 2589 $pagination.find('li.paginate_button:not(.previous):not(.next) > a').each(function () { 2590 const $link = $(this); 2591 const text = $link.text().trim(); 2592 if (/^\d+$/.test(text)) { 2593 $link.attr('aria-label', 'Page ' + text); 2594 } 2595 }); 2596 $pagination.find('a[aria-current]').removeAttr('aria-current'); 2597 $pagination.find('li.paginate_button.active > a').attr('aria-current', 'page'); 2598 }; 2599 2600 syncToggleButtons(); 2601 syncPaginationA11y(); 2602 // Re-run on the next tick so any pending responsive layout work (browsers 2603 // can finalize column widths after synchronous DataTables init returns) 2604 // has settled before we decide whether buttons are needed. 2605 setTimeout(syncToggleButtons, 0); 2606 2607 dt.on('draw.dt', function () { 2608 syncToggleButtons(); 2609 syncPaginationA11y(); 2610 }); 2611 dt.on('responsive-resize.dt', syncToggleButtons); 2612 dt.on('responsive-display.dt', function (e, datatable, row, showHide) { 2613 const $row = $(row.node()); 2614 $row.find('> td:first-child .dtr-toggle-btn') 2615 .attr('aria-expanded', showHide ? 'true' : 'false'); 2616 2617 // The detail panel only exists in the DOM while expanded â DataTables 2618 // inserts `<tr class="child">` immediately after the parent on show 2619 // and removes it on hide. Stamp the matching panel id (and a back- 2620 // reference to the trigger) each time it appears so aria-controls on 2621 // the button always resolves to a real element. 2622 if (showHide) { 2623 const buttonId = $row.attr('data-dtr-button-id'); 2624 const panelId = $row.attr('data-dtr-panel-id'); 2625 if (panelId) { 2626 $row.next('tr.child').attr({ 2627 id: panelId, 2628 'aria-labelledby': buttonId || null 2629 }); 2630 } 2631 } 2632 }); 2633} 2634 2635let timeout = null; // used to prevent ajax call from firing too often 2636$(document).on('keyup', '.staff_directory_search', function (e) { 2637 if (timeout) { 2638 clearTimeout(timeout); 2639 } 2640 2641 timeout = setTimeout(() => { // only run ajax call once in .75 seconds 2642 const item_id = $(this).data('id'); 2643 getOnDemandDirectoryContent(item_id, true) 2644 }, 750); 2645}); 2646 2647//ENSURE A11Y COMPLIANCE FOR OPTIONAL HEADINGS ON COMPONENTS THAT DO NOT HAVE COLORS SET FOR THEM YET 2648function a11yOptHeading(divID, bgColor){ 2649 const contrastCheck = new ColorContrast(); 2650 const siteDesignColorCheck = contrastCheck.calculateContrast({0: "Heading Text Color", 1: siteDesignOptionsHeadingColor}
2650, {"Background Color": bgColor}); 2651 2652 //ONLY CHANGE TEXT COLOR IF HEADING COLOR IN SITE DESIGN DOES NOT PASS CONTRAST WITH BACKGROUND COLOR 2653 if(siteDesignColorCheck.includes("warn")){ 2654 let tempColor = "#000000"; 2655 let txtColorCheck = contrastCheck.calculateContrast({0: "Heading Text Color", 1: "#FFFFFF"}, {"Background Color": bgColor}); 2656 2657 if(!txtColorCheck.includes("warn")){ 2658 tempColor = "#FFFFFF"; 2659 } 2660 2661 let a11yHeadingCSS = `<style type="text/css"> 2662 #${divID} .ss-component-header h2 { 2663 color: ${tempColor}; 2664 } 2665 #${divID} .ss-column-heading { 2666 color: ${tempColor}; 2667 } 2668 #${divID} .ss-component-header h2::before, 2669 #${divID} .ss-component-header h2::after { 2670 border-color: ${tempColor}; 2671 } 2672 </style>`; 2673 2674 $(`#${divID}`).append(a11yHeadingCSS); 2675 } 2676} 2677 2678// Bridge for MobileMenuAccessibility.ts: allows the TypeScript module to 2679// refresh the ssLightbox focus loop after a mobile submenu closes. 2680window.ssMobileMenuUpdateFocusLoop = function(container) { 2681 ssLightbox.GetFocusableElements(container); 2682 ssLightbox.NavigateFocusLoop(container); 2683};
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.